fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文
This commit is contained in:
21 files changed
+1074
-129
No files matched your search
+37
-1
@@ -1361,7 +1361,6 @@ paths:
|
|||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- { name: mc_uuid, in: query, required: false, schema: { type: string } }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Menu projection for the lobby UI.
|
description: Menu projection for the lobby UI.
|
||||||
@@ -1382,6 +1381,43 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$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:
|
/api/v1/internal/account/link/code:
|
||||||
post:
|
post:
|
||||||
tags: [account-internal]
|
tags: [account-internal]
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// list/status views never carry.
|
// list/status views never carry.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
|
{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},
|
{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
|
// 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
|
// 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).
|
// web never holds (account_link_codes has no user_id column).
|
||||||
|
|||||||
@@ -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
|
// authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec
|
||||||
// §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where
|
// §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
|
// the joining player is known only by their verified online-mode UUID rather than
|
||||||
@@ -358,42 +432,68 @@ func (a *API) authorizeWakeByUUID(ctx context.Context, mcUUID string, info *Serv
|
|||||||
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) {
|
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Resolve the UUID to its linked user once; staff role or ownership grants
|
s, err := a.standingByUUID(ctx, mcUUID)
|
||||||
// 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:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch info.AutostartPolicy {
|
|
||||||
case string(v1alpha1.AutostartAllowlist):
|
|
||||||
ok, err := a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok {
|
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
|
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
|
||||||
}
|
}
|
||||||
return errForbidden
|
switch u, err := a.Repo.UserByID(ctx, userID); {
|
||||||
default: // ownerOnly or unset → only the owner (handled above) may wake
|
case err == nil:
|
||||||
return errForbidden
|
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
|
// writeLookupError maps a repo/cluster lookup error onto an HTTP status: a
|
||||||
// missing record is 404, an atomic precondition failure is 409, anything else is
|
// missing record is 404, an atomic precondition failure is 409, anything else is
|
||||||
// an opaque 500.
|
// an opaque 500.
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for
|
// 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)
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -230,6 +230,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/internal/account/link/code": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3347,9 +3367,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
internalMenuStatus: {
|
internalMenuStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: never;
|
||||||
mc_uuid?: string;
|
|
||||||
};
|
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -3380,6 +3398,33 @@ export interface operations {
|
|||||||
404: components["responses"]["NotFound"];
|
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: {
|
createLinkCode: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+19
-10
@@ -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
|
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
|
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
|
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/Control.class (channel framing)
|
||||||
best/lolicon/felis/link/ControlFrame.class (the frame model)
|
best/lolicon/felis/link/ControlFrame.class (the frame model)
|
||||||
best/lolicon/felis/link/Json.class (codec)
|
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/MenuHolder.class
|
||||||
|
best/lolicon/felis/paper/MenuTiles.class (+ $Kind, $Tile)
|
||||||
```
|
```
|
||||||
|
|
||||||
— and **no** `FelisApiClient`, `LinkClient`, or token-config class. If a codec
|
— 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
|
`StatusUpdate`, `TransferReady` and `Error`. `/menu` sends a `ListRequest`, and the
|
||||||
proxy answers with a `ListUpdate` naming every user server it routes, built from 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
|
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
|
in the bottom row) and fires a `StatusQuery` for each; the proxy answers with
|
||||||
`StatusUpdate` frames that repaint each tile by phase + ownership.
|
`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
|
trusted payload — it only names *which* tile was clicked. A fully compromised
|
||||||
lobby therefore cannot act as another player or reach the API directly.
|
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 |
|
| Tile state | Label | Frame sent |
|
||||||
| ---------- | ----- | ---------- |
|
| ---------- | ----- | ---------- |
|
||||||
| ownerless + stopped (`claimable`) | **Claim & Start** | `ClaimRequest{server}` |
|
| up (`ready`), any verdict | **Join** (green) | `WakeRequest{server}` |
|
||||||
| owned + running (`ready`) | **Join** | `WakeRequest{server}` |
|
| ownerless (`claimable`) | **Claim & Start** (gold) | `ClaimRequest{server}` |
|
||||||
| owned + stopped | **Wake** | `WakeRequest{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
|
The player's own servers are marked ★ and "Your server". The status line shows the
|
||||||
label differs; the proxy treats a wake of an already-running owned server as a
|
phase in the player's language (运行中 / Running, 启动中 / Starting, 停止中 / Stopping,
|
||||||
join. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` /
|
已停止 / 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/
|
`already_claimed` → a friendly message), which is the only place a claim/quota/
|
||||||
policy failure surfaces to the player; readiness arrives as `TransferReady` just
|
policy failure surfaces to the player; readiness arrives as `TransferReady` just
|
||||||
before the proxy Connects them.
|
before the proxy Connects them.
|
||||||
|
|||||||
@@ -62,23 +62,33 @@ tasks.withType(JavaCompile).configureEach {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around
|
// 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
|
// Proxy-built fakes, and MenuTilesTest checks the menu's tile judgement (plain mains, no
|
||||||
// not part of `build`, which the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`.
|
// 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 {
|
sourceSets {
|
||||||
lobbyTest {
|
lobbyTest {
|
||||||
java {
|
java {
|
||||||
srcDir 'test'
|
srcDir 'test'
|
||||||
include 'best/lolicon/felis/paper/Fakes.java'
|
include 'best/lolicon/felis/paper/Fakes.java'
|
||||||
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
|
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
|
||||||
|
include 'best/lolicon/felis/paper/MenuTilesTest.java'
|
||||||
}
|
}
|
||||||
compileClasspath += sourceSets.main.output + configurations.compileClasspath
|
compileClasspath += sourceSets.main.output + configurations.compileClasspath
|
||||||
runtimeClasspath += output + 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) {
|
tasks.register('lobbyTest', JavaExec) {
|
||||||
group = 'verification'
|
group = 'verification'
|
||||||
description = 'Runs the LobbyGuardTest self-test main.'
|
description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.'
|
||||||
|
dependsOn 'menuTilesTest'
|
||||||
classpath = sourceSets.lobbyTest.runtimeClasspath
|
classpath = sourceSets.lobbyTest.runtimeClasspath
|
||||||
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
|
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
|
||||||
}
|
}
|
||||||
@@ -48,14 +48,16 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* <p><b>Flow.</b> {@code /menu} sends a {@code ListRequest}; the proxy answers with a
|
* <p><b>Flow.</b> {@code /menu} sends a {@code ListRequest}; the proxy answers with a
|
||||||
* {@code ListUpdate} naming every user server it routes, built from the same registry
|
* {@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
|
* 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
|
* this plugin. The list puts the player's own servers first and carries felis-api's
|
||||||
* page, arrows in the bottom row) and fires a {@code StatusQuery} for each; the proxy
|
* verdict on each: whether this player may start it, and why not. The menu then
|
||||||
* answers with {@code StatusUpdate} frames that repaint each tile by phase +
|
* paints a "loading" tile per server on the page (45 per page, arrows in the bottom
|
||||||
* ownership. Clicking a tile sends a
|
* row) and fires a {@code StatusQuery} for each; the proxy answers with
|
||||||
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
|
* {@code StatusUpdate} frames that repaint each tile ({@link MenuTiles}): Join when it
|
||||||
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
|
* is up, Claim & Start when it is ownerless, a grey tile naming the reason when the
|
||||||
* of a running owned server and the "Wake" of a stopped owned one), then closes the
|
* player may not start it, Start otherwise, with the phase in the player's language.
|
||||||
* menu. A claim refusal comes back as an {@code Error} frame and is shown to the
|
* 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
|
* player here; wake-path refusals (policy gate, capacity) are chat messages the
|
||||||
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
|
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
|
||||||
* just before the proxy Connects them.
|
* just before the proxy Connects them.
|
||||||
@@ -122,7 +124,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
}, LIST_TIMEOUT_TICKS);
|
}, LIST_TIMEOUT_TICKS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openPage(Player player, List<String> all, int page) {
|
private void openPage(Player player, List<String> all, Map<String, String> access, int page) {
|
||||||
boolean zh = zh(player);
|
boolean zh = zh(player);
|
||||||
if (all.isEmpty()) {
|
if (all.isEmpty()) {
|
||||||
player.sendMessage(Component.text(
|
player.sendMessage(Component.text(
|
||||||
@@ -133,7 +135,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
}
|
}
|
||||||
int pages = MenuHolder.pageCount(all.size());
|
int pages = MenuHolder.pageCount(all.size());
|
||||||
int p = Math.max(0, Math.min(page, pages - 1));
|
int p = Math.max(0, Math.min(page, pages - 1));
|
||||||
MenuHolder holder = new MenuHolder(all, p);
|
MenuHolder holder = new MenuHolder(all, access, p);
|
||||||
List<String> view = holder.servers();
|
List<String> view = holder.servers();
|
||||||
int size = pages > 1 ? 54 : invSize(view.size());
|
int size = pages > 1 ? 54 : invSize(view.size());
|
||||||
Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages));
|
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)) {
|
if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) {
|
||||||
int target = holder.page() + (slot == PREV_SLOT ? -1 : 1);
|
int target = holder.page() + (slot == PREV_SLOT ? -1 : 1);
|
||||||
if (target >= 0 && target < holder.pages()) {
|
if (target >= 0 && target < holder.pages()) {
|
||||||
openPage(player, holder.all(), target);
|
openPage(player, holder.all(), holder.access(), target);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -192,13 +194,17 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
if (state == null) {
|
if (state == null) {
|
||||||
return; // still loading — no status yet, so we don't know which frame to send
|
return; // still loading — no status yet, so we don't know which frame to send
|
||||||
}
|
}
|
||||||
// Claimable (ownerless + stopped) → Claim & Start; everything else → Wake
|
boolean zh = zh(player);
|
||||||
// (which the proxy treats as Join when the owned server is already running).
|
MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh);
|
||||||
if (state.claimable()) {
|
ControlFrame click = MenuTiles.click(tile, player.getName(), server);
|
||||||
sendUpstream(player, ControlFrame.claimRequest(player.getName(), server));
|
if (click == null) {
|
||||||
} else {
|
// A start felis-api would refuse: say why and leave the menu open, so the
|
||||||
sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server));
|
// player can pick another server.
|
||||||
|
player.sendMessage(Component.text("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh),
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
sendUpstream(player, click);
|
||||||
player.closeInventory();
|
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
|
// Only a /menu that is still waiting opens; a late answer after the
|
||||||
// timeout message is dropped rather than popping a menu up unasked.
|
// timeout message is dropped rather than popping a menu up unasked.
|
||||||
if (pendingOpen.remove(player.getUniqueId()) != null) {
|
if (pendingOpen.remove(player.getUniqueId()) != null) {
|
||||||
openPage(player, frame.servers(), 0);
|
openPage(player, frame.servers(), MenuTiles.accessByName(frame), 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ControlFrame.STATUS_UPDATE:
|
case ControlFrame.STATUS_UPDATE:
|
||||||
@@ -266,7 +272,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
return; // a server we are not showing
|
return; // a server we are not showing
|
||||||
}
|
}
|
||||||
holder.put(frame.server(), frame);
|
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
|
// 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;
|
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;
|
Material material;
|
||||||
String action;
|
|
||||||
NamedTextColor color;
|
NamedTextColor color;
|
||||||
if (f.claimable()) {
|
switch (t.kind()) {
|
||||||
|
case CLAIM -> {
|
||||||
material = Material.GOLD_BLOCK;
|
material = Material.GOLD_BLOCK;
|
||||||
action = zh ? "认领并启动" : "Claim & Start";
|
|
||||||
color = NamedTextColor.GOLD;
|
color = NamedTextColor.GOLD;
|
||||||
} else if (f.ready()) {
|
}
|
||||||
|
case JOIN -> {
|
||||||
material = Material.LIME_CONCRETE;
|
material = Material.LIME_CONCRETE;
|
||||||
action = zh ? "加入" : "Join";
|
|
||||||
color = NamedTextColor.GREEN;
|
color = NamedTextColor.GREEN;
|
||||||
} else {
|
}
|
||||||
|
case LOCKED -> {
|
||||||
|
material = Material.GRAY_CONCRETE;
|
||||||
|
color = NamedTextColor.GRAY;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
material = Material.RED_CONCRETE;
|
material = Material.RED_CONCRETE;
|
||||||
action = zh ? "唤醒" : "Wake";
|
|
||||||
color = NamedTextColor.RED;
|
color = NamedTextColor.RED;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ItemStack item = new ItemStack(material);
|
ItemStack item = new ItemStack(material);
|
||||||
ItemMeta meta = item.getItemMeta();
|
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));
|
.decoration(TextDecoration.ITALIC, false));
|
||||||
List<Component> lore = new ArrayList<>();
|
List<Component> 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()));
|
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);
|
meta.lore(lore);
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import java.util.Map;
|
|||||||
* on the plugin) means it is garbage-collected with the menu and never leaks across
|
* on the plugin) means it is garbage-collected with the menu and never leaks across
|
||||||
* reopen.
|
* reopen.
|
||||||
*
|
*
|
||||||
* <p>It holds the full server list the proxy sent (so paging needs no new request),
|
* <p>It holds the full server list the proxy sent and felis-api's verdict for this
|
||||||
* which page is showing, and the latest {@link ControlFrame} seen for each server on
|
* player on each (so paging needs no new request), which page is showing, and the
|
||||||
* it, so a click knows whether to send a Claim or a Wake without re-querying. Within
|
* latest {@link ControlFrame} seen for each server on it, so a click knows what to
|
||||||
* a page the list index <em>is</em> the slot.
|
* send ({@link MenuTiles}) without re-querying. Within a page the list index
|
||||||
|
* <em>is</em> the slot.
|
||||||
*/
|
*/
|
||||||
final class MenuHolder implements InventoryHolder {
|
final class MenuHolder implements InventoryHolder {
|
||||||
|
|
||||||
@@ -29,13 +30,15 @@ final class MenuHolder implements InventoryHolder {
|
|||||||
static final int PAGE_SIZE = 45;
|
static final int PAGE_SIZE = 45;
|
||||||
|
|
||||||
private final List<String> all;
|
private final List<String> all;
|
||||||
|
private final Map<String, String> access; // server → verdict, when known
|
||||||
private final int page;
|
private final int page;
|
||||||
private final List<String> servers; // this page; index = slot
|
private final List<String> servers; // this page; index = slot
|
||||||
private final Map<String, ControlFrame> latest = new HashMap<>();
|
private final Map<String, ControlFrame> latest = new HashMap<>();
|
||||||
private Inventory inventory;
|
private Inventory inventory;
|
||||||
|
|
||||||
MenuHolder(List<String> all, int page) {
|
MenuHolder(List<String> all, Map<String, String> access, int page) {
|
||||||
this.all = all;
|
this.all = all;
|
||||||
|
this.access = access;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
int from = Math.min(page * PAGE_SIZE, all.size());
|
int from = Math.min(page * PAGE_SIZE, all.size());
|
||||||
this.servers = all.subList(from, Math.min(from + 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;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** access is felis-api's verdict per server, as the proxy sent it. */
|
||||||
|
Map<String, String> 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() {
|
int page() {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<String> 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<String> 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<String, String> accessByName(ControlFrame list) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
List<String> names = list.servers();
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<String, String> 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<String, String> 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<String> names = new ArrayList<>();
|
||||||
|
List<String> 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<String, String> 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,15 +70,12 @@ public final class Control {
|
|||||||
case ControlFrame.LIST_REQUEST:
|
case ControlFrame.LIST_REQUEST:
|
||||||
break;
|
break;
|
||||||
case ControlFrame.LIST_UPDATE:
|
case ControlFrame.LIST_UPDATE:
|
||||||
sb.append(",\"servers\":[");
|
kvList(sb, "servers", frame.servers());
|
||||||
List<String> names = frame.servers();
|
// Only a list that carries verdicts writes them, so a names-only
|
||||||
for (int i = 0; i < names.size(); i++) {
|
// frame stays the shape it always was.
|
||||||
if (i > 0) {
|
if (!frame.access().isEmpty()) {
|
||||||
sb.append(',');
|
kvList(sb, "access", frame.access());
|
||||||
}
|
}
|
||||||
jsonString(sb, names.get(i));
|
|
||||||
}
|
|
||||||
sb.append(']');
|
|
||||||
break;
|
break;
|
||||||
case ControlFrame.LOGIN_RELEASE:
|
case ControlFrame.LOGIN_RELEASE:
|
||||||
kv(sb, "player", frame.player());
|
kv(sb, "player", frame.player());
|
||||||
@@ -128,7 +125,7 @@ public final class Control {
|
|||||||
case ControlFrame.LIST_REQUEST:
|
case ControlFrame.LIST_REQUEST:
|
||||||
return ControlFrame.listRequest();
|
return ControlFrame.listRequest();
|
||||||
case ControlFrame.LIST_UPDATE:
|
case ControlFrame.LIST_UPDATE:
|
||||||
return ControlFrame.listUpdate(strList(o, "servers"));
|
return listUpdate(o);
|
||||||
case ControlFrame.LOGIN_RELEASE:
|
case ControlFrame.LOGIN_RELEASE:
|
||||||
return ControlFrame.loginRelease(str(o, "player"));
|
return ControlFrame.loginRelease(str(o, "player"));
|
||||||
default:
|
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) {
|
private static void kvBool(StringBuilder sb, String key, boolean value) {
|
||||||
sb.append(",\"").append(key).append("\":").append(value);
|
sb.append(",\"").append(key).append("\":").append(value);
|
||||||
}
|
}
|
||||||
@@ -199,19 +207,22 @@ public final class Control {
|
|||||||
return v instanceof String ? (String) v : null;
|
return v instanceof String ? (String) v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// strList keeps the string entries of an array field and skips anything else, so a
|
// listUpdate keeps the string entries of "servers" and skips anything else, so a
|
||||||
// partly malformed list still yields the names that are well-formed.
|
// partly malformed list still yields the names that are well-formed. "access" is
|
||||||
private static List<String> strList(Map<?, ?> o, String key) {
|
// read by the same index, so a skipped name takes its verdict with it and a
|
||||||
List<String> out = new ArrayList<>();
|
// verdict that is not a string reads as unknown.
|
||||||
Object v = o.get(key);
|
private static ControlFrame listUpdate(Map<?, ?> o) {
|
||||||
if (v instanceof List) {
|
List<String> names = new ArrayList<>();
|
||||||
for (Object e : (List<?>) v) {
|
List<String> access = new ArrayList<>();
|
||||||
if (e instanceof String) {
|
List<?> rawNames = o.get("servers") instanceof List<?> l ? l : List.of();
|
||||||
out.add((String) e);
|
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 ControlFrame.listUpdate(names, access);
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean bool(Map<?, ?> o, String key) {
|
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
|
* {@code Connect} the gate used to send, so {@code bungeecord:main} can be
|
||||||
* switched off proxy-wide.</li>
|
* switched off proxy-wide.</li>
|
||||||
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
|
* <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 #TRANSFER_READY} tells the lobby a parked player's backend is up;
|
||||||
* {@link #ERROR} reports a refusal.</li>
|
* {@link #ERROR} reports a refusal.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -65,7 +65,7 @@ public final class ControlFrame {
|
|||||||
public static final String ERROR = "Error";
|
public static final String ERROR = "Error";
|
||||||
/** Upstream (lobby): ask for the current tile list; answered by {@link #LIST_UPDATE}. */
|
/** Upstream (lobby): ask for the current tile list; answered by {@link #LIST_UPDATE}. */
|
||||||
public static final String LIST_REQUEST = "ListRequest";
|
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";
|
public static final String LIST_UPDATE = "ListUpdate";
|
||||||
/** Upstream (login gate): the player finished signing in; move them to the lobby (player). */
|
/** Upstream (login gate): the player finished signing in; move them to the lobby (player). */
|
||||||
public static final String LOGIN_RELEASE = "LoginRelease";
|
public static final String LOGIN_RELEASE = "LoginRelease";
|
||||||
@@ -81,15 +81,16 @@ public final class ControlFrame {
|
|||||||
private final String code;
|
private final String code;
|
||||||
private final String message;
|
private final String message;
|
||||||
private final List<String> servers;
|
private final List<String> servers;
|
||||||
|
private final List<String> access;
|
||||||
|
|
||||||
private ControlFrame(String type, String player, String server, String phase, boolean ready,
|
private ControlFrame(String type, String player, String server, String phase, boolean ready,
|
||||||
int playersOnline, int playersMax, boolean claimable, String code, String message) {
|
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,
|
private ControlFrame(String type, String player, String server, String phase, boolean ready,
|
||||||
int playersOnline, int playersMax, boolean claimable, String code, String message,
|
int playersOnline, int playersMax, boolean claimable, String code, String message,
|
||||||
List<String> servers) {
|
List<String> servers, List<String> access) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
@@ -101,6 +102,7 @@ public final class ControlFrame {
|
|||||||
this.code = code;
|
this.code = code;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.servers = servers;
|
this.servers = servers;
|
||||||
|
this.access = access;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
|
// ---- 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. */
|
/** listUpdate carries the tile names; null entries are dropped, the list is copied. */
|
||||||
public static ControlFrame listUpdate(List<String> servers) {
|
public static ControlFrame listUpdate(List<String> servers) {
|
||||||
List<String> copy = new ArrayList<>();
|
return listUpdate(servers, null);
|
||||||
if (servers != null) {
|
|
||||||
for (String s : servers) {
|
|
||||||
if (s != null) {
|
|
||||||
copy.add(s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (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,
|
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) {
|
public static ControlFrame loginRelease(String player) {
|
||||||
@@ -200,6 +220,15 @@ public final class ControlFrame {
|
|||||||
return servers;
|
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
|
@Override
|
||||||
public boolean equals(Object o) {
|
public boolean equals(Object o) {
|
||||||
if (this == o) {
|
if (this == o) {
|
||||||
@@ -219,13 +248,14 @@ public final class ControlFrame {
|
|||||||
&& Objects.equals(phase, f.phase)
|
&& Objects.equals(phase, f.phase)
|
||||||
&& Objects.equals(code, f.code)
|
&& Objects.equals(code, f.code)
|
||||||
&& Objects.equals(message, f.message)
|
&& Objects.equals(message, f.message)
|
||||||
&& Objects.equals(servers, f.servers);
|
&& Objects.equals(servers, f.servers)
|
||||||
|
&& Objects.equals(access, f.access);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message,
|
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message,
|
||||||
servers);
|
servers, access);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.net.http.HttpTimeoutException;
|
import java.net.http.HttpTimeoutException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -145,6 +146,28 @@ public final class FelisApiClient {
|
|||||||
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + serverSegment(name) + "/menu", 200));
|
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
|
* 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
|
* 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
|
* <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:
|
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
|
||||||
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
|
* {@code ready} and {@code claimable}, with felis-api's per-player verdict from the
|
||||||
* (Claim & Start / Join / Wake) and {@code playersOnline}/{@code
|
* {@code ListUpdate}, choose the button (Join / Claim & Start / Start, or
|
||||||
* playersMax} render the "3/20" count.
|
* 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
|
* <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
|
* absent field degrades to null/zero/false rather than throwing, so a partial body
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public final class ControlRoundTripTest {
|
|||||||
errorOmitsServerWhenAbsentButRoundTrips();
|
errorOmitsServerWhenAbsentButRoundTrips();
|
||||||
escapesAwkwardStrings();
|
escapesAwkwardStrings();
|
||||||
listUpdateCarriesNamesInOrder();
|
listUpdateCarriesNamesInOrder();
|
||||||
|
listUpdateCarriesAccessByName();
|
||||||
rejectsMalformedAndUnknownFrames();
|
rejectsMalformedAndUnknownFrames();
|
||||||
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
|
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ public final class ControlRoundTripTest {
|
|||||||
roundTrip(ControlFrame.listRequest());
|
roundTrip(ControlFrame.listRequest());
|
||||||
roundTrip(ControlFrame.listUpdate(Arrays.asList("alpha", "beta-2", "gamma")));
|
roundTrip(ControlFrame.listUpdate(Arrays.asList("alpha", "beta-2", "gamma")));
|
||||||
roundTrip(ControlFrame.listUpdate(List.of()));
|
roundTrip(ControlFrame.listUpdate(List.of()));
|
||||||
|
roundTrip(ControlFrame.listUpdate(List.of("mine", "theirs"), List.of("owner", "owner_only")));
|
||||||
roundTrip(ControlFrame.loginRelease("Notch"));
|
roundTrip(ControlFrame.loginRelease("Notch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +83,34 @@ public final class ControlRoundTripTest {
|
|||||||
assertEq("non-string entries skipped", List.of("a", "b"), mixed.servers());
|
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
|
// 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.
|
// renders all three, so all three must survive the round-trip with exact values.
|
||||||
private static void wireCarriesRefinedStatusFields() {
|
private static void wireCarriesRefinedStatusFields() {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ public final class FelisApiClientTest {
|
|||||||
if (path.endsWith("/wake")) {
|
if (path.endsWith("/wake")) {
|
||||||
status = 202;
|
status = 202;
|
||||||
body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}";
|
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")) {
|
} else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) {
|
||||||
status = 409;
|
status = 409;
|
||||||
body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}";
|
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);
|
opaqueSegmentsArePercentEncoded(api);
|
||||||
opLoginShowNamesTheAccount(api);
|
opLoginShowNamesTheAccount(api);
|
||||||
opLoginApproveSendsTheTypedName(api);
|
opLoginApproveSendsTheTypedName(api);
|
||||||
|
menuAccessReadsVerdictsByName(api);
|
||||||
} finally {
|
} finally {
|
||||||
stub.stop(0);
|
stub.stop(0);
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,15 @@ public final class FelisApiClientTest {
|
|||||||
"GET /api/v1/internal/servers/abc/status"), seen);
|
"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) {
|
private static void pathBendingNamesNeverLeaveTheClient(FelisApiClient api) {
|
||||||
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
String[] bad = {
|
String[] bad = {
|
||||||
|
|||||||
+7
-3
@@ -46,16 +46,20 @@
|
|||||||
# message (the stub answers a wake from the server's state in felis-api's own
|
# 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),
|
# 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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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,
|
# No test framework: the mains are the same javac one-liners their javadocs document,
|
||||||
# so a local run and CI run the same bytes.
|
# so a local run and CI run the same bytes.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -47,6 +48,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* frames are metered per player ({@link FrameBudget}) and menu projections are
|
* frames are metered per player ({@link FrameBudget}) and menu projections are
|
||||||
* shared across players for {@link #STATUS_TTL_MILLIS}: a lobby full of players
|
* 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.
|
* 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.
|
||||||
*
|
*
|
||||||
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
|
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
|
||||||
* thread, but every felis-api call below blocks on HTTP. So each handler does the
|
* 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.
|
* it cannot be set from the async hop.
|
||||||
*
|
*
|
||||||
* <p>The lobby's upstream frames map onto the menu (spec §12): a {@code ListRequest}
|
* <p>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 StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
|
||||||
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
|
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
|
||||||
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
|
* (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.
|
// 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 int FRAME_BURST = 96;
|
||||||
private static final double FRAME_REFILL_PER_SECOND = 10.0;
|
private static final double FRAME_REFILL_PER_SECOND = 10.0;
|
||||||
// Upper bound on names in one ListUpdate. A proxy→backend plugin message is capped
|
// Upper bound on tiles in one ListUpdate. A proxy→backend plugin message is capped
|
||||||
// at 32767 bytes; 500 names of at most 32 chars stays well under it.
|
// 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;
|
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
|
// A refused source is logged at most once per interval, so a hostile backend
|
||||||
// cannot turn its own refusals into a log flood.
|
// cannot turn its own refusals into a log flood.
|
||||||
private static final long REFUSAL_LOG_INTERVAL_MILLIS = 60_000L;
|
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());
|
handleClaim(source, player, frame.server());
|
||||||
break;
|
break;
|
||||||
case ControlFrame.LIST_REQUEST:
|
case ControlFrame.LIST_REQUEST:
|
||||||
send(source, ControlFrame.listUpdate(listed()));
|
handleList(source, player);
|
||||||
break;
|
break;
|
||||||
case ControlFrame.LOGIN_RELEASE:
|
case ControlFrame.LOGIN_RELEASE:
|
||||||
router.releaseFromLogin(player);
|
router.releaseFromLogin(player);
|
||||||
@@ -198,6 +205,47 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
|
|||||||
budget.forget(event.getPlayer().getUniqueId());
|
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<String> names = listed();
|
||||||
|
UUID id = player.getUniqueId();
|
||||||
|
boolean taken = plugin.async(() -> {
|
||||||
|
Map<String, String> 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<String> names, Map<String, String> access) {
|
||||||
|
List<String> 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<String> 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
|
// 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.
|
// servers are the lobby itself and the gate in front of it, so neither is a tile.
|
||||||
private List<String> listed() {
|
private List<String> listed() {
|
||||||
@@ -207,8 +255,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
|
|||||||
names.add(v.name());
|
names.add(v.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
names.sort(String::compareTo);
|
return names;
|
||||||
return names.size() > MAX_LISTED ? names.subList(0, MAX_LISTED) : names;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refused(String sourceName, ControlFrame frame, ControlPolicy.Verdict verdict) {
|
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
|
// 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.
|
// and reports its own refusals to the player; nothing to await here.
|
||||||
private void handleWake(ServerConnection source, Player player, String server) {
|
private void handleWake(ServerConnection source, Player player, String server) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ControlChannelTest drives the real ControlChannel (with the real WaitingRouter,
|
* ControlChannelTest drives the real ControlChannel (with the real WaitingRouter,
|
||||||
@@ -74,6 +76,7 @@ public final class ControlChannelTest {
|
|||||||
|
|
||||||
consumption();
|
consumption();
|
||||||
sources();
|
sources();
|
||||||
|
lists();
|
||||||
statusQueries(reg, plugin);
|
statusQueries(reg, plugin);
|
||||||
claims();
|
claims();
|
||||||
wakeAndTransfer();
|
wakeAndTransfer();
|
||||||
@@ -133,12 +136,90 @@ public final class ControlChannelTest {
|
|||||||
assertEq("malformed name not echoed", "Error not_found null", brief(r.get(2)));
|
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"));
|
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()));
|
message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
|
||||||
|
Fakes.await("list answered", () -> q.pluginMessages.size() == 4);
|
||||||
List<ControlFrame> all = frames(q);
|
List<ControlFrame> all = frames(q);
|
||||||
ControlFrame list = all.get(all.size() - 1);
|
ControlFrame list = all.get(all.size() - 1);
|
||||||
assertEq("list type", ControlFrame.LIST_UPDATE, list.type());
|
assertEq("list type", ControlFrame.LIST_UPDATE, list.type());
|
||||||
assertEq("list: user servers, sorted", List.of("alpha", "beta", "gamma"), list.servers());
|
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<ControlFrame> 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<ControlFrame> 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<String> 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<String> longest = new ArrayList<>();
|
||||||
|
Map<String, String> 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 {
|
private static void statusQueries(ServerRegistry reg, FelisVelocityPlugin plugin) throws Exception {
|
||||||
@@ -246,19 +327,20 @@ public final class ControlChannelTest {
|
|||||||
|
|
||||||
private static void budget() {
|
private static void budget() {
|
||||||
// 96 frames in a burst, then 10 a second: a flood is cut off, the connection
|
// 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);
|
Fakes.FakePlayer p = player(true);
|
||||||
for (int i = 0; i < 150; i++) {
|
for (int i = 0; i < 150; i++) {
|
||||||
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
|
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,
|
assertEq("flood cut at the burst (plus what refilled meanwhile): " + answered,
|
||||||
true, answered >= 96 && answered <= 99);
|
true, answered >= 96 && answered <= 99);
|
||||||
|
|
||||||
// Another player has a budget of their own.
|
// Another player has a budget of their own.
|
||||||
Fakes.FakePlayer q = player(true);
|
Fakes.FakePlayer q = player(true);
|
||||||
message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
|
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.
|
// Leaving the proxy forgets the budget: a fresh burst, far more than a refill.
|
||||||
channel.onDisconnect(new DisconnectEvent(p.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN));
|
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++) {
|
for (int i = 0; i < 50; i++) {
|
||||||
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
|
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 ----
|
// ---- helpers ----
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import java.util.Set;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -482,6 +484,15 @@ final class Fakes {
|
|||||||
/** menuError and claimError map a server to "status code", like wakeError. */
|
/** menuError and claimError map a server to "status code", like wakeError. */
|
||||||
final Map<String, String> menuError = new ConcurrentHashMap<>();
|
final Map<String, String> menuError = new ConcurrentHashMap<>();
|
||||||
final Map<String, String> claimError = new ConcurrentHashMap<>();
|
final Map<String, String> 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<UUID, Map<String, String>> 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". */
|
/** bodies holds the last request body per "METHOD path". */
|
||||||
final Map<String, String> bodies = new ConcurrentHashMap<>();
|
final Map<String, String> bodies = new ConcurrentHashMap<>();
|
||||||
/** calls lists every request as "METHOD path", in arrival order. */
|
/** calls lists every request as "METHOD path", in arrival order. */
|
||||||
@@ -506,6 +517,7 @@ final class Fakes {
|
|||||||
calls.add(method + " " + path);
|
calls.add(method + " " + path);
|
||||||
String status = "/api/v1/internal/account/link/status/";
|
String status = "/api/v1/internal/account/link/status/";
|
||||||
String servers = "/api/v1/internal/servers/";
|
String servers = "/api/v1/internal/servers/";
|
||||||
|
String menuAccess = "/api/v1/internal/player/menu-access/";
|
||||||
if (path.startsWith(status)) {
|
if (path.startsWith(status)) {
|
||||||
if (linkDown) {
|
if (linkDown) {
|
||||||
reply(ex, 500, "{\"error\":{\"code\":\"internal\",\"message\":\"down\"}}");
|
reply(ex, 500, "{\"error\":{\"code\":\"internal\",\"message\":\"down\"}}");
|
||||||
@@ -515,6 +527,24 @@ final class Fakes {
|
|||||||
reply(ex, 200, "{\"linked\":" + yes + "}");
|
reply(ex, 200, "{\"linked\":" + yes + "}");
|
||||||
return;
|
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)) {
|
if (path.startsWith(servers)) {
|
||||||
String[] parts = path.substring(servers.length()).split("/");
|
String[] parts = path.substring(servers.length()).split("/");
|
||||||
String name = parts[0];
|
String name = parts[0];
|
||||||
|
|||||||
Reference in new issue
Block a user