From 4ae64cc2e80f547e0d5c1d080616441b1b532804 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 05:02:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(menu):=20=E5=A4=A7=E5=8E=85=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=8C=89=E7=8E=A9=E5=AE=B6=E6=A0=87=E5=87=BA=E6=AF=8F?= =?UTF-8?q?=E5=8F=B0=E6=9C=8D=E8=83=BD=E5=90=A6=E5=90=AF=E5=8A=A8=E5=8F=8A?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=EF=BC=8C=E8=87=AA=E5=B7=B1=E7=9A=84=E6=9C=8D?= =?UTF-8?q?=E6=8E=92=E5=89=8D=E9=9D=A2=EF=BC=8C=E7=8A=B6=E6=80=81=E6=94=B9?= =?UTF-8?q?=E6=88=90=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 38 ++++- internal/api/api.go | 2 + internal/api/handlers_internal.go | 160 +++++++++++++---- internal/api/handlers_internal_menu_test.go | 118 +++++++++++++ panel/src/lib/openapi.gen.ts | 51 +++++- plugins/README.md | 29 ++-- plugins/paper/build.gradle | 16 +- .../lolicon/felis/paper/FelisPaperPlugin.java | 88 ++++++---- .../best/lolicon/felis/paper/MenuHolder.java | 23 ++- .../best/lolicon/felis/paper/MenuTiles.java | 140 +++++++++++++++ .../lolicon/felis/paper/MenuTilesTest.java | 161 ++++++++++++++++++ .../java/best/lolicon/felis/link/Control.java | 51 +++--- .../best/lolicon/felis/link/ControlFrame.java | 52 ++++-- .../lolicon/felis/link/FelisApiClient.java | 23 +++ .../best/lolicon/felis/link/MenuStatus.java | 7 +- .../felis/link/ControlRoundTripTest.java | 30 ++++ .../felis/link/FelisApiClientTest.java | 13 ++ plugins/test.sh | 10 +- .../felis/velocity/ControlChannel.java | 61 ++++++- .../felis/velocity/ControlChannelTest.java | 114 ++++++++++++- .../best/lolicon/felis/velocity/Fakes.java | 30 ++++ 21 files changed, 1081 insertions(+), 136 deletions(-) create mode 100644 plugins/paper/src/main/java/best/lolicon/felis/paper/MenuTiles.java create mode 100644 plugins/paper/test/best/lolicon/felis/paper/MenuTilesTest.java diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 278e9c2..e1ed1d9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1361,7 +1361,6 @@ paths: security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } - - { name: mc_uuid, in: query, required: false, schema: { type: string } } responses: '200': description: Menu projection for the lobby UI. @@ -1382,6 +1381,43 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/v1/internal/player/menu-access/{mc_uuid}: + get: + tags: [lobby] + operationId: internalMenuAccess + summary: What one player may start, for every user server — the lobby menu's per-player verdicts. + description: > + One call per menu open; each tile's live state stays in the shared + per-server projection (…/menu). A server that is up is open to every + linked player, so the lobby shows Join there whatever the verdict. The + verdicts follow the internal wake's gates without the transient ones + (cooldown, running-server cap): `retiring` (given up or being deleted), + `start_failed` (automatic restarts spent), `owner` (the player's own), + `wake` (the autostart policy admits the player), `owner_only`, and + `allowlist` (the player is not on the list). + x-felis-face: [internal] + x-felis-tier: service + x-felis-callers: [velocity] + security: [{ serviceToken: [] }] + parameters: + - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: Verdict per user server name. + content: + application/json: + schema: + type: object + required: [servers] + properties: + servers: + type: object + additionalProperties: + type: string + enum: [retiring, start_failed, owner, wake, owner_only, allowlist] + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/internal/account/link/code: post: tags: [account-internal] diff --git a/internal/api/api.go b/internal/api/api.go index 27579ba..5bcfcda 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -399,6 +399,8 @@ func (a *API) internalAPIRoutes() []apiRoute { // list/status views never carry. {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim}, {Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", Callers: proxy, h: a.handleInternalMenuStatus}, + // What this player may start, for every tile at once: one call per menu open. + {Method: "GET", Pattern: "/api/v1/internal/player/menu-access/{mc_uuid}", Callers: proxy, h: a.handleInternalMenuAccess}, // Account linking (spec §10): the in-game /link side mints a one-time code for a // verified UUID. Internal-only — the code is born from an online-mode UUID the // web never holds (account_link_codes has no user_id column). diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index 8c3a77e..07f540d 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -343,6 +343,80 @@ func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) { }) } +// Menu access verdicts: what a lobby menu click on a server that is not up would +// meet for one player (handleInternalMenuAccess). +const ( + menuRetiring = "retiring" // given up or being deleted: nobody starts it + menuStartFailed = "start_failed" // automatic restarts spent: waits for its owner + menuOwner = "owner" // the player's own server + menuWake = "wake" // the policy lets this player start it + menuOwnerOnly = "owner_only" // ownerOnly (or unset): only its owner starts it + menuAllowlist = "allowlist" // allowlist, and the player is not on it +) + +// handleInternalMenuAccess answers the lobby menu's per-player question (spec §12): +// for every user server, whether this verified UUID may start it and why not. The +// tiles' live state stays in the shared per-server projection (…/menu); this is one +// call per menu open, so a lobby full of players still reads each server's status +// once. A server that is up is open to every linked player (handleInternalWake), so +// the lobby shows Join there whatever the verdict. The verdicts follow the wake's +// own gates with the transient refusals (cooldown, the running-server cap) left out, +// since a retry gets past those. Retiring comes first: nobody may start such a +// server, so it is the reason a stranger is shown too. +func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) { + mcUUID := r.PathValue("mc_uuid") + if mcUUID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) + return + } + infos, err := a.Cluster.ListServers(r.Context()) + if err != nil { + writeError(w, r, err) + return + } + owners, err := a.Repo.ServerOwners(r.Context()) + if err != nil { + writeError(w, r, err) + return + } + standing, err := a.standingByUUID(r.Context(), mcUUID) + if err != nil { + writeError(w, r, err) + return + } + verdicts := make(map[string]string, len(infos)) + for i := range infos { + info := &infos[i] + if naming.ValidateServerName(info.Name) != nil { + continue // the login gate and the lobby are not menu tiles + } + own := owners[info.Name] + switch { + case own.Retire != nil: + verdicts[info.Name] = menuRetiring + case info.StartGaveUp && info.DesiredState == string(v1alpha1.DesiredRunning): + verdicts[info.Name] = menuStartFailed + case standing.userID != "" && standing.userID == own.OwnerID: + verdicts[info.Name] = menuOwner + default: + ok, err := a.policyAdmits(r.Context(), mcUUID, standing, info, own.OwnerID) + if err != nil { + writeError(w, r, err) + return + } + switch { + case ok: + verdicts[info.Name] = menuWake + case info.AutostartPolicy == string(v1alpha1.AutostartAllowlist): + verdicts[info.Name] = menuAllowlist + default: + verdicts[info.Name] = menuOwnerOnly + } + } + } + writeJSON(w, http.StatusOK, map[string]any{"servers": verdicts}) +} + // authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec // §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where // the joining player is known only by their verified online-mode UUID rather than @@ -358,40 +432,66 @@ func (a *API) authorizeWakeByUUID(ctx context.Context, mcUUID string, info *Serv if info.AutostartPolicy == string(v1alpha1.AutostartPublic) { return nil } - // Resolve the UUID to its linked user once; staff role or ownership grants - // the bypass. A missing link is not an error here — it just means "no - // standing", and a link pointing at a vanished user reads the same way. - switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); { - case err == nil: - switch u, err := a.Repo.UserByID(ctx, userID); { - case err == nil: - if staffRole(u.Role) { - return nil - } - case !errors.Is(err, ErrNotFound): - return err - } - if rec != nil && rec.OwnerID != "" && userID == rec.OwnerID { - return nil - } - case errors.Is(err, ErrNotFound): - // unlinked UUID → fall through to the policy gate - default: + s, err := a.standingByUUID(ctx, mcUUID) + if err != nil { return err } - switch info.AutostartPolicy { - case string(v1alpha1.AutostartAllowlist): - ok, err := a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID) - if err != nil { - return err - } - if ok { - return nil - } - return errForbidden - default: // ownerOnly or unset → only the owner (handled above) may wake + owner := "" + if rec != nil { + owner = rec.OwnerID + } + ok, err := a.policyAdmits(ctx, mcUUID, s, info, owner) + if err != nil { + return err + } + if !ok { return errForbidden } + return nil +} + +// uuidStanding is what a verified in-game UUID brings to the autostartPolicy gate: +// the user it is linked to ("" when unlinked) and whether that user is staff. +type uuidStanding struct { + userID string + staff bool +} + +// standingByUUID resolves the UUID to its linked user once. A missing link is not +// an error here — it just means "no standing", and a link pointing at a vanished +// user reads as the link without the staff role. +func (a *API) standingByUUID(ctx context.Context, mcUUID string) (uuidStanding, error) { + userID, err := a.Repo.UserByMCUUID(ctx, mcUUID) + switch { + case errors.Is(err, ErrNotFound): + return uuidStanding{}, nil + case err != nil: + return uuidStanding{}, err + } + switch u, err := a.Repo.UserByID(ctx, userID); { + case err == nil: + return uuidStanding{userID: userID, staff: staffRole(u.Role)}, nil + case errors.Is(err, ErrNotFound): + return uuidStanding{userID: userID}, nil + default: + return uuidStanding{}, err + } +} + +// policyAdmits is the autostartPolicy gate itself: public admits anyone, staff and +// the owner (ownerID, "" while unclaimed) pass every policy, allowlist admits a +// listed UUID, and ownerOnly or unset admits no one else. +func (a *API) policyAdmits(ctx context.Context, mcUUID string, s uuidStanding, info *ServerInfo, ownerID string) (bool, error) { + if info.AutostartPolicy == string(v1alpha1.AutostartPublic) || s.staff { + return true, nil + } + if s.userID != "" && s.userID == ownerID { + return true, nil + } + if info.AutostartPolicy == string(v1alpha1.AutostartAllowlist) { + return a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID) + } + return false, nil } // writeLookupError maps a repo/cluster lookup error onto an HTTP status: a diff --git a/internal/api/handlers_internal_menu_test.go b/internal/api/handlers_internal_menu_test.go index 507a65c..4413f09 100644 --- a/internal/api/handlers_internal_menu_test.go +++ b/internal/api/handlers_internal_menu_test.go @@ -2,9 +2,13 @@ package api import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + "time" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" ) // The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for @@ -217,3 +221,117 @@ func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) { } t.Fatalf("no velocity/internal claim audit for %q in %+v", name, f.audits) } + +func internalMenuAccess(api *API, uuid string) *httptest.ResponseRecorder { + return do(api.InternalHandler(), "GET", "/api/v1/internal/player/menu-access/"+uuid, "", nil) +} + +// The menu-access verdicts tell the lobby, per tile, whether this player may start +// the server and why not, with the wake's own gates behind each one. +func TestInternalMenuAccess(t *testing.T) { + gone := &RetireState{RequestedAt: time.Now()} + setup := func() (*API, *fakeRepo) { + repo := newFakeRepo() + cl := newFakeCluster() + running := string(v1alpha1.DesiredRunning) + cl.list = []ServerInfo{ + {Name: "pub", AutostartPolicy: "public"}, + {Name: "mine", AutostartPolicy: "ownerOnly"}, + {Name: "theirs", AutostartPolicy: "ownerOnly"}, + {Name: "unset"}, + {Name: "listed", AutostartPolicy: "allowlist"}, + {Name: "unlisted", AutostartPolicy: "allowlist"}, + {Name: "ownerless", AutostartPolicy: "ownerOnly"}, + {Name: "gone", AutostartPolicy: "public"}, + {Name: "mine-gone", AutostartPolicy: "ownerOnly"}, + {Name: "broken", AutostartPolicy: "public", StartGaveUp: true, DesiredState: running}, + {Name: "mine-broken", AutostartPolicy: "ownerOnly", StartGaveUp: true, DesiredState: running}, + {Name: "broken-stopped", AutostartPolicy: "public", StartGaveUp: true}, + {Name: "lobby", AutostartPolicy: "public"}, + } + repo.owners = map[string]ServerOwnership{ + "pub": {OwnerID: "u2"}, + "mine": {OwnerID: "user1"}, + "theirs": {OwnerID: "u2"}, + "unset": {OwnerID: "u2"}, + "listed": {OwnerID: "u2"}, + "unlisted": {OwnerID: "u2"}, + "ownerless": {}, + "gone": {OwnerID: "u2", Retire: gone}, + "mine-gone": {OwnerID: "user1", Retire: gone}, + "broken": {OwnerID: "u2"}, + "mine-broken": {OwnerID: "user1"}, + "broken-stopped": {OwnerID: "u2"}, + } + repo.allowUUID["listed"] = map[string]bool{menuUUID: true} + return newTestAPI(repo, cl), repo + } + verdicts := func(t *testing.T, api *API) map[string]any { + t.Helper() + got := decodeMenu(t, internalMenuAccess(api, menuUUID)) + servers, ok := got["servers"].(map[string]any) + if !ok { + t.Fatalf("servers = %v, want an object", got["servers"]) + } + return servers + } + + t.Run("a linked player", func(t *testing.T) { + api, repo := setup() + repo.links[menuUUID] = "user1" + got := verdicts(t, api) + for name, want := range map[string]string{ + "pub": "wake", + "mine": "owner", + "theirs": "owner_only", + "unset": "owner_only", + "listed": "wake", + "unlisted": "allowlist", + "ownerless": "owner_only", + "gone": "retiring", + "mine-gone": "retiring", + "broken": "start_failed", + "mine-broken": "start_failed", + "broken-stopped": "wake", + } { + assertEq(t, name, got[name], want) + } + if _, listed := got["lobby"]; listed { + t.Fatal("the lobby is not a menu tile, yet it has a verdict") + } + assertEq(t, "verdict count", len(got), 12) + }) + + t.Run("staff start any server that is not retiring or failed", func(t *testing.T) { + api, repo := setup() + repo.links[menuUUID] = "a1" + repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "admin"} + got := verdicts(t, api) + assertEq(t, "theirs", got["theirs"], "wake") + assertEq(t, "unlisted", got["unlisted"], "wake") + assertEq(t, "ownerless", got["ownerless"], "wake") + assertEq(t, "gone", got["gone"], "retiring") + }) + + t.Run("an unlinked UUID owns nothing, not even an ownerless server", func(t *testing.T) { + api, _ := setup() + got := verdicts(t, api) + assertEq(t, "mine", got["mine"], "owner_only") + assertEq(t, "ownerless", got["ownerless"], "owner_only") + assertEq(t, "listed", got["listed"], "wake") + assertEq(t, "pub", got["pub"], "wake") + }) + + t.Run("a failed read fails the call", func(t *testing.T) { + api, repo := setup() + repo.ownersErr = errors.New("db down") + if w := internalMenuAccess(api, menuUUID); w.Code != http.StatusInternalServerError { + t.Fatalf("owners unreadable: code = %d, want 500 (%s)", w.Code, w.Body.String()) + } + api, _ = setup() + api.Cluster.(*fakeCluster).listErr = errors.New("apiserver down") + if w := internalMenuAccess(api, menuUUID); w.Code < 500 { + t.Fatalf("cluster unreadable: code = %d, want 5xx (%s)", w.Code, w.Body.String()) + } + }) +} diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index daca30f..10e8388 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -230,6 +230,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/internal/player/menu-access/{mc_uuid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * What one player may start, for every user server — the lobby menu's per-player verdicts. + * @description One call per menu open; each tile's live state stays in the shared per-server projection (…/menu). A server that is up is open to every linked player, so the lobby shows Join there whatever the verdict. The verdicts follow the internal wake's gates without the transient ones (cooldown, running-server cap): `retiring` (given up or being deleted), `start_failed` (automatic restarts spent), `owner` (the player's own), `wake` (the autostart policy admits the player), `owner_only`, and `allowlist` (the player is not on the list). + */ + get: operations["internalMenuAccess"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/internal/account/link/code": { parameters: { query?: never; @@ -3347,9 +3367,7 @@ export interface operations { }; internalMenuStatus: { parameters: { - query?: { - mc_uuid?: string; - }; + query?: never; header?: never; path: { name: string; @@ -3380,6 +3398,33 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + internalMenuAccess: { + parameters: { + query?: never; + header?: never; + path: { + mc_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Verdict per user server name. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + servers: { + [key: string]: "retiring" | "start_failed" | "owner" | "wake" | "owner_only" | "allowlist"; + }; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; createLinkCode: { parameters: { query?: never; diff --git a/plugins/README.md b/plugins/README.md index b7d5bc0..6dbd926 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -219,14 +219,16 @@ a frame on the same channel. Velocity (the `ControlChannel`, above) is the only side that talks to felis-api. This is enforced **physically** by the build, not just by convention: the module's `sourceSets` include-filter compiles in only the paper package plus the three codec classes, so the lobby jar contains exactly -five classes — +these classes — ``` best/lolicon/felis/link/Control.class (channel framing) best/lolicon/felis/link/ControlFrame.class (the frame model) best/lolicon/felis/link/Json.class (codec) -best/lolicon/felis/paper/FelisPaperPlugin.class +best/lolicon/felis/paper/FelisPaperPlugin.class (+ $1) +best/lolicon/felis/paper/LobbyGuard.class best/lolicon/felis/paper/MenuHolder.class +best/lolicon/felis/paper/MenuTiles.class (+ $Kind, $Tile) ``` — and **no** `FelisApiClient`, `LinkClient`, or token-config class. If a codec @@ -238,7 +240,11 @@ rather than silently widen the lobby's reach. `StatusUpdate`, `TransferReady` and `Error`. `/menu` sends a `ListRequest`, and the proxy answers with a `ListUpdate` naming every user server it routes, built from the registry it routes by, so a server created in the panel appears without anyone editing -the lobby. The menu then paints a grey "loading" tile per server (45 per page, arrows +the lobby. The list leads with the player's own servers and carries, per server, +felis-api's verdict for this player (`GET /api/v1/internal/player/menu-access/{uuid}`, +one call per menu open): `owner`, `wake`, `owner_only`, `allowlist`, `retiring` or +`start_failed`. When felis-api cannot answer, the names go out alone and the tiles +fall back to the wake's own judgement. The menu then paints a grey "loading" tile per server (45 per page, arrows in the bottom row) and fires a `StatusQuery` for each; the proxy answers with `StatusUpdate` frames that repaint each tile by phase + ownership. @@ -249,17 +255,20 @@ gates authorize against that verified identity. The frame's `server` field is th trusted payload — it only names *which* tile was clicked. A fully compromised lobby therefore cannot act as another player or reach the API directly. -**Button rules** (the tile a click sends depends on the last `StatusUpdate`): +**Button rules** (`MenuTiles`: the last `StatusUpdate` plus the player's verdict, first match wins): | Tile state | Label | Frame sent | | ---------- | ----- | ---------- | -| ownerless + stopped (`claimable`) | **Claim & Start** | `ClaimRequest{server}` | -| owned + running (`ready`) | **Join** | `WakeRequest{server}` | -| owned + stopped | **Wake** | `WakeRequest{server}` | +| up (`ready`), any verdict | **Join** (green) | `WakeRequest{server}` | +| ownerless (`claimable`) | **Claim & Start** (gold) | `ClaimRequest{server}` | +| `retiring` / `start_failed` / `owner_only` / `allowlist` | **Can't start** (grey, reason in the lore) | nothing; the reason goes to chat and the menu stays open | +| anything else (`owner`, `wake`, no verdict) | **Start** (red) | `WakeRequest{server}` | -"Join" and "Wake" are the **same** upstream frame (`WakeRequest`) — only the -label differs; the proxy treats a wake of an already-running owned server as a -join. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` / +The player's own servers are marked ★ and "Your server". The status line shows the +phase in the player's language (运行中 / Running, 启动中 / Starting, 停止中 / Stopping, +已停止 / Stopped, 启动失败 / Failed to start, 未知 / Unknown). "Join" and "Start" are the +**same** upstream frame (`WakeRequest`): the proxy joins a server that is already up, +and every linked player may join one. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` / `already_claimed` → a friendly message), which is the only place a claim/quota/ policy failure surfaces to the player; readiness arrives as `TransferReady` just before the proxy Connects them. diff --git a/plugins/paper/build.gradle b/plugins/paper/build.gradle index d5a2e5e..7c7415b 100644 --- a/plugins/paper/build.gradle +++ b/plugins/paper/build.gradle @@ -62,23 +62,33 @@ tasks.withType(JavaCompile).configureEach { } // LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around -// Proxy-built fakes (plain main, no framework, like the velocity routing tests). It is -// not part of `build`, which the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`. +// Proxy-built fakes, and MenuTilesTest checks the menu's tile judgement (plain mains, no +// framework, like the velocity routing tests). Neither is part of `build`, which the +// lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs both. sourceSets { lobbyTest { java { srcDir 'test' include 'best/lolicon/felis/paper/Fakes.java' include 'best/lolicon/felis/paper/LobbyGuardTest.java' + include 'best/lolicon/felis/paper/MenuTilesTest.java' } compileClasspath += sourceSets.main.output + configurations.compileClasspath runtimeClasspath += output + compileClasspath } } +tasks.register('menuTilesTest', JavaExec) { + group = 'verification' + description = 'Runs the MenuTilesTest self-test main.' + classpath = sourceSets.lobbyTest.runtimeClasspath + mainClass = 'best.lolicon.felis.paper.MenuTilesTest' +} + tasks.register('lobbyTest', JavaExec) { group = 'verification' - description = 'Runs the LobbyGuardTest self-test main.' + description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.' + dependsOn 'menuTilesTest' classpath = sourceSets.lobbyTest.runtimeClasspath mainClass = 'best.lolicon.felis.paper.LobbyGuardTest' } diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java index 9aaded2..e77647f 100644 --- a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java @@ -48,14 +48,16 @@ import java.util.concurrent.ConcurrentHashMap; *

Flow. {@code /menu} sends a {@code ListRequest}; the proxy answers with a * {@code ListUpdate} naming every user server it routes, built from the same registry * it routes by, so a server created in the panel shows up here without anyone editing - * this plugin. The menu then paints a "loading" tile per server on the page (45 per - * page, arrows in the bottom row) and fires a {@code StatusQuery} for each; the proxy - * answers with {@code StatusUpdate} frames that repaint each tile by phase + - * ownership. Clicking a tile sends a - * {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim & - * Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join" - * of a running owned server and the "Wake" of a stopped owned one), then closes the - * menu. A claim refusal comes back as an {@code Error} frame and is shown to the + * this plugin. The list puts the player's own servers first and carries felis-api's + * verdict on each: whether this player may start it, and why not. The menu then + * paints a "loading" tile per server on the page (45 per page, arrows in the bottom + * row) and fires a {@code StatusQuery} for each; the proxy answers with + * {@code StatusUpdate} frames that repaint each tile ({@link MenuTiles}): Join when it + * is up, Claim & Start when it is ownerless, a grey tile naming the reason when the + * player may not start it, Start otherwise, with the phase in the player's language. + * Claim sends a {@code ClaimRequest}; Join and Start both send a {@code WakeRequest}; + * each closes the menu. A grey tile sends nothing: its reason goes to chat and the + * menu stays open. A claim refusal comes back as an {@code Error} frame and is shown to the * player here; wake-path refusals (policy gate, capacity) are chat messages the * proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady} * just before the proxy Connects them. @@ -122,7 +124,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug }, LIST_TIMEOUT_TICKS); } - private void openPage(Player player, List all, int page) { + private void openPage(Player player, List all, Map access, int page) { boolean zh = zh(player); if (all.isEmpty()) { player.sendMessage(Component.text( @@ -133,7 +135,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug } int pages = MenuHolder.pageCount(all.size()); int p = Math.max(0, Math.min(page, pages - 1)); - MenuHolder holder = new MenuHolder(all, p); + MenuHolder holder = new MenuHolder(all, access, p); List view = holder.servers(); int size = pages > 1 ? 54 : invSize(view.size()); Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages)); @@ -180,7 +182,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) { int target = holder.page() + (slot == PREV_SLOT ? -1 : 1); if (target >= 0 && target < holder.pages()) { - openPage(player, holder.all(), target); + openPage(player, holder.all(), holder.access(), target); } return; } @@ -192,13 +194,17 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug if (state == null) { return; // still loading — no status yet, so we don't know which frame to send } - // Claimable (ownerless + stopped) → Claim & Start; everything else → Wake - // (which the proxy treats as Join when the owned server is already running). - if (state.claimable()) { - sendUpstream(player, ControlFrame.claimRequest(player.getName(), server)); - } else { - sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server)); + boolean zh = zh(player); + MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh); + ControlFrame click = MenuTiles.click(tile, player.getName(), server); + if (click == null) { + // A start felis-api would refuse: say why and leave the menu open, so the + // player can pick another server. + player.sendMessage(Component.text("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh), + NamedTextColor.YELLOW)); + return; } + sendUpstream(player, click); player.closeInventory(); } @@ -231,7 +237,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug // Only a /menu that is still waiting opens; a late answer after the // timeout message is dropped rather than popping a menu up unasked. if (pendingOpen.remove(player.getUniqueId()) != null) { - openPage(player, frame.servers(), 0); + openPage(player, frame.servers(), MenuTiles.accessByName(frame), 0); } break; case ControlFrame.STATUS_UPDATE: @@ -266,7 +272,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug return; // a server we are not showing } holder.put(frame.server(), frame); - top.setItem(slot, tile(frame, zh(player))); + top.setItem(slot, tile(frame, holder.verdict(frame.server()), zh(player))); } // markUnavailable repaints a still-loading tile whose status query was refused, so @@ -318,30 +324,42 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug return item; } - private ItemStack tile(ControlFrame f, boolean zh) { + private ItemStack tile(ControlFrame f, String verdict, boolean zh) { + MenuTiles.Tile t = MenuTiles.tile(f, verdict, zh); Material material; - String action; NamedTextColor color; - if (f.claimable()) { - material = Material.GOLD_BLOCK; - action = zh ? "认领并启动" : "Claim & Start"; - color = NamedTextColor.GOLD; - } else if (f.ready()) { - material = Material.LIME_CONCRETE; - action = zh ? "加入" : "Join"; - color = NamedTextColor.GREEN; - } else { - material = Material.RED_CONCRETE; - action = zh ? "唤醒" : "Wake"; - color = NamedTextColor.RED; + switch (t.kind()) { + case CLAIM -> { + material = Material.GOLD_BLOCK; + color = NamedTextColor.GOLD; + } + case JOIN -> { + material = Material.LIME_CONCRETE; + color = NamedTextColor.GREEN; + } + case LOCKED -> { + material = Material.GRAY_CONCRETE; + color = NamedTextColor.GRAY; + } + default -> { + material = Material.RED_CONCRETE; + color = NamedTextColor.RED; + } } ItemStack item = new ItemStack(material); ItemMeta meta = item.getItemMeta(); - meta.displayName(Component.text(action + " · " + f.server(), color) + meta.displayName(Component.text((t.mine() ? "★ " : "") + t.action() + " · " + f.server(), color) .decoration(TextDecoration.ITALIC, false)); List lore = new ArrayList<>(); - lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase())); + if (t.mine()) { + lore.add(Component.text(zh ? "你的服务器" : "Your server", NamedTextColor.GOLD) + .decoration(TextDecoration.ITALIC, false)); + } + lore.add(line(zh ? "状态" : "Status", MenuTiles.phase(f.phase(), zh))); lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax())); + for (String r : t.reason()) { + lore.add(Component.text(r, NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)); + } meta.lore(lore); item.setItemMeta(meta); return item; diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java index 13714a8..44dd5ce 100644 --- a/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java @@ -18,10 +18,11 @@ import java.util.Map; * on the plugin) means it is garbage-collected with the menu and never leaks across * reopen. * - *

It holds the full server list the proxy sent (so paging needs no new request), - * which page is showing, and the latest {@link ControlFrame} seen for each server on - * it, so a click knows whether to send a Claim or a Wake without re-querying. Within - * a page the list index is the slot. + *

It holds the full server list the proxy sent and felis-api's verdict for this + * player on each (so paging needs no new request), which page is showing, and the + * latest {@link ControlFrame} seen for each server on it, so a click knows what to + * send ({@link MenuTiles}) without re-querying. Within a page the list index + * is the slot. */ final class MenuHolder implements InventoryHolder { @@ -29,13 +30,15 @@ final class MenuHolder implements InventoryHolder { static final int PAGE_SIZE = 45; private final List all; + private final Map access; // server → verdict, when known private final int page; private final List servers; // this page; index = slot private final Map latest = new HashMap<>(); private Inventory inventory; - MenuHolder(List all, int page) { + MenuHolder(List all, Map access, int page) { this.all = all; + this.access = access; this.page = page; int from = Math.min(page * PAGE_SIZE, all.size()); this.servers = all.subList(from, Math.min(from + PAGE_SIZE, all.size())); @@ -50,6 +53,16 @@ final class MenuHolder implements InventoryHolder { return all; } + /** access is felis-api's verdict per server, as the proxy sent it. */ + Map access() { + return access; + } + + /** verdict is felis-api's verdict for one server, or null when none came. */ + String verdict(String server) { + return access.get(server); + } + int page() { return page; } diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuTiles.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuTiles.java new file mode 100644 index 0000000..16fc161 --- /dev/null +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuTiles.java @@ -0,0 +1,140 @@ +package best.lolicon.felis.paper; + +import best.lolicon.felis.link.ControlFrame; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * MenuTiles decides what a {@code /menu} tile says and what clicking it does, from the + * server's live status ({@code StatusUpdate}) and felis-api's verdict for this player + * (the {@code ListUpdate}'s access list). It is the whole of the menu's judgement, kept + * free of Bukkit so it can be tested as plain values; {@link FelisPaperPlugin} only + * turns a {@link Tile} into an item. + * + *

The order follows the proxy's wake. A server that is up is open to every linked + * player, so it is Join whatever the verdict. An ownerless one is Claim & Start: + * claiming makes the player its owner before the wake. Past those, a verdict that + * refuses the wake locks the tile and names the reason, so nobody queues for a start + * that cannot happen. Everything else is Wake, including a tile with no verdict (the + * proxy could not ask felis-api), which the wake then judges as before. + */ +final class MenuTiles { + + /** Kind is what a click on the tile does. */ + enum Kind { CLAIM, JOIN, WAKE, LOCKED } + + /** + * Tile is one tile's content: its kind, the action word in its title, whether it is + * the player's own server, and for a locked tile the reason, one lore line each. + */ + record Tile(Kind kind, String action, boolean mine, List reason) { + } + + // The verdicts felis-api's menu-access answers (handleInternalMenuAccess). + static final String OWNER = "owner"; + static final String RETIRING = "retiring"; + static final String START_FAILED = "start_failed"; + static final String OWNER_ONLY = "owner_only"; + static final String ALLOWLIST = "allowlist"; + + private MenuTiles() { + } + + static Tile tile(ControlFrame status, String verdict, boolean zh) { + boolean mine = OWNER.equals(verdict); + if (status.ready()) { + return new Tile(Kind.JOIN, zh ? "加入" : "Join", mine, List.of()); + } + if (status.claimable()) { + return new Tile(Kind.CLAIM, zh ? "认领并启动" : "Claim & Start", false, List.of()); + } + List reason = lockReason(verdict, zh); + if (!reason.isEmpty()) { + return new Tile(Kind.LOCKED, zh ? "无法启动" : "Can't start", mine, reason); + } + return new Tile(Kind.WAKE, zh ? "启动" : "Start", mine, List.of()); + } + + /** + * click is the frame a click on the tile sends, or null for a locked tile, which + * sends nothing and tells the player why ({@link #chatReason}). + */ + static ControlFrame click(Tile tile, String player, String server) { + switch (tile.kind()) { + case CLAIM: + return ControlFrame.claimRequest(player, server); + case LOCKED: + return null; + default: + // Join and Start are both a wake: the proxy joins a server that is up. + return ControlFrame.wakeRequest(player, server); + } + } + + /** chatReason is a locked tile's reason as one sentence. */ + static String chatReason(Tile tile, boolean zh) { + return zh ? String.join(",", tile.reason()) + "。" : String.join(". ", tile.reason()) + "."; + } + + // lockReason is why a verdict refuses the wake, or empty when it does not. + private static List lockReason(String verdict, boolean zh) { + if (verdict == null) { + return List.of(); + } + switch (verdict) { + case RETIRING: + return zh ? List.of("这台服务器已被放弃或正在删除", "不能再启动") + : List.of("This server was given up or is being deleted", "It can't be started"); + case START_FAILED: + return zh ? List.of("多次启动失败,已停止自动重试", "需要在网页控制台处理") + : List.of("It failed to start several times", "It needs a fix on the web console"); + case OWNER_ONLY: + return zh ? List.of("只有主人能启动这台服务器", "它运行时任何人都能加入") + : List.of("Only its owner can start it", "Anyone can join while it's running"); + case ALLOWLIST: + return zh ? List.of("你不在这台服务器的启动名单上", "它运行时任何人都能加入") + : List.of("You're not on its start list", "Anyone can join while it's running"); + default: + return List.of(); + } + } + + /** phase is the tile's status line: the lifecycle phase in the player's language. */ + static String phase(String phase, boolean zh) { + if (phase == null) { + return zh ? "未知" : "Unknown"; + } + switch (phase) { + case "Running": + return zh ? "运行中" : "Running"; + case "Starting": + return zh ? "启动中" : "Starting"; + case "Stopping": + return zh ? "停止中" : "Stopping"; + case "Stopped": + return zh ? "已停止" : "Stopped"; + case "Failed": + return zh ? "启动失败" : "Failed to start"; + default: + return zh ? "未知" : "Unknown"; + } + } + + /** + * accessByName reads a {@code ListUpdate}'s verdicts into server → verdict, leaving + * out the servers it gave none for (and every server when the proxy sent names only). + */ + static Map accessByName(ControlFrame list) { + Map out = new HashMap<>(); + List names = list.servers(); + List access = list.access(); + for (int i = 0; i < names.size() && i < access.size(); i++) { + if (!access.get(i).isEmpty()) { + out.put(names.get(i), access.get(i)); + } + } + return out; + } +} diff --git a/plugins/paper/test/best/lolicon/felis/paper/MenuTilesTest.java b/plugins/paper/test/best/lolicon/felis/paper/MenuTilesTest.java new file mode 100644 index 0000000..82dbed9 --- /dev/null +++ b/plugins/paper/test/best/lolicon/felis/paper/MenuTilesTest.java @@ -0,0 +1,161 @@ +package best.lolicon.felis.paper; + +import best.lolicon.felis.link.Control; +import best.lolicon.felis.link.ControlFrame; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * MenuTilesTest checks the menu's judgement: which tile a server gets from its live + * status and felis-api's verdict for the player, what a click on it sends, the reason + * a grey tile gives, the phase in each language, and that the verdicts survive the + * wire and the paging. Framework free: a failed assertion throws. + * + *

Run: {@code ./gradlew menuTilesTest} (or {@code lobbyTest}, which runs both mains) + * in plugins/paper. + */ +public final class MenuTilesTest { + + private static int checks; + + // Every verdict felis-api answers, then none, an empty one, and one from a newer felis-api. + private static final List VERDICTS = Arrays.asList( + "owner", "wake", "owner_only", "allowlist", "retiring", "start_failed", null, "", "future_verdict"); + + public static void main(String[] args) { + upServers(); + ownerless(); + stopped(); + clicks(); + phases(); + wire(); + System.out.println("MenuTilesTest OK (" + checks + " checks)"); + } + + // An up server is open to every linked player: Join whatever the verdict, ownerless or not. + private static void upServers() { + for (String v : VERDICTS) { + for (boolean claimable : new boolean[] {false, true}) { + MenuTiles.Tile t = MenuTiles.tile(status(true, claimable), v, true); + assertEq("up, " + v + ", claimable=" + claimable, "JOIN 加入 " + "owner".equals(v) + " []", brief(t)); + } + } + assertEq("up, English", "JOIN Join true []", brief(MenuTiles.tile(status(true, false), "owner", false))); + } + + // An ownerless server that is down is Claim & Start: the claim makes the player its owner first. + private static void ownerless() { + for (String v : VERDICTS) { + assertEq("ownerless, " + v, "CLAIM 认领并启动 false []", brief(MenuTiles.tile(status(false, true), v, true))); + } + assertEq("ownerless, English", "CLAIM Claim & Start false []", + brief(MenuTiles.tile(status(false, true), "owner_only", false))); + } + + // Down and owned: the verdict decides between Start and a grey tile with its reason. + private static void stopped() { + Map zh = new HashMap<>(); + zh.put("owner", "WAKE 启动 true []"); + zh.put("wake", "WAKE 启动 false []"); + zh.put(null, "WAKE 启动 false []"); + zh.put("", "WAKE 启动 false []"); + zh.put("future_verdict", "WAKE 启动 false []"); + zh.put("owner_only", "LOCKED 无法启动 false [只有主人能启动这台服务器, 它运行时任何人都能加入]"); + zh.put("allowlist", "LOCKED 无法启动 false [你不在这台服务器的启动名单上, 它运行时任何人都能加入]"); + zh.put("retiring", "LOCKED 无法启动 false [这台服务器已被放弃或正在删除, 不能再启动]"); + zh.put("start_failed", "LOCKED 无法启动 false [多次启动失败,已停止自动重试, 需要在网页控制台处理]"); + for (String v : VERDICTS) { + assertEq("stopped, " + v, zh.get(v), brief(MenuTiles.tile(status(false, false), v, true))); + } + + Map en = new HashMap<>(); + en.put("owner", "WAKE Start true []"); + en.put("owner_only", "LOCKED Can't start false [Only its owner can start it, Anyone can join while it's running]"); + en.put("allowlist", "LOCKED Can't start false [You're not on its start list, Anyone can join while it's running]"); + en.put("retiring", "LOCKED Can't start false [This server was given up or is being deleted, It can't be started]"); + en.put("start_failed", "LOCKED Can't start false [It failed to start several times, It needs a fix on the web console]"); + en.forEach((v, want) -> + assertEq("stopped, English, " + v, want, brief(MenuTiles.tile(status(false, false), v, false)))); + } + + // Claim sends a ClaimRequest, Join and Start a WakeRequest, a grey tile nothing. + private static void clicks() { + ControlFrame down = status(false, false); + assertEq("claim click", ControlFrame.claimRequest("Steve", "alpha"), + MenuTiles.click(MenuTiles.tile(status(false, true), "wake", true), "Steve", "alpha")); + assertEq("join click", ControlFrame.wakeRequest("Steve", "alpha"), + MenuTiles.click(MenuTiles.tile(status(true, false), "owner_only", true), "Steve", "alpha")); + assertEq("start click", ControlFrame.wakeRequest("Steve", "alpha"), + MenuTiles.click(MenuTiles.tile(down, "owner", true), "Steve", "alpha")); + assertEq("start click, no verdict", ControlFrame.wakeRequest("Steve", "alpha"), + MenuTiles.click(MenuTiles.tile(down, null, true), "Steve", "alpha")); + for (String v : List.of("owner_only", "allowlist", "retiring", "start_failed")) { + assertEq("locked click sends nothing: " + v, null, + MenuTiles.click(MenuTiles.tile(down, v, true), "Steve", "alpha")); + } + assertEq("chat reason, zh", "只有主人能启动这台服务器,它运行时任何人都能加入。", + MenuTiles.chatReason(MenuTiles.tile(down, "owner_only", true), true)); + assertEq("chat reason, en", "Only its owner can start it. Anyone can join while it's running.", + MenuTiles.chatReason(MenuTiles.tile(down, "owner_only", false), false)); + } + + private static void phases() { + String[][] table = { + {"Running", "运行中", "Running"}, + {"Starting", "启动中", "Starting"}, + {"Stopping", "停止中", "Stopping"}, + {"Stopped", "已停止", "Stopped"}, + {"Failed", "启动失败", "Failed to start"}, + {"Unknown", "未知", "Unknown"}, + {null, "未知", "Unknown"}, + {"", "未知", "Unknown"}, + {"Hibernating", "未知", "Unknown"}, + }; + for (String[] row : table) { + assertEq("phase " + row[0] + ", zh", row[1], MenuTiles.phase(row[0], true)); + assertEq("phase " + row[0] + ", en", row[2], MenuTiles.phase(row[0], false)); + } + } + + // The verdicts ride the ListUpdate by position and must land on the right server, + // on every page. + private static void wire() { + List names = new ArrayList<>(); + List access = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + names.add(String.format("s%02d", i)); + access.add(i == 0 ? "owner" : i == 47 ? "owner_only" : ""); + } + ControlFrame list = Control.decode(Control.encode(ControlFrame.listUpdate(names, access))); + Map byName = MenuTiles.accessByName(list); + assertEq("verdicts by name, unknowns left out", Map.of("s00", "owner", "s47", "owner_only"), byName); + assertEq("names only: no verdicts", + Map.of(), MenuTiles.accessByName(ControlFrame.listUpdate(List.of("a", "b")))); + + MenuHolder second = new MenuHolder(list.servers(), byName, 1); + assertEq("page 2 starts at s45", "s45", second.servers().get(0)); + assertEq("page 2 knows s47's verdict", "owner_only", second.verdict("s47")); + assertEq("... and s46 has none", null, second.verdict("s46")); + assertEq("paging keeps every verdict", byName, second.access()); + } + + // status is a StatusUpdate for server "alpha" in the given state. + private static ControlFrame status(boolean ready, boolean claimable) { + return ControlFrame.statusUpdate("alpha", ready ? "Running" : "Stopped", ready, 0, 20, claimable); + } + + private static String brief(MenuTiles.Tile t) { + return t.kind() + " " + t.action() + " " + t.mine() + " " + t.reason(); + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + ": got " + got + ", want " + want); + } + checks++; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java index 7ff3e65..c8d42cb 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java @@ -70,15 +70,12 @@ public final class Control { case ControlFrame.LIST_REQUEST: break; case ControlFrame.LIST_UPDATE: - sb.append(",\"servers\":["); - List 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 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 strList(Map o, String key) { - List 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 names = new ArrayList<>(); + List 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) { diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java index 96935c1..a77fe16 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java @@ -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. *

  • Downstream (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.
  • * @@ -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 servers; + private final List 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 servers) { + List servers, List 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 servers) { - List 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 servers, List access) { + List names = new ArrayList<>(); + List 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 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 diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 8dcc7e2..8d4d011 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -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":{"":""}}}): {@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 menuAccess(UUID mcUuid) throws LinkException { + Objects.requireNonNull(mcUuid, "mcUuid"); + Map obj = getObject("/api/v1/internal/player/menu-access/" + mcUuid, 200); + Map 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 diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java b/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java index 5d6ab00..0129d88 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java @@ -13,9 +13,10 @@ import java.util.Map; * *

    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. * *

    {@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 diff --git a/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java index 91bd8a0..e9b1788 100644 --- a/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java +++ b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java @@ -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() { diff --git a/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java index c94be51..f89e14d 100644 --- a/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java +++ b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java @@ -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 = { diff --git a/plugins/test.sh b/plugins/test.sh index efe3f2f..a1d69eb 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -46,16 +46,20 @@ # message (the stub answers a wake from the server's state in felis-api's own # order, so it cannot hand the router an answer the real API never gives), # felis:control acts only for the connection's player and holds its -# frame budget, and Velocity's own /server steps aside for the backend's while +# frame budget, the menu list leads with the player's own servers and falls +# back to names alone when felis-api or the call pool cannot answer, and Velocity's own /server steps aside for the backend's while # one another proxy plugin registered stays. They ride the module's verified dependency set, which is why # they live in Gradle rather than in the javac mains above. # -# 4. The lobby guard self-test (`./gradlew lobbyTest` in plugins/paper): LobbyGuard +# 4. The lobby self-tests (`./gradlew lobbyTest` in plugins/paper): LobbyGuard # runs against real paper-api events around fake players and worlds, so a # passer-by cannot change the lobby while a builder can, nobody is hurt or # starved, a fall into the void lands at spawn, a join sets adventure mode and # names /menu with a click, and a world rule the server refuses is logged -# without taking the menu down with it. +# without taking the menu down with it. MenuTiles decides each menu tile from +# the server's status and felis-api's verdict for the player: Join when it is up, +# Claim when ownerless, a grey tile with the reason (and no frame) when the +# player may not start it, the phase in the player's language. # # No test framework: the mains are the same javac one-liners their javadocs document, # so a local run and CI run the same bytes. diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java index ee6d23d..7fb1cb5 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java @@ -18,6 +18,7 @@ import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; import org.slf4j.Logger; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -47,6 +48,8 @@ import java.util.concurrent.ConcurrentHashMap; * frames are metered per player ({@link FrameBudget}) and menu projections are * shared across players for {@link #STATUS_TTL_MILLIS}: a lobby full of players * opening the menu at once reads each server's status once, not once per player. + * The one per-player read is a menu open's {@code ListRequest}: a single call that + * says what the player may do with every tile. * *

    Threading. {@code felis:control} frames arrive on a Velocity event * thread, but every felis-api call below blocks on HTTP. So each handler does the @@ -57,7 +60,8 @@ import java.util.concurrent.ConcurrentHashMap; * it cannot be set from the async hop. * *

    The lobby's upstream frames map onto the menu (spec §12): a {@code ListRequest} - * asks which tiles to draw ({@code ListUpdate} back, built from the registry); a + * asks which tiles to draw ({@code ListUpdate} back: the registry's names, the + * player's own first, each with what felis-api says the player may do with it); a * {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a * {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest} * (ownerless server) runs the two-rule split — claim asserts ownership/quota, then @@ -82,9 +86,12 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener // Two full menu pages (45 tiles + the list each) in a burst, then 10 frames a second. private static final int FRAME_BURST = 96; private static final double FRAME_REFILL_PER_SECOND = 10.0; - // Upper bound on names in one ListUpdate. A proxy→backend plugin message is capped - // at 32767 bytes; 500 names of at most 32 chars stays well under it. + // Upper bound on tiles in one ListUpdate. A proxy→backend plugin message is capped + // at 32767 bytes; 500 names of at most 32 chars plus 500 verdicts of at most 12 + // (each quoted, comma-separated) come to about 25 KB. static final int MAX_LISTED = 500; + // The menu-access verdict for a server the player owns; those tiles lead the list. + static final String MENU_OWNER = "owner"; // A refused source is logged at most once per interval, so a hostile backend // cannot turn its own refusals into a log flood. private static final long REFUSAL_LOG_INTERVAL_MILLIS = 60_000L; @@ -182,7 +189,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener handleClaim(source, player, frame.server()); break; case ControlFrame.LIST_REQUEST: - send(source, ControlFrame.listUpdate(listed())); + handleList(source, player); break; case ControlFrame.LOGIN_RELEASE: router.releaseFromLogin(player); @@ -198,6 +205,47 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener budget.forget(event.getPlayer().getUniqueId()); } + // A ListRequest draws the menu: the names come from the registry, and what this + // player may do with each comes from felis-api in one call. When felis-api cannot + // answer (or the pool is full) the names still go out, and the lobby draws its + // tiles without verdicts, as before they existed. + private void handleList(ServerConnection source, Player player) { + List names = listed(); + UUID id = player.getUniqueId(); + boolean taken = plugin.async(() -> { + Map access; + try { + access = api.menuAccess(id); + } catch (LinkException e) { + log.debug("Felis: menu access for {} unavailable; listing names only: {}", id, e.getMessage()); + access = Map.of(); + } + send(source, menuList(names, access)); + }); + if (!taken) { + send(source, menuList(names, Map.of())); + } + } + + /** + * menuList orders the tiles and pairs each with its verdict: the player's own + * servers first, then everything else, each group by name. The cap applies after + * the ordering, so a player's own servers are never the ones cut. + */ + static ControlFrame menuList(List names, Map access) { + List sorted = new ArrayList<>(names); + sorted.sort(Comparator.comparing((String n) -> !MENU_OWNER.equals(access.get(n))) + .thenComparing(Comparator.naturalOrder())); + if (sorted.size() > MAX_LISTED) { + sorted = sorted.subList(0, MAX_LISTED); + } + List verdicts = new ArrayList<>(sorted.size()); + for (String n : sorted) { + verdicts.add(access.getOrDefault(n, "")); + } + return ControlFrame.listUpdate(sorted, verdicts); + } + // listed is the lobby's tile set: every managed user server, by name. The system // servers are the lobby itself and the gate in front of it, so neither is a tile. private List listed() { @@ -207,8 +255,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener names.add(v.name()); } } - names.sort(String::compareTo); - return names.size() > MAX_LISTED ? names.subList(0, MAX_LISTED) : names; + return names; } private void refused(String sourceName, ControlFrame frame, ControlPolicy.Verdict verdict) { @@ -253,7 +300,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener } } - // A WakeRequest is the menu's Join/Wake button on a server the player owns: wake + // A WakeRequest is the menu's Join or Start button: wake // it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread // and reports its own refusals to the player; nothing to await here. private void handleWake(ServerConnection source, Player player, String server) { diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java index 66d6441..16158a4 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java @@ -19,6 +19,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; /** * ControlChannelTest drives the real ControlChannel (with the real WaitingRouter, @@ -74,6 +76,7 @@ public final class ControlChannelTest { consumption(); sources(); + lists(); statusQueries(reg, plugin); claims(); wakeAndTransfer(); @@ -133,12 +136,90 @@ public final class ControlChannelTest { assertEq("malformed name not echoed", "Error not_found null", brief(r.get(2))); assertEq("bad server: no felis-api call", 0, api.count("POST " + SERVERS + "nope/wake")); - // The tile list: user servers only, sorted. + // The tile list: user servers only, sorted; no verdicts known for this player. message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); + Fakes.await("list answered", () -> q.pluginMessages.size() == 4); List all = frames(q); ControlFrame list = all.get(all.size() - 1); assertEq("list type", ControlFrame.LIST_UPDATE, list.type()); assertEq("list: user servers, sorted", List.of("alpha", "beta", "gamma"), list.servers()); + assertEq("list: no verdicts", List.of(), list.access()); + } + + private static void lists() throws Exception { + String accessPath = "GET /api/v1/internal/player/menu-access/"; + + // A menu open: the player's own servers lead, and each tile carries what felis-api + // says this player may do with it. A verdict for a server the proxy does not + // route is no tile. + Fakes.FakePlayer p = player(true); + api.access.put(p.id, Map.of("gamma", "owner", "alpha", "owner_only", "beta", "wake", "zeta", "wake")); + message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); + Fakes.await("list with verdicts", () -> p.pluginMessages.size() == 1); + ControlFrame f = frames(p).get(0); + assertEq("own servers first, then by name", List.of("gamma", "alpha", "beta"), f.servers()); + assertEq("verdicts aligned with the names", List.of("owner", "owner_only", "wake"), f.access()); + assertEq("asked felis-api about the connection's player", 1, api.count(accessPath + p.id)); + + // felis-api down: the names still go out, sorted, without verdicts. + api.menuAccessError = "500 internal"; + Fakes.FakePlayer d = player(true); + api.access.put(d.id, Map.of("gamma", "owner")); + message(d.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); + Fakes.await("list while felis-api is down", () -> d.pluginMessages.size() == 1); + ControlFrame down = frames(d).get(0); + assertEq("felis-api down: names, sorted", List.of("alpha", "beta", "gamma"), down.servers()); + assertEq("felis-api down: no verdicts", List.of(), down.access()); + api.menuAccessError = null; + + // A full call pool (8 in flight, 64 waiting): the rest are answered at once with + // the names alone, and the held ones still arrive with verdicts once felis-api answers. + CountDownLatch hold = new CountDownLatch(1); + api.menuAccessHold = hold; + Fakes.FakePlayer b = player(true); + api.access.put(b.id, Map.of("gamma", "owner")); + try { + for (int i = 0; i < 80; i++) { + message(b.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); + } + List inline = frames(b); + assertEq("pool full: the overflow answered at once (" + inline.size() + ")", + true, inline.size() >= 8 && inline.size() <= 16); + for (ControlFrame x : inline) { + assertEq("pool full: names only", "[alpha, beta, gamma] []", x.servers() + " " + x.access()); + } + } finally { + hold.countDown(); + api.menuAccessHold = null; + } + Fakes.await("held lists answered", () -> b.pluginMessages.size() == 80); + List after = frames(b); + assertEq("held lists carry verdicts", "[gamma, alpha, beta] [owner, , ]", + after.get(after.size() - 1).servers() + " " + after.get(after.size() - 1).access()); + + // The cap cuts after the ordering: a player's own servers are never the ones dropped. + List many = new ArrayList<>(); + for (int i = 0; i < ControlChannel.MAX_LISTED + 100; i++) { + many.add(String.format("s%03d", i)); + } + ControlFrame capped = ControlChannel.menuList(many, Map.of("s599", "owner", "s001", "wake")); + assertEq("cap: size", ControlChannel.MAX_LISTED, capped.servers().size()); + assertEq("cap: own server kept, first", "s599 owner", capped.servers().get(0) + " " + capped.access().get(0)); + assertEq("cap: then by name", "s000 s001 wake", capped.servers().get(1) + " " + + capped.servers().get(2) + " " + capped.access().get(2)); + assertEq("cap: the tail is what goes", "s498", capped.servers().get(ControlChannel.MAX_LISTED - 1)); + + // The worst case still fits one proxy→backend plugin message: longest names, + // longest verdict on every tile. + List longest = new ArrayList<>(); + Map worst = new java.util.HashMap<>(); + for (int i = 0; i < ControlChannel.MAX_LISTED + 100; i++) { + String n = String.format("%032d", i); + longest.add(n); + worst.put(n, "start_failed"); + } + int bytes = Control.encode(ControlChannel.menuList(longest, worst)).length; + assertEq("worst case fits one plugin message (" + bytes + " bytes)", true, bytes < 32767); } private static void statusQueries(ServerRegistry reg, FelisVelocityPlugin plugin) throws Exception { @@ -246,19 +327,20 @@ public final class ControlChannelTest { private static void budget() { // 96 frames in a burst, then 10 a second: a flood is cut off, the connection - // is not. ListRequest is answered inline, so every accepted frame shows at once. + // is not. Every accepted ListRequest is answered exactly once (from the call pool, + // or at once when the pool is full), so the answers count the accepted frames. Fakes.FakePlayer p = player(true); for (int i = 0; i < 150; i++) { message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); } - int answered = p.pluginMessages.size(); + int answered = settled(p, 96); assertEq("flood cut at the burst (plus what refilled meanwhile): " + answered, true, answered >= 96 && answered <= 99); // Another player has a budget of their own. Fakes.FakePlayer q = player(true); message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); - assertEq("other player unaffected", 1, q.pluginMessages.size()); + assertEq("other player unaffected", 1, settled(q, 1)); // Leaving the proxy forgets the budget: a fresh burst, far more than a refill. channel.onDisconnect(new DisconnectEvent(p.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)); @@ -266,7 +348,29 @@ public final class ControlChannelTest { for (int i = 0; i < 50; i++) { message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest())); } - assertEq("budget forgotten on disconnect", 50, p.pluginMessages.size()); + assertEq("budget forgotten on disconnect", 50, settled(p, 50)); + } + + // settled waits for at least min answers, then until none has arrived for 300 ms, + // and returns how many there are: the count once the pool has drained. + private static int settled(Fakes.FakePlayer p, int min) { + Fakes.await(min + " answers", () -> p.pluginMessages.size() >= min); + int seen = p.pluginMessages.size(); + long quietSince = System.nanoTime(); + while (System.nanoTime() - quietSince < 300_000_000L) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted settling"); + } + int now = p.pluginMessages.size(); + if (now != seen) { + seen = now; + quietSince = System.nanoTime(); + } + } + return seen; } // ---- helpers ---- diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java index d1f5869..6bb481c 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java @@ -31,6 +31,8 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; import java.util.function.Function; import java.util.regex.Matcher; @@ -482,6 +484,15 @@ final class Fakes { /** menuError and claimError map a server to "status code", like wakeError. */ final Map menuError = new ConcurrentHashMap<>(); final Map claimError = new ConcurrentHashMap<>(); + /** + * access is what the menu-access route answers per player: server → verdict. + * A player with no entry gets an empty map; menuAccessError ("status code") + * makes the route fail for everyone. + */ + final Map> access = new ConcurrentHashMap<>(); + volatile String menuAccessError; + /** menuAccessHold, while set, holds every menu-access request until it opens. */ + volatile CountDownLatch menuAccessHold; /** bodies holds the last request body per "METHOD path". */ final Map bodies = new ConcurrentHashMap<>(); /** calls lists every request as "METHOD path", in arrival order. */ @@ -506,6 +517,7 @@ final class Fakes { calls.add(method + " " + path); String status = "/api/v1/internal/account/link/status/"; String servers = "/api/v1/internal/servers/"; + String menuAccess = "/api/v1/internal/player/menu-access/"; if (path.startsWith(status)) { if (linkDown) { reply(ex, 500, "{\"error\":{\"code\":\"internal\",\"message\":\"down\"}}"); @@ -515,6 +527,24 @@ final class Fakes { reply(ex, 200, "{\"linked\":" + yes + "}"); return; } + if (path.startsWith(menuAccess) && "GET".equals(method)) { + CountDownLatch hold = menuAccessHold; + if (hold != null) { + try { + hold.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + if (!error(ex, menuAccessError)) { + UUID who = UUID.fromString(path.substring(menuAccess.length())); + StringBuilder sb = new StringBuilder("{\"servers\":{"); + access.getOrDefault(who, Map.of()).forEach((n, v) -> + sb.append(sb.length() > 12 ? "," : "").append('"').append(n).append("\":\"").append(v).append('"')); + reply(ex, 200, sb.append("}}").toString()); + } + return; + } if (path.startsWith(servers)) { String[] parts = path.substring(servers.length()).split("/"); String name = parts[0];