fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文
This commit is contained in:
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 & 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 & 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 = {
|
||||
|
||||
Reference in new issue
Block a user