diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinJoinMultiplayerScreen.java b/src/main/java/top/fpsmaster/mixin/impl/MixinJoinMultiplayerScreen.java new file mode 100644 index 0000000..7dd9c12 --- /dev/null +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinJoinMultiplayerScreen.java @@ -0,0 +1,156 @@ +package top.fpsmaster.mixin.impl; + +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.ServerList; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import top.fpsmaster.multiplayer.ServerBrowser; +import top.fpsmaster.translation.Language; + +/** + * Screen-side half of the promoted/pinned server browser (see {@link ServerBrowser}): kicks the + * async fetch of the promoted list, adds the Pin/Unpin button for player-added servers, blocks + * "Edit" on promoted rows (they are not in servers.dat, editing would change a phantom), and turns + * "Delete" on a promoted row into a local, persistent hide instead of a no-op ServerList removal. + * + *

The list-side half ({@link MixinServerSelectionList}) owns the display order. + */ +@Mixin(JoinMultiplayerScreen.class) +public abstract class MixinJoinMultiplayerScreen extends Screen { + @Shadow + protected ServerSelectionList serverSelectionList; + + @Shadow + private ServerList servers; + + @Shadow + private Button editButton; + + @Unique + private Button fpsmaster$pinButton; + + protected MixinJoinMultiplayerScreen(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void fpsmaster$initServerBrowser(CallbackInfo ci) { + top.fpsmaster.diagnostics.Smoke.mixin("join_multiplayer_screen"); + Component label = Component.literal(Language.Companion.get("multiplayer.pin")); + //? if >=1.20 { + fpsmaster$pinButton = this.addRenderableWidget( + Button.builder(label, button -> fpsmaster$togglePin()).bounds(this.width - 54, 6, 50, 20).build()); + //?} else { + /*fpsmaster$pinButton = this.addRenderableWidget( + new Button(this.width - 54, 6, 50, 20, label, button -> fpsmaster$togglePin())); + *///?} + fpsmaster$updatePinButton(); + // The fetch callback runs on the HTTP executor; hop to the client thread and only touch the + // list if this screen is still the one on display (the fetch can outlive it). + ServerBrowser.INSTANCE.refreshAsync(() -> this.minecraft.execute(() -> { + //? if >=26 { + /*if (this.minecraft.gui.screen() != (Object) this) { + *///?} else { + if (this.minecraft.screen != (Object) this) { + //?} + return; + } + this.serverSelectionList.updateOnlineServers(this.servers); + })); + } + + /** + * Deleting a promoted row must not fall through to vanilla: the promoted ServerData is not in + * {@code servers}, so vanilla's remove+save would silently do nothing and the row would come + * back on refresh. Record the hide locally (persisted) and rebuild the list instead. + */ + @Inject(method = "deleteCallback", at = @At("HEAD"), cancellable = true) + private void fpsmaster$hidePromotedInsteadOfDelete(boolean confirmed, CallbackInfo ci) { + ServerSelectionList.Entry selected = this.serverSelectionList.getSelected(); + if (!(selected instanceof ServerSelectionList.OnlineServerEntry entry) + || !ServerBrowser.INSTANCE.shownAsPromoted(entry.getServerData().ip)) { + return; + } + if (confirmed) { + ServerBrowser.INSTANCE.hidePromoted(entry.getServerData().ip); + this.serverSelectionList.setSelected(null); + this.serverSelectionList.updateOnlineServers(this.servers); + } + // Same screen restore vanilla does after the confirm dialog. + //? if >=26 { + /*this.minecraft.gui.setScreen(this); + *///?} else { + this.minecraft.setScreen(this); + //?} + ci.cancel(); + } + + @Inject(method = "onSelectedChange", at = @At("TAIL")) + private void fpsmaster$adjustButtonsForPromoted(CallbackInfo ci) { + ServerSelectionList.Entry selected = this.serverSelectionList.getSelected(); + if (selected instanceof ServerSelectionList.OnlineServerEntry entry + && ServerBrowser.INSTANCE.shownAsPromoted(entry.getServerData().ip)) { + this.editButton.active = false; + } + fpsmaster$updatePinButton(); + } + + @Unique + private void fpsmaster$togglePin() { + ServerSelectionList.Entry selected = this.serverSelectionList.getSelected(); + if (!(selected instanceof ServerSelectionList.OnlineServerEntry entry)) { + return; + } + ServerData data = entry.getServerData(); + if (ServerBrowser.INSTANCE.shownAsPromoted(data.ip)) { + return; + } + ServerBrowser.INSTANCE.togglePin(data.ip); + this.serverSelectionList.updateOnlineServers(this.servers); + fpsmaster$reselect(data); + } + + /** + * {@code updateOnlineServers} rebuilds every entry object, so the previous selection points at + * an entry that is no longer displayed. Re-select the row backing the same ServerData; the + * {@code setSelected} call also re-runs {@code onSelectedChange}, refreshing the button states. + */ + @Unique + private void fpsmaster$reselect(ServerData data) { + for (ServerSelectionList.Entry child : this.serverSelectionList.children()) { + if (child instanceof ServerSelectionList.OnlineServerEntry entry && entry.getServerData() == data) { + this.serverSelectionList.setSelected(child); + return; + } + } + this.serverSelectionList.setSelected(null); + } + + @Unique + private void fpsmaster$updatePinButton() { + // onSelectedChange can run from vanilla init before our init tail created the button. + if (fpsmaster$pinButton == null) { + return; + } + ServerSelectionList.Entry selected = this.serverSelectionList.getSelected(); + boolean pinnable = false; + boolean pinned = false; + if (selected instanceof ServerSelectionList.OnlineServerEntry entry) { + String address = entry.getServerData().ip; + pinnable = !ServerBrowser.INSTANCE.shownAsPromoted(address); + pinned = pinnable && ServerBrowser.INSTANCE.isPinned(address); + } + fpsmaster$pinButton.active = pinnable; + fpsmaster$pinButton.setMessage( + Component.literal(Language.Companion.get(pinned ? "multiplayer.unpin" : "multiplayer.pin"))); + } +} diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java new file mode 100644 index 0000000..18a27e4 --- /dev/null +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java @@ -0,0 +1,82 @@ +package top.fpsmaster.mixin.impl; + +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.ServerList; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; +import top.fpsmaster.multiplayer.ServerBrowser; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Feeds {@code updateOnlineServers} the arranged display order from {@link ServerBrowser} + * (pinned player servers, then promoted servers, then the rest) instead of raw servers.dat order. + * + *

Only the loop's data source is swapped ({@code ServerList.size/get} redirects): the entries + * are still vanilla {@code OnlineServerEntry} objects, so promoted rows ping, join and render like + * any other server, and player rows keep their real ServerList-backed ServerData (delete/move still + * operate on servers.dat). Promoted ServerData never enters {@code ServerList}, so nothing promoted + * can leak into servers.dat through any vanilla save path. + */ +@Mixin(ServerSelectionList.class) +public class MixinServerSelectionList { + @Unique + private List fpsmaster$arranged; + + /** + * Promoted rows keyed by their backend snapshot. Recreating the ServerData on every rearrange + * would reset {@code pinged} and flash "Pinging..." on each pin/hide; reusing the instance + * keeps the ping state. A changed backend entry (name/address) is a different key, so it gets + * a fresh ServerData. + */ + @Unique + private static final Map fpsmaster$promotedData = new HashMap<>(); + + @Inject(method = "updateOnlineServers", at = @At("HEAD")) + private void fpsmaster$arrangeServers(ServerList list, CallbackInfo ci) { + top.fpsmaster.diagnostics.Smoke.mixin("server_selection_list"); + List userServers = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + userServers.add(list.get(i)); + } + fpsmaster$arranged = ServerBrowser.INSTANCE.arrange( + userServers, + data -> data.ip, + MixinServerSelectionList::fpsmaster$promotedServerData); + } + + @Redirect( + method = "updateOnlineServers", + at = @At(value = "INVOKE", target = "net/minecraft/client/multiplayer/ServerList.size()I") + ) + private int fpsmaster$arrangedSize(ServerList list) { + return fpsmaster$arranged.size(); + } + + @Redirect( + method = "updateOnlineServers", + at = @At(value = "INVOKE", target = "net/minecraft/client/multiplayer/ServerList.get(I)Lnet/minecraft/client/multiplayer/ServerData;") + ) + private ServerData fpsmaster$arrangedEntry(ServerList list, int index) { + return fpsmaster$arranged.get(index); + } + + @Unique + private static ServerData fpsmaster$promotedServerData(ServerBrowser.PromotedServer promoted) { + return fpsmaster$promotedData.computeIfAbsent(promoted, key -> { + //? if >=1.21 { + return new ServerData(key.getName(), key.getAddress(), ServerData.Type.OTHER); + //?} else { + /*return new ServerData(key.getName(), key.getAddress(), false);*/ + //?} + }); + } +} diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionListEntry.java b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionListEntry.java new file mode 100644 index 0000000..340f105 --- /dev/null +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionListEntry.java @@ -0,0 +1,103 @@ +package top.fpsmaster.mixin.impl; + +//? if <1.20 { +/*import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.gui.GuiComponent; +*///?} +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +//? if >=26 { +/*import net.minecraft.client.gui.GuiGraphicsExtractor; +*///?} +//? if >=1.21.11 && <26 { +import net.minecraft.client.gui.GuiGraphics; +//?} +//? if >=1.20 && <1.21.11 { +/*import net.minecraft.client.gui.GuiGraphics; +*///?} +import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; +import net.minecraft.client.multiplayer.ServerData; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import top.fpsmaster.multiplayer.ServerBrowser; +import top.fpsmaster.translation.Language; + +/** + * Draws the row badge that tells the two special server kinds apart: gold "Featured" on promoted + * rows, aqua "Pinned" on player-pinned rows. Text-only, right-aligned just before the status icon, + * drawn with the same per-version text call the row itself uses - no new visual language. + */ +@Mixin(ServerSelectionList.OnlineServerEntry.class) +public abstract class MixinServerSelectionListEntry { + @Shadow + @Final + private ServerData serverData; + + //? if >=26 { + /*@Inject(method = "extractContent", at = @At("TAIL")) + private void fpsmaster$drawBadge(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, boolean hovered, float partialTick, CallbackInfo ci) { + String tag = fpsmaster$badgeText(); + if (tag == null) { + return; + } + ServerSelectionList.Entry self = (ServerSelectionList.Entry) (Object) this; + Font font = Minecraft.getInstance().font; + guiGraphics.text(font, tag, self.getContentRight() - 19 - font.width(tag), self.getContentY() + 1, fpsmaster$badgeColor()); + } + *///?} elif >=1.21.11 { + @Inject(method = "renderContent", at = @At("TAIL")) + private void fpsmaster$drawBadge(GuiGraphics guiGraphics, int mouseX, int mouseY, boolean hovered, float partialTick, CallbackInfo ci) { + String tag = fpsmaster$badgeText(); + if (tag == null) { + return; + } + ServerSelectionList.Entry self = (ServerSelectionList.Entry) (Object) this; + Font font = Minecraft.getInstance().font; + guiGraphics.drawString(font, tag, self.getContentRight() - 19 - font.width(tag), self.getContentY() + 1, fpsmaster$badgeColor()); + } + //?} elif >=1.20 { + /*@Inject(method = "render", at = @At("TAIL")) + private void fpsmaster$drawBadge(GuiGraphics guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovered, float partialTick, CallbackInfo ci) { + String tag = fpsmaster$badgeText(); + if (tag == null) { + return; + } + Font font = Minecraft.getInstance().font; + guiGraphics.drawString(font, tag, left + width - 19 - font.width(tag), top + 1, fpsmaster$badgeColor()); + } + *///?} else { + /*@Inject(method = "render", at = @At("TAIL")) + private void fpsmaster$drawBadge(PoseStack poseStack, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovered, float partialTick, CallbackInfo ci) { + String tag = fpsmaster$badgeText(); + if (tag == null) { + return; + } + Font font = Minecraft.getInstance().font; + GuiComponent.drawString(poseStack, font, tag, left + width - 19 - font.width(tag), top + 1, fpsmaster$badgeColor()); + } + *///?} + + @Unique + private String fpsmaster$badgeText() { + if (ServerBrowser.INSTANCE.shownAsPromoted(serverData.ip)) { + top.fpsmaster.diagnostics.Smoke.feature("multiplayer_badge_promoted"); + return Language.Companion.get("multiplayer.badge.promoted"); + } + if (ServerBrowser.INSTANCE.isPinned(serverData.ip)) { + top.fpsmaster.diagnostics.Smoke.feature("multiplayer_badge_pinned"); + return Language.Companion.get("multiplayer.badge.pinned"); + } + return null; + } + + /** Valid only while {@link #fpsmaster$badgeText()} is non-null. Full alpha, per HUD color rules. */ + @Unique + private int fpsmaster$badgeColor() { + return ServerBrowser.INSTANCE.shownAsPromoted(serverData.ip) ? 0xFFFFAA00 : 0xFF55FFFF; + } +} diff --git a/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt b/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt index d5773a5..e95b19d 100644 --- a/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt +++ b/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt @@ -24,6 +24,7 @@ object FPSMasterApiClient { private val LOADOUT_RESOLVE = ApiBase.v1("/cosmetics/loadouts/resolve") private val LINK_CHALLENGE = ApiBase.v1("/me/minecraft-links/challenge") private val LINK_CONFIRM = ApiBase.v1("/me/minecraft-links/confirm") + private val LAUNCHER_SERVERS = ApiBase.v1("/launcher/servers") /** Backend cap for one resolve call. */ const val RESOLVE_BATCH_LIMIT = 200 @@ -394,6 +395,12 @@ object FPSMasterApiClient { .thenApply { response -> parseResponse(response, Array::class.java) } } + /** 官方推荐/合作服务器列表(公开端点,后端只返回启用中的条目)。 */ + fun getLauncherServers(): CompletableFuture>> { + return sendGet(LAUNCHER_SERVERS, authenticated = false) + .thenApply { response -> parseResponse(response, Array::class.java) } + } + fun createMinecraftLinkChallenge(): CompletableFuture> { return sendJson(LINK_CHALLENGE, null, authenticated = true) .thenApply { response -> parseResponse(response, MinecraftLinkChallengeView::class.java) } @@ -651,6 +658,15 @@ data class ResolvedLoadoutView( val loadout: CosmeticLoadoutView? = null ) +/** `GET /api/v1/launcher/servers` 的单项:官方推荐/合作服务器。未知新字段由 Gson 忽略。 */ +data class LauncherServerView( + val id: String? = null, + val name: String? = null, + val address: String? = null, + val description: String? = null, + val active: Boolean = true +) + /** Challenge to prove Minecraft account ownership: join [serverId] with the Mojang session, then confirm. */ data class MinecraftLinkChallengeView( val challengeId: String = "", diff --git a/src/main/kotlin/top/fpsmaster/config/ServerBrowserPrefs.kt b/src/main/kotlin/top/fpsmaster/config/ServerBrowserPrefs.kt new file mode 100644 index 0000000..0e90e14 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/config/ServerBrowserPrefs.kt @@ -0,0 +1,98 @@ +package top.fpsmaster.config + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParseException +import top.fpsmaster.logger +import top.fpsmaster.mc +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.reader + +/** + * 多人游戏服务器浏览器的本地偏好:被玩家删掉的官方推荐服(按后端 id,老数据可能是地址), + * 以及玩家置顶的自建服地址。独立于配置档案存放(`fpsmaster/server_browser.json`)—— + * 「删过的推荐服不再出现」是设备级决定,不该跟着配置档案切换而回来。 + * + * 地址的归一化在 [top.fpsmaster.multiplayer.ServerBrowser] 做,这里只存取已归一化的字符串。 + */ +object ServerBrowserPrefs { + private val gson = GsonBuilder().setPrettyPrinting().create() + private var loaded = false + private val hiddenPromoted = LinkedHashSet() + private val pinnedAddresses = LinkedHashSet() + + fun isHidden(key: String): Boolean { + ensureLoaded() + return key in hiddenPromoted + } + + fun hide(key: String) { + ensureLoaded() + if (hiddenPromoted.add(key)) { + save() + } + } + + fun isPinned(address: String): Boolean { + ensureLoaded() + return address in pinnedAddresses + } + + fun setPinned(address: String, pinned: Boolean) { + ensureLoaded() + val changed = if (pinned) pinnedAddresses.add(address) else pinnedAddresses.remove(address) + if (changed) { + save() + } + } + + private fun ensureLoaded() { + if (loaded) { + return + } + loaded = true + val path = filePath() + if (!path.exists() || !path.isRegularFile()) { + return + } + try { + val file = path.reader().use { gson.fromJson(it, PrefsFile::class.java) } ?: return + hiddenPromoted.addAll(file.hiddenPromoted.filter { it.isNotBlank() }) + pinnedAddresses.addAll(file.pinnedAddresses.filter { it.isNotBlank() }) + } catch (exception: JsonParseException) { + logger.warn("Server browser prefs file is invalid, starting fresh: {}", path, exception) + } + } + + private fun save() { + val path = filePath() + Files.createDirectories(path.parent) + // 与 ConfigManager 同一套「临时文件 + 原子替换」,坏一半的写入不会毁掉整份偏好。 + val temp = path.resolveSibling("${path.fileName}.${System.nanoTime()}.tmp") + try { + Files.writeString(temp, gson.toJson(PrefsFile(hiddenPromoted.toList(), pinnedAddresses.toList()))) + try { + Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (exception: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING) + } + } catch (exception: java.io.IOException) { + logger.warn("Failed to save server browser prefs to {}", path, exception) + } finally { + temp.deleteIfExists() + } + } + + private fun filePath(): Path { + return mc.gameDirectory.toPath().resolve("fpsmaster").resolve("server_browser.json") + } + + private data class PrefsFile( + val hiddenPromoted: List = emptyList(), + val pinnedAddresses: List = emptyList() + ) +} diff --git a/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt b/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt new file mode 100644 index 0000000..b6dc596 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt @@ -0,0 +1,171 @@ +package top.fpsmaster.multiplayer + +import top.fpsmaster.auth.FPSMasterApiClient +import top.fpsmaster.config.ServerBrowserPrefs +import top.fpsmaster.logger +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Function + +/** + * 原版多人游戏界面(JoinMultiplayerScreen)的 FPSMaster 增强:后端推荐服置顶展示、 + * 推荐服可删(本地隐藏、重启仍生效)、玩家可置顶自建服。 + * + * 这里是全部业务决策——排序、同地址去重、隐藏与置顶的判定;各版本 mixin 只负责把 + * 结果注入 vanilla 列表。所有读写都发生在客户端主线程(mixin 的调用点都在渲染/点击 + * 路径上),只有 [promoted] 缓存由 HTTP 回调线程写入,故为 @Volatile。 + */ +object ServerBrowser { + data class PromotedServer( + val id: String, + val name: String, + val address: String, + val description: String + ) + + @Volatile + private var promoted: List = emptyList() + private val fetchInFlight = AtomicBoolean(false) + + @Volatile + private var lastFetchAt = 0L + + /** + * 异步拉一次推荐服列表;结果与缓存不同的时候在 HTTP 回调线程上跑 [onChanged] + * (调用方负责切回客户端线程)。请求失败或后端返回异常时缓存原样保留—— + * 玩家自己的服务器列表不受任何影响。 + * + * 30 秒节流:调用点在 `JoinMultiplayerScreen.init`,而 MC 每次改窗口大小都会重跑 + * init,不节流的话拖一下窗口就是一串请求(同 FPSMasterApiClient.refreshProfileIfStale)。 + */ + fun refreshAsync(onChanged: Runnable) { + val last = lastFetchAt + if (last != 0L && System.currentTimeMillis() - last in 0 until 30_000) { + return + } + if (!fetchInFlight.compareAndSet(false, true)) { + return + } + lastFetchAt = System.currentTimeMillis() + // 请求构造是同步跑在调用者线程上的(URI 解析等),当场抛的话闸门必须放开, + // 而调用点是 Screen.init —— 异常漏出去就是一份 crash report。 + val pending = try { + FPSMasterApiClient.getLauncherServers() + } catch (exception: Exception) { + fetchInFlight.set(false) + logger.warn("Promoted server list request could not be started", exception) + return + } + pending.whenComplete { result, exception -> + fetchInFlight.set(false) + if (exception != null) { + logger.warn("Promoted server list fetch failed", exception) + return@whenComplete + } + if (result?.success != true || result.data == null) { + logger.warn("Promoted server list fetch rejected: {}", result?.message ?: "no response") + return@whenComplete + } + val fresh = result.data + .filter { it.active && !it.address.isNullOrBlank() } + .map { + PromotedServer( + id = it.id.orEmpty(), + name = it.name?.takeIf { name -> name.isNotBlank() } ?: it.address.orEmpty(), + address = it.address.orEmpty(), + description = it.description.orEmpty() + ) + } + if (fresh != promoted) { + promoted = fresh + onChanged.run() + } + } + } + + /** 当前应当展示的推荐服:active、未被本地隐藏,保持后端顺序。 */ + fun visiblePromoted(): List { + return promoted.filter { !isHidden(it) } + } + + /** + * 这个地址在列表里是不是以「推荐服」的身份展示。 + * 玩家置顶的同地址自建服优先(同地址去重规则),所以被置顶的地址永远不算推荐。 + */ + fun shownAsPromoted(address: String?): Boolean { + val key = normalize(address) + if (key.isEmpty() || isPinned(address)) { + return false + } + return visiblePromoted().any { normalize(it.address) == key } + } + + fun isPinned(address: String?): Boolean { + val key = normalize(address) + return key.isNotEmpty() && ServerBrowserPrefs.isPinned(key) + } + + fun togglePin(address: String?) { + val key = normalize(address) + if (key.isNotEmpty()) { + ServerBrowserPrefs.setPinned(key, !ServerBrowserPrefs.isPinned(key)) + } + } + + /** + * 玩家删除了这个地址上展示的推荐服:按后端 id 记住(id 缺失时退回地址),重启仍隐藏。 + */ + fun hidePromoted(address: String?) { + val key = normalize(address) + if (key.isEmpty()) { + return + } + val target = visiblePromoted().firstOrNull { normalize(it.address) == key } ?: return + ServerBrowserPrefs.hide(target.id.ifBlank { key }) + } + + /** + * 计算多人列表的展示顺序:玩家置顶的自建服(保持 servers.dat 顺序)→ 未隐藏的推荐服 + * (保持后端顺序)→ 其余自建服。同地址去重:被置顶的地址不再出推荐条目;未置顶但与 + * 推荐服同地址的自建服则让位给推荐条目(隐藏该推荐服后自建服会回来)。 + * + * 泛型 + 工厂是为了让排序逻辑独立于各 MC 版本的条目类型:mixin 提供「条目 → 地址」 + * 和「推荐服 → 条目」两个函数,业务判断全部留在这里。 + */ + fun arrange( + userEntries: List, + addressOf: Function, + promotedEntryFactory: Function + ): List { + val pinned = ArrayList() + val rest = ArrayList() + for (entry in userEntries) { + if (isPinned(addressOf.apply(entry))) { + pinned.add(entry) + } else { + rest.add(entry) + } + } + val pinnedAddresses = pinned.mapTo(HashSet()) { normalize(addressOf.apply(it)) } + val shownPromoted = visiblePromoted().filter { normalize(it.address) !in pinnedAddresses } + val promotedAddresses = shownPromoted.mapTo(HashSet()) { normalize(it.address) } + val shownRest = rest.filter { normalize(addressOf.apply(it)) !in promotedAddresses } + + val arranged = ArrayList(pinned.size + shownPromoted.size + shownRest.size) + arranged.addAll(pinned) + shownPromoted.mapTo(arranged) { promotedEntryFactory.apply(it) } + arranged.addAll(shownRest) + return arranged + } + + private fun isHidden(server: PromotedServer): Boolean { + return (server.id.isNotBlank() && ServerBrowserPrefs.isHidden(server.id)) || + ServerBrowserPrefs.isHidden(normalize(server.address)) + } + + /** 玩家手输的地址和后端下发的地址要能对上:大小写、空白、显式默认端口都不算差别。 */ + private fun normalize(address: String?): String { + val trimmed = address?.trim()?.lowercase(Locale.ROOT) ?: return "" + return trimmed.removeSuffix(":25565") + } +} diff --git a/src/main/resources/assets/fpsmaster/lang/en_us.lang b/src/main/resources/assets/fpsmaster/lang/en_us.lang index 0447ba5..3f507df 100644 --- a/src/main/resources/assets/fpsmaster/lang/en_us.lang +++ b/src/main/resources/assets/fpsmaster/lang/en_us.lang @@ -49,6 +49,10 @@ multiplayer.edit=Edit multiplayer.delete=Delete multiplayer.refresh=Refresh multiplayer.back=Back +multiplayer.pin=Pin +multiplayer.unpin=Unpin +multiplayer.badge.promoted=Featured +multiplayer.badge.pinned=Pinned backgroundselector.title=Background Settings backgroundselector.pick=Choose Image backgroundselector.preview.image=IMG diff --git a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang index 8d0c950..197c826 100644 --- a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang +++ b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang @@ -50,6 +50,10 @@ multiplayer.edit=编辑 multiplayer.delete=删除 multiplayer.refresh=刷新 multiplayer.back=返回 +multiplayer.pin=置顶 +multiplayer.unpin=取消置顶 +multiplayer.badge.promoted=推荐 +multiplayer.badge.pinned=已置顶 backgroundselector.title=背景设置 backgroundselector.pick=选择图片 backgroundselector.preview.image=IMG diff --git a/src/main/resources/fpsmaster-1.19.2.mixins.json b/src/main/resources/fpsmaster-1.19.2.mixins.json index 51a6c8f..ddf6c33 100644 --- a/src/main/resources/fpsmaster-1.19.2.mixins.json +++ b/src/main/resources/fpsmaster-1.19.2.mixins.json @@ -33,6 +33,7 @@ "MixinItemEntityRenderer", "MixinItemInHandLayer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -52,6 +53,8 @@ "MixinReplayPacketCapture", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTitleScreenBackground", diff --git a/src/main/resources/fpsmaster-1.20.1.mixins.json b/src/main/resources/fpsmaster-1.20.1.mixins.json index 266c02b..6c87b83 100644 --- a/src/main/resources/fpsmaster-1.20.1.mixins.json +++ b/src/main/resources/fpsmaster-1.20.1.mixins.json @@ -34,6 +34,7 @@ "MixinItemEntityRenderer", "MixinItemInHandLayer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -53,6 +54,8 @@ "MixinReplayPacketCapture", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTitleScreenBackground", diff --git a/src/main/resources/fpsmaster-1.21.1.mixins.json b/src/main/resources/fpsmaster-1.21.1.mixins.json index 0f6a55b..0d900c4 100644 --- a/src/main/resources/fpsmaster-1.21.1.mixins.json +++ b/src/main/resources/fpsmaster-1.21.1.mixins.json @@ -34,6 +34,7 @@ "MixinItemEntityRenderer", "MixinItemInHandLayer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -53,6 +54,8 @@ "MixinReplayPacketCapture", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTntRenderer", diff --git a/src/main/resources/fpsmaster-1.21.8.mixins.json b/src/main/resources/fpsmaster-1.21.8.mixins.json index f5f7f2d..e35739e 100644 --- a/src/main/resources/fpsmaster-1.21.8.mixins.json +++ b/src/main/resources/fpsmaster-1.21.8.mixins.json @@ -37,6 +37,7 @@ "MixinItemEntityRenderer", "MixinItemInHandLayer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -58,6 +59,8 @@ "MixinReplayPacketCapture", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTntRenderer", diff --git a/src/main/resources/fpsmaster-26.2.mixins.json b/src/main/resources/fpsmaster-26.2.mixins.json index 1d21e72..f4a84fc 100644 --- a/src/main/resources/fpsmaster-26.2.mixins.json +++ b/src/main/resources/fpsmaster-26.2.mixins.json @@ -41,6 +41,7 @@ "MixinItemEntityRenderState", "MixinItemEntityRenderer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -63,6 +64,8 @@ "MixinReplayPacketCapture", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTntRenderer", diff --git a/src/main/resources/fpsmaster.mixins.json b/src/main/resources/fpsmaster.mixins.json index 8ba1587..e827e1b 100644 --- a/src/main/resources/fpsmaster.mixins.json +++ b/src/main/resources/fpsmaster.mixins.json @@ -39,6 +39,7 @@ "MixinItemEntityRenderer", "MixinItemInHandLayer", "MixinItemInHandRenderer", + "MixinJoinMultiplayerScreen", "MixinKeyMapping", "MixinLevelRenderer", "MixinLightTexture", @@ -58,6 +59,8 @@ "MixinRenderType", "MixinScreen", "MixinScreenEffectRenderer", + "MixinServerSelectionList", + "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", "MixinTntRenderer",