diff --git a/resources/assets/edenmod/lang/en_us.json b/resources/assets/edenmod/lang/en_us.json index cae9eb5..3d04cf5 100644 --- a/resources/assets/edenmod/lang/en_us.json +++ b/resources/assets/edenmod/lang/en_us.json @@ -1,9 +1,10 @@ -{ - "key.edenmod.open_config": "Open Config", - "key.edenmod.open_emote_picker": "Open Emote Picker", - "key.edenmod.open_emote_wheel": "Open Emote Wheel", - "key.edenmod.open_menu": "Open Menu", - "key.edenmod.open_territory_menu": "Open Territory Menu", - "key.edenmod.toggle_territory_outlines": "Toggle Territory Wall", - "key.category.minecraft.edenmod": "EdenMod" -} +{ + "key.edenmod.open_config": "Open Config", + "key.edenmod.open_emote_picker": "Open Emote Picker", + "key.edenmod.open_emote_wheel": "Open Emote Wheel", + "key.edenmod.open_menu": "Open Menu", + "key.edenmod.open_territory_menu": "Open Territory Menu", + "key.edenmod.toggle_territory_outlines": "Toggle Territory Wall", + "key.edenmod.tcc_report_exit": "Report TCC Exit", + "key.category.minecraft.edenmod": "EdenMod" +} diff --git a/resources/assets/edenmod/textures/raid/tcc_golem_x.png b/resources/assets/edenmod/textures/raid/tcc_golem_x.png new file mode 100644 index 0000000..1d182db Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_golem_x.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_minimap_frame.png b/resources/assets/edenmod/textures/raid/tcc_minimap_frame.png new file mode 100644 index 0000000..2ffe8a7 Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_minimap_frame.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_room2_map.png b/resources/assets/edenmod/textures/raid/tcc_room2_map.png new file mode 100644 index 0000000..6166aa5 Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_room2_map.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_wings_0.png b/resources/assets/edenmod/textures/raid/tcc_wings_0.png new file mode 100644 index 0000000..bd28cf2 Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_wings_0.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_wings_1.png b/resources/assets/edenmod/textures/raid/tcc_wings_1.png new file mode 100644 index 0000000..4ea369c Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_wings_1.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_wings_2.png b/resources/assets/edenmod/textures/raid/tcc_wings_2.png new file mode 100644 index 0000000..d3a6979 Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_wings_2.png differ diff --git a/resources/assets/edenmod/textures/raid/tcc_wings_3.png b/resources/assets/edenmod/textures/raid/tcc_wings_3.png new file mode 100644 index 0000000..bae1dd7 Binary files /dev/null and b/resources/assets/edenmod/textures/raid/tcc_wings_3.png differ diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 443ca7b..98c34bc 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -518,6 +518,7 @@ public boolean isChiefOwnerOrStrategist() { public void onWorldRespawn() { enteredWynnGameWorld = true; guildLogSync.onWorldJoin(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.reset(); } /** Packet hooks for the invisible guild-log query (mirrors Wynntils' query path). */ @@ -633,12 +634,14 @@ public void release() { TerritoryMenuKeybind.register(edenCategory); TerritoryOutlineRenderer.register(edenCategory); tel.eden.mod.emote.EmoteWheel.register(edenCategory); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.registerKeybind(edenCategory); WarTracker.setReporter(this::relayWarAttended); // War-suite 2D HUD overlays, drawn only while on Wynncraft. HudRenderCallback.EVENT.register((graphics, tickCounter) -> { if (onWynncraft) { WarHud.render(config, graphics); + tel.eden.mod.raid.tcc.TccLabyrinthMapHud.render(config, graphics); } }); // War-suite territory outlines, added to the per-frame gizmo collector during @@ -663,6 +666,7 @@ public void release() { AllianceMenuScraper.reset(); GuildMenuScraper.reset(); AttackTimerMenu.onWorldJoin(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.reset(); tel.eden.mod.war.radiance.RadianceTracker.onJoin(); guildAllyManager.requestCancel(); // Connect if applicable; the backend reports link/membership standing via @@ -710,7 +714,12 @@ public void release() { }))).then(ClientCommandManager.literal("congratulate").then(ClientCommandManager.argument("name", StringArgumentType.word()).executes(ctx -> { congratulate(ctx.getSource(), StringArgumentType.getString(ctx, "name")); return 1; - }))).then(ClientCommandManager.literal("debug").executes(ctx -> { + }))).then(ClientCommandManager.literal("tcctest").executes(ctx -> { + boolean next = !tel.eden.mod.raid.tcc.TccLabyrinthTracker.isDebugForceShow(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.setDebugForceShow(next); + ctx.getSource().sendFeedback(Component.literal("TCC Labyrinth minimap test force-show: " + (next ? "ON" : "OFF")).withStyle(ChatFormatting.AQUA)); + return 1; + })).then(ClientCommandManager.literal("debug").executes(ctx -> { for (String line : AttackTimerMenu.debugSidebarLines()) { ctx.getSource().sendFeedback(Component.literal("[debug] " + line).withStyle(ChatFormatting.AQUA)); } @@ -761,6 +770,7 @@ private void onClientTick(Minecraft client) { tel.eden.mod.war.radiance.RadianceTracker.onClientTick(config, client); RaidPartyTracker.onTick(); RaidReadyTracker.onClientTick(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.tick(config, socket); PartyHealthTracker.tick(); AttackMenuScraper.onTick(client); AllianceMenuScraper.onTick(client); @@ -1086,6 +1096,36 @@ public void onRadianceCast(String sender, String senderUuid, String groupKey, in } @Override + public void onTccMapPos(String sender, String senderUuid, double x, double y, double z, float yaw, boolean isWings) { + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (mc.player != null) { + String myUuid = mc.player.getUUID().toString().replace("-", ""); + String cleanSenderUuid = senderUuid != null ? senderUuid.replace("-", "").trim() : ""; + if (!cleanSenderUuid.isEmpty() && cleanSenderUuid.equalsIgnoreCase(myUuid)) { + return; + } + } + tel.eden.mod.raid.tcc.TccLabyrinthTracker.onPeerPosition(sender, senderUuid, x, y, z, yaw, isWings); + }); + } + + @Override + public void onTccGolemDefeated(String sender, String golemId) { + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> tel.eden.mod.raid.tcc.TccLabyrinthTracker.onPeerGolemDefeated(golemId)); + } + + @Override + public void onTccExitReached(String sender, String path, double x, double z) { + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (mc.player != null && sender != null && sender.equalsIgnoreCase(mc.player.getScoreboardName())) { + return; + } + tel.eden.mod.raid.tcc.TccLabyrinthTracker.onPeerExitReached(sender, path, x, z, config); + }); + } public void onConnectionRejected(String code) { pendingConnectionCode = code; Minecraft.getInstance().execute(() -> socket = null); @@ -2370,7 +2410,8 @@ private synchronized void disconnect() { RaidPartyTracker.reset(); RaidReadyTracker.reset(); PartyHealthTracker.reset(); - ChatReplyManager.reset(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.reset(); + tel.eden.mod.util.PlayerSkinResolver.reset(); tel.eden.mod.war.radiance.RadianceTracker.onDisconnect(); guildAllyManager.requestCancel(); territoryDefendedTracker.clear(); diff --git a/src/tel/eden/mod/chat/RaidPartyTracker.java b/src/tel/eden/mod/chat/RaidPartyTracker.java index a8e8b10..2b5abde 100644 --- a/src/tel/eden/mod/chat/RaidPartyTracker.java +++ b/src/tel/eden/mod/chat/RaidPartyTracker.java @@ -416,6 +416,17 @@ public static List debugState() { return out; } + /** + * The current raid's party as last read from Wynntils or the hidden {@code /party list} + * reply (real IGNs where they could be resolved). Empty outside a raid or before the + * reply arrives. + */ + public static List partyMembers() { + synchronized (LOCK) { + return new ArrayList<>(partyMembers); + } + } + public static Set partyMembersForTesting() { synchronized (LOCK) { return new LinkedHashSet<>(partyMembers); diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 18e74bb..fd0c08e 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -277,6 +277,12 @@ public void sanitize() { /** Minimum number of gambits required to ready up normally (range 0-3, default 1). */ public int raidGambitMinimum = 1; + /** Live minimap HUD for The Canyon Colossus Room 2 Labyrinth. */ + public boolean tccLabyrinthMap = true; + + /** Audio ping when an exit in TCC Room 2 Labyrinth is discovered/confirmed. */ + public boolean tccExitPing = true; + /** Disallow readying up in the raid menu if the raid is not a guild raid. */ public boolean requireGuildRaid = false; diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index dd8c606..c1eafb2 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -148,6 +148,8 @@ protected void init() { addToggleRow(Category.PARTY_RAIDS, "Raid gambit ready safety", () -> config.raidGambitSafety, v -> config.raidGambitSafety = v, "On", "Off", true); IntConfigSlider gambitSlider = new IntConfigSlider(CONTROL_W, 20, 0, 3, 1, () -> config.raidGambitMinimum, v -> config.raidGambitMinimum = v, v -> Component.literal(v == 0 ? "Off (0)" : String.valueOf(v))); addSliderRow(Category.PARTY_RAIDS, "Minimum raid gambits", gambitSlider, gambitSlider::syncFromConfig, () -> config.raidGambitMinimum = 1); + addToggleRow(Category.PARTY_RAIDS, "TCC Labyrinth minimap (Map credits: kimchaewon)", () -> config.tccLabyrinthMap, v -> config.tccLabyrinthMap = v, "On", "Off", true); + addToggleRow(Category.PARTY_RAIDS, "TCC exit discovery ping", () -> config.tccExitPing, v -> config.tccExitPing = v, "On", "Off", true); addToggleRow(Category.PARTY_RAIDS, "Require guild raid to ready", () -> config.requireGuildRaid, v -> config.requireGuildRaid = v, "On", "Off", false); // War & Territory diff --git a/src/tel/eden/mod/hud/HudPanels.java b/src/tel/eden/mod/hud/HudPanels.java index 2825ec3..4894dc4 100644 --- a/src/tel/eden/mod/hud/HudPanels.java +++ b/src/tel/eden/mod/hud/HudPanels.java @@ -10,5 +10,5 @@ */ public record HudPanels(String name, String label, float defaultX, float defaultY, int previewWidth, int previewHeight) { - public static final List ALL = List.of(new HudPanels("attackTimers", "Attack timers", 0.40f, 0.14f, 130, 44), new HudPanels("warInfo", "War info overlay", 0.015f, 0.30f, 150, 74), new HudPanels("weeklyWars", "Weekly war count", 0.83f, 0.04f, 60, 12), new HudPanels("radianceTracker", "Radiance tracker", 0.50f, 0.35f, 110, 24)); + public static final List ALL = List.of(new HudPanels("attackTimers", "Attack timers", 0.40f, 0.14f, 130, 44), new HudPanels("warInfo", "War info overlay", 0.015f, 0.30f, 150, 74), new HudPanels("weeklyWars", "Weekly war count", 0.83f, 0.04f, 60, 12), new HudPanels("radianceTracker", "Radiance tracker", 0.50f, 0.35f, 110, 24), new HudPanels("tccLabyrinthMap", "TCC Labyrinth minimap", 0.02f, 0.05f, 130, 180)); } diff --git a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java index 632f5aa..eb4ea53 100644 --- a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java +++ b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java @@ -14,6 +14,8 @@ import net.minecraft.network.protocol.game.ClientboundOpenScreenPacket; import net.minecraft.network.protocol.game.ClientboundSoundPacket; import net.minecraft.network.protocol.game.ClientboundSetActionBarTextPacket; +import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket; +import net.minecraft.network.protocol.game.ClientboundSetSubtitleTextPacket; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -33,6 +35,24 @@ public class ClientPacketListenerMixin { } } + @Inject(method = "setTitleText", at = @At("HEAD")) + private void edenBridge$observeTitleText(ClientboundSetTitleTextPacket packet, CallbackInfo ci) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.isSameThread()) { + EdenModClient mod = EdenModClient.instance(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.onTitle(packet.text(), mod != null ? mod.config() : null, mod != null ? mod.socket() : null); + } + } + + @Inject(method = "setSubtitleText", at = @At("HEAD")) + private void edenBridge$observeSubtitleText(ClientboundSetSubtitleTextPacket packet, CallbackInfo ci) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.isSameThread()) { + EdenModClient mod = EdenModClient.instance(); + tel.eden.mod.raid.tcc.TccLabyrinthTracker.onTitle(packet.text(), mod != null ? mod.config() : null, mod != null ? mod.socket() : null); + } + } + @Inject(method = "handleSoundEvent", at = @At("HEAD"), cancellable = true) private void edenBridge$muteBackgroundContainerClick(ClientboundSoundPacket packet, CallbackInfo ci) { Minecraft minecraft = Minecraft.getInstance(); diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index cd932d6..4cfdd1a 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -158,6 +158,17 @@ default void onRadianceCast(String sender, String groupKey, int tier, long remai default void onRadianceGroupCount(String groupKey, int count) { } + /** TCC Room 2 Labyrinth position from party member. */ + default void onTccMapPos(String sender, String senderUuid, double x, double y, double z, float yaw, boolean isWings) { + } + + /** TCC Room 2 Golem defeated notice. */ + default void onTccGolemDefeated(String sender, String golemId) { + } + + /** TCC Room 2 Exit reached notice. */ + default void onTccExitReached(String sender, String path, double x, double z) { + } /** * The Mojang session was verified ({@code authOk}); {@code linked} and * {@code member} report this player's backend standing. Both true means full @@ -193,6 +204,7 @@ public interface SessionAuthenticator { }); private volatile WebSocket socket; + private volatile boolean authenticated = false; // Tail of the serialized outbound send queue — see enqueueSend(). private CompletableFuture sendChain = CompletableFuture.completedFuture(null); private volatile boolean running; @@ -243,12 +255,28 @@ public synchronized void close() { running = false; WebSocket current = socket; socket = null; + authenticated = false; if (current != null) { current.sendClose(WebSocket.NORMAL_CLOSURE, "client closing"); } scheduler.shutdownNow(); } + /** Whether the WebSocket connection is currently active and authenticated. */ + public boolean isConnected() { + return socket != null && authenticated; + } + + /** Whether raw WebSocket connection is open (even if still authenticating). */ + public boolean isSocketOpen() { + return socket != null; + } + + /** Whether the Mojang session verification has completed. */ + public boolean isAuthenticated() { + return socket != null && authenticated; + } + /** * Queue {@code text} to send on {@code ws}, serialized against every other send. * {@code WebSocket.sendText} allows only one outstanding send at a time, but sends @@ -563,6 +591,85 @@ public void sendDiceroll() { sendType("diceroll"); } + /** Broadcast player position for TCC Room 2 Labyrinth. */ + public void sendTccMapPos(double x, double y, double z, float yaw, boolean isWings) { + sendTccMapPos(x, y, z, yaw, isWings, null); + } + + public void sendTccMapPos(double x, double y, double z, float yaw, boolean isWings, java.util.List partyMembers) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "tccMapPos"); + obj.addProperty("x", x); + obj.addProperty("y", y); + obj.addProperty("z", z); + obj.addProperty("yaw", yaw); + obj.addProperty("isWings", isWings); + addParty(obj, partyMembers); + enqueueSend(current, obj.toString()); + } + + /** Broadcast defeated golem notice for TCC Room 2 Labyrinth. */ + public void sendTccGolemDefeated(String golemId) { + sendTccGolemDefeated(golemId, null); + } + + /** + * Broadcast a defeated golem to this raid party. Without a party list the backend relays + * it to every Eden member active in Room 2, marking it on other groups' maps too. + */ + public void sendTccGolemDefeated(String golemId, java.util.List partyMembers) { + WebSocket current = socket; + if (current == null) { + return; + } + enqueueSend(current, tccGolemDefeatedPayload(golemId, partyMembers).toString()); + } + + static JsonObject tccGolemDefeatedPayload(String golemId, java.util.List partyMembers) { + JsonObject obj = new JsonObject(); + obj.addProperty("type", "tccGolemDefeated"); + obj.addProperty("golemId", golemId); + addParty(obj, partyMembers); + return obj; + } + + /** Attach the raid party (blank names dropped) that the backend scopes TCC relays to. */ + private static void addParty(JsonObject obj, java.util.List partyMembers) { + if (partyMembers == null || partyMembers.isEmpty()) { + return; + } + JsonArray arr = new JsonArray(); + for (String member : partyMembers) { + if (member != null && !member.isBlank()) { + arr.add(member); + } + } + obj.add("party", arr); + } + + /** Broadcast exit reached notice for TCC Room 2 Labyrinth. */ + public void sendTccExitReached(String path, double x, double z) { + sendTccExitReached(path, x, z, null); + } + + public void sendTccExitReached(String path, double x, double z, java.util.List partyMembers) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "tccExitReached"); + obj.addProperty("path", path); + obj.addProperty("x", x); + obj.addProperty("z", z); + addParty(obj, partyMembers); + enqueueSend(current, obj.toString()); + } + /** * Report a detected guild war: the territory and the players seen fighting in it * (this client included). The backend merges reports from every attending client @@ -1032,6 +1139,7 @@ private synchronized void scheduleReconnect(WebSocket disconnected) { return; } socket = null; + authenticated = false; if (!running || reconnectScheduled) { return; } @@ -1058,6 +1166,7 @@ private void rejectConnection(String code) { running = false; current = socket; socket = null; + authenticated = false; scheduler.shutdownNow(); } if (current != null) { @@ -1078,6 +1187,7 @@ public void onOpen(WebSocket webSocket) { authChallengeSeen.set(false); pendingAuthUsername = null; socket = webSocket; + authenticated = false; } } if (!accepted) { @@ -1136,8 +1246,12 @@ private void handlePayload(String payload) { case "warBoard" -> sink.onWarBoard(parseWarBoard(obj)); case "radianceGroupCount" -> sink.onRadianceGroupCount(get(obj, "groupKey"), getInt(obj, "count", 0)); case "radianceCast" -> sink.onRadianceCast(get(obj, "sender"), get(obj, "senderUuid"), get(obj, "groupKey"), getInt(obj, "tier", 0), getLong(obj, "remainingMs", 0L), getLong(obj, "timestamp", System.currentTimeMillis())); + 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 "authChallenge" -> handleAuthChallenge(get(obj, "serverId")); case "authOk" -> { + authenticated = true; // Session verified: reset backoff and hand the backend-reported // standing (linked/member/ranks) to the sink, which begins the session // on full access or prompts the player otherwise. @@ -1265,6 +1379,22 @@ private static long getLong(JsonObject obj, String key, long fallback) { } } + private static double getDouble(JsonObject obj, String key, double fallback) { + try { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsDouble() : fallback; + } catch (RuntimeException e) { + return fallback; + } + } + + private static float getFloat(JsonObject obj, String key, float fallback) { + try { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsFloat() : fallback; + } catch (RuntimeException e) { + return fallback; + } + } + private static PartyInfo parseParty(JsonObject obj) { return new PartyInfo(getInt(obj, "id", 0), get(obj, "raid"), get(obj, "host"), getStringArray(obj, "members"), getInt(obj, "max", 4), get(obj, "note")); } diff --git a/src/tel/eden/mod/raid/tcc/TccLabyrinthMapHud.java b/src/tel/eden/mod/raid/tcc/TccLabyrinthMapHud.java new file mode 100644 index 0000000..d6cf340 --- /dev/null +++ b/src/tel/eden/mod/raid/tcc/TccLabyrinthMapHud.java @@ -0,0 +1,262 @@ +package tel.eden.mod.raid.tcc; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.PlayerFaceRenderer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.PlayerSkin; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.hud.HudPanel; +import tel.eden.mod.hud.HudRender; +import tel.eden.mod.net.BridgeWebSocketClient; +import tel.eden.mod.party.PartyHealthTracker; +import tel.eden.mod.util.PlayerSkinResolver; + +/** + * Minimap HUD for The Canyon Colossus Room 2 Labyrinth framed with a custom ornate border. + * Features player heads with mini HP bars, offline red-dot fallback, defeated golem "X" markers, + * and exit reach indicators. + */ +public final class TccLabyrinthMapHud { + public static final String PANEL = "tccLabyrinthMap"; + public static final float DEFAULT_X = 0.02f; + public static final float DEFAULT_Y = 0.05f; + public static final int PANEL_W = 130; + public static final int PANEL_H = 180; + + private static final int INSET_X = 5; + private static final int INSET_Y = 6; + private static final int VIEWPORT_W = 120; + private static final int VIEWPORT_H = 167; + + private static final Identifier MAP_TEXTURE = Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_room2_map.png"); + private static final Identifier FRAME_TEXTURE = Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_minimap_frame.png"); + private static final Identifier X_TEXTURE = Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_golem_x.png"); + private static final Identifier[] WING_TEXTURES = new Identifier[]{Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_wings_0.png"), Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_wings_1.png"), Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_wings_2.png"), Identifier.fromNamespaceAndPath("edenmod", "textures/raid/tcc_wings_3.png")}; + + private static final int COLOR_PROVI_GREEN = 0xFF00E817; + private static final int COLOR_AMBER = 0xFFF59E0B; + private static final int COLOR_CRIMSON = 0xFFEF4444; + private static final int COLOR_GOLD = 0xFFFBBF24; + + private TccLabyrinthMapHud() { + } + + public static void render(BridgeConfig config, GuiGraphics graphics) { + if (config == null || !config.tccLabyrinthMap) { + return; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null || mc.level == null || mc.options.hideGui) { + return; + } + + if (!TccLabyrinthTracker.isInRoom2()) { + return; + } + + HudPanel panel = new HudPanel(); + panel.add(TccLabyrinthMapHud::drawMinimap); + + HudRender.draw(config, PANEL, DEFAULT_X, DEFAULT_Y, PANEL_W, PANEL_H, panel, graphics); + } + + private static void drawMinimap(GuiGraphics graphics) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + + // 1. Dark viewport background + graphics.fill(INSET_X, INSET_Y, INSET_X + VIEWPORT_W, INSET_Y + VIEWPORT_H, 0xEE0B0E14); + + // 2. Base map texture (556 x 775 source) + graphics.blit(RenderPipelines.GUI_TEXTURED, MAP_TEXTURE, INSET_X, INSET_Y, 0.0f, 0.0f, VIEWPORT_W, VIEWPORT_H, 556, 775, 556, 775); + + // 3. Defeated Golems (Red 'X') + for (String golemId : TccLabyrinthTracker.getDefeatedGolems()) { + TccLabyrinthTracker.Golem g = TccLabyrinthTracker.Golem.fromId(golemId); + if (g != null) { + float[] screen = projectMapPixel(g.mapPixelX, g.mapPixelY); + drawGolemX(graphics, Math.round(screen[0]), Math.round(screen[1])); + } + } + + // 4. Confirmed Exit Marker (white circle drawn inside of purple circle) + TccLabyrinthTracker.ExitPoint confirmed = TccLabyrinthTracker.getConfirmedExit(); + if (confirmed != null) { + float[] screen = projectMapPixel(confirmed.mapPixelX, confirmed.mapPixelY); + drawConfirmedExitCircle(graphics, Math.round(screen[0]), Math.round(screen[1])); + } + + // 5. Exit Reached Banner + if (TccLabyrinthTracker.isExitReached()) { + int bannerY = INSET_Y + VIEWPORT_H - 12; + graphics.fill(INSET_X, bannerY, INSET_X + VIEWPORT_W, INSET_Y + VIEWPORT_H, 0xDD006622); + graphics.pose().pushMatrix(); + graphics.pose().translate(INSET_X + (VIEWPORT_W / 2.0f), bannerY + 2); + graphics.pose().scale(0.7f, 0.7f); + String exitText = "EXIT REACHED"; + int tw = mc.font.width(exitText); + graphics.drawString(mc.font, exitText, -tw / 2, 0, 0xFFFFFFFF, true); + graphics.pose().popMatrix(); + } + + // 5. Player markers + EdenModClient mod = EdenModClient.instance(); + BridgeWebSocketClient socket = mod != null ? mod.socket() : null; + boolean connected = socket != null && socket.isConnected(); + + List players = new ArrayList<>(); + // Local player entry + double lx = mc.player.getX(); + double ly = mc.player.getY(); + double lz = mc.player.getZ(); + float lyaw = mc.player.getYRot(); + boolean lWings = mc.player.getUUID().equals(TccLabyrinthTracker.getWingsPlayerUUID()); + players.add(new TccLabyrinthTracker.PlayerEntry(mc.player.getGameProfile().name(), mc.player.getUUID(), lx, ly, lz, lyaw, lWings, true)); + + // Peer players if connected + if (connected) { + players.addAll(TccLabyrinthTracker.getPeers()); + } + + for (TccLabyrinthTracker.PlayerEntry p : players) { + float[] mapPix = TccLabyrinthTracker.worldToMapPixels(p.x(), p.z()); + float[] screen = projectMapPixel(mapPix[0], mapPix[1]); + int sx = Math.max(INSET_X + 6, Math.min(INSET_X + VIEWPORT_W - 6, Math.round(screen[0]))); + int sy = Math.max(INSET_Y + 6, Math.min(INSET_Y + VIEWPORT_H - 6, Math.round(screen[1]))); + + drawPlayer(graphics, mc, p, sx, sy, !connected && p.isLocal()); + } + + // 6. Minimap Ornate Frame overlay (144 x 143 visible frame cropped from 164 x 155 source at u=13, v=6) + graphics.blit(RenderPipelines.GUI_TEXTURED, FRAME_TEXTURE, 0, 0, 13.0f, 6.0f, PANEL_W, PANEL_H, 144, 143, 164, 155); + } + + private static float[] projectMapPixel(float mapPixelX, float mapPixelY) { + float sx = INSET_X + (mapPixelX / 556.0f) * VIEWPORT_W; + float sy = INSET_Y + (mapPixelY / 775.0f) * VIEWPORT_H; + return new float[]{sx, sy}; + } + + private static void drawGolemX(GuiGraphics graphics, int cx, int cy) { + int sz = 10; + graphics.blit(RenderPipelines.GUI_TEXTURED, X_TEXTURE, cx - sz / 2, cy - sz / 2, 0.0f, 0.0f, sz, sz, 424, 424, 424, 424); + } + + private static void drawConfirmedExitCircle(GuiGraphics graphics, int cx, int cy) { + long time = System.currentTimeMillis(); + double pulse = (Math.sin(time / 200.0) + 1.0) * 0.5; + float radius = 5.0f + (float) (pulse * 2.5); + int alpha = (int) (180 + pulse * 75); + int color = (alpha << 24) | 0x00FFFFFF; + + int rCeil = (int) Math.ceil(radius + 1.0f); + double rMinSq = (radius - 1.0) * (radius - 1.0); + double rMaxSq = (radius + 1.0) * (radius + 1.0); + for (int dx = -rCeil; dx <= rCeil; dx++) { + int dxSq = dx * dx; + for (int dy = -rCeil; dy <= rCeil; dy++) { + int distSq = dxSq + dy * dy; + if (distSq >= rMinSq && distSq <= rMaxSq) { + graphics.fill(cx + dx, cy + dy, cx + dx + 1, cy + dy + 1, color); + } + } + } + } + + private static void drawPlayer(GuiGraphics graphics, Minecraft mc, TccLabyrinthTracker.PlayerEntry p, int sx, int sy, boolean fallbackRedDot) { + int headSize = 10; + int headX = sx - headSize / 2; + int headY = sy - headSize / 2; + + // 0. Fluttering wings for wings player (rendered behind head starting halfway down model) + if (p.isWings()) { + long cycleStep = (System.currentTimeMillis() / 90L) % 6L; + int frameIdx; + if (cycleStep == 0) + frameIdx = 0; + else if (cycleStep == 1) + frameIdx = 1; + else if (cycleStep == 2) + frameIdx = 2; + else if (cycleStep == 3) + frameIdx = 3; + else if (cycleStep == 4) + frameIdx = 2; + else + frameIdx = 1; + + Identifier wingTex = WING_TEXTURES[frameIdx]; + int wingSize = 30; + int wingX = sx - wingSize / 2; + int wingY = sy - 10; + graphics.blit(RenderPipelines.GUI_TEXTURED, wingTex, wingX, wingY, 0.0f, 0.0f, wingSize, wingSize, 192, 192, 192, 192); + } + + // A. Mini HP Bar (Above head / dot) + int barW = 14; + int barH = 2; + int barX = sx - barW / 2; + int barY = headY - 4; + + PartyHealthTracker.PlayerHealthData health = PartyHealthTracker.getHealth(p.uuid()); + float percent; + boolean overMax = false; + float overPercent = 0.0f; + + if (health != null) { + percent = health.percent(); + overMax = health.overMax(); + overPercent = health.overPercent(); + } else if (p.isLocal() && mc.player != null) { + float cur = mc.player.getHealth(); + float max = Math.max(1.0f, mc.player.getMaxHealth()); + percent = cur / max; + } else { + percent = 1.0f; + } + percent = Math.max(0.0f, Math.min(1.0f, percent)); + + // HP Bar Underlay + graphics.fill(barX - 1, barY - 1, barX + barW + 1, barY + barH + 1, 0xDD111111); + int hpColor = percent > 0.5f ? COLOR_PROVI_GREEN : (percent > 0.25f ? COLOR_AMBER : COLOR_CRIMSON); + int fillW = Math.round(barW * percent); + if (fillW > 0) { + graphics.fill(barX, barY, barX + fillW, barY + barH, hpColor); + } + if (overMax && overPercent > 0.0f) { + int overFillW = Math.round(barW * Math.min(1.0f, overPercent)); + if (overFillW > 0) { + graphics.fill(barX, barY, barX + overFillW, barY + barH, COLOR_GOLD); + } + } + + // B. Player marker: Red dot (fallback) or Player Skin Head + if (fallbackRedDot) { + int r = 3; + graphics.fill(sx - r, sy - r, sx + r + 1, sy + r + 1, 0xFFFF2222); + graphics.renderOutline(sx - r - 1, sy - r - 1, (r + 1) * 2, (r + 1) * 2, 0xFFFFFFFF); + } else { + PlayerSkin skin = PlayerSkinResolver.skinFor(mc, p.uuid(), p.name()); + PlayerFaceRenderer.draw(graphics, skin, headX, headY, headSize); + // Border around head + graphics.renderOutline(headX - 1, headY - 1, headSize + 2, headSize + 2, p.isWings() ? 0xFF00E5FF : 0xAAFFFFFF); + } + + // C. Player Name (Below head / dot) + graphics.pose().pushMatrix(); + graphics.pose().translate(sx, headY + headSize + 2); + graphics.pose().scale(0.60f, 0.60f); + String nameStr = p.name(); + int tw = mc.font.width(nameStr); + int nameColor = p.isWings() ? 0xFF00E5FF : (fallbackRedDot ? 0xFFFF8888 : 0xFFFFFFFF); + graphics.drawString(mc.font, nameStr, -tw / 2, 0, nameColor, true); + graphics.pose().popMatrix(); + } +} diff --git a/src/tel/eden/mod/raid/tcc/TccLabyrinthTracker.java b/src/tel/eden/mod/raid/tcc/TccLabyrinthTracker.java new file mode 100644 index 0000000..cbf1f5a --- /dev/null +++ b/src/tel/eden/mod/raid/tcc/TccLabyrinthTracker.java @@ -0,0 +1,687 @@ +package tel.eden.mod.raid.tcc; + +import com.mojang.blaze3d.platform.InputConstants; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.ChatFormatting; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import org.lwjgl.glfw.GLFW; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.net.BridgeWebSocketClient; + +/** + * Tracks player positions, golem defeats, and exit status for The Canyon Colossus + * Room 2: The Labyrinth with zero entity scanning and zero world scanning. + */ +public final class TccLabyrinthTracker { + public enum ExitColor { + RED("Red", ChatFormatting.RED, 0xFFFF5555), BLUE("Blue", ChatFormatting.AQUA, 0xFF55FFFF), YELLOW("Yellow", ChatFormatting.YELLOW, 0xFFFFFF55), GREEN("Green", ChatFormatting.GREEN, 0xFF55FF55); + + public final String displayName; + public final ChatFormatting chatFormatting; + public final int argb; + + ExitColor(String displayName, ChatFormatting chatFormatting, int argb) { + this.displayName = displayName; + this.chatFormatting = chatFormatting; + this.argb = argb; + } + } + + public enum ExitPoint { + RL(1, "RL", ExitColor.RED, 10839, 3512, 360, 644), RR2(2, "RR2", ExitColor.RED, 10804, 3449, 243, 713), RR3(3, "RR3", ExitColor.RED, 10827, 3396, 135, 668), RR4(4, "RR4", ExitColor.RED, 10857, 3370, 87, 599), RR5(5, "RR5", ExitColor.RED, 10829, 3348, 43, 669), + + BLUE_LEFT_1(6, "Blue left 1", ExitColor.BLUE, 11101, 3458, 256, 116), BLUE_LEFT_2(7, "Blue left 2", ExitColor.BLUE, 11117, 3474, 278, 83), BLUE_RIGHT_1(8, "Blue right 1", ExitColor.BLUE, 11119, 3500, 345, 75), BLUE_RIGHT_2(9, "Blue right 2", ExitColor.BLUE, 11127, 3531, 399, 67), BLUE_RIGHT_3(10, "Blue right 3", ExitColor.BLUE, 11103, 3532, 407, 119), + + Y_LEFT(11, "Y left", ExitColor.YELLOW, 11023, 3549, 441, 266), Y_RIGHT(12, "Y right", ExitColor.YELLOW, 11029, 3580, 509, 260), Y_STRAIGHT(13, "Y straight", ExitColor.YELLOW, 11049, 3554, 441, 220), + + G_LEFT(14, "G left", ExitColor.GREEN, 10901, 3416, 178, 516), G_RIGHT(15, "G right", ExitColor.GREEN, 10860, 3390, 121, 599), G_STRAIGHT(16, "G straight", ExitColor.GREEN, 10879, 3431, 207, 567); + + public final int id; + public final String displayName; + public final ExitColor color; + public final int worldX; + public final int worldZ; + public final int mapPixelX; + public final int mapPixelY; + + ExitPoint(int id, String displayName, ExitColor color, int worldX, int worldZ, int mapPixelX, int mapPixelY) { + this.id = id; + this.displayName = displayName; + this.color = color; + this.worldX = worldX; + this.worldZ = worldZ; + this.mapPixelX = mapPixelX; + this.mapPixelY = mapPixelY; + } + + public static ExitPoint findClosest(float mapX, float mapY) { + ExitPoint best = null; + double bestDistSq = Double.MAX_VALUE; + for (ExitPoint ep : values()) { + double dx = mapX - ep.mapPixelX; + double dy = mapY - ep.mapPixelY; + double distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = ep; + } + } + return best; + } + + public static ExitPoint findClosestLookedAt(double px, double pz, float yaw) { + double rad = Math.toRadians(yaw); + double lx = -Math.sin(rad); + double lz = Math.cos(rad); + + ExitPoint best = null; + double bestScore = Double.MAX_VALUE; + + for (ExitPoint ep : values()) { + double dx = ep.worldX - px; + double dz = ep.worldZ - pz; + double dist = Math.hypot(dx, dz); + double score; + if (dist <= 8.0) { + score = dist; + } else { + double forward = dx * lx + dz * lz; + double perp = Math.abs(dx * (-lz) + dz * lx); + if (forward > 0 && (forward / dist) >= 0.3) { + score = perp * 2.5 + forward * 0.5; + } else { + score = 10000.0 + dist; + } + } + if (score < bestScore) { + bestScore = score; + best = ep; + } + } + return best; + } + + public static ExitPoint fromName(String name) { + if (name == null || name.isBlank()) { + return null; + } + String norm = name.trim().replace(" ", "_").toUpperCase(); + for (ExitPoint ep : values()) { + if (ep.name().equalsIgnoreCase(name) || ep.displayName.equalsIgnoreCase(name) || ep.name().equals(norm)) { + return ep; + } + } + return switch (norm) { + case "BLUE_TOP_RIGHT" -> BLUE_RIGHT_2; + case "BLUE_TOP_MID" -> BLUE_RIGHT_1; + case "BLUE_TOP_LEFT" -> BLUE_LEFT_2; + case "BLUE_FAR_LEFT" -> BLUE_LEFT_1; + case "BLUE_FAR_RIGHT" -> BLUE_RIGHT_3; + case "YELLOW_UPPER" -> Y_STRAIGHT; + case "YELLOW_FAR_RIGHT" -> Y_RIGHT; + case "YELLOW_LOWER" -> Y_LEFT; + case "GREEN_UPPER" -> G_LEFT; + case "GREEN_MID" -> G_STRAIGHT; + case "RED_UPPER_LEFT" -> RR4; + case "GREEN_LOWER" -> G_RIGHT; + case "RED_MID_EAST" -> RL; + case "RED_LOWER_MID" -> RR3; + case "RED_FAR_SOUTHWEST" -> RR5; + case "RED_BOTTOM" -> RR2; + default -> null; + }; + } + } + + public enum Golem { + B1("B1", "Blue", 10996, 3477, 300, 336), B2("B2", "Blue", 11081, 3479, 296, 170), B3("B3", "Blue", 11106, 3502, 337, 124), + + Y1("Y1", "Yellow", 10937, 3539, 416, 441), Y2("Y2", "Yellow", 10988, 3544, 433, 349), Y3("Y3", "Yellow", 11008, 3564, 479, 300), + + G1("G1", "Green", 10934, 3404, 159, 444), G2("G2", "Green", 10888, 3395, 130, 537), G3("G3", "Green", 10886, 3428, 183, 541), G4("G4", "Green", 10866, 3409, 163, 581), + + R1("R1", "Red", 10889, 3481, 300, 505), R2("R2", "Red", 10823, 3500, 336, 668), R3("R3", "Red", 10832, 3453, 252, 644), R4("R4", "Red", 10847, 3414, 183, 620), R5("R5", "Red", 10842, 3376, 105, 636); + + public static final double SPAWN_X = 10934.0; + public static final double SPAWN_Z = 3479.0; + public static final double MAX_TRIGGER_DIST_SQ = 56.25; + // How far past the barrier point (away from spawn) a ground player must be before the + // golem counts as dead. Players fighting it stand right against the bars, so a + // threshold at or just before the barrier marked golems dead mid-fight. + public static final double CLEAR_DISTANCE = 1.0; + + public final String id; + public final String path; + public final int worldX; + public final int worldZ; + public final int mapPixelX; + public final int mapPixelY; + public final double dirX; + public final double dirZ; + + Golem(String id, String path, int worldX, int worldZ, int mapPixelX, int mapPixelY) { + this.id = id; + this.path = path; + this.worldX = worldX; + this.worldZ = worldZ; + this.mapPixelX = mapPixelX; + this.mapPixelY = mapPixelY; + double vx = worldX - SPAWN_X; + double vz = worldZ - SPAWN_Z; + double len = Math.sqrt(vx * vx + vz * vz); + this.dirX = len > 0 ? (vx / len) : 1.0; + this.dirZ = len > 0 ? (vz / len) : 0.0; + } + + /** + * Checks whether the given coordinate has crossed this barrier moving away from spawn. + * The trigger zone starts {@link #CLEAR_DISTANCE} past the barrier and extends into the + * hallway beyond it (never towards spawn). + */ + public boolean isPastBarrier(double px, double pz) { + double dx = px - worldX; + double dz = pz - worldZ; + double distSq = dx * dx + dz * dz; + if (distSq > MAX_TRIGGER_DIST_SQ) { + return false; + } + double projOut = dx * dirX + dz * dirZ; + double projLat = Math.abs(dx * (-dirZ) + dz * dirX); + return projOut >= CLEAR_DISTANCE && projOut <= 7.0 && projLat <= 4.5; + } + + public static Golem fromId(String id) { + for (Golem g : values()) { + if (g.id.equalsIgnoreCase(id)) { + return g; + } + } + return null; + } + } + + public record PlayerEntry(String name, UUID uuid, double x, double y, double z, float yaw, boolean isWings, boolean isLocal) { + } + + // Bounding box for Room 2 (Spawn ~ 10934, 45, 3479) + private static final double MIN_X = 10750.0; + private static final double MAX_X = 11180.0; + private static final double MIN_Z = 3320.0; + private static final double MAX_Z = 3620.0; + private static final double MIN_Y = 20.0; + private static final double MAX_Y = 120.0; + private static final double WINGS_THRESHOLD_Y = 74.0; + private static final long POSITION_BROADCAST_INTERVAL_MS = 100L; + private static final long PEER_TIMEOUT_MS = 10_000L; + + private static final Set DEFEATED_GOLEMS = ConcurrentHashMap.newKeySet(); + private static final Map PEERS = new ConcurrentHashMap<>(); + private static final Map PEER_LAST_SEEN = new ConcurrentHashMap<>(); + + private static UUID wingsPlayerUUID = null; + private static boolean exitReached = false; + private static ExitPoint confirmedExit = null; + private static long lastBroadcastMs = 0L; + private static double lastSentX = 0.0; + private static double lastSentZ = 0.0; + private static float lastSentYaw = 0.0f; + private static boolean debugForceShow = false; + private static boolean wasInRoom2 = false; + private static boolean hasExitTitle = false; + private static long lastExitTitleMs = 0L; + private static long lastReportKeyMs = 0L; + private static final long REPORT_COOLDOWN_MS = 5000L; + private static KeyMapping reportExitKey; + + private TccLabyrinthTracker() { + } + + public static KeyMapping getReportExitKey() { + return reportExitKey; + } + + public static void registerKeybind(KeyMapping.Category category) { + reportExitKey = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.edenmod.tcc_report_exit", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, category)); + } + + public static boolean isDebugForceShow() { + return debugForceShow; + } + + public static void setDebugForceShow(boolean value) { + debugForceShow = value; + } + + public static boolean isInRoom2() { + if (debugForceShow) { + return true; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null) { + return false; + } + double px = mc.player.getX(); + double py = mc.player.getY(); + double pz = mc.player.getZ(); + return px >= MIN_X && px <= MAX_X && pz >= MIN_Z && pz <= MAX_Z && py >= MIN_Y && py <= MAX_Y; + } + + public static void tick(BridgeConfig config, BridgeWebSocketClient socket) { + while (reportExitKey != null && reportExitKey.consumeClick()) { + onReportExitKeybind(config, socket); + } + + if (config == null || !config.tccLabyrinthMap) { + return; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null) { + return; + } + + boolean inRoom = isInRoom2(); + if (inRoom && !wasInRoom2) { + reset(); + } else if (!inRoom && wasInRoom2) { + reset(); + } + wasInRoom2 = inRoom; + + if (!inRoom) { + return; + } + + long now = System.currentTimeMillis(); + double px = mc.player.getX(); + double py = mc.player.getY(); + double pz = mc.player.getZ(); + float yaw = mc.player.getYRot(); + UUID myUuid = mc.player.getUUID(); + + // Wings Player Lock-in: First player to reach Y >= 74 is designated Wings + if (wingsPlayerUUID == null && py >= WINGS_THRESHOLD_Y) { + wingsPlayerUUID = myUuid; + } + boolean isWings = myUuid.equals(wingsPlayerUUID); + + // Ground player traversal golem check + if (!isWings) { + checkTraversalGolems(px, pz, socket); + } + + // Prune timed-out peers + PEER_LAST_SEEN.entrySet().removeIf(entry -> { + if (now - entry.getValue() > PEER_TIMEOUT_MS) { + PEERS.remove(entry.getKey()); + return true; + } + return false; + }); + + // Broadcast own position to peers (throttled, with keepalive heartbeat for stationary players) + if (now - lastBroadcastMs >= POSITION_BROADCAST_INTERVAL_MS) { + boolean moved = Math.abs(px - lastSentX) > 0.08 || Math.abs(pz - lastSentZ) > 0.08 || Math.abs(yaw - lastSentYaw) > 1.5f; + boolean keepalive = (now - lastBroadcastMs >= 2500L); + if ((moved || keepalive) && socket != null && socket.isConnected()) { + java.util.List party = getRaidPartyMembers(); + socket.sendTccMapPos(px, py, pz, yaw, isWings, party); + lastSentX = px; + lastSentZ = pz; + lastSentYaw = yaw; + lastBroadcastMs = now; + } + } + } + + public static boolean isLocalPlayerWings() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null) { + return false; + } + if (wingsPlayerUUID != null && mc.player.getUUID().equals(wingsPlayerUUID)) { + return true; + } + return mc.player.getY() >= WINGS_THRESHOLD_Y; + } + + public static ExitPoint findClosestLookedAt(double px, double pz, float yaw) { + return ExitPoint.findClosestLookedAt(px, pz, yaw); + } + + public static void onReportExitKeybind(BridgeConfig config, BridgeWebSocketClient socket) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null) { + return; + } + if (!isInRoom2()) { + mc.player.displayClientMessage(Component.literal("Unable to report exit").withStyle(ChatFormatting.RED), true); + return; + } + + long now = System.currentTimeMillis(); + long cooldownMs = isLocalPlayerWings() ? 250L : 2500L; + if (now - lastReportKeyMs < cooldownMs) { + double remainingSec = (cooldownMs - (now - lastReportKeyMs)) / 1000.0; + String timeStr = String.format(java.util.Locale.ROOT, "%.1fs", remainingSec); + mc.player.displayClientMessage(Component.literal("Reporting exits on cooldown, try again in " + timeStr).withStyle(ChatFormatting.RED), true); + return; + } + + java.util.List party = getRaidPartyMembers(); + if (party.isEmpty() && !debugForceShow) { + mc.player.displayClientMessage(Component.literal("Unable to report exit, could not resolve party").withStyle(ChatFormatting.RED), true); + return; + } + + ExitPoint prev = confirmedExit; + double px = mc.player.getX(); + double pz = mc.player.getZ(); + float yaw = mc.player.getYRot(); + ExitPoint ep = reportExit(px, pz, yaw, config, socket); + if (ep == null) { + mc.player.displayClientMessage(Component.literal("Unable to report exit").withStyle(ChatFormatting.RED), true); + return; + } + + lastReportKeyMs = now; + String myName = mc.player.getScoreboardName(); + mc.player.displayClientMessage(Component.literal(myName + " has reported an exit at " + ep.displayName).withStyle(ep.color.chatFormatting), false); + if (prev != null && prev != ep) { + mc.player.displayClientMessage(Component.literal("Corrected TCC Exit: " + ep.displayName).withStyle(ChatFormatting.YELLOW), true); + } else { + mc.player.displayClientMessage(Component.literal("Reported TCC Exit: " + ep.displayName).withStyle(ep.color.chatFormatting), true); + } + if (prev == ep && config != null && config.tccExitPing) { + playDiscoveryPing(); + } + } + + public static ExitPoint reportExit(double worldX, double worldZ, float yaw, BridgeConfig config, BridgeWebSocketClient socket) { + if (!isInRoom2()) { + return null; + } + ExitPoint ep = ExitPoint.findClosestLookedAt(worldX, worldZ, yaw); + if (ep != null) { + confirmExit(ep, config); + if (socket != null && socket.isConnected()) { + java.util.List party = getRaidPartyMembers(); + socket.sendTccExitReached(ep.name(), ep.worldX, ep.worldZ, party); + } + } + return ep; + } + + public static ExitPoint reportExit(double worldX, double worldZ, BridgeConfig config, BridgeWebSocketClient socket) { + return reportExit(worldX, worldZ, 0.0f, config, socket); + } + + private static void checkTraversalGolems(double px, double pz, BridgeWebSocketClient socket) { + for (Golem g : Golem.values()) { + if (DEFEATED_GOLEMS.contains(g.id)) { + continue; + } + if (g.isPastBarrier(px, pz)) { + markGolemDefeated(g.id, socket); + } + } + } + + public static void onTitle(Component component) { + onTitle(component, null, null); + } + + public static void onTitle(Component component, BridgeWebSocketClient socket) { + onTitle(component, null, socket); + } + + public static void onTitle(Component component, BridgeConfig config, BridgeWebSocketClient socket) { + if (component == null || !isInRoom2()) { + return; + } + String text = component.getString().toLowerCase(); + if (text.contains("waiting for players")) { + hasExitTitle = true; + lastExitTitleMs = System.currentTimeMillis(); + } + } + + public static void confirmExit(ExitPoint ep, BridgeConfig config) { + if (ep == null) { + return; + } + boolean wasAlreadyConfirmed = (confirmedExit != null); + boolean changed = (confirmedExit != ep); + confirmedExit = ep; + exitReached = true; + if ((!wasAlreadyConfirmed || changed) && config != null && config.tccExitPing) { + playDiscoveryPing(); + } + } + + private static void playDiscoveryPing() { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.getSoundManager() != null) { + mc.execute(() -> mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.EXPERIENCE_ORB_PICKUP, 1.25f))); + } + } + + public static void markGolemDefeated(String golemId, BridgeWebSocketClient socket) { + if (golemId == null) { + return; + } + String id = golemId.toUpperCase(); + if (DEFEATED_GOLEMS.add(id)) { + if (socket != null && socket.isConnected()) { + socket.sendTccGolemDefeated(id, getRaidPartyMembers()); + } + } + } + + public static void onPeerPosition(String name, String uuidStr, double x, double y, double z, float yaw, boolean isWings) { + if (!isInRoom2()) { + return; + } + if (x < MIN_X || x > MAX_X || z < MIN_Z || z > MAX_Z) { + return; + } + UUID uuid = null; + if (uuidStr != null && !uuidStr.isBlank()) { + try { + uuid = UUID.fromString(uuidStr); + } catch (IllegalArgumentException ignored) { + } + } + if (uuid == null && name != null) { + uuid = UUID.nameUUIDFromBytes(name.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + if (uuid == null) { + return; + } + + // First player to reach high Y lock-in + if (wingsPlayerUUID == null && (isWings || y >= WINGS_THRESHOLD_Y)) { + wingsPlayerUUID = uuid; + } + boolean wingsRole = uuid.equals(wingsPlayerUUID); + + // If this peer is a ground player, their position can verify dead golems + if (!wingsRole) { + checkTraversalGolems(x, z, null); + } + + PEERS.put(uuid, new PlayerEntry(name != null ? name : "Unknown", uuid, x, y, z, yaw, wingsRole, false)); + PEER_LAST_SEEN.put(uuid, System.currentTimeMillis()); + } + + public static void onPeerGolemDefeated(String golemId) { + if (!isInRoom2()) { + return; + } + if (golemId != null) { + DEFEATED_GOLEMS.add(golemId.toUpperCase()); + } + } + + public static void onPeerExitReached(String sender, String path, double x, double z) { + onPeerExitReached(sender, path, x, z, null); + } + + public static void onPeerExitReached(String sender, String path, double x, double z, BridgeConfig config) { + if (!isInRoom2()) { + return; + } + if (x >= MIN_X && x <= MAX_X && z >= MIN_Z && z <= MAX_Z) { + ExitPoint ep = ExitPoint.fromName(path); + if (ep == null) { + float[] mapPix = worldToMapPixels(x, z); + ep = ExitPoint.findClosest(mapPix[0], mapPix[1]); + } + if (ep == null) { + return; + } + confirmExit(ep, config); + Minecraft mc = Minecraft.getInstance(); + if (mc != null) { + String reporter = (sender != null && !sender.isBlank()) ? sender : "A player"; + Component msg = Component.literal(reporter + " has reported an exit at " + ep.displayName).withStyle(ep.color.chatFormatting); + if (mc.player != null) { + mc.player.displayClientMessage(msg, false); + } + mc.execute(() -> { + if (mc.gui != null) { + mc.gui.setTimes(10, 70, 20); + mc.gui.setSubtitle(Component.empty()); + mc.gui.setTitle(msg); + } + }); + } + } + } + + public static Set getDefeatedGolems() { + return Collections.unmodifiableSet(DEFEATED_GOLEMS); + } + + public static boolean isExitReached() { + return exitReached; + } + + public static ExitPoint getConfirmedExit() { + return confirmedExit; + } + + public static void setConfirmedExitForTest(ExitPoint ep) { + confirmedExit = ep; + } + + public static Collection getPeers() { + return Collections.unmodifiableCollection(PEERS.values()); + } + + public static UUID getWingsPlayerUUID() { + return wingsPlayerUUID; + } + + /** + * Calibrated least-squares affine formula mapping Minecraft world coordinates + * to pixel coordinates on the 556x775 map texture. + */ + public static float[] worldToMapPixels(double worldX, double worldZ) { + double pixelX = -6461.0940 - 0.011214 * worldX + 1.978794 * worldZ; + double pixelY = 21347.6896 - 1.895976 * worldX - 0.048631 * worldZ; + return new float[]{(float) pixelX, (float) pixelY}; + } + + public static void reset() { + DEFEATED_GOLEMS.clear(); + PEERS.clear(); + PEER_LAST_SEEN.clear(); + wingsPlayerUUID = null; + exitReached = false; + confirmedExit = null; + hasExitTitle = false; + lastExitTitleMs = 0L; + lastReportKeyMs = 0L; + lastBroadcastMs = 0L; + lastSentX = 0.0; + lastSentZ = 0.0; + lastSentYaw = 0.0f; + } + + public static boolean hasExitTitle() { + return hasExitTitle; + } + + public static void setHasExitTitleForTest(boolean value) { + hasExitTitle = value; + } + + public static long getLastReportKeyMs() { + return lastReportKeyMs; + } + + public static void setLastReportKeyMsForTest(long ms) { + lastReportKeyMs = ms; + } + + /** + * The raid party the backend scopes TCC relays to. Wynntils is the primary source; without + * it (or before it knows the party) fall back to the party RaidPartyTracker read from the + * hidden {@code /party list} reply, otherwise this player's map events would reach nobody. + */ + public static java.util.List getRaidPartyMembers() { + java.util.Set members = new java.util.LinkedHashSet<>(); + java.util.List wynntilsMembers = tel.eden.mod.party.WynntilsPartyBridge.getPartyMembers(); + if (wynntilsMembers != null) { + for (String m : wynntilsMembers) { + String canon = tel.eden.mod.chat.PlayerNameResolver.canonicalize(m); + if (canon != null && !canon.isBlank()) { + members.add(canon); + } + } + } + for (String m : tel.eden.mod.chat.RaidPartyTracker.partyMembers()) { + String canon = tel.eden.mod.chat.PlayerNameResolver.canonicalize(m); + if (canon != null && !canon.isBlank()) { + members.add(canon); + } + } + for (PlayerEntry peer : PEERS.values()) { + String canon = tel.eden.mod.chat.PlayerNameResolver.canonicalize(peer.name()); + if (canon != null && !canon.isBlank()) { + members.add(canon); + } + } + + Minecraft mc = Minecraft.getInstance(); + String myName = (mc != null && mc.player != null) ? mc.player.getScoreboardName() : null; + String myCanon = (myName != null) ? tel.eden.mod.chat.PlayerNameResolver.canonicalize(myName) : null; + + boolean hasTeammate = false; + for (String m : members) { + if (myCanon == null || !m.equalsIgnoreCase(myCanon)) { + hasTeammate = true; + break; + } + } + + if (!hasTeammate) { + return Collections.emptyList(); + } + + if (myCanon != null && !myCanon.isBlank()) { + members.add(myCanon); + } + return new java.util.ArrayList<>(members); + } +} diff --git a/src/tel/eden/mod/util/PlayerSkinResolver.java b/src/tel/eden/mod/util/PlayerSkinResolver.java new file mode 100644 index 0000000..ea907a8 --- /dev/null +++ b/src/tel/eden/mod/util/PlayerSkinResolver.java @@ -0,0 +1,82 @@ +package tel.eden.mod.util; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.world.entity.player.PlayerSkin; + +/** + * Resolves player skins for HUDs and menus, serving from the local client player list + * or an off-thread Mojang profile cache, falling back to a Steve/Alex skin. + */ +public final class PlayerSkinResolver { + private static final Map RESOLVED_SKINS = new ConcurrentHashMap<>(); + private static final Map SKIN_NEXT_ATTEMPT = new ConcurrentHashMap<>(); + private static final long SKIN_RETRY_MS = 30_000L; + private static final ExecutorService SKIN_WORKER = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "eden-skin-resolver"); + thread.setDaemon(true); + return thread; + }); + + private PlayerSkinResolver() { + } + + public static PlayerSkin skinFor(Minecraft mc, UUID uuid, String name) { + if (uuid == null) { + if (name != null && !name.isBlank()) { + uuid = UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)); + } else { + return DefaultPlayerSkin.getDefaultSkin(); + } + } + if (mc != null && mc.getConnection() != null) { + PlayerInfo info = mc.getConnection().getPlayerInfo(uuid); + if (info == null && name != null && !name.isBlank()) { + info = mc.getConnection().getPlayerInfo(name); + } + if (info != null) { + return info.getSkin(); + } + } + PlayerSkin resolved = RESOLVED_SKINS.get(uuid); + if (resolved != null) { + return resolved; + } + fetchSkin(mc, uuid); + return DefaultPlayerSkin.get(uuid); + } + + private static void fetchSkin(Minecraft mc, UUID uuid) { + if (mc == null) { + return; + } + long now = System.currentTimeMillis(); + Long next = SKIN_NEXT_ATTEMPT.get(uuid); + if (next != null && now < next) { + return; + } + SKIN_NEXT_ATTEMPT.put(uuid, now + SKIN_RETRY_MS); + SKIN_WORKER.submit(() -> { + try { + var result = mc.services().sessionService().fetchProfile(uuid, false); + if (result != null) { + com.mojang.authlib.GameProfile profile = result.profile(); + mc.execute(() -> mc.getSkinManager().get(profile).thenAccept(skin -> skin.ifPresent(value -> RESOLVED_SKINS.put(uuid, value)))); + } + } catch (Exception ignored) { + } + }); + } + + public static void reset() { + RESOLVED_SKINS.clear(); + SKIN_NEXT_ATTEMPT.clear(); + } +} diff --git a/test/tel/eden/mod/net/BridgeWebSocketClientTccTest.java b/test/tel/eden/mod/net/BridgeWebSocketClientTccTest.java new file mode 100644 index 0000000..d8f27e8 --- /dev/null +++ b/test/tel/eden/mod/net/BridgeWebSocketClientTccTest.java @@ -0,0 +1,29 @@ +package tel.eden.mod.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BridgeWebSocketClientTccTest { + @Test + void golemDefeatCarriesThePartySoTheBackendOnlyRelaysToIt() { + JsonObject payload = BridgeWebSocketClient.tccGolemDefeatedPayload("B1", Arrays.asList("Alice", "", null, "Bob")); + assertEquals("tccGolemDefeated", payload.get("type").getAsString()); + assertEquals("B1", payload.get("golemId").getAsString()); + JsonArray party = payload.getAsJsonArray("party"); + assertEquals(2, party.size()); + assertEquals("Alice", party.get(0).getAsString()); + assertEquals("Bob", party.get(1).getAsString()); + } + + @Test + void golemDefeatWithoutAPartyOmitsTheField() { + assertFalse(BridgeWebSocketClient.tccGolemDefeatedPayload("B1", null).has("party")); + assertFalse(BridgeWebSocketClient.tccGolemDefeatedPayload("B1", List.of()).has("party")); + } +} diff --git a/test/tel/eden/mod/raid/tcc/TccLabyrinthTrackerTest.java b/test/tel/eden/mod/raid/tcc/TccLabyrinthTrackerTest.java new file mode 100644 index 0000000..2faf836 --- /dev/null +++ b/test/tel/eden/mod/raid/tcc/TccLabyrinthTrackerTest.java @@ -0,0 +1,273 @@ +package tel.eden.mod.raid.tcc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import net.minecraft.network.chat.Component; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TccLabyrinthTrackerTest { + + @BeforeEach + public void setup() { + TccLabyrinthTracker.reset(); + TccLabyrinthTracker.setDebugForceShow(true); + } + + @Test + public void testGolemCountAndLookup() { + assertEquals(15, TccLabyrinthTracker.Golem.values().length); + assertNotNull(TccLabyrinthTracker.Golem.fromId("B1")); + assertNotNull(TccLabyrinthTracker.Golem.fromId("b2")); + assertNotNull(TccLabyrinthTracker.Golem.fromId("Y3")); + assertNotNull(TccLabyrinthTracker.Golem.fromId("g4")); + assertNotNull(TccLabyrinthTracker.Golem.fromId("R5")); + assertNull(TccLabyrinthTracker.Golem.fromId("Z99")); + } + + @Test + public void testCoordinateProjectionAccuracy() { + // Spawn (10934, 3479) should project near center (302, 446) + float[] spawnPix = TccLabyrinthTracker.worldToMapPixels(10934.0, 3479.0); + assertEquals(300.5f, spawnPix[0], 4.0f); + assertEquals(447.9f, spawnPix[1], 4.0f); + + // B1 (10996, 3477) + float[] b1Pix = TccLabyrinthTracker.worldToMapPixels(10996.0, 3477.0); + assertEquals(300.0f, b1Pix[0], 10.0f); + assertEquals(336.0f, b1Pix[1], 10.0f); + + // Y1 (10937, 3539) + float[] y1Pix = TccLabyrinthTracker.worldToMapPixels(10937.0, 3539.0); + assertEquals(416.0f, y1Pix[0], 10.0f); + assertEquals(441.0f, y1Pix[1], 10.0f); + + // G1 (10934, 3404) + float[] g1Pix = TccLabyrinthTracker.worldToMapPixels(10934.0, 3404.0); + assertEquals(159.0f, g1Pix[0], 12.0f); + assertEquals(444.0f, g1Pix[1], 12.0f); + + // R1 (10889, 3481) + float[] r1Pix = TccLabyrinthTracker.worldToMapPixels(10889.0, 3481.0); + assertEquals(300.0f, r1Pix[0], 12.0f); + } + + @Test + public void testFirstPlayerWingsLockIn() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + // Player 1 reaches Y = 75 (wings) + TccLabyrinthTracker.onPeerPosition("Flyer1", player1.toString(), 10934, 75.0, 3479, 0f, false); + assertEquals(player1, TccLabyrinthTracker.getWingsPlayerUUID()); + + // Player 2 subsequently reaches Y = 80 (should NOT override player 1) + TccLabyrinthTracker.onPeerPosition("Jumper2", player2.toString(), 10934, 80.0, 3479, 0f, false); + assertEquals(player1, TccLabyrinthTracker.getWingsPlayerUUID()); + } + + @Test + public void testGroundPlayerTriggersGolemButWingsDoesNot() { + UUID flyer = UUID.randomUUID(); + UUID ground = UUID.randomUUID(); + + // Lock in flyer + TccLabyrinthTracker.onPeerPosition("Flyer", flyer.toString(), 10934, 75.0, 3479, 0f, false); + + // Flyer moves right over B1 barrier (10998, 3477) away from spawn + TccLabyrinthTracker.onPeerPosition("Flyer", flyer.toString(), 10998, 75.0, 3477, 0f, true); + assertFalse(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + + // Ground player before barrier (towards spawn: 10993, 3477) should NOT trigger + TccLabyrinthTracker.onPeerPosition("Ground", ground.toString(), 10993, 45.0, 3477, 0f, false); + assertFalse(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + + // Ground player steps past B1 barrier (10998, 3477) away from spawn -> triggers! + TccLabyrinthTracker.onPeerPosition("Ground", ground.toString(), 10998, 45.0, 3477, 0f, false); + assertTrue(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + } + + @Test + public void testTraversalZoneExtendsAwayFromSpawnNotTowards() { + UUID player = UUID.randomUUID(); + + // G1 is at 10934, 3404 (North of spawn at 10934, 3479) + // Approaching G1 from spawn (Z = 3406, 2 blocks towards spawn) should NOT trigger + TccLabyrinthTracker.onPeerPosition("Walker", player.toString(), 10934, 45.0, 3406, 0f, false); + assertFalse(TccLabyrinthTracker.getDefeatedGolems().contains("G1")); + + // Moving past G1 barrier away from spawn (Z = 3401, North) triggers defeat + TccLabyrinthTracker.onPeerPosition("Walker", player.toString(), 10934, 45.0, 3401, 0f, false); + assertTrue(TccLabyrinthTracker.getDefeatedGolems().contains("G1")); + } + + @Test + public void testPartyFallsBackToRaidPartyTrackerWithoutWynntils() { + // Wynntils isn't loaded in tests, exactly the case the fallback is for. + tel.eden.mod.chat.RaidPartyTracker.reset(); + assertTrue(TccLabyrinthTracker.getRaidPartyMembers().isEmpty()); + try { + tel.eden.mod.chat.RaidPartyTracker.addPartyMemberForTesting("Alice"); + tel.eden.mod.chat.RaidPartyTracker.addPartyMemberForTesting("Bob"); + assertEquals(java.util.List.of("Alice", "Bob"), TccLabyrinthTracker.getRaidPartyMembers()); + } finally { + tel.eden.mod.chat.RaidPartyTracker.reset(); + } + } + + @Test + public void testFightingAgainstTheBarrierDoesNotTrigger() { + UUID fighter = UUID.randomUUID(); + + // B1's barrier point is 10996, 3477 and its hallway runs east, away from spawn. Standing + // against the bars (slightly before, on, or just under a block past the point) while the + // golem is still alive must not mark it dead. + for (double x : new double[]{10995.9, 10996.0, 10996.5, 10996.9}) { + TccLabyrinthTracker.onPeerPosition("Fighter", fighter.toString(), x, 45.0, 3477, 0f, false); + assertFalse(TccLabyrinthTracker.getDefeatedGolems().contains("B1"), "triggered at x=" + x); + } + + // Over a block past the barrier is only reachable once it has opened. + TccLabyrinthTracker.onPeerPosition("Fighter", fighter.toString(), 10997.2, 45.0, 3477, 0f, false); + assertTrue(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + } + + @Test + public void testExitTitleDetectionWithoutAutoReporting() { + assertFalse(TccLabyrinthTracker.hasExitTitle()); + assertFalse(TccLabyrinthTracker.isExitReached()); + assertNull(TccLabyrinthTracker.getConfirmedExit()); + + TccLabyrinthTracker.onTitle(Component.literal("Waiting for players..."), null); + + assertTrue(TccLabyrinthTracker.hasExitTitle()); + // Auto-detection is removed: title alone does NOT confirm exit! + assertFalse(TccLabyrinthTracker.isExitReached()); + assertNull(TccLabyrinthTracker.getConfirmedExit()); + } + + @Test + public void testReportExitDoesNotRequireTitle() { + TccLabyrinthTracker.setDebugForceShow(true); + TccLabyrinthTracker.setHasExitTitleForTest(false); + + // Without title, reportExit succeeds directly based on look direction + TccLabyrinthTracker.ExitPoint reported = TccLabyrinthTracker.reportExit(10827.0, 3396.0, 0f, null, null); + assertNotNull(reported); + assertEquals(TccLabyrinthTracker.ExitPoint.RR3, reported); + assertEquals(reported, TccLabyrinthTracker.getConfirmedExit()); + assertTrue(TccLabyrinthTracker.isExitReached()); + } + + @Test + public void testLookBasedExitSelection() { + TccLabyrinthTracker.setDebugForceShow(true); + + // 1. Player standing right at RR3 (10827, 3396) + TccLabyrinthTracker.ExitPoint atExit = TccLabyrinthTracker.findClosestLookedAt(10827.0, 3396.0, 180f); + assertEquals(TccLabyrinthTracker.ExitPoint.RR3, atExit); + + // 2. Wings / Center player (10934, 3479) looking East toward Blue (yaw = -90) + TccLabyrinthTracker.ExitPoint lookingBlue = TccLabyrinthTracker.findClosestLookedAt(10934.0, 3479.0, -90f); + assertNotNull(lookingBlue); + assertEquals(TccLabyrinthTracker.ExitColor.BLUE, lookingBlue.color); + + // 3. Wings / Center player looking toward Yellow (South / South-East, yaw = -30) + TccLabyrinthTracker.ExitPoint lookingYellow = TccLabyrinthTracker.findClosestLookedAt(10934.0, 3479.0, -30f); + assertNotNull(lookingYellow); + assertEquals(TccLabyrinthTracker.ExitColor.YELLOW, lookingYellow.color); + + // 4. Wings / Center player looking toward Green (West / North-West, yaw = 135) + TccLabyrinthTracker.ExitPoint lookingGreen = TccLabyrinthTracker.findClosestLookedAt(10934.0, 3479.0, 135f); + assertNotNull(lookingGreen); + assertEquals(TccLabyrinthTracker.ExitColor.GREEN, lookingGreen.color); + } + + @Test + public void testExitPointsCountAndLookup() { + assertEquals(16, TccLabyrinthTracker.ExitPoint.values().length); + + // New 16 exit names + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("RL")); + assertEquals(TccLabyrinthTracker.ExitColor.RED, TccLabyrinthTracker.ExitPoint.fromName("RL").color); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("RR2")); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("RR3")); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("RR4")); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("RR5")); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("Blue left 1")); + assertEquals(TccLabyrinthTracker.ExitColor.BLUE, TccLabyrinthTracker.ExitPoint.fromName("Blue left 1").color); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("Y straight")); + assertEquals(TccLabyrinthTracker.ExitColor.YELLOW, TccLabyrinthTracker.ExitPoint.fromName("Y straight").color); + assertNotNull(TccLabyrinthTracker.ExitPoint.fromName("G left")); + assertEquals(TccLabyrinthTracker.ExitColor.GREEN, TccLabyrinthTracker.ExitPoint.fromName("G left").color); + + // Legacy alias backwards compatibility + assertEquals(TccLabyrinthTracker.ExitPoint.BLUE_RIGHT_2, TccLabyrinthTracker.ExitPoint.fromName("BLUE_TOP_RIGHT")); + assertEquals(TccLabyrinthTracker.ExitPoint.Y_RIGHT, TccLabyrinthTracker.ExitPoint.fromName("yellow_far_right")); + assertEquals(TccLabyrinthTracker.ExitPoint.RR2, TccLabyrinthTracker.ExitPoint.fromName("red_bottom")); + assertNull(TccLabyrinthTracker.ExitPoint.fromName("NON_EXISTENT")); + } + + @Test + public void testExitConfirmationAndReset() { + assertNull(TccLabyrinthTracker.getConfirmedExit()); + assertFalse(TccLabyrinthTracker.isExitReached()); + + TccLabyrinthTracker.confirmExit(TccLabyrinthTracker.ExitPoint.Y_RIGHT, null); + assertEquals(TccLabyrinthTracker.ExitPoint.Y_RIGHT, TccLabyrinthTracker.getConfirmedExit()); + assertTrue(TccLabyrinthTracker.isExitReached()); + + TccLabyrinthTracker.reset(); + assertNull(TccLabyrinthTracker.getConfirmedExit()); + assertFalse(TccLabyrinthTracker.isExitReached()); + } + + @Test + public void testPeerExitReachedConfirmation() { + assertNull(TccLabyrinthTracker.getConfirmedExit()); + TccLabyrinthTracker.onPeerExitReached("Peer1", "RR2", 10804.0, 3449.0); + assertEquals(TccLabyrinthTracker.ExitPoint.RR2, TccLabyrinthTracker.getConfirmedExit()); + assertTrue(TccLabyrinthTracker.isExitReached()); + + // Peer corrects exit to BLUE_LEFT_2 + TccLabyrinthTracker.onPeerExitReached("Peer1", "BLUE_LEFT_2", 11117.0, 3474.0); + assertEquals(TccLabyrinthTracker.ExitPoint.BLUE_LEFT_2, TccLabyrinthTracker.getConfirmedExit()); + } + + @Test + public void testReportExitCooldown() { + assertEquals(0L, TccLabyrinthTracker.getLastReportKeyMs()); + long now = System.currentTimeMillis(); + TccLabyrinthTracker.setLastReportKeyMsForTest(now); + assertEquals(now, TccLabyrinthTracker.getLastReportKeyMs()); + + TccLabyrinthTracker.reset(); + assertEquals(0L, TccLabyrinthTracker.getLastReportKeyMs()); + } + + @Test + public void testResetClearsState() { + TccLabyrinthTracker.markGolemDefeated("B1", null); + TccLabyrinthTracker.confirmExit(TccLabyrinthTracker.ExitPoint.BLUE_RIGHT_2, null); + TccLabyrinthTracker.setHasExitTitleForTest(true); + TccLabyrinthTracker.setLastReportKeyMsForTest(System.currentTimeMillis()); + assertTrue(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + assertTrue(TccLabyrinthTracker.isExitReached()); + assertTrue(TccLabyrinthTracker.hasExitTitle()); + assertTrue(TccLabyrinthTracker.getLastReportKeyMs() > 0L); + assertEquals(TccLabyrinthTracker.ExitPoint.BLUE_RIGHT_2, TccLabyrinthTracker.getConfirmedExit()); + + TccLabyrinthTracker.reset(); + assertFalse(TccLabyrinthTracker.getDefeatedGolems().contains("B1")); + assertFalse(TccLabyrinthTracker.isExitReached()); + assertFalse(TccLabyrinthTracker.hasExitTitle()); + assertEquals(0L, TccLabyrinthTracker.getLastReportKeyMs()); + assertNull(TccLabyrinthTracker.getConfirmedExit()); + assertNull(TccLabyrinthTracker.getWingsPlayerUUID()); + } +} diff --git a/test/tel/eden/mod/util/PlayerSkinResolverTest.java b/test/tel/eden/mod/util/PlayerSkinResolverTest.java new file mode 100644 index 0000000..e46cba1 --- /dev/null +++ b/test/tel/eden/mod/util/PlayerSkinResolverTest.java @@ -0,0 +1,25 @@ +package tel.eden.mod.util; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.world.entity.player.PlayerSkin; +import org.junit.jupiter.api.Test; + +class PlayerSkinResolverTest { + @Test + void returnsDefaultSkinImmediatelyWhenUuidAndNameAreAbsent() { + PlayerSkin expected = DefaultPlayerSkin.getDefaultSkin(); + + PlayerSkin bothNull = PlayerSkinResolver.skinFor(null, null, null); + assertNotNull(bothNull); + assertSame(expected, bothNull, "Must return DefaultPlayerSkin directly when both uuid and name are null"); + + PlayerSkin emptyName = PlayerSkinResolver.skinFor(null, null, ""); + assertSame(expected, emptyName, "Must return DefaultPlayerSkin directly when name is empty"); + + PlayerSkin blankName = PlayerSkinResolver.skinFor(null, null, " "); + assertSame(expected, blankName, "Must return DefaultPlayerSkin directly when name is blank"); + } +}