Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/main/java/top/fpsmaster/mixin/impl/MixinJoinMultiplayerScreen.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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")));
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<ServerData> 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<ServerBrowser.PromotedServer, ServerData> 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<ServerData> 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);*/
//?}
});
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 16 additions & 0 deletions src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -394,6 +395,12 @@ object FPSMasterApiClient {
.thenApply { response -> parseResponse(response, Array<ResolvedLoadoutView>::class.java) }
}

/** 官方推荐/合作服务器列表(公开端点,后端只返回启用中的条目)。 */
fun getLauncherServers(): CompletableFuture<ApiResult<Array<LauncherServerView>>> {
return sendGet(LAUNCHER_SERVERS, authenticated = false)
.thenApply { response -> parseResponse(response, Array<LauncherServerView>::class.java) }
}

fun createMinecraftLinkChallenge(): CompletableFuture<ApiResult<MinecraftLinkChallengeView>> {
return sendJson(LINK_CHALLENGE, null, authenticated = true)
.thenApply { response -> parseResponse(response, MinecraftLinkChallengeView::class.java) }
Expand Down Expand Up @@ -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 = "",
Expand Down
Loading
Loading