diff --git a/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java b/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java new file mode 100644 index 00000000..26632804 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java @@ -0,0 +1,258 @@ +package top.fpsmaster.modules.client.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import top.fpsmaster.exception.FileException; +import top.fpsmaster.modules.client.api.model.ApiResponse; +import top.fpsmaster.modules.client.api.model.FPSMasterGson; +import top.fpsmaster.modules.client.api.model.PromotedServerView; +import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.utils.io.FileUtils; +import top.fpsmaster.utils.io.HttpRequest; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Featured/partner servers for the multiplayer screen, backed by + * {@code GET /api/v1/launcher/servers}, plus the player's local overrides: promoted entries the + * player deleted (never inserted again) and the player's own pinned servers. + * + *

The fetch is fully asynchronous — the GUI only ever reads the cached snapshot via + * {@link #promotedServers()} and watches {@link #revision()} for changes, so an offline client + * simply keeps an empty promoted list and the local server list stays usable. + * + *

Overrides persist in a sidecar JSON ({@code promoted_servers.json} in the client data dir); + * {@code servers.dat} is never touched with new keys. + */ +public final class PromotedServersService { + private static final PromotedServersService INSTANCE = new PromotedServersService(); + private static final String SIDECAR_FILE = "promoted_servers.json"; + private static final String HIDDEN_KEY = "hiddenPromoted"; + private static final String PINNED_KEY = "pinnedAddresses"; + /** Hidden entries without a backend id fall back to the address, marked with this prefix. */ + private static final String ADDRESS_KEY_PREFIX = "addr:"; + private static final long REFRESH_INTERVAL_MS = 60_000L; + private static final String SERVERS_ENDPOINT = + FPSMasterConstants.API_BASE_URL + FPSMasterConstants.API_VERSION + "/launcher/servers"; + + private volatile List promoted = Collections.emptyList(); + private final AtomicLong revision = new AtomicLong(0); + private final AtomicBoolean fetching = new AtomicBoolean(false); + private volatile long lastFetchAt = 0L; + + private final Object stateLock = new Object(); + private boolean stateLoaded; + private final Set hiddenKeys = new HashSet<>(); + private final Set pinnedAddresses = new HashSet<>(); + + private PromotedServersService() { + } + + public static PromotedServersService getInstance() { + return INSTANCE; + } + + /** Bumped once per successful fetch; the GUI rebuilds its entry list when it changes. */ + public long revision() { + return revision.get(); + } + + /** Cached snapshot in backend order; never blocks, empty until a fetch succeeds. */ + public List promotedServers() { + return promoted; + } + + /** + * Kicks off a background fetch unless one is in flight or a recent one already ran. + * Safe to call from {@code initGui()} — resizing the window re-runs it every time. + */ + public void refreshIfStale() { + long now = System.currentTimeMillis(); + long last = lastFetchAt; + if (last != 0L && now - last >= 0L && now - last < REFRESH_INTERVAL_MS) { + return; + } + if (!fetching.compareAndSet(false, true)) { + return; + } + lastFetchAt = now; + CompletableFuture.runAsync(this::fetch).whenComplete((unused, error) -> { + fetching.set(false); + if (error != null) { + ClientLogger.warn("Promoted servers refresh failed: " + error); + } + }); + } + + private void fetch() { + try { + Map headers = new HashMap<>(); + headers.put("User-Agent", FPSMasterConstants.USER_AGENT); + headers.put("Accept", "application/json"); + HttpRequest.HttpResponseResult response = HttpRequest.get(SERVERS_ENDPOINT, headers); + if (!response.isSuccess() || response.getBody() == null || response.getBody().isEmpty()) { + ClientLogger.warn("Promoted servers request failed: HTTP " + response.getStatusCode()); + return; + } + JsonObject json = FPSMasterGson.getInstance().fromJson(response.getBody(), JsonObject.class); + ApiResponse apiResponse = + ApiResponse.fromJson(json, PromotedServerView[].class, response.getStatusCode()); + if (!apiResponse.isSuccess() || apiResponse.getData() == null) { + ClientLogger.warn("Promoted servers response rejected: " + apiResponse.getMessage()); + return; + } + List fetched = new ArrayList<>(); + for (PromotedServerView view : apiResponse.getData()) { + if (view != null && view.getAddress() != null && !view.getAddress().trim().isEmpty()) { + fetched.add(view); + } + } + promoted = Collections.unmodifiableList(fetched); + revision.incrementAndGet(); + } catch (IOException e) { + ClientLogger.warn("Promoted servers request failed: " + e.getMessage()); + } catch (RuntimeException e) { + ClientLogger.warn("Promoted servers response could not be parsed: " + e.getMessage()); + } + } + + // ================== Local overrides ================== // + + public boolean isHidden(PromotedServerView view) { + synchronized (stateLock) { + ensureStateLoaded(); + return hiddenKeys.contains(hideKey(view)); + } + } + + /** + * Records the promoted server matching {@code address} as deleted, so it is never inserted + * into the list again. Prefers the backend id; falls back to the address when absent. + */ + public void hideByAddress(String address) { + String normalized = normalizeAddress(address); + if (normalized.isEmpty()) { + return; + } + String key = ADDRESS_KEY_PREFIX + normalized; + for (PromotedServerView view : promoted) { + if (normalizeAddress(view.getAddress()).equals(normalized)) { + key = hideKey(view); + break; + } + } + synchronized (stateLock) { + ensureStateLoaded(); + if (hiddenKeys.add(key)) { + saveState(); + } + } + } + + public boolean isPinned(String address) { + String normalized = normalizeAddress(address); + if (normalized.isEmpty()) { + return false; + } + synchronized (stateLock) { + ensureStateLoaded(); + return pinnedAddresses.contains(normalized); + } + } + + /** Pin state for the player's own servers, keyed by address. */ + public void setPinned(String address, boolean pinned) { + String normalized = normalizeAddress(address); + if (normalized.isEmpty()) { + return; + } + synchronized (stateLock) { + ensureStateLoaded(); + boolean changed = pinned ? pinnedAddresses.add(normalized) : pinnedAddresses.remove(normalized); + if (changed) { + saveState(); + } + } + } + + public static String normalizeAddress(String address) { + return address == null ? "" : address.trim().toLowerCase(Locale.ROOT); + } + + private String hideKey(PromotedServerView view) { + if (view.getId() != null && !view.getId().trim().isEmpty()) { + return view.getId().trim(); + } + return ADDRESS_KEY_PREFIX + normalizeAddress(view.getAddress()); + } + + /** Callers hold {@code stateLock}. */ + private void ensureStateLoaded() { + if (stateLoaded) { + return; + } + stateLoaded = true; + try { + String content = FileUtils.readFile(SIDECAR_FILE); + if (content == null || content.trim().isEmpty()) { + return; + } + JsonObject json = FPSMasterGson.getInstance().fromJson(content, JsonObject.class); + if (json == null) { + return; + } + readStrings(json, HIDDEN_KEY, hiddenKeys); + readStrings(json, PINNED_KEY, pinnedAddresses); + } catch (FileException e) { + ClientLogger.warn("Failed to read promoted servers state: " + e.getMessage()); + } catch (RuntimeException e) { + ClientLogger.warn("Promoted servers state is invalid JSON, starting fresh: " + e.getMessage()); + } + } + + private void readStrings(JsonObject json, String key, Set target) { + if (!json.has(key) || !json.get(key).isJsonArray()) { + return; + } + for (JsonElement element : json.getAsJsonArray(key)) { + if (element.isJsonPrimitive()) { + String value = element.getAsString(); + if (value != null && !value.isEmpty()) { + target.add(value); + } + } + } + } + + /** Callers hold {@code stateLock}. */ + private void saveState() { + JsonObject json = new JsonObject(); + JsonArray hidden = new JsonArray(); + for (String key : hiddenKeys) { + hidden.add(new com.google.gson.JsonPrimitive(key)); + } + json.add(HIDDEN_KEY, hidden); + JsonArray pinned = new JsonArray(); + for (String address : pinnedAddresses) { + pinned.add(new com.google.gson.JsonPrimitive(address)); + } + json.add(PINNED_KEY, pinned); + try { + FileUtils.saveFile(SIDECAR_FILE, FPSMasterGson.getInstance().toJson(json)); + } catch (FileException e) { + ClientLogger.error("Failed to save promoted servers state: " + e.getMessage()); + } + } +} diff --git a/src/main/java/top/fpsmaster/modules/client/api/model/PromotedServerView.java b/src/main/java/top/fpsmaster/modules/client/api/model/PromotedServerView.java new file mode 100644 index 00000000..019eb34e --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/client/api/model/PromotedServerView.java @@ -0,0 +1,33 @@ +package top.fpsmaster.modules.client.api.model; + +/** + * One entry of {@code GET /api/v1/launcher/servers} — the official featured/partner server list. + * Unknown backend fields are ignored by Gson. + */ +public class PromotedServerView { + private String id; + private String name; + private String address; + private String description; + private boolean active; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getAddress() { + return address; + } + + public String getDescription() { + return description; + } + + public boolean isActive() { + return active; + } +} diff --git a/src/main/java/top/fpsmaster/ui/mc/GuiMultiplayer.java b/src/main/java/top/fpsmaster/ui/mc/GuiMultiplayer.java index 15cacb35..f2eb0ff2 100644 --- a/src/main/java/top/fpsmaster/ui/mc/GuiMultiplayer.java +++ b/src/main/java/top/fpsmaster/ui/mc/GuiMultiplayer.java @@ -17,6 +17,8 @@ import org.lwjgl.opengl.GL11; import top.fpsmaster.FPSMaster; import top.fpsmaster.font.impl.UFontRenderer; +import top.fpsmaster.modules.client.api.PromotedServersService; +import top.fpsmaster.modules.client.api.model.PromotedServerView; import top.fpsmaster.ui.click.ClickGuiTheme; import top.fpsmaster.ui.click.UiChrome; import top.fpsmaster.ui.click.component.ScrollContainer; @@ -32,7 +34,13 @@ import java.awt.*; import java.io.File; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; public class GuiMultiplayer extends ScaledGuiScreen { private ServerData selectedServer; @@ -46,6 +54,10 @@ public class GuiMultiplayer extends ScaledGuiScreen { private TextField searchField; private String lastQuery = ""; + /** Synthetic {@link ServerData} per promoted-only address, reused across rebuilds so each is pinged once. */ + private final Map promotedServerData = new HashMap<>(); + private long promotedRevisionSeen = -1L; + private void joinSelected() { if (selectedServer != null) { this.mc.displayGuiScreen(new GuiConnecting(this, this.mc, selectedServer)); @@ -100,16 +112,13 @@ public void initGui() { } } loadServerList(); + PromotedServersService promotedService = PromotedServersService.getInstance(); + promotedService.refreshIfStale(); + promotedRevisionSeen = promotedService.revision(); + rebuildServerEntries(); // Prototype opens with the first server already selected so the detail column is // never an empty pane when there is anything to show. - selectedServer = servers.isEmpty() ? null : servers.get(0); - - serverListInternet.clear(); - for (ServerData server : servers) { - this.serverListInternet.add(new ServerListEntry(this, server)); - } - serverListDisplay.clear(); - serverListDisplay.addAll(serverListInternet); + selectedServer = serverListInternet.isEmpty() ? null : serverListInternet.get(0).getServerData(); if (searchField == null) { searchField = new TextField( FPSMaster.fontManager.getFont(12), @@ -136,6 +145,11 @@ public void confirmClicked(boolean result, int id) { saveServerList(); break; case "remove": + // Deleting a promoted row is a local dismissal: remember it so it is never + // inserted again, without touching the backend. + if (isPromotedEntry(selectedServer)) { + PromotedServersService.getInstance().hideByAddress(selectedServer.serverIP); + } servers.remove(selectedServer); saveServerList(); break; @@ -375,7 +389,9 @@ private void renderDetail(float x, float y, float width, float height, int mouse ping < 0 ? FPSMaster.i18n.get("multiplayer.offline") : FPSMaster.i18n.get("multiplayer.online"), FPSMaster.i18n.get("multiplayer.status")); - // Actions pinned to the bottom + // Actions pinned to the bottom. Promoted-only rows are not the player's servers: + // no edit and no pin, delete just dismisses the promotion locally. + boolean ownServer = servers.contains(selectedServer); float joinH = 21f; float rowH = 18f; float rowY = y + height - pad - rowH; @@ -390,14 +406,29 @@ private void renderDetail(float x, float y, float width, float height, int mouse joinSelected(); } - float halfW = (width - pad * 2f - 4f) / 2f; - if (UiChrome.buttonClicked(this, x + pad, rowY, halfW, rowH, "rename", - FPSMaster.i18n.get("multiplayer.edit"), UiChrome.Style.DEFAULT, mouseX, mouseY)) { - editSelected(); - } - if (UiChrome.buttonClicked(this, x + pad + halfW + 4f, rowY, halfW, rowH, "delete", - FPSMaster.i18n.get("multiplayer.delete"), UiChrome.Style.DANGER, mouseX, mouseY)) { - removeSelected(); + if (ownServer) { + boolean pinned = PromotedServersService.getInstance().isPinned(selectedServer.serverIP); + float pinY = joinY - 4f - rowH; + if (UiChrome.buttonClicked(this, x + pad, pinY, width - pad * 2f, rowH, null, + FPSMaster.i18n.get(pinned ? "multiplayer.unpin" : "multiplayer.pin"), + UiChrome.Style.DEFAULT, mouseX, mouseY)) { + PromotedServersService.getInstance().setPinned(selectedServer.serverIP, !pinned); + rebuildServerEntries(); + } + float halfW = (width - pad * 2f - 4f) / 2f; + if (UiChrome.buttonClicked(this, x + pad, rowY, halfW, rowH, "rename", + FPSMaster.i18n.get("multiplayer.edit"), UiChrome.Style.DEFAULT, mouseX, mouseY)) { + editSelected(); + } + if (UiChrome.buttonClicked(this, x + pad + halfW + 4f, rowY, halfW, rowH, "delete", + FPSMaster.i18n.get("multiplayer.delete"), UiChrome.Style.DANGER, mouseX, mouseY)) { + removeSelected(); + } + } else { + if (UiChrome.buttonClicked(this, x + pad, rowY, width - pad * 2f, rowH, "delete", + FPSMaster.i18n.get("multiplayer.delete"), UiChrome.Style.DANGER, mouseX, mouseY)) { + removeSelected(); + } } } @@ -415,6 +446,14 @@ private void drawStat(float x, float y, float width, String icon, String value, @Override public void updateScreen() { super.updateScreen(); + long promotedRevision = PromotedServersService.getInstance().revision(); + if (promotedRevision != promotedRevisionSeen) { + promotedRevisionSeen = promotedRevision; + rebuildServerEntries(); + if (selectedServer == null && !serverListInternet.isEmpty()) { + selectedServer = serverListInternet.get(0).getServerData(); + } + } // Forge's FMLClientHandler.setupServerList() only wires LAN/Realms discovery, which this // custom multiplayer screen does not use; the vanilla ping loop below is all we need. // Guard the ping loop: OldServerPinger's legacy-ping fallback can surface a netty NPE @@ -453,6 +492,88 @@ public void loadServerList() { } } + + /** + * Rebuilds the entry list in display order: the player's pinned servers first, then visible + * promoted servers in backend order, then the remaining own servers in {@code servers.dat} + * order. A promoted server whose address matches an own server collapses into one row — styled + * as user-pinned when the player pinned it, as promoted otherwise. + */ + private void rebuildServerEntries() { + PromotedServersService service = PromotedServersService.getInstance(); + serverListInternet.clear(); + + Set placed = Collections.newSetFromMap(new IdentityHashMap<>()); + for (ServerData server : servers) { + if (service.isPinned(server.serverIP)) { + serverListInternet.add(new ServerListEntry(this, server, false, true, null)); + placed.add(server); + } + } + + Set promotedAddressesSeen = new HashSet<>(); + for (PromotedServerView view : service.promotedServers()) { + String address = PromotedServersService.normalizeAddress(view.getAddress()); + if (!view.isActive() || service.isHidden(view) || !promotedAddressesSeen.add(address)) { + continue; + } + ServerData ownServer = findServerByAddress(address); + if (ownServer != null) { + // User-pinned styling wins for a pinned own server; the promoted row is dropped. + if (!placed.contains(ownServer)) { + serverListInternet.add(new ServerListEntry(this, ownServer, true, false, view.getDescription())); + placed.add(ownServer); + } + } else { + serverListInternet.add(new ServerListEntry(this, promotedServerData(view), true, false, view.getDescription())); + } + } + + for (ServerData server : servers) { + if (!placed.contains(server)) { + serverListInternet.add(new ServerListEntry(this, server, false, false, null)); + } + } + + serverListDisplay.clear(); + serverListDisplay.addAll(serverListInternet); + // Force applySearchFilter to re-filter the fresh entries on the next frame. + lastQuery = null; + } + + private ServerData findServerByAddress(String normalizedAddress) { + for (ServerData server : servers) { + if (PromotedServersService.normalizeAddress(server.serverIP).equals(normalizedAddress)) { + return server; + } + } + return null; + } + + private ServerData promotedServerData(PromotedServerView view) { + String key = PromotedServersService.normalizeAddress(view.getAddress()); + ServerData data = promotedServerData.get(key); + if (data == null) { + String name = view.getName() == null || view.getName().trim().isEmpty() + ? view.getAddress().trim() + : view.getName().trim(); + data = new ServerData(name, view.getAddress().trim(), false); + promotedServerData.put(key, data); + } + return data; + } + + private boolean isPromotedEntry(ServerData server) { + if (server == null) { + return false; + } + for (ServerListEntry entry : serverListInternet) { + if (entry.getServerData() == server) { + return entry.isPromoted(); + } + } + return false; + } } diff --git a/src/main/java/top/fpsmaster/ui/mc/ServerListEntry.java b/src/main/java/top/fpsmaster/ui/mc/ServerListEntry.java index e2cc2bcf..3cb43015 100644 --- a/src/main/java/top/fpsmaster/ui/mc/ServerListEntry.java +++ b/src/main/java/top/fpsmaster/ui/mc/ServerListEntry.java @@ -41,12 +41,25 @@ public class ServerListEntry { private DynamicTexture field_148305_h; private final GuiMultiplayer owner; private long lastClick = 0; + /** Backend-featured row: drawn with the accent "featured" badge, delete dismisses locally. */ + private final boolean promoted; + /** Player-pinned own server: drawn with the neutral "pinned" chip, sorted to the very top. */ + private final boolean pinned; + private final String promotedDescription; + + protected ServerListEntry(GuiMultiplayer multiplayer, ServerData server) { + this(multiplayer, server, false, false, null); + } - protected ServerListEntry(GuiMultiplayer multiplayer, ServerData p_i45048_2_) { + protected ServerListEntry(GuiMultiplayer multiplayer, ServerData server, boolean promoted, + boolean pinned, String promotedDescription) { this.owner = multiplayer; - this.server = p_i45048_2_; + this.server = server; + this.promoted = promoted; + this.pinned = pinned; + this.promotedDescription = promotedDescription; this.mc = Minecraft.getMinecraft(); - this.serverIcon = new ResourceLocation("servers/" + p_i45048_2_.serverIP + "/icon"); + this.serverIcon = new ResourceLocation("servers/" + server.serverIP + "/icon"); this.field_148305_h = (DynamicTexture) this.mc.getTextureManager().getTexture(this.serverIcon); } @@ -89,9 +102,30 @@ public void drawEntry(int x, int y, int listWidth, int rowHeight, int mouseX, in float textW = listWidth - (textX - x) - rightW - 5f; UFontRenderer nameFont = FPSMaster.fontManager.s14; UFontRenderer subFont = FPSMaster.fontManager.getFont(12); - nameFont.drawString(this.server.serverName, textX, y + 5f, + // Two distinct markers next to the name: featured rows get the accent badge pill, + // player-pinned rows get the neutral bordered chip. Both are existing theme widgets. + String markerText = promoted + ? FPSMaster.i18n.get("multiplayer.promoted.badge") + : pinned ? FPSMaster.i18n.get("multiplayer.pinned.badge") : null; + float nameW = textW; + if (markerText != null) { + nameW -= UiChrome.keyChipWidth(markerText) + 4f; + } + String name = nameFont.trimStringToWidth(this.server.serverName, nameW); + nameFont.drawString(name, textX, y + 5f, (unreachable ? ClickGuiTheme.textSecondary() : ClickGuiTheme.textPrimary()).getRGB()); + if (markerText != null) { + float markerX = textX + nameFont.getStringWidth(name) + 4f; + if (promoted) { + UiChrome.badge(markerX, y + 3.5f, markerText); + } else { + UiChrome.keyChip(markerX, y + 3.5f, UiChrome.keyChipWidth(markerText), 10f, markerText, false, false); + } + } String motd = this.server.serverMOTD == null ? "" : this.server.serverMOTD.replaceAll("§.", ""); + if (motd.isEmpty() && promoted && promotedDescription != null) { + motd = promotedDescription; + } if (unreachable) { subFont.drawString(subFont.trimStringToWidth(motd, textW), textX, y + 15f, ClickGuiTheme.danger().getRGB()); @@ -178,6 +212,10 @@ public ServerData getServerData() { return this.server; } + public boolean isPromoted() { + return this.promoted; + } + public void triggerClick() { if (Minecraft.getSystemTime() - lastClick < 250L) { this.mc.displayGuiScreen(new GuiConnecting(owner, this.mc, getServerData())); diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 1c83fc8f..7819d04a 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -35,6 +35,10 @@ multiplayer.players=Players multiplayer.ping=Ping multiplayer.version=Version multiplayer.offline=Offline +multiplayer.promoted.badge=Featured +multiplayer.pinned.badge=Pinned +multiplayer.pin=Pin +multiplayer.unpin=Unpin backgroundselector.title=Background Settings backgroundselector.pick=Choose Image backgroundselector.preview.image=IMG diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index a26c4b09..e8c68b30 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -35,6 +35,10 @@ multiplayer.players=在线玩家 multiplayer.ping=延迟 multiplayer.version=版本 multiplayer.offline=离线 +multiplayer.promoted.badge=推荐 +multiplayer.pinned.badge=置顶 +multiplayer.pin=置顶 +multiplayer.unpin=取消置顶 backgroundselector.title=背景设置 backgroundselector.pick=选择图片 backgroundselector.preview.image=IMG