fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:02:33 +08:00
1 parent 82310d02ec
commit 4ae64cc2e8
21 files changed
+1081 -136

No files matched your search

@@ -70,15 +70,12 @@ public final class Control {
case ControlFrame.LIST_REQUEST:
break;
case ControlFrame.LIST_UPDATE:
sb.append(",\"servers\":[");
List<String> names = frame.servers();
for (int i = 0; i < names.size(); i++) {
if (i > 0) {
sb.append(',');
}
jsonString(sb, names.get(i));
kvList(sb, "servers", frame.servers());
// Only a list that carries verdicts writes them, so a names-only
// frame stays the shape it always was.
if (!frame.access().isEmpty()) {
kvList(sb, "access", frame.access());
}
sb.append(']');
break;
case ControlFrame.LOGIN_RELEASE:
kv(sb, "player", frame.player());
@@ -128,7 +125,7 @@ public final class Control {
case ControlFrame.LIST_REQUEST:
return ControlFrame.listRequest();
case ControlFrame.LIST_UPDATE:
return ControlFrame.listUpdate(strList(o, "servers"));
return listUpdate(o);
case ControlFrame.LOGIN_RELEASE:
return ControlFrame.loginRelease(str(o, "player"));
default:
@@ -147,6 +144,17 @@ public final class Control {
}
}
private static void kvList(StringBuilder sb, String key, List<String> values) {
sb.append(",\"").append(key).append("\":[");
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
sb.append(',');
}
jsonString(sb, values.get(i));
}
sb.append(']');
}
private static void kvBool(StringBuilder sb, String key, boolean value) {
sb.append(",\"").append(key).append("\":").append(value);
}
@@ -199,19 +207,22 @@ public final class Control {
return v instanceof String ? (String) v : null;
}
// strList keeps the string entries of an array field and skips anything else, so a
// partly malformed list still yields the names that are well-formed.
private static List<String> strList(Map<?, ?> o, String key) {
List<String> out = new ArrayList<>();
Object v = o.get(key);
if (v instanceof List) {
for (Object e : (List<?>) v) {
if (e instanceof String) {
out.add((String) e);
}
// listUpdate keeps the string entries of "servers" and skips anything else, so a
// partly malformed list still yields the names that are well-formed. "access" is
// read by the same index, so a skipped name takes its verdict with it and a
// verdict that is not a string reads as unknown.
private static ControlFrame listUpdate(Map<?, ?> o) {
List<String> names = new ArrayList<>();
List<String> access = new ArrayList<>();
List<?> rawNames = o.get("servers") instanceof List<?> l ? l : List.of();
List<?> rawAccess = o.get("access") instanceof List<?> l ? l : List.of();
for (int i = 0; i < rawNames.size(); i++) {
if (rawNames.get(i) instanceof String name) {
names.add(name);
access.add(i < rawAccess.size() && rawAccess.get(i) instanceof String v ? v : "");
}
}
return out;
return ControlFrame.listUpdate(names, access);
}
private static boolean bool(Map<?, ?> o, String key) {
@@ -25,7 +25,7 @@ import java.util.Objects;
* {@code Connect} the gate used to send, so {@code bungeecord:main} can be
* switched off proxy-wide.</li>
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
* projection; {@link #LIST_UPDATE} is the set of tiles to show;
* projection; {@link #LIST_UPDATE} is the set of tiles to show, with what the player may do with each;
* {@link #TRANSFER_READY} tells the lobby a parked player's backend is up;
* {@link #ERROR} reports a refusal.</li>
* </ul>
@@ -65,7 +65,7 @@ public final class ControlFrame {
public static final String ERROR = "Error";
/** Upstream (lobby): ask for the current tile list; answered by {@link #LIST_UPDATE}. */
public static final String LIST_REQUEST = "ListRequest";
/** Downstream: the user servers the lobby should show, in display order (servers). */
/** Downstream: the user servers the lobby should show, in display order (servers, access). */
public static final String LIST_UPDATE = "ListUpdate";
/** Upstream (login gate): the player finished signing in; move them to the lobby (player). */
public static final String LOGIN_RELEASE = "LoginRelease";
@@ -81,15 +81,16 @@ public final class ControlFrame {
private final String code;
private final String message;
private final List<String> servers;
private final List<String> access;
private ControlFrame(String type, String player, String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable, String code, String message) {
this(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, List.of());
this(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, List.of(), List.of());
}
private ControlFrame(String type, String player, String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable, String code, String message,
List<String> servers) {
List<String> servers, List<String> access) {
this.type = type;
this.player = player;
this.server = server;
@@ -101,6 +102,7 @@ public final class ControlFrame {
this.code = code;
this.message = message;
this.servers = servers;
this.access = access;
}
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
@@ -137,16 +139,34 @@ public final class ControlFrame {
/** listUpdate carries the tile names; null entries are dropped, the list is copied. */
public static ControlFrame listUpdate(List<String> servers) {
List<String> copy = new ArrayList<>();
return listUpdate(servers, null);
}
/**
* listUpdate with {@code access} also carries what the player may do with each
* server: {@code access.get(i)} is felis-api's verdict for {@code servers.get(i)}
* ({@code ""} when unknown). A null name drops its verdict with it; missing
* verdicts read as unknown, and extra ones are cut. With no known verdict at all
* the list stays empty and the frame is the plain name list.
*/
public static ControlFrame listUpdate(List<String> servers, List<String> access) {
List<String> names = new ArrayList<>();
List<String> verdicts = new ArrayList<>();
boolean anyKnown = false;
if (servers != null) {
for (String s : servers) {
if (s != null) {
copy.add(s);
for (int i = 0; i < servers.size(); i++) {
if (servers.get(i) == null) {
continue;
}
String v = access != null && i < access.size() && access.get(i) != null ? access.get(i) : "";
names.add(servers.get(i));
verdicts.add(v);
anyKnown |= !v.isEmpty();
}
}
return new ControlFrame(LIST_UPDATE, null, null, null, false, 0, 0, false, null, null,
Collections.unmodifiableList(copy));
Collections.unmodifiableList(names),
anyKnown ? Collections.unmodifiableList(verdicts) : List.of());
}
public static ControlFrame loginRelease(String player) {
@@ -200,6 +220,15 @@ public final class ControlFrame {
return servers;
}
/**
* access is the {@link #LIST_UPDATE}'s verdict per server, aligned with
* {@link #servers()} ({@code ""} for one felis-api gave none); empty when the
* proxy sent names only, and on every other type.
*/
public List<String> access() {
return access;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -219,13 +248,14 @@ public final class ControlFrame {
&& Objects.equals(phase, f.phase)
&& Objects.equals(code, f.code)
&& Objects.equals(message, f.message)
&& Objects.equals(servers, f.servers);
&& Objects.equals(servers, f.servers)
&& Objects.equals(access, f.access);
}
@Override
public int hashCode() {
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message,
servers);
servers, access);
}
@Override
@@ -8,6 +8,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -145,6 +146,28 @@ public final class FelisApiClient {
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + serverSegment(name) + "/menu", 200));
}
/**
* menuAccess reads what one player may start, for every user server at once
* ({@code GET /api/v1/internal/player/menu-access/{mc_uuid}} →
* {@code {"servers":{"<name>":"<verdict>"}}}): {@code owner}, {@code wake},
* {@code owner_only}, {@code allowlist}, {@code retiring} or {@code start_failed}.
* Velocity calls it once per menu open. An entry whose value is not a string is
* left out, as is the whole map when {@code servers} is missing.
*/
public Map<String, String> menuAccess(UUID mcUuid) throws LinkException {
Objects.requireNonNull(mcUuid, "mcUuid");
Map<?, ?> obj = getObject("/api/v1/internal/player/menu-access/" + mcUuid, 200);
Map<String, String> out = new HashMap<>();
if (obj.get("servers") instanceof Map<?, ?> servers) {
for (Map.Entry<?, ?> e : servers.entrySet()) {
if (e.getKey() instanceof String name && e.getValue() instanceof String verdict) {
out.put(name, verdict);
}
}
}
return out;
}
/**
* linkStatus polls whether the verified UUID has finished web account-link — the
* completion leg of the in-game login flow (spec §B3). After the player redeems
@@ -13,9 +13,10 @@ import java.util.Map;
*
* <p>Velocity reads this for a {@code StatusQuery} and projects it onto a
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
* (Claim&nbsp;&amp;&nbsp;Start / Join / Wake) and {@code playersOnline}/{@code
* playersMax} render the "3/20" count.
* {@code ready} and {@code claimable}, with felis-api's per-player verdict from the
* {@code ListUpdate}, choose the button (Join / Claim&nbsp;&amp;&nbsp;Start / Start, or
* a grey tile naming why not), {@code phase} is the status line, and
* {@code playersOnline}/{@code playersMax} render the "3/20" count.
*
* <p>{@link #fromJson(Map)} is tolerant in the same way as {@link ServerView}: an
* absent field degrades to null/zero/false rather than throwing, so a partial body
@@ -34,6 +34,7 @@ public final class ControlRoundTripTest {
errorOmitsServerWhenAbsentButRoundTrips();
escapesAwkwardStrings();
listUpdateCarriesNamesInOrder();
listUpdateCarriesAccessByName();
rejectsMalformedAndUnknownFrames();
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
}
@@ -52,6 +53,7 @@ public final class ControlRoundTripTest {
roundTrip(ControlFrame.listRequest());
roundTrip(ControlFrame.listUpdate(Arrays.asList("alpha", "beta-2", "gamma")));
roundTrip(ControlFrame.listUpdate(List.of()));
roundTrip(ControlFrame.listUpdate(List.of("mine", "theirs"), List.of("owner", "owner_only")));
roundTrip(ControlFrame.loginRelease("Notch"));
}
@@ -81,6 +83,34 @@ public final class ControlRoundTripTest {
assertEq("non-string entries skipped", List.of("a", "b"), mixed.servers());
}
// ListUpdate's access list is the player's verdict per name, by index: it holds
// its alignment through a dropped name on either end, a missing or non-string
// verdict reads as unknown (""), and a list with no verdict at all is the plain
// name list on the wire.
private static void listUpdateCarriesAccessByName() {
ControlFrame f = decode(ControlFrame.listUpdate(
Arrays.asList("mine", null, "theirs", "fresh"),
Arrays.asList("owner", "wake", "owner_only")));
assertEq("names", List.of("mine", "theirs", "fresh"), f.servers());
assertEq("verdicts follow their names", List.of("owner", "owner_only", ""), f.access());
assertEq("names only: no verdicts", List.of(), decode(ControlFrame.listUpdate(List.of("a"))).access());
assertEq("all unknown: no verdicts", List.of(),
decode(ControlFrame.listUpdate(List.of("a", "b"), Arrays.asList("", null))).access());
assertEq("names only: nothing extra on the wire",
"{\"type\":\"ListUpdate\",\"servers\":[\"a\"]}",
new String(Control.encode(ControlFrame.listUpdate(List.of("a"), List.of(""))), StandardCharsets.UTF_8));
assertEq("extra verdicts are cut", List.of("wake"),
ControlFrame.listUpdate(List.of("a"), List.of("wake", "owner")).access());
ControlFrame mixed = Control.decode(("{\"type\":\"ListUpdate\",\"servers\":[\"a\",1,\"b\",\"c\"],"
+ "\"access\":[\"owner\",\"wake\",7,\"allowlist\"]}").getBytes(StandardCharsets.UTF_8));
assertEq("hand-written: names", List.of("a", "b", "c"), mixed.servers());
assertEq("hand-written: a skipped name takes its verdict", List.of("owner", "", "allowlist"), mixed.access());
ControlFrame shortList = Control.decode(("{\"type\":\"ListUpdate\",\"servers\":[\"a\",\"b\"],"
+ "\"access\":[\"retiring\"]}").getBytes(StandardCharsets.UTF_8));
assertEq("hand-written: a missing verdict is unknown", List.of("retiring", ""), shortList.access());
assertEq("access on a non-list frame", List.of(), decode(ControlFrame.statusQuery("s")).access());
}
// StatusUpdate refines the spec's "players" into ready + online + max; the GUI
// renders all three, so all three must survive the round-trip with exact values.
private static void wireCarriesRefinedStatusFields() {
@@ -44,6 +44,9 @@ public final class FelisApiClientTest {
if (path.endsWith("/wake")) {
status = 202;
body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}";
} else if (path.startsWith("/api/v1/internal/player/menu-access/")) {
status = 200;
body = "{\"servers\":{\"mine\":\"owner\",\"odd\":7,\"pub\":\"wake\"}}";
} else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) {
status = 409;
body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}";
@@ -78,6 +81,7 @@ public final class FelisApiClientTest {
opaqueSegmentsArePercentEncoded(api);
opLoginShowNamesTheAccount(api);
opLoginApproveSendsTheTypedName(api);
menuAccessReadsVerdictsByName(api);
} finally {
stub.stop(0);
}
@@ -96,6 +100,15 @@ public final class FelisApiClientTest {
"GET /api/v1/internal/servers/abc/status"), seen);
}
// menuAccess reads the verdict map for the player's own UUID and keeps only the
// string verdicts, so one malformed entry cannot hide the others.
private static void menuAccessReadsVerdictsByName(FelisApiClient api) throws LinkException {
seen.clear();
UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a");
assertEq("verdicts", java.util.Map.of("mine", "owner", "pub", "wake"), api.menuAccess(id));
assertEq("route", List.of("GET /api/v1/internal/player/menu-access/" + id), seen);
}
private static void pathBendingNamesNeverLeaveTheClient(FelisApiClient api) {
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002");
String[] bad = {