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",