feat: customize login and lobby spaces from the panel
This commit is contained in:
54 files changed
+1137
-149
No files matched your search
+10
-14
@@ -3659,16 +3659,12 @@ build_velocity_plugin() {
|
|||||||
# signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range
|
# signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range
|
||||||
# is its own documented support.
|
# is its own documented support.
|
||||||
#
|
#
|
||||||
# Pinned by hash and not by "latest" on purpose. These three jars sit in front of every packet
|
# Pin the three jars as one compatible set. ViaVersion/ViaBackwards 5.12.0 add
|
||||||
# on the proxy, and they are the exact bytes FL-007 measured — a moving tag would quietly make
|
# the 26.3 protocol used by game-stack.lock; ViaRewind 4.2.0 explicitly supports
|
||||||
# this an unmeasured configuration. Bumping a version means bumping its checksum here.
|
# that pair. The earlier FL-007 join measured 1.8 against Paper 1.21.11, not every
|
||||||
#
|
# client on 26.3. Release notes:
|
||||||
# The three versions are a set, not three independent pins. ViaRewind is the component that
|
# https://github.com/ViaVersion/ViaBackwards/releases/tag/5.12.0
|
||||||
# carries 1.8/1.7 support, and 4.1.2 against ViaVersion/ViaBackwards 5.11.0 fails to load
|
# https://github.com/ViaVersion/ViaRewind/releases/tag/4.2.0
|
||||||
# Protocol1_9To1_8 — the single protocol every 1.8 client needs — with "Invalid version: 1"
|
|
||||||
# at proxy startup. 4.1.3 is the release that adds 5.11.0 compatibility; a two-arm run of the
|
|
||||||
# same proxy image logs that error three times on 4.1.2 and not at all on 4.1.3. Read the
|
|
||||||
# ViaRewind release notes before moving ViaVersion or ViaBackwards.
|
|
||||||
install_via_plugins() {
|
install_via_plugins() {
|
||||||
prepare_velocity_layout
|
prepare_velocity_layout
|
||||||
local name version want target url tmp have
|
local name version want target url tmp have
|
||||||
@@ -3694,12 +3690,12 @@ install_via_plugins() {
|
|||||||
|| die "${name} ${version} checksum mismatch: got ${have}, expected ${want}"
|
|| die "${name} ${version} checksum mismatch: got ${have}, expected ${want}"
|
||||||
atomic_install_file "$tmp" "$target" 0644 root root
|
atomic_install_file "$tmp" "$target" 0644 root root
|
||||||
done <<'EOF'
|
done <<'EOF'
|
||||||
ViaVersion 5.11.0 18d19e90fc9467d68128c076630ae8700449c901402a3ef421837ce006bc8cae
|
ViaVersion 5.12.0 72c40a6a702d67f226fc9a0d8ad82aba1483fdabe2e6159bcdddb2dc070750b0
|
||||||
ViaBackwards 5.11.0 b21983d561e3f92df257683f0133ab6c68ec68175e8acfd82c6231723bf83587
|
ViaBackwards 5.12.0 194e9250224632274d7b3c17e411e031a9223c1863c6f5138d53c721f07ab78d
|
||||||
ViaRewind 4.1.3 2d5970d22b4711c9ab2800932326c7b08acdace25ed7c6bbb8f6ea81054962b4
|
ViaRewind 4.2.0 d6634ba57bb82d5161c68dfb393571cdf40511a0beb1b04b8c7ed794a3532c6a
|
||||||
EOF
|
EOF
|
||||||
pin_via_block_connections
|
pin_via_block_connections
|
||||||
ok "Via staged; clients from 1.8 up can join under modern forwarding"
|
ok "Via staged with 26.3 support; verify client versions against your chosen backend images"
|
||||||
}
|
}
|
||||||
|
|
||||||
# pin_via_block_connections turns ViaVersion's serverside block-connection tracking off.
|
# pin_via_block_connections turns ViaVersion's serverside block-connection tracking off.
|
||||||
|
|||||||
@@ -164,3 +164,20 @@ set them by hand:
|
|||||||
The Velocity gate/lobby wiring is printed by `felis setup` and enforces the
|
The Velocity gate/lobby wiring is printed by `felis setup` and enforces the
|
||||||
invariant: fresh connections hit `login` first, and only an authenticated release
|
invariant: fresh connections hit `login` first, and only an authenticated release
|
||||||
from that gate can enter the post-auth lobby or a remembered user backend.
|
from that gate can enter the post-auth lobby or a remembered user backend.
|
||||||
|
|
||||||
|
## Customize in the panel
|
||||||
|
|
||||||
|
Administrators open **Login & lobby**, select **Login space**, and stop it before
|
||||||
|
editing. The form configures the login book title/author/heading/link text/help,
|
||||||
|
automatic book opening and the login timeout (30–3600 seconds). These settings
|
||||||
|
persist in `/data/felis-experience.json`; an explicit
|
||||||
|
`FELIS_LOGIN_TIMEOUT_SECONDS` environment variable takes precedence. The generated
|
||||||
|
code, generated login URL and chat guidance are preserved. The authentication
|
||||||
|
and transfer destination are not player-facing customization fields.
|
||||||
|
|
||||||
|
Use the linked file manager to upload a replacement `/data/spawn.schem`, edit
|
||||||
|
Limbo's `server.properties` or add Limbo-compatible plugins, then start the space.
|
||||||
|
Paper world ZIPs and Paper plugins do not work in Limbo. The page also exposes
|
||||||
|
logs, backups/restore and image/resource settings. New joins are unavailable
|
||||||
|
while this front door is stopped; a custom image must retain the login plugin
|
||||||
|
and support the proxy's forwarding protocol.
|
||||||
+28
-3
@@ -28,7 +28,7 @@ this at every layer:
|
|||||||
|
|
||||||
```
|
```
|
||||||
docker build -f deploy/lobby/Dockerfile \
|
docker build -f deploy/lobby/Dockerfile \
|
||||||
--build-arg PAPER_JAR_URL=https://<mirror>/paper-1.21.x-<build>.jar \
|
--build-arg PAPER_JAR_URL=https://<mirror>/paper-26.3-<build>.jar \
|
||||||
--build-arg PAPER_JAR_SHA256=<sha256 of that jar> \
|
--build-arg PAPER_JAR_SHA256=<sha256 of that jar> \
|
||||||
-t felis-lobby:demo .
|
-t felis-lobby:demo .
|
||||||
# Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as
|
# Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as
|
||||||
@@ -40,6 +40,30 @@ docker push 127.0.0.1:5000/felis/lobby:demo
|
|||||||
sudo felis setup
|
sudo felis setup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Customize in the panel
|
||||||
|
|
||||||
|
Administrators open **Login & lobby** (`/admin/lobby`). Stop the selected space
|
||||||
|
before reading or saving its settings, then start it to apply them. The lobby
|
||||||
|
form configures welcome text, menu titles, join behavior, game mode, building
|
||||||
|
protection, damage/hunger/void handling, difficulty, time/weather and world rules.
|
||||||
|
Settings live in `/data/felis-experience.json`, independently of the image, and
|
||||||
|
retain unknown keys when saved. Existing installations without this file use the
|
||||||
|
same protected-lobby defaults as before.
|
||||||
|
|
||||||
|
The page also exposes the existing file manager (including upload and ZIP
|
||||||
|
extraction), console, backups/restore, builder permissions and image/resource
|
||||||
|
settings. To replace a map: back up and stop the lobby, upload a world ZIP,
|
||||||
|
extract it at the volume root, verify the world directory directly contains
|
||||||
|
`level.dat`, and set `level-name` in `server.properties`. Use `setworldspawn x y z`
|
||||||
|
in the running lobby console to set its spawn. Plugin JARs go in `plugins/` and
|
||||||
|
must match Paper's version; the bundled Felis and LuckPerms JARs are refreshed
|
||||||
|
from the image at boot. A custom image must retain the menu/control plugin.
|
||||||
|
|
||||||
|
The operator reuses its `init-forwarding` YAML merge for the lobby, preserving
|
||||||
|
custom Paper globals while refreshing mandatory authentication settings. The
|
||||||
|
image only rewrites that file for standalone runs without a managed forwarding
|
||||||
|
initContainer. RCON secrets and the proxy forwarding secret remain managed.
|
||||||
|
|
||||||
## Configure (deployer's responsibility)
|
## Configure (deployer's responsibility)
|
||||||
|
|
||||||
- Game port must be `25565` (the CRD `GamePort`).
|
- Game port must be `25565` (the CRD `GamePort`).
|
||||||
@@ -47,7 +71,7 @@ sudo felis setup
|
|||||||
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
|
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
|
||||||
felis-api token by design (spec §12).
|
felis-api token by design (spec §12).
|
||||||
|
|
||||||
## What the lobby allows
|
## Default lobby behavior
|
||||||
|
|
||||||
felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
|
felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
|
||||||
or leave a mark on:
|
or leave a mark on:
|
||||||
@@ -65,6 +89,7 @@ or leave a mark on:
|
|||||||
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
|
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
|
||||||
or op them.
|
or op them.
|
||||||
|
|
||||||
The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every
|
The entrypoint seeds `max-players=200` when absent, over Paper's default of 20;
|
||||||
|
subsequent file-editor changes survive restarts: every
|
||||||
authenticated player passes through here, and a stopped server's players arrive all at
|
authenticated player passes through here, and a stopped server's players arrive all at
|
||||||
once.
|
once.
|
||||||
@@ -76,7 +76,10 @@ set_prop online-mode false
|
|||||||
# what one node serves at once, and a flood beyond it is refused at the door instead
|
# what one node serves at once, and a flood beyond it is refused at the door instead
|
||||||
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
|
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
|
||||||
# building, the /menu hint) is felis-paper's LobbyGuard.
|
# building, the /menu hint) is felis-paper's LobbyGuard.
|
||||||
|
# Seed capacity once; administrators can tune it in the panel file editor.
|
||||||
|
if ! grep -q '^max-players=' "$PROPS"; then
|
||||||
set_prop max-players 200
|
set_prop max-players 200
|
||||||
|
fi
|
||||||
|
|
||||||
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
|
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
|
||||||
# for readiness and the player tally, and felis-api runs console/permission commands over
|
# for readiness and the player tally, and felis-api runs console/permission commands over
|
||||||
@@ -105,21 +108,21 @@ else
|
|||||||
echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2
|
echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rewritten whole, not merged. Paper loads this file and fills every key it does
|
# The operator's existing init-forwarding step merges the proxy keys on every
|
||||||
# not find with the default, then writes the full tree back — so a proxies-only file is a
|
# start, preserving other Paper globals. Standalone runs retain the mandatory
|
||||||
# complete, stable input, and the lobby's other globals are simply always the defaults.
|
# rewrite because no initContainer has verified their forwarding settings.
|
||||||
# That is true of a system server Felis owns end to end; if admins are ever allowed to tune
|
|
||||||
# the lobby's globals, this has to become a real YAML merge (yq) instead.
|
|
||||||
mkdir -p config
|
mkdir -p config
|
||||||
|
if [ "${FELIS_MANAGED_FORWARDING:-false}" = true ]; then
|
||||||
|
[ -f config/paper-global.yml ] || { echo "felis-lobby: missing managed forwarding config" >&2; exit 1; }
|
||||||
|
else
|
||||||
cat > config/paper-global.yml <<YAML
|
cat > config/paper-global.yml <<YAML
|
||||||
# Written by felis-lobby's entrypoint on every boot. Do not hand-edit: the forwarding
|
|
||||||
# secret is injected from the felis-forwarding-secret Secret and must match the proxy.
|
|
||||||
proxies:
|
proxies:
|
||||||
velocity:
|
velocity:
|
||||||
enabled: true
|
enabled: true
|
||||||
online-mode: true
|
online-mode: true
|
||||||
secret: "${SECRET}"
|
secret: "${SECRET}"
|
||||||
YAML
|
YAML
|
||||||
|
fi
|
||||||
|
|
||||||
echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (UUIDs are Mojang-verified)"
|
echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (UUIDs are Mojang-verified)"
|
||||||
JAVA_MEMORY_ARG=""
|
JAVA_MEMORY_ARG=""
|
||||||
|
|||||||
+18
-6
@@ -62,6 +62,12 @@ info:
|
|||||||
title: felis-api
|
title: felis-api
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
description: |
|
description: |
|
||||||
|
Staff on the operator console may manage the reserved login/lobby system
|
||||||
|
services through existing management routes, without player ownership rows.
|
||||||
|
Creating and claiming these reserved names remain prohibited. System patches
|
||||||
|
keep public autostart and idle stop disabled. Customization is persisted as
|
||||||
|
felis-experience.json using the existing stopped-server file API.
|
||||||
|
|
||||||
Control plane for the Felis Minecraft orchestration platform. The same binary
|
Control plane for the Felis Minecraft orchestration platform. The same binary
|
||||||
exposes an internal face (per-caller service tokens, for velocity / backend
|
exposes an internal face (per-caller service tokens, for velocity / backend
|
||||||
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
|
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
|
||||||
@@ -600,9 +606,9 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
description: >-
|
||||||
True for a platform-provisioned system service (the login gate, the
|
True for a platform-provisioned system service (the login gate, the
|
||||||
lobby). Their reserved names are rejected by every per-server route,
|
lobby). Staff can manage them through the existing server routes;
|
||||||
so the cockpit renders them read-only instead of offering actions
|
players see them read-only. Creating, claiming and deleting these
|
||||||
that would 400.
|
reserved names remain prohibited.
|
||||||
|
|
||||||
RetireState:
|
RetireState:
|
||||||
type: object
|
type: object
|
||||||
@@ -2437,6 +2443,8 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
desiredState: { type: string, const: Stopped }
|
desiredState: { type: string, const: Stopped }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -2465,6 +2473,8 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
claimed: { type: boolean, const: true }
|
claimed: { type: boolean, const: true }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -3182,6 +3192,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/ServerInfo' }
|
schema: { $ref: '#/components/schemas/ServerInfo' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -4488,7 +4500,7 @@ paths:
|
|||||||
recorded when it was written as it streams; a mismatch cuts the
|
recorded when it was written as it streams; a mismatch cuts the
|
||||||
download off short of its end. On the way out config/paper-global.yml
|
download off short of its end. On the way out config/paper-global.yml
|
||||||
(the cluster's forwarding secret) is left out and server.properties has
|
(the cluster's forwarding secret) is left out and server.properties has
|
||||||
its rcon.password redacted, so the download carries no Content-Length.
|
its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length.
|
||||||
A user gets 404 for a backup outside their scope, as their list never
|
A user gets 404 for a backup outside their scope, as their list never
|
||||||
shows it. One export per user at a time, 2 across the install, 6 per
|
shows it. One export per user at a time, 2 across the install, 6 per
|
||||||
user per hour.
|
user per hour.
|
||||||
@@ -4850,7 +4862,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
pattern: '^[0-9a-f]{64}$'
|
pattern: '^[0-9a-f]{64}$'
|
||||||
description: >-
|
description: >-
|
||||||
SHA-256 of the file as stored (before the rcon.password redaction in
|
SHA-256 of the file as stored (before secret redaction in
|
||||||
server.properties). Send it back as expect_sha256 on the next write.
|
server.properties). Send it back as expect_sha256 on the next write.
|
||||||
content_sha256:
|
content_sha256:
|
||||||
type: string
|
type: string
|
||||||
@@ -5204,7 +5216,7 @@ paths:
|
|||||||
without one, with symbolic links, devices and sockets left out.
|
without one, with symbolic links, devices and sockets left out.
|
||||||
config/paper-global.yml, the cluster's forwarding secret, is refused as
|
config/paper-global.yml, the cluster's forwarding secret, is refused as
|
||||||
a file and left out of a folder, and server.properties goes out with
|
a file and left out of a folder, and server.properties goes out with
|
||||||
its rcon.password redacted; both are matched by the file itself, so a
|
its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a
|
||||||
link to either under another name is guarded too. The server cannot
|
link to either under another name is guarded too. The server cannot
|
||||||
start until the download has ended. Two file downloads per user at a
|
start until the download has ended. Two file downloads per user at a
|
||||||
time, 4 across the install, 30 per user per hour, counted apart from
|
time, 4 across the install, 30 per user per hour, counted apart from
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The lobby and login gate take their player cap from server.properties, which the
|
// The lobby and login gate take their player cap from server.properties, which the
|
||||||
// entrypoint rewrites on every boot over whatever the volume already holds. These run
|
// login gate rewrites on every boot; the lobby seeds it only when absent. These run
|
||||||
// the shipped entrypoints the way a pod does (image and volume paths pointed into temp
|
// the shipped entrypoints the way a pod does (image and volume paths pointed into temp
|
||||||
// dirs, java replaced by a stub that exits) and read the file the server would start on.
|
// dirs, java replaced by a stub that exits) and read the file the server would start on.
|
||||||
|
|
||||||
@@ -91,11 +91,11 @@ func assertProp(t *testing.T, props, key, want string) {
|
|||||||
|
|
||||||
var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"}
|
var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"}
|
||||||
|
|
||||||
func TestLobbyEntrypointLiftsThePlayerCap(t *testing.T) {
|
func TestLobbyEntrypointSeedsAndPreservesThePlayerCap(t *testing.T) {
|
||||||
t.Run("over the cap Paper wrote on an earlier boot", func(t *testing.T) {
|
t.Run("preserves an administrator capacity", func(t *testing.T) {
|
||||||
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
|
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
|
||||||
"#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n")
|
"#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n")
|
||||||
assertProp(t, props, "max-players", "200")
|
assertProp(t, props, "max-players", "20")
|
||||||
assertProp(t, props, "motd", "Kept as it was")
|
assertProp(t, props, "motd", "Kept as it was")
|
||||||
})
|
})
|
||||||
t.Run("on a first boot", func(t *testing.T) {
|
t.Run("on a first boot", func(t *testing.T) {
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ import (
|
|||||||
|
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/fileedit"
|
"felis.lolicon.best/internal/fileedit"
|
||||||
|
"felis.lolicon.best/internal/naming"
|
||||||
)
|
)
|
||||||
|
|
||||||
// API holds the dependencies shared by every handler.
|
// API holds the dependencies shared by every handler.
|
||||||
@@ -1197,7 +1198,7 @@ func (l *streamLimiter) release(key string) {
|
|||||||
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
|
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
|
||||||
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
|
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
|
||||||
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
|
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
|
||||||
if a.MaxRunningServers <= 0 {
|
if a.MaxRunningServers <= 0 || naming.IsSystemServer(info.Name) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if info.DesiredState == string(v1alpha1.DesiredRunning) {
|
if info.DesiredState == string(v1alpha1.DesiredRunning) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/fileedit"
|
"felis.lolicon.best/internal/fileedit"
|
||||||
"felis.lolicon.best/internal/maintenance"
|
"felis.lolicon.best/internal/maintenance"
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
"felis.lolicon.best/internal/worldexport"
|
"felis.lolicon.best/internal/worldexport"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -605,11 +604,11 @@ func errExportUnavailable() error {
|
|||||||
func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *ServerRecord, bool) {
|
func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *ServerRecord, bool) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
}
|
}
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Access / permissions domain (spec §7). These endpoints let an owner manage
|
// Access / permissions domain (spec §7). These endpoints let an owner manage
|
||||||
@@ -101,12 +99,12 @@ func (a *API) issueLuckPermsCommand(w http.ResponseWriter, r *http.Request, name
|
|||||||
// the path, not the body) is validated here.
|
// the path, not the body) is validated here.
|
||||||
func (a *API) issueAccessCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) {
|
func (a *API) issueAccessCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func restoreMayRead(jobs []AsyncJob, id string) bool {
|
|||||||
func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404; an
|
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404; an
|
||||||
// unowned (released) server fails the owner check for everyone but admin, which
|
// unowned (released) server fails the owner check for everyone but admin, which
|
||||||
// is exactly the "must re-claim first" rule.
|
// is exactly the "must re-claim first" rule.
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -367,12 +367,12 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxConsoleCommandLen caps the command body well under RCON's single-packet
|
// maxConsoleCommandLen caps the command body well under RCON's single-packet
|
||||||
@@ -39,7 +37,7 @@ type commandRequest struct {
|
|||||||
func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -58,7 +56,7 @@ func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404.
|
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404.
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"felis.lolicon.best/internal/fileedit"
|
"felis.lolicon.best/internal/fileedit"
|
||||||
"felis.lolicon.best/internal/maintenance"
|
"felis.lolicon.best/internal/maintenance"
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A file too big for the one-request upload (handleUploadFile) arrives as an
|
// A file too big for the one-request upload (handleUploadFile) arrives as an
|
||||||
@@ -430,11 +429,11 @@ func (a *API) requireFileStage(w http.ResponseWriter, r *http.Request) (string,
|
|||||||
// staff. It returns the server name.
|
// staff. It returns the server name.
|
||||||
func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) {
|
func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleServerConsole streams the caller's server console as Server-Sent Events
|
// handleServerConsole streams the caller's server console as Server-Sent Events
|
||||||
@@ -39,13 +37,13 @@ import (
|
|||||||
func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership: owner or admin, mirroring handleCommand. An unknown server is 404.
|
// Ownership: owner or admin, mirroring handleCommand. An unknown server is 404.
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -105,7 +103,7 @@ func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
a.audit(r, "console.attach", name)
|
a.audit(r, "console.attach", name)
|
||||||
relayLogStream(w, r, src, a.streamRecheck(r, func(ctx context.Context, p *Principal) error {
|
relayLogStream(w, r, src, a.streamRecheck(r, func(ctx context.Context, p *Principal) error {
|
||||||
rec, err := a.Repo.ServerByName(ctx, name)
|
rec, err := a.managedServerRecord(ctx, name)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrNotFound):
|
case errors.Is(err, ErrNotFound):
|
||||||
return errForbidden // the server is gone, and the grant with it
|
return errForbidden // the server is gone, and the grant with it
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,12 +97,12 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -217,7 +217,7 @@ func (a *API) claimResources(ctx context.Context, name string) (ResourceSpec, er
|
|||||||
// handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status).
|
// handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status).
|
||||||
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -899,7 +899,7 @@ const (
|
|||||||
// the adminOnly wrapper in routing — every caller here is already an admin.
|
// the adminOnly wrapper in routing — every caller here is already an admin.
|
||||||
func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -910,6 +910,13 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if naming.IsSystemServer(name) &&
|
||||||
|
((body.AutostartPolicy != nil && *body.AutostartPolicy != string(v1alpha1.AutostartPublic)) ||
|
||||||
|
(body.IdleStopSeconds != nil && *body.IdleStopSeconds != 0)) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "system services must remain public and exempt from idle stop"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// An empty patch is a client mistake, not a no-op success.
|
// An empty patch is a client mistake, not a no-op success.
|
||||||
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
|
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
|
||||||
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
|
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
|
||||||
@@ -1073,7 +1080,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
// fits (ResizeServer). Only growth is held to the caps: a change that grows
|
// fits (ResizeServer). Only growth is held to the caps: a change that grows
|
||||||
// neither CPU nor memory cannot push the owner past one, and it is how an admin
|
// neither CPU nor memory cannot push the owner past one, and it is how an admin
|
||||||
// brings a server back under a cap lowered below what the owner already uses.
|
// brings a server back under a cap lowered below what the owner already uses.
|
||||||
if resUpdated {
|
if resUpdated && !naming.IsSystemServer(name) {
|
||||||
newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU])
|
newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU])
|
||||||
newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory])
|
newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory])
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AsyncJob is the observable outcome of one asynchronous world operation. The API
|
// AsyncJob is the observable outcome of one asynchronous world operation. The API
|
||||||
@@ -45,11 +43,11 @@ type JobStatusReader interface {
|
|||||||
func (a *API) handleServerJobs(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleServerJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if err := naming.ValidateServerName(name); err != nil {
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
rec, err := a.managedServerRecord(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.writeLookupError(w, r, err)
|
a.writeLookupError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/naming"
|
||||||
|
)
|
||||||
|
|
||||||
|
// System services have no player owner or business-layer row. Staff manage their
|
||||||
|
// existing cluster objects; creation and claiming keep the reserved-name gate.
|
||||||
|
func validateManagedServerName(r *http.Request, name string) error {
|
||||||
|
if p := principalFromContext(r.Context()); naming.IsSystemServer(name) && p != nil && p.IsAdmin() {
|
||||||
|
return naming.ValidateSystemServerName(name)
|
||||||
|
}
|
||||||
|
return naming.ValidateServerName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) managedServerRecord(ctx context.Context, name string) (*ServerRecord, error) {
|
||||||
|
if !naming.IsSystemServer(name) {
|
||||||
|
return a.Repo.ServerByName(ctx, name)
|
||||||
|
}
|
||||||
|
info, err := a.Cluster.GetServer(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ServerRecord{Name: name, Subdomain: info.Subdomain}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemServerManagement(t *testing.T) {
|
||||||
|
for _, name := range []string{"login", "lobby"} {
|
||||||
|
for _, principal := range []*Principal{
|
||||||
|
{UserID: "staff", Role: "admin", ViaAdminAccess: true},
|
||||||
|
{UserID: "staff", Role: "admin"},
|
||||||
|
{UserID: "player", Role: "user"},
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s/operator=%t", name, principal.Role, principal.ViaAdminAccess), func(t *testing.T) {
|
||||||
|
api, repo, cl, files := mkFiles(t)
|
||||||
|
api.External = staticExternal{p: principal}
|
||||||
|
cl.byName[name] = &ServerInfo{Name: name, Subdomain: name, Phase: "Stopped", DesiredState: "Stopped", ReaperExempt: true}
|
||||||
|
// These services are cluster-owned; there deliberately is no database row.
|
||||||
|
for _, route := range []struct {
|
||||||
|
method, suffix, body string
|
||||||
|
success int
|
||||||
|
}{
|
||||||
|
{"GET", "/status", "", 200},
|
||||||
|
{"GET", "/files", "", 200},
|
||||||
|
{"GET", "/file?path=felis-experience.json", "", 200},
|
||||||
|
{"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, 200},
|
||||||
|
{"PATCH", "", `{"displayName":"Custom Space"}`, 200},
|
||||||
|
{"POST", "/stop", "", 202},
|
||||||
|
} {
|
||||||
|
before := files.calls
|
||||||
|
result := do(api.ExternalHandler(), route.method, "/api/v1/servers/"+name+route.suffix, route.body, jsonHeader)
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
if result.Code != route.success {
|
||||||
|
t.Fatalf("%s %s: %d %s", route.method, route.suffix, result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if result.Code < 400 || files.calls != before {
|
||||||
|
t.Fatalf("player management admitted: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/"+name+"/claim", "", nil)
|
||||||
|
if result.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("system service claim: %d", result.Code)
|
||||||
|
}
|
||||||
|
if _, exists := repo.byName[name]; exists {
|
||||||
|
t.Fatal("management created a claimable business row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemServerResourcesAndInvariants(t *testing.T) {
|
||||||
|
api, repo, cl, _ := newPatchAPI()
|
||||||
|
cl.byName["lobby"] = &ServerInfo{Name: "lobby", ReaperExempt: true, Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
|
||||||
|
Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
|
||||||
|
}}
|
||||||
|
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", `{"memory":"2Gi"}`, jsonHeader)
|
||||||
|
if result.Code != http.StatusOK || cl.patched["lobby"].JavaMemory == nil {
|
||||||
|
t.Fatalf("resource patch: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
if _, exists := repo.byName["lobby"]; exists {
|
||||||
|
t.Fatal("system resources added an ownership row")
|
||||||
|
}
|
||||||
|
for _, body := range []string{`{"autostartPolicy":"ownerOnly"}`, `{"idleStopSeconds":600}`} {
|
||||||
|
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", body, jsonHeader)
|
||||||
|
if result.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("system invariant changed: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(cl.byName, "lobby")
|
||||||
|
result = do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/stop", "", nil)
|
||||||
|
if result.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("missing cluster service: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemLobbyBuilderAccess(t *testing.T) {
|
||||||
|
api, repo, cl, console := mkAccess(t)
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
|
||||||
|
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
|
||||||
|
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/access/permission",
|
||||||
|
`{"action":"set","player":"Steve","node":"felis.lobby.build","value":true}`, jsonHeader)
|
||||||
|
if result.Code != http.StatusOK || console.gotCommand != "lp user Steve permission set felis.lobby.build true" {
|
||||||
|
t.Fatalf("builder grant: %d %s; command %q", result.Code, result.Body.String(), console.gotCommand)
|
||||||
|
}
|
||||||
|
if _, exists := repo.byName["lobby"]; exists {
|
||||||
|
t.Fatal("builder access created an ownership row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemConsoleStreamRechecksWithoutOwnershipRow(t *testing.T) {
|
||||||
|
shrinkStreamTimers(t, 10*time.Millisecond, 80*time.Millisecond)
|
||||||
|
a, _, cl, _ := mkAccess(t)
|
||||||
|
a.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
|
||||||
|
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
|
||||||
|
a.Logs = &fakeLogStreamer{srcFromCtx: func(ctx context.Context) io.ReadCloser {
|
||||||
|
return &ctxBlockingReadCloser{ctx: ctx, first: []byte("boot\n"), firstRead: make(chan struct{}), closed: make(chan struct{})}
|
||||||
|
}}
|
||||||
|
begun := time.Now()
|
||||||
|
done := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() { done <- do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/console", "", nil) }()
|
||||||
|
select {
|
||||||
|
case result := <-done:
|
||||||
|
if result.Code != http.StatusOK || strings.Contains(result.Body.String(), "event: revoked") || time.Since(begun) < 80*time.Millisecond {
|
||||||
|
t.Fatalf("system logs ended before their lifetime: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("system log stream outlived its lifetime")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -456,7 +456,7 @@ const (
|
|||||||
redactedValue = "<redacted by felis>"
|
redactedValue = "<redacted by felis>"
|
||||||
)
|
)
|
||||||
|
|
||||||
// redactSecretProps blanks the RCON password when server.properties is read.
|
// redactSecretProps hides RCON and Limbo forwarding secrets when server.properties is read.
|
||||||
//
|
//
|
||||||
// Unlike secretConfigPath this is a value redaction rather than a whole-file
|
// Unlike secretConfigPath this is a value redaction rather than a whole-file
|
||||||
// denial, because the file is not platform material that merely happens to sit in
|
// denial, because the file is not platform material that merely happens to sit in
|
||||||
@@ -468,6 +468,9 @@ const (
|
|||||||
// regardless of blast radius, and because the console already gives an owner every
|
// regardless of blast radius, and because the console already gives an owner every
|
||||||
// capability the password would.
|
// capability the password would.
|
||||||
//
|
//
|
||||||
|
// Limbo stores its cluster forwarding key in this file too; that value is
|
||||||
|
// withheld by the same redaction and refreshed by the Limbo entrypoint.
|
||||||
|
//
|
||||||
// The write path is left alone on purpose, mirroring the reasoning at
|
// The write path is left alone on purpose, mirroring the reasoning at
|
||||||
// secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the
|
// secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the
|
||||||
// injected Secret on every boot, so saving the placeholder back cannot lock the
|
// injected Secret on every boot, so saving the placeholder back cannot lock the
|
||||||
@@ -481,8 +484,10 @@ func redactSecretProps(name string, content []byte) []byte {
|
|||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
// TrimSpace before matching: a properties key may be indented, and the
|
// TrimSpace before matching: a properties key may be indented, and the
|
||||||
// trailing \r of a CRLF file would otherwise ride along into the value.
|
// trailing \r of a CRLF file would otherwise ride along into the value.
|
||||||
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(rconPasswordKey+"=")) {
|
for _, key := range []string{rconPasswordKey, "forwarding-secrets"} {
|
||||||
lines[i] = []byte(rconPasswordKey + "=" + redactedValue)
|
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(key+"=")) {
|
||||||
|
lines[i] = []byte(key + "=" + redactedValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bytes.Join(lines, []byte("\n"))
|
return bytes.Join(lines, []byte("\n"))
|
||||||
|
|||||||
@@ -678,3 +678,23 @@ func assertNoTemporaries(t *testing.T, dir string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadRedactsLimboForwardingSecret(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
props := "spawn-x=8\nforwarding-secrets=shared-key\nvelocity-modern=true\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "server.properties"), []byte(props), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "copy.properties")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"server.properties", "copy.properties"} {
|
||||||
|
res, err := run(root, OpRead, path, nil, "")
|
||||||
|
if err != nil || res.Code != "" {
|
||||||
|
t.Fatalf("read: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(res.Content), "shared-key") || !strings.Contains(string(res.Content), "spawn-x=8") {
|
||||||
|
t.Fatalf("unsafe redaction: %s", res.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// What leaves a world mount — a read, a download, a world export, a backup
|
// What leaves a world mount — a read, a download, a world export, a backup
|
||||||
// export — passes the same two guards: the forwarding-secret file
|
// export — passes the same two guards: the forwarding-secret file
|
||||||
// (secretConfigPath) is withheld, and server.properties has its RCON password
|
// (secretConfigPath) is withheld, and server.properties has its RCON password
|
||||||
// redacted (propsPath).
|
// redacted (propsPath), along with Limbo's forwarding-secrets.
|
||||||
//
|
//
|
||||||
// On a live mount both are matched by the file itself (os.SameFile), not by the
|
// On a live mount both are matched by the file itself (os.SameFile), not by the
|
||||||
// name it was reached under. A plugin runs arbitrary code as the game uid and can
|
// name it was reached under. A plugin runs arbitrary code as the game uid and can
|
||||||
@@ -51,7 +51,7 @@ func ArchiveRule(name string) (withhold, redact bool) {
|
|||||||
return name == secretConfigPath, name == propsPath
|
return name == secretConfigPath, name == propsPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedactProps replaces the RCON password in server.properties content with
|
// RedactProps replaces RCON and Limbo forwarding secrets in server.properties with
|
||||||
// redactedValue (see redactSecretProps for why a placeholder and not a blank).
|
// redactedValue (see redactSecretProps for why a placeholder and not a blank).
|
||||||
func RedactProps(content []byte) []byte {
|
func RedactProps(content []byte) []byte {
|
||||||
return redactSecretProps(propsPath, content)
|
return redactSecretProps(propsPath, content)
|
||||||
|
|||||||
@@ -48,11 +48,8 @@ const (
|
|||||||
SystemLobbyServer = "lobby"
|
SystemLobbyServer = "lobby"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsSystemServer reports whether name is one of the platform-provisioned system
|
// IsSystemServer identifies platform services whose reserved names may be managed
|
||||||
// services above. They carry reserved names on purpose, and the API's per-server
|
// by staff, but never created or claimed through player-facing routes.
|
||||||
// routes reject those names outright (ValidateServerName) — so a caller that only
|
|
||||||
// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of
|
|
||||||
// offering actions (claim/wake/stop/console) that would answer 400.
|
|
||||||
func IsSystemServer(name string) bool {
|
func IsSystemServer(name string) bool {
|
||||||
return name == SystemLoginServer || name == SystemLobbyServer
|
return name == SystemLoginServer || name == SystemLobbyServer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,15 +269,18 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
|
|||||||
// Every server first hands its world volume to the game uid (prepareDataInitContainer),
|
// Every server first hands its world volume to the game uid (prepareDataInitContainer),
|
||||||
// since the pod runs as that uid and a world an older root-run release wrote would
|
// since the pod runs as that uid and a world an older root-run release wrote would
|
||||||
// otherwise be read-only to it. An arbitrary user Paper image then gets the forwarding
|
// otherwise be read-only to it. An arbitrary user Paper image then gets the forwarding
|
||||||
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself); system
|
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself).
|
||||||
// servers (login/lobby) are Felis-built and handle forwarding in their own
|
// The lobby uses the same merge so custom settings survive; the login Limbo
|
||||||
// entrypoints. Last, every server waits for its egress fence (egressGateInitContainer).
|
// handles its own properties format. Every server then waits for its egress fence.
|
||||||
// Without a felis image name there is nothing to run any step with.
|
// Without a felis image name there is nothing to run any step with.
|
||||||
var initContainers []corev1.Container
|
var initContainers []corev1.Container
|
||||||
if felisImage != "" {
|
if felisImage != "" {
|
||||||
initContainers = append(initContainers, prepareDataInitContainer(felisImage))
|
initContainers = append(initContainers, prepareDataInitContainer(felisImage))
|
||||||
if server.Labels[v1alpha1.LabelSystemRole] == "" {
|
if server.Labels[v1alpha1.LabelSystemRole] != naming.SystemLoginServer {
|
||||||
initContainers = append(initContainers, forwardingInitContainer(felisImage))
|
initContainers = append(initContainers, forwardingInitContainer(felisImage))
|
||||||
|
if server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLobbyServer {
|
||||||
|
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_MANAGED_FORWARDING", Value: "true"})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
gate := egressGateInitContainer(felisImage)
|
gate := egressGateInitContainer(felisImage)
|
||||||
if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") {
|
if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") {
|
||||||
@@ -447,8 +450,8 @@ func forwardingSecretEnvVar() corev1.EnvVar {
|
|||||||
// at all: no capability, a read-only root filesystem, and the files it writes are
|
// at all: no capability, a read-only root filesystem, and the files it writes are
|
||||||
// owned by the very uid that rewrites them on boot.
|
// owned by the very uid that rewrites them on boot.
|
||||||
//
|
//
|
||||||
// Only user servers get it: the Felis-built system images (login limbo, lobby) already
|
// User Paper servers and the lobby share this merge. The login Limbo handles
|
||||||
// consume the secret in their own entrypoints, and the login limbo is not Paper at all.
|
// its own properties format in its entrypoint.
|
||||||
func forwardingInitContainer(felisImage string) corev1.Container {
|
func forwardingInitContainer(felisImage string) corev1.Container {
|
||||||
return corev1.Container{
|
return corev1.Container{
|
||||||
Name: "init-forwarding",
|
Name: "init-forwarding",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestReadinessProbeHTTPCustomPath(t *testing.T) {
|
|||||||
|
|
||||||
// A user server (no system-role label) gets the forwarding-config initContainer after
|
// A user server (no system-role label) gets the forwarding-config initContainer after
|
||||||
// prepare-data, running the felis image and mounting the world volume. A system
|
// prepare-data, running the felis image and mounting the world volume. A system
|
||||||
// server gets no forwarding step, and a build with no felis image name gets no step.
|
// login gate handles its own properties; a build with no felis image name gets no step.
|
||||||
func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
|
func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
|
||||||
user := &v1alpha1.MinecraftServer{}
|
user := &v1alpha1.MinecraftServer{}
|
||||||
user.Spec.Storage.Size = "1Gi"
|
user.Spec.Storage.Size = "1Gi"
|
||||||
@@ -143,14 +143,23 @@ func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
|
|||||||
t.Error("no felis image must yield no initContainer")
|
t.Error("no felis image must yield no initContainer")
|
||||||
}
|
}
|
||||||
|
|
||||||
// System server handles forwarding in its own entrypoint, but its world still
|
// The lobby shares the forwarding merge, preserving its custom Paper globals.
|
||||||
// needs handing to the game uid and its image waits for the fence all the same.
|
|
||||||
sys := &v1alpha1.MinecraftServer{}
|
sys := &v1alpha1.MinecraftServer{}
|
||||||
sys.Spec.Storage.Size = "1Gi"
|
sys.Spec.Storage.Size = "1Gi"
|
||||||
sys.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"}
|
sys.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"}
|
||||||
sysSts, _ := buildStatefulSet(sys, 1, "felis:demo")
|
sysSts, _ := buildStatefulSet(sys, 1, "felis:demo")
|
||||||
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 2 || got[0].Name != "prepare-data" || got[1].Name != "egress-gate" {
|
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 3 || got[0].Name != "prepare-data" || got[1].Name != "init-forwarding" || got[2].Name != "egress-gate" {
|
||||||
t.Errorf("system server must get [prepare-data egress-gate], got %+v", got)
|
t.Errorf("lobby must get [prepare-data init-forwarding egress-gate], got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
managed := false
|
||||||
|
for _, env := range sysSts.Spec.Template.Spec.Containers[0].Env {
|
||||||
|
if env.Name == "FELIS_MANAGED_FORWARDING" && env.Value == "true" {
|
||||||
|
managed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !managed {
|
||||||
|
t.Fatal("lobby entrypoint would overwrite the merged forwarding config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -286,6 +286,10 @@ function initialState(): MockState {
|
|||||||
server("claim-me", "Claimable Node", "Stopped", null, {
|
server("claim-me", "Claimable Node", "Stopped", null, {
|
||||||
playersMax: 10,
|
playersMax: 10,
|
||||||
}),
|
}),
|
||||||
|
server("login", "Login space", "Stopped", null, {
|
||||||
|
autostartPolicy: "public", idleStopSeconds: 0, reaperExempt: true,
|
||||||
|
image: "registry.felis.svc:5000/felis/limbo:demo", memory: "512Mi", storageSize: "1Gi",
|
||||||
|
}),
|
||||||
...generatedServers(),
|
...generatedServers(),
|
||||||
],
|
],
|
||||||
access: {
|
access: {
|
||||||
@@ -764,7 +768,8 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
|
|||||||
playersOnline: s.ready ? s.playersOnline : 0,
|
playersOnline: s.ready ? s.playersOnline : 0,
|
||||||
owner: owner ? state.accounts[owner].email : "",
|
owner: owner ? state.accounts[owner].email : "",
|
||||||
owned: owner === accountInfo.id,
|
owned: owner === accountInfo.id,
|
||||||
claimable: owner === null && !s.retiring,
|
claimable: owner === null && !s.retiring && !s.reaperExempt,
|
||||||
|
system: s.reaperExempt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2241,6 +2246,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 404, "not_found", "server not found");
|
sendError(ctx.res, 404, "not_found", "server not found");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (serverInfo.reaperExempt && !isAdmin(ctx.account.role)) {
|
||||||
|
sendError(ctx.res, 400, "bad_name", "system service is reserved for staff");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!canSee(ctx.account, serverInfo)) {
|
if (!canSee(ctx.account, serverInfo)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
|
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
|
||||||
return true;
|
return true;
|
||||||
@@ -2474,7 +2483,7 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro
|
|||||||
if (!node) return fail(404, "not_found", `${p} does not exist`);
|
if (!node) return fail(404, "not_found", `${p} does not exist`);
|
||||||
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
|
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
|
||||||
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
|
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
|
||||||
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) });
|
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data), content_sha256: mockSha(node.data) });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "PUT file": {
|
case "PUT file": {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ for (const [account, path] of [
|
|||||||
["linked", "/account"],
|
["linked", "/account"],
|
||||||
["owner", "/"],
|
["owner", "/"],
|
||||||
["owner", "/servers"],
|
["owner", "/servers"],
|
||||||
|
["owner", "/admin/lobby?space=login"],
|
||||||
["owner", "/admin/images"],
|
["owner", "/admin/images"],
|
||||||
["owner", "/admin/builds"],
|
["owner", "/admin/builds"],
|
||||||
["owner", "/admin/submissions"],
|
["owner", "/admin/submissions"],
|
||||||
|
|||||||
@@ -50,3 +50,14 @@ test("the LuckPerms player list leaves the rest of the page on the first screen"
|
|||||||
await page.getByRole("button", { name: "Herobrine", exact: true }).click();
|
await page.getByRole("button", { name: "Herobrine", exact: true }).click();
|
||||||
await expect(page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") })).toHaveValue("Herobrine");
|
await expect(page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") })).toHaveValue("Herobrine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("login customization fits a phone and retains a saved title", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/lobby?space=login");
|
||||||
|
await page.getByLabel(t("lobby:bookTitle")).fill("Our Network");
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
|
||||||
|
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network");
|
||||||
|
});
|
||||||
+20
-1
@@ -98,7 +98,7 @@ test("an unlinked Owner can manage the panel, then preview and confirm a game ro
|
|||||||
// Admin pages are chunks of their own, so a player never downloads them: every
|
// Admin pages are chunks of their own, so a player never downloads them: every
|
||||||
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
|
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
|
||||||
// dev server, /assets/UsersPage-<hash>.js in a build).
|
// dev server, /assets/UsersPage-<hash>.js in a build).
|
||||||
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
|
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|LobbyPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
|
||||||
const ACCOUNT_PAGE = /\/Account[.-]/;
|
const ACCOUNT_PAGE = /\/Account[.-]/;
|
||||||
|
|
||||||
test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => {
|
test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => {
|
||||||
@@ -130,3 +130,22 @@ test("on a wide screen the LuckPerms player list runs down its column", async ({
|
|||||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||||
await expect.poll(async () => (await list.locator("..").boundingBox())!.height).toBe(700);
|
await expect.poll(async () => (await list.locator("..").boundingBox())!.height).toBe(700);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("staff customize the login space through the persistent file editor", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/lobby?space=login");
|
||||||
|
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toBeVisible();
|
||||||
|
const heading = page.getByLabel(t("lobby:bookHeading"));
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
await heading.fill("Welcome to our network");
|
||||||
|
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
|
||||||
|
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(heading).toHaveValue("Welcome to our network");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("players cannot open the lobby administration page", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/admin/lobby");
|
||||||
|
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -59,6 +59,9 @@ const UsersPage = lazyWithReload(() =>
|
|||||||
const UserDetailPage = lazyWithReload(() =>
|
const UserDetailPage = lazyWithReload(() =>
|
||||||
import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })),
|
import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })),
|
||||||
);
|
);
|
||||||
|
const LobbyPage = lazyWithReload(() =>
|
||||||
|
import("@/pages/admin/LobbyPage").then((m) => ({ default: m.LobbyPage })),
|
||||||
|
);
|
||||||
const UpdatesPage = lazyWithReload(() =>
|
const UpdatesPage = lazyWithReload(() =>
|
||||||
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
||||||
);
|
);
|
||||||
@@ -121,6 +124,7 @@ export default function App() {
|
|||||||
so the section root and any stale bookmarks land somewhere useful. */}
|
so the section root and any stale bookmarks land somewhere useful. */}
|
||||||
<Route path="admin" element={<RequireAdmin />}>
|
<Route path="admin" element={<RequireAdmin />}>
|
||||||
<Route index element={<Navigate to="/admin/images" replace />} />
|
<Route index element={<Navigate to="/admin/images" replace />} />
|
||||||
|
<Route path="lobby" element={<LobbyPage />} />
|
||||||
<Route path="images" element={<ImageAdmin />} />
|
<Route path="images" element={<ImageAdmin />} />
|
||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
<Route path="submissions" element={<SubmissionsPage />} />
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ interface EditServerForm {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
systemService?: boolean;
|
||||||
currentDisplayName?: string;
|
currentDisplayName?: string;
|
||||||
currentPolicy?: AutostartPolicy;
|
currentPolicy?: AutostartPolicy;
|
||||||
currentImage?: string;
|
currentImage?: string;
|
||||||
@@ -97,6 +98,7 @@ interface Props {
|
|||||||
|
|
||||||
export function EditServerDialog({
|
export function EditServerDialog({
|
||||||
serverName,
|
serverName,
|
||||||
|
systemService = false,
|
||||||
currentDisplayName = "",
|
currentDisplayName = "",
|
||||||
currentPolicy = "ownerOnly",
|
currentPolicy = "ownerOnly",
|
||||||
currentImage = "",
|
currentImage = "",
|
||||||
@@ -176,7 +178,7 @@ export function EditServerDialog({
|
|||||||
if (displayName !== currentDisplayName) {
|
if (displayName !== currentDisplayName) {
|
||||||
payload.displayName = displayName;
|
payload.displayName = displayName;
|
||||||
}
|
}
|
||||||
if (form.autostartPolicy !== currentPolicy) {
|
if (!systemService && form.autostartPolicy !== currentPolicy) {
|
||||||
payload.autostartPolicy = form.autostartPolicy;
|
payload.autostartPolicy = form.autostartPolicy;
|
||||||
}
|
}
|
||||||
if (imageChanged) {
|
if (imageChanged) {
|
||||||
@@ -190,7 +192,7 @@ export function EditServerDialog({
|
|||||||
if (cpu !== currentCpu) {
|
if (cpu !== currentCpu) {
|
||||||
payload.resources = { cpu };
|
payload.resources = { cpu };
|
||||||
}
|
}
|
||||||
if (form.idleStop !== currentIdleStop) {
|
if (!systemService && form.idleStop !== currentIdleStop) {
|
||||||
payload.idleStopSeconds = Number(form.idleStop);
|
payload.idleStopSeconds = Number(form.idleStop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +220,7 @@ export function EditServerDialog({
|
|||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<p className="text-sm font-medium">{t("edit_server_title")}</p>
|
<p className="text-sm font-medium">{t("edit_server_title")}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("edit_server_desc")}
|
{t(systemService ? "edit_system_desc" : "edit_server_desc")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
@@ -228,7 +230,7 @@ export function EditServerDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("edit_server_title")}</DialogTitle>
|
<DialogTitle>{t("edit_server_title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("edit_server_desc_long")}
|
{t(systemService ? "edit_system_desc" : "edit_server_desc_long")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -350,6 +352,7 @@ export function EditServerDialog({
|
|||||||
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
|
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.autostartPolicy}
|
value={form.autostartPolicy}
|
||||||
|
disabled={systemService}
|
||||||
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="edit-server-policy">
|
<SelectTrigger id="edit-server-policy">
|
||||||
@@ -368,7 +371,7 @@ export function EditServerDialog({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-server-idle">{t("edit_server_idle")}</Label>
|
<Label htmlFor="edit-server-idle">{t("edit_server_idle")}</Label>
|
||||||
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
|
<Select disabled={systemService} value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
|
||||||
<SelectTrigger id="edit-server-idle">
|
<SelectTrigger id="edit-server-idle">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import enNavigation from "./resources/en-US/navigation.json";
|
|||||||
import enBackups from "./resources/en-US/backups.json";
|
import enBackups from "./resources/en-US/backups.json";
|
||||||
import enSubmissions from "./resources/en-US/submissions.json";
|
import enSubmissions from "./resources/en-US/submissions.json";
|
||||||
import enFiles from "./resources/en-US/files.json";
|
import enFiles from "./resources/en-US/files.json";
|
||||||
|
import enLobby from "./resources/en-US/lobby.json";
|
||||||
|
import zhLobby from "./resources/zh-CN/lobby.json";
|
||||||
import enSchedules from "./resources/en-US/schedules.json";
|
import enSchedules from "./resources/en-US/schedules.json";
|
||||||
import zhCommon from "./resources/zh-CN/common.json";
|
import zhCommon from "./resources/zh-CN/common.json";
|
||||||
import zhAuth from "./resources/zh-CN/auth.json";
|
import zhAuth from "./resources/zh-CN/auth.json";
|
||||||
@@ -46,6 +48,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
submissions: enSubmissions,
|
submissions: enSubmissions,
|
||||||
files: enFiles,
|
files: enFiles,
|
||||||
schedules: enSchedules,
|
schedules: enSchedules,
|
||||||
|
lobby: enLobby,
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -61,6 +64,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
submissions: zhSubmissions,
|
submissions: zhSubmissions,
|
||||||
files: zhFiles,
|
files: zhFiles,
|
||||||
schedules: zhSchedules,
|
schedules: zhSchedules,
|
||||||
|
lobby: zhLobby,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"title": "Login & lobby",
|
||||||
|
"subtitle": "Customize the scenes, guidance and rules players encounter when joining.",
|
||||||
|
"space_label": "Choose a space",
|
||||||
|
"login": "Login space",
|
||||||
|
"lobby": "Lobby",
|
||||||
|
"login_description": "Players authenticate here before entering the lobby. This Limbo scene uses a schematic, rather than a Paper world map.",
|
||||||
|
"lobby_description": "Players choose their server here. Replace maps, install plugins, customize the experience and grant builders access.",
|
||||||
|
"connection_version": "Installed game version: Java {{version}}",
|
||||||
|
"unknown_version": "not recorded",
|
||||||
|
"version_hint": "Client compatibility depends on proxy plugins. Version changes need matching login, lobby and proxy support; this is the installation version, while the running version comes from each image.",
|
||||||
|
"images_link": "Manage images & versions",
|
||||||
|
"stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.",
|
||||||
|
"stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.",
|
||||||
|
"read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.",
|
||||||
|
"saved": "Saved. Start this space to apply the settings.",
|
||||||
|
"save": "Save settings",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.",
|
||||||
|
"appearance": "Welcome & menu",
|
||||||
|
"players": "Player behavior & protection",
|
||||||
|
"world": "World environment",
|
||||||
|
"loginBook": "Login guidance & waiting",
|
||||||
|
"menuTitleZh": "Chinese menu title",
|
||||||
|
"menuTitleEn": "English menu title",
|
||||||
|
"welcomeZh": "Chinese welcome message",
|
||||||
|
"welcomeEn": "English welcome message",
|
||||||
|
"showWelcome": "Send a welcome message on join",
|
||||||
|
"openMenuOnJoin": "Open the server menu on join",
|
||||||
|
"gameMode": "Default player game mode",
|
||||||
|
"teleportOnJoin": "Return to world spawn on join",
|
||||||
|
"protectBuild": "Protect against building and damage",
|
||||||
|
"invulnerable": "Prevent player damage",
|
||||||
|
"disableHunger": "Prevent hunger",
|
||||||
|
"voidRescue": "Return to spawn after a void fall",
|
||||||
|
"difficulty": "Difficulty",
|
||||||
|
"time": "Fixed time (0–23999)",
|
||||||
|
"freezeTime": "Freeze world time",
|
||||||
|
"clearWeather": "Keep clear weather",
|
||||||
|
"pvp": "Allow PvP",
|
||||||
|
"spawnMobs": "Allow natural mob spawning",
|
||||||
|
"mobGriefing": "Allow mob griefing",
|
||||||
|
"tntExplodes": "Allow TNT explosions",
|
||||||
|
"keepInventory": "Keep inventory on death",
|
||||||
|
"immediateRespawn": "Respawn immediately",
|
||||||
|
"showAdvancementMessages": "Show advancement announcements",
|
||||||
|
"bookTitle": "Login book title",
|
||||||
|
"bookAuthor": "Login book author",
|
||||||
|
"bookHeading": "Login page heading",
|
||||||
|
"bookAction": "Login link text",
|
||||||
|
"bookHelp": "Login instructions",
|
||||||
|
"openBook": "Open the login book automatically",
|
||||||
|
"loginTimeoutSeconds": "Login timeout (30–3600 seconds)",
|
||||||
|
"login_settings_hint": "The system still generates the code and login URL and sends the chat guidance. An explicit login timeout environment variable takes precedence over this setting.",
|
||||||
|
"lobby_settings_hint": "The welcome message includes a clickable server menu. Spawn comes from the map; use setworldspawn x y z in the lobby console to change it. Protection switches apply across the lobby. Builders keep their own game mode.",
|
||||||
|
"ADVENTURE": "Adventure",
|
||||||
|
"SURVIVAL": "Survival",
|
||||||
|
"CREATIVE": "Creative",
|
||||||
|
"SPECTATOR": "Spectator",
|
||||||
|
"PEACEFUL": "Peaceful",
|
||||||
|
"EASY": "Easy",
|
||||||
|
"NORMAL": "Normal",
|
||||||
|
"HARD": "Hard",
|
||||||
|
"map_title": "Maps & scenes",
|
||||||
|
"login_map_hint": "Stop the login space, upload your scene as spawn.schem at the root, then start it. Adjust spawn coordinates in server.properties.",
|
||||||
|
"lobby_map_hint": "Stop the lobby, upload and extract a world ZIP at the root. The world folder must directly contain level.dat. Set level-name in server.properties, then start it. Back up the existing map first.",
|
||||||
|
"plugins_title": "Plugins & plugin settings",
|
||||||
|
"plugins_hint": "Upload plugin JARs and edit their configuration. Plugins must match the core and version of this space; bundled Felis plugins are refreshed from the image.",
|
||||||
|
"files_title": "Advanced file manager",
|
||||||
|
"files_hint": "Edit server properties, world settings and plugin data. Upload, extract, rename or download files. Authentication forwarding settings are maintained by the system.",
|
||||||
|
"console_title": "Console & runtime logs",
|
||||||
|
"console_hint": "Read logs, run lobby commands, set world spawn or manage players.",
|
||||||
|
"login_console_hint": "Read login runtime logs. Limbo has no RCON command channel; edit configuration in the file manager.",
|
||||||
|
"backups_title": "Map backups & restore",
|
||||||
|
"backups_hint": "Back up before replacing maps, plugins or images; choose a saved backup to restore.",
|
||||||
|
"builder_title": "Lobby builder access",
|
||||||
|
"builder_hint": "Grant felis.lobby.build to let a player build without opening building to everyone.",
|
||||||
|
"builder_player": "Minecraft player name",
|
||||||
|
"builder_grant": "Allow building",
|
||||||
|
"builder_revoke": "Deny building",
|
||||||
|
"builder_running": "Start the lobby before changing builder access.",
|
||||||
|
"discard_title": "Discard unsaved settings?",
|
||||||
|
"discard_hint": "Switching spaces discards your edits. Cancel to save them first.",
|
||||||
|
"discard": "Discard & switch"
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
"admin_builds": "Build Pipeline",
|
"admin_builds": "Build Pipeline",
|
||||||
"admin_submissions": "Submissions",
|
"admin_submissions": "Submissions",
|
||||||
"admin_updates": "Maintenance & Backups"
|
"admin_updates": "Maintenance & Backups",
|
||||||
|
"admin_lobby": "Login & lobby"
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"schedules_link_title": "Scheduled tasks",
|
"schedules_link_title": "Scheduled tasks",
|
||||||
"schedules_link_desc": "Restart, back up or run commands at set times.",
|
"schedules_link_desc": "Restart, back up or run commands at set times.",
|
||||||
"system_service": "System service",
|
"system_service": "System service",
|
||||||
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
|
"system_service_hint": "Staff manage system services in Login & lobby.",
|
||||||
"players_back_to_console": "Back to console",
|
"players_back_to_console": "Back to console",
|
||||||
"players_not_yours_title": "Not your server",
|
"players_not_yours_title": "Not your server",
|
||||||
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
|
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
|
||||||
@@ -268,5 +268,7 @@
|
|||||||
"migration_state_restoring": "Restoring and verifying target world",
|
"migration_state_restoring": "Restoring and verifying target world",
|
||||||
"migration_state_switching": "Switching active volume",
|
"migration_state_switching": "Switching active volume",
|
||||||
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
||||||
"migration_state_failed": "Migration failed; server remains stopped"
|
"migration_state_failed": "Migration failed; server remains stopped",
|
||||||
|
"system_customize": "Customize space",
|
||||||
|
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle."
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"title": "登录与大厅",
|
||||||
|
"subtitle": "设置玩家加入时看到的场景、提示和大厅规则。",
|
||||||
|
"space_label": "选择要自定义的空间",
|
||||||
|
"login": "登录空间",
|
||||||
|
"lobby": "正式大厅",
|
||||||
|
"login_description": "玩家先在这里验证身份,再进入正式大厅。登录场景使用 Limbo schematic,不能直接使用 Paper 世界地图。",
|
||||||
|
"lobby_description": "玩家在这里选择服务器。可以更换地图、安装插件、自定义大厅体验并授权建造。",
|
||||||
|
"connection_version": "安装时的游戏版本:Java {{version}}",
|
||||||
|
"unknown_version": "未记录",
|
||||||
|
"version_hint": "客户端版本兼容由代理插件决定。更换版本需配套更新登录空间、大厅镜像和代理;此处显示安装版本,实际版本以镜像为准。",
|
||||||
|
"images_link": "管理镜像与版本",
|
||||||
|
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
|
||||||
|
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
|
||||||
|
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
|
||||||
|
"saved": "已保存。启动该空间后生效。",
|
||||||
|
"save": "保存设置",
|
||||||
|
"saving": "正在保存…",
|
||||||
|
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
|
||||||
|
"appearance": "欢迎与菜单",
|
||||||
|
"players": "玩家行为与保护",
|
||||||
|
"world": "世界环境",
|
||||||
|
"loginBook": "登录提示与等待",
|
||||||
|
"menuTitleZh": "中文菜单标题",
|
||||||
|
"menuTitleEn": "英文菜单标题",
|
||||||
|
"welcomeZh": "中文欢迎文案",
|
||||||
|
"welcomeEn": "英文欢迎文案",
|
||||||
|
"showWelcome": "加入时发送欢迎提示",
|
||||||
|
"openMenuOnJoin": "加入时自动打开服务器菜单",
|
||||||
|
"gameMode": "玩家默认游戏模式",
|
||||||
|
"teleportOnJoin": "加入时回到世界出生点",
|
||||||
|
"protectBuild": "禁止普通玩家破坏和建造",
|
||||||
|
"invulnerable": "玩家免受伤害",
|
||||||
|
"disableHunger": "禁止饥饿",
|
||||||
|
"voidRescue": "掉入虚空时回到出生点",
|
||||||
|
"difficulty": "难度",
|
||||||
|
"time": "固定时间(0–23999)",
|
||||||
|
"freezeTime": "固定世界时间",
|
||||||
|
"clearWeather": "保持晴天",
|
||||||
|
"pvp": "允许 PvP",
|
||||||
|
"spawnMobs": "允许自然生成生物",
|
||||||
|
"mobGriefing": "允许生物破坏",
|
||||||
|
"tntExplodes": "允许 TNT 爆炸",
|
||||||
|
"keepInventory": "死亡后保留物品",
|
||||||
|
"immediateRespawn": "立即重生",
|
||||||
|
"showAdvancementMessages": "显示进度公告",
|
||||||
|
"bookTitle": "登录书标题",
|
||||||
|
"bookAuthor": "登录书作者",
|
||||||
|
"bookHeading": "登录书页标题",
|
||||||
|
"bookAction": "登录链接文案",
|
||||||
|
"bookHelp": "登录说明",
|
||||||
|
"openBook": "自动打开登录书",
|
||||||
|
"loginTimeoutSeconds": "登录等待时间(30–3600 秒)",
|
||||||
|
"login_settings_hint": "绑定码和登录链接始终由系统生成,聊天中的提示仍保留。部署中若显式设置登录超时环境变量,它优先于这里的等待时间。",
|
||||||
|
"lobby_settings_hint": "欢迎提示会附带可点击的服务器菜单。出生点使用地图保存的位置,可在大厅控制台用 setworldspawn x y z 设置。保护开关影响整个大厅;建造权限玩家保留自己的游戏模式。",
|
||||||
|
"ADVENTURE": "冒险",
|
||||||
|
"SURVIVAL": "生存",
|
||||||
|
"CREATIVE": "创造",
|
||||||
|
"SPECTATOR": "旁观",
|
||||||
|
"PEACEFUL": "和平",
|
||||||
|
"EASY": "简单",
|
||||||
|
"NORMAL": "普通",
|
||||||
|
"HARD": "困难",
|
||||||
|
"map_title": "地图与场景",
|
||||||
|
"login_map_hint": "停止登录空间,上传自定义场景为根目录的 spawn.schem,再启动。出生位置等设置在 server.properties 中调整。",
|
||||||
|
"lobby_map_hint": "停止大厅,上传世界 ZIP 并解压到根目录,确保世界目录直接包含 level.dat;在 server.properties 中设置 level-name 后启动。建议先备份现有地图。",
|
||||||
|
"plugins_title": "插件与插件配置",
|
||||||
|
"plugins_hint": "上传插件 JAR、编辑插件配置。插件需适配该空间的核心与版本;系统自带的 Felis 插件会随镜像更新。",
|
||||||
|
"files_title": "高级文件管理",
|
||||||
|
"files_hint": "编辑服务器属性、世界配置和插件数据,上传、解压、重命名或下载文件。认证转发设置由系统维护。",
|
||||||
|
"console_title": "控制台与运行日志",
|
||||||
|
"console_hint": "查看日志,执行大厅命令、设置出生点或管理玩家。",
|
||||||
|
"login_console_hint": "查看登录空间日志。Limbo 不支持 RCON 命令,请通过文件管理修改配置。",
|
||||||
|
"backups_title": "地图备份与恢复",
|
||||||
|
"backups_hint": "更换地图、插件或镜像前保存备份;可选择已有备份恢复。",
|
||||||
|
"builder_title": "大厅建造权限",
|
||||||
|
"builder_hint": "授予 felis.lobby.build 后,该玩家可在大厅建造,不必开放所有人的建造权限。",
|
||||||
|
"builder_player": "Minecraft 角色名",
|
||||||
|
"builder_grant": "允许建造",
|
||||||
|
"builder_revoke": "禁止建造",
|
||||||
|
"builder_running": "请先启动大厅,才能修改建造权限。",
|
||||||
|
"discard_title": "放弃未保存的设置?",
|
||||||
|
"discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。",
|
||||||
|
"discard": "放弃并切换"
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
"admin_builds": "构建流水线",
|
"admin_builds": "构建流水线",
|
||||||
"admin_submissions": "审核提交",
|
"admin_submissions": "审核提交",
|
||||||
"admin_updates": "维护与备份"
|
"admin_updates": "维护与备份",
|
||||||
|
"admin_lobby": "登录与大厅"
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"schedules_link_title": "计划任务",
|
"schedules_link_title": "计划任务",
|
||||||
"schedules_link_desc": "定时重启、备份或执行命令。",
|
"schedules_link_desc": "定时重启、备份或执行命令。",
|
||||||
"system_service": "系统服务",
|
"system_service": "系统服务",
|
||||||
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
|
"system_service_hint": "系统服务由管理员在“登录与大厅”中管理。",
|
||||||
"players_back_to_console": "返回控制台",
|
"players_back_to_console": "返回控制台",
|
||||||
"players_not_yours_title": "这不是你的服务器",
|
"players_not_yours_title": "这不是你的服务器",
|
||||||
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
|
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
|
||||||
@@ -267,5 +267,7 @@
|
|||||||
"migration_state_restoring": "正在恢复并校验目标卷",
|
"migration_state_restoring": "正在恢复并校验目标卷",
|
||||||
"migration_state_switching": "正在切换活动卷",
|
"migration_state_switching": "正在切换活动卷",
|
||||||
"migration_state_succeeded": "迁移完成,等待手动启动",
|
"migration_state_succeeded": "迁移完成,等待手动启动",
|
||||||
"migration_state_failed": "迁移失败,服务器保持停服"
|
"migration_state_failed": "迁移失败,服务器保持停服",
|
||||||
|
"system_customize": "自定义大厅",
|
||||||
|
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { api } from "./api";
|
||||||
|
import type { ApiError } from "./types";
|
||||||
|
|
||||||
|
export const EXPERIENCE_PATH = "felis-experience.json";
|
||||||
|
export type Experience = Record<string, unknown>;
|
||||||
|
export type ExperienceField = {
|
||||||
|
key: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
choices?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
multiline?: boolean;
|
||||||
|
};
|
||||||
|
export type ExperienceGroup = { key: string; fields: ExperienceField[] };
|
||||||
|
|
||||||
|
export const LOBBY_GROUPS: ExperienceGroup[] = [
|
||||||
|
{ key: "appearance", fields: [
|
||||||
|
{ key: "menuTitleZh", value: "Felis 服务器" },
|
||||||
|
{ key: "menuTitleEn", value: "Felis Servers" },
|
||||||
|
{ key: "welcomeZh", value: "欢迎来到大厅。输入 /menu 或点击 " },
|
||||||
|
{ key: "welcomeEn", value: "Welcome to the lobby. Type /menu or click " },
|
||||||
|
{ key: "showWelcome", value: true },
|
||||||
|
{ key: "openMenuOnJoin", value: false },
|
||||||
|
] },
|
||||||
|
{ key: "players", fields: [
|
||||||
|
{ key: "gameMode", value: "ADVENTURE", choices: ["ADVENTURE", "SURVIVAL", "CREATIVE", "SPECTATOR"] },
|
||||||
|
{ key: "teleportOnJoin", value: true },
|
||||||
|
{ key: "protectBuild", value: true },
|
||||||
|
{ key: "invulnerable", value: true },
|
||||||
|
{ key: "disableHunger", value: true },
|
||||||
|
{ key: "voidRescue", value: true },
|
||||||
|
] },
|
||||||
|
{ key: "world", fields: [
|
||||||
|
{ key: "difficulty", value: "PEACEFUL", choices: ["PEACEFUL", "EASY", "NORMAL", "HARD"] },
|
||||||
|
{ key: "time", value: 6000, min: 0, max: 23999 },
|
||||||
|
{ key: "freezeTime", value: true },
|
||||||
|
{ key: "clearWeather", value: true },
|
||||||
|
{ key: "pvp", value: false },
|
||||||
|
{ key: "spawnMobs", value: false },
|
||||||
|
{ key: "mobGriefing", value: false },
|
||||||
|
{ key: "tntExplodes", value: false },
|
||||||
|
{ key: "keepInventory", value: true },
|
||||||
|
{ key: "immediateRespawn", value: true },
|
||||||
|
{ key: "showAdvancementMessages", value: false },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOGIN_GROUPS: ExperienceGroup[] = [
|
||||||
|
{ key: "loginBook", fields: [
|
||||||
|
{ key: "bookTitle", value: "Felis Login" },
|
||||||
|
{ key: "bookAuthor", value: "Felis" },
|
||||||
|
{ key: "bookHeading", value: "Felis 登录 / Login" },
|
||||||
|
{ key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true },
|
||||||
|
{ key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true },
|
||||||
|
{ key: "openBook", value: true },
|
||||||
|
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function experienceValid(values: Experience, groups: ExperienceGroup[]): boolean {
|
||||||
|
return groups.flatMap((g) => g.fields).every((field) => {
|
||||||
|
const value = values[field.key] ?? field.value;
|
||||||
|
if (typeof value !== typeof field.value) return false;
|
||||||
|
if (typeof value === "number") return Number.isInteger(value) && value >= field.min! && value <= field.max!;
|
||||||
|
if (typeof value === "string") return value.length <= 512 && (!field.choices || field.choices.includes(value));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readExperience(name: string, groups: ExperienceGroup[]): Promise<{ values: Experience; sha256: string }> {
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = await api.readServerFile(name, EXPERIENCE_PATH);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as ApiError)?.code === "not_found") return { values: {}, sha256: "" };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const bytes = Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0));
|
||||||
|
const values: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
||||||
|
if (!values || typeof values !== "object" || Array.isArray(values) || !experienceValid(values as Experience, groups)) {
|
||||||
|
throw new Error("invalid_experience");
|
||||||
|
}
|
||||||
|
return { values: values as Experience, sha256: file.sha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeExperience(name: string, values: Experience, sha256: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(values, null, 2) + "\n");
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
const content = btoa(binary);
|
||||||
|
const file = sha256
|
||||||
|
? await api.writeServerFile(name, EXPERIENCE_PATH, content, sha256)
|
||||||
|
: await api.createServerFile(name, EXPERIENCE_PATH, content);
|
||||||
|
return file.sha256;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DoorOpen,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Server,
|
Server,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -61,6 +62,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
ownerOnly: false,
|
ownerOnly: false,
|
||||||
items: [
|
items: [
|
||||||
|
{ to: "/admin/lobby", key: "admin_lobby", icon: DoorOpen },
|
||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
||||||
|
|||||||
@@ -1378,7 +1378,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Start downloading one backup (owner-or-admin plus a former-owner match).
|
* Start downloading one backup (owner-or-admin plus a former-owner match).
|
||||||
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
|
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
|
||||||
*/
|
*/
|
||||||
post: operations["exportBackup"];
|
post: operations["exportBackup"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1566,7 +1566,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Start downloading one file or folder of a stopped server's world (owner-or-admin).
|
* Start downloading one file or folder of a stopped server's world (owner-or-admin).
|
||||||
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
|
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
|
||||||
*/
|
*/
|
||||||
post: operations["downloadServerFile"];
|
post: operations["downloadServerFile"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -3035,7 +3035,7 @@ export interface components {
|
|||||||
claimable: boolean;
|
claimable: boolean;
|
||||||
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
|
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
|
||||||
ownerUnknown?: boolean;
|
ownerUnknown?: boolean;
|
||||||
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
|
/** @description True for a platform-provisioned system service (the login gate, the lobby). Staff can manage them through the existing server routes; players see them read-only. Creating, claiming and deleting these reserved names remain prohibited. */
|
||||||
system?: boolean;
|
system?: boolean;
|
||||||
};
|
};
|
||||||
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
|
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
|
||||||
@@ -4921,6 +4921,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
@@ -4950,6 +4951,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
/** @description Quota exceeded. */
|
/** @description Quota exceeded. */
|
||||||
403: {
|
403: {
|
||||||
@@ -5627,6 +5629,7 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["ServerInfo"];
|
"application/json": components["schemas"]["ServerInfo"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
@@ -7256,7 +7259,7 @@ export interface operations {
|
|||||||
* @description Base64-encoded file bytes.
|
* @description Base64-encoded file bytes.
|
||||||
*/
|
*/
|
||||||
content: string;
|
content: string;
|
||||||
/** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */
|
/** @description SHA-256 of the file as stored (before secret redaction in server.properties). Send it back as expect_sha256 on the next write. */
|
||||||
sha256: string;
|
sha256: string;
|
||||||
/** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */
|
/** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */
|
||||||
content_sha256: string;
|
content_sha256: string;
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export function ServerConsole() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<EditServerDialog
|
<EditServerDialog
|
||||||
serverName={name}
|
serverName={name}
|
||||||
|
systemService={name === "login" || name === "lobby"}
|
||||||
currentDisplayName={data.displayName}
|
currentDisplayName={data.displayName}
|
||||||
currentPolicy={data.autostartPolicy as AutostartPolicy}
|
currentPolicy={data.autostartPolicy as AutostartPolicy}
|
||||||
currentImage={data.image}
|
currentImage={data.image}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -115,6 +115,7 @@ type Naming =
|
|||||||
* instead of letting each call fail. */
|
* instead of letting each call fail. */
|
||||||
export function ServerFiles() {
|
export function ServerFiles() {
|
||||||
const { name = "" } = useParams();
|
const { name = "" } = useParams();
|
||||||
|
const [params] = useSearchParams();
|
||||||
const { t, i18n } = useTranslation("files");
|
const { t, i18n } = useTranslation("files");
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const { isAdmin, loading: tierLoading } = useTier();
|
const { isAdmin, loading: tierLoading } = useTier();
|
||||||
@@ -128,7 +129,7 @@ export function ServerFiles() {
|
|||||||
const owned = canManage(isAdmin, mineQ.data, name);
|
const owned = canManage(isAdmin, mineQ.data, name);
|
||||||
const stopped = statusQ.data?.phase === "Stopped";
|
const stopped = statusQ.data?.phase === "Stopped";
|
||||||
|
|
||||||
const [dir, setDir] = useState("");
|
const [dir, setDir] = useState(params.get("path") ?? "");
|
||||||
const [entries, setEntries] = useState<ServerFileEntry[] | null>(null);
|
const [entries, setEntries] = useState<ServerFileEntry[] | null>(null);
|
||||||
const [truncated, setTruncated] = useState(false);
|
const [truncated, setTruncated] = useState(false);
|
||||||
const [listErr, setListErr] = useState<unknown>(null);
|
const [listErr, setListErr] = useState<unknown>(null);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { LobbyPage } from "./LobbyPage";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({
|
||||||
|
status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||||
|
vi.mock("@/lib/config", async (original) => ({ ...await original<typeof import("@/lib/config")>(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
calls.status.mockImplementation(async (name) => ({ name, subdomain: name, phase: "Stopped", desiredState: "Stopped", ready: false, reaperExempt: true, playersOnline: 0, playersMax: 200 }));
|
||||||
|
calls.listImages.mockResolvedValue([]);
|
||||||
|
calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
|
||||||
|
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
|
||||||
|
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
|
||||||
|
});
|
||||||
|
|
||||||
|
function page(space = "lobby") {
|
||||||
|
render(<MemoryRouter initialEntries={[`/admin/lobby?space=${space}`]}><LobbyPage /></MemoryRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LobbyPage", () => {
|
||||||
|
it("saves with the read hash and preserves unknown plugin settings", async () => {
|
||||||
|
page();
|
||||||
|
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
|
await screen.findByText("Saved. Start this space to apply the settings.");
|
||||||
|
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
|
||||||
|
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
|
||||||
|
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
|
||||||
|
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
|
||||||
|
page("login");
|
||||||
|
fireEvent.change(await screen.findByLabelText("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
|
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
|
||||||
|
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
|
||||||
|
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a stop before reading or writing a running space", async () => {
|
||||||
|
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true });
|
||||||
|
page();
|
||||||
|
await screen.findByText(/Stop the lobby to read and save settings/);
|
||||||
|
expect(calls.readServerFile).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the draft when another editor changed the file", async () => {
|
||||||
|
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
|
||||||
|
page();
|
||||||
|
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
|
||||||
|
expect(calls.createServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a switch with unsaved edits", async () => {
|
||||||
|
page();
|
||||||
|
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
|
||||||
|
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
|
||||||
|
expect(calls.status).not.toHaveBeenCalledWith("login");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Discard & switch" }));
|
||||||
|
await screen.findByLabelText("Login book title");
|
||||||
|
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer to save malformed JSON", async () => {
|
||||||
|
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
|
||||||
|
page();
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { DoorOpen, FolderOpen, Map, Package, Save, Shield, Terminal } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||||
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
|
import { CopyAddress } from "@/components/CopyAddress";
|
||||||
|
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||||
|
import { ErrorState, Loading } from "@/components/States";
|
||||||
|
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { joinAddress } from "@/lib/config";
|
||||||
|
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
|
||||||
|
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
|
||||||
|
|
||||||
|
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
|
||||||
|
const { t } = useTranslation("lobby");
|
||||||
|
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
|
||||||
|
const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]);
|
||||||
|
const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.data && !draft) {
|
||||||
|
setDraft({ ...query.data, original: JSON.stringify(query.data.values) });
|
||||||
|
}
|
||||||
|
}, [query.data, draft]);
|
||||||
|
const dirty = draft !== null && JSON.stringify(draft.values) !== draft.original;
|
||||||
|
useUnsavedGuard(dirty);
|
||||||
|
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const sha256 = await writeExperience(name, draft.values, draft.sha256);
|
||||||
|
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
|
||||||
|
setMessage({ kind: "success", text: t("saved") });
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ kind: "error", text: humanizeError(error) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
|
||||||
|
{stopped && query.loading && !draft && <Loading />}
|
||||||
|
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
|
||||||
|
{draft && groups.map((group) => (
|
||||||
|
<Card key={group.key}>
|
||||||
|
<CardHeader><CardTitle>{t(group.key)}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{group.fields.map((field) => {
|
||||||
|
const value = draft.values[field.key] ?? field.value;
|
||||||
|
const id = `experience-${field.key}`;
|
||||||
|
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
|
||||||
|
if (typeof field.value === "boolean") return (
|
||||||
|
<label key={id} className="flex min-h-11 items-center gap-3 rounded-md border border-border px-3 text-sm" htmlFor={id}>
|
||||||
|
<input id={id} type="checkbox" checked={value as boolean} onChange={(e) => set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" />
|
||||||
|
{t(field.key)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={id} className="grid min-w-0 gap-2">
|
||||||
|
<Label htmlFor={id}>{t(field.key)}</Label>
|
||||||
|
{field.choices ? (
|
||||||
|
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm">
|
||||||
|
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
|
||||||
|
</select>
|
||||||
|
) : field.multiline ? (
|
||||||
|
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" />
|
||||||
|
) : (
|
||||||
|
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{draft && <>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
|
||||||
|
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||||
|
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
||||||
|
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuilderAccess({ ready }: { ready: boolean }) {
|
||||||
|
const { t } = useTranslation("lobby");
|
||||||
|
const [player, setPlayer] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
|
||||||
|
async function grant(value: boolean) {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const result = await api.accessPermission("lobby", "set", player.trim(), "felis.lobby.build", value);
|
||||||
|
setMessage({ kind: "success", text: result.output });
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ kind: "error", text: humanizeError(error) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Card>
|
||||||
|
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
|
||||||
|
<Label htmlFor="builder-player">{t("builder_player")}</Label>
|
||||||
|
<Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(true)}>{t("builder_grant")}</Button>
|
||||||
|
<Button variant="outline" disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(false)}>{t("builder_revoke")}</Button>
|
||||||
|
</div>
|
||||||
|
{!ready && <p className="text-sm text-muted-foreground">{t("builder_running")}</p>}
|
||||||
|
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LobbyPage() {
|
||||||
|
const { t } = useTranslation("lobby");
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const name = params.get("space") === "login" ? "login" : "lobby";
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [nextSpace, setNextSpace] = useState<string | null>(null);
|
||||||
|
const cfg = useConfig();
|
||||||
|
const status = useAsync(() => api.status(name), [name]);
|
||||||
|
usePolling(status.reload, STATUS_POLL_FAST_MS);
|
||||||
|
const data = status.data;
|
||||||
|
const links = [
|
||||||
|
{ icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` },
|
||||||
|
{ icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` },
|
||||||
|
{ icon: FolderOpen, title: "files_title", hint: "files_hint", to: `/servers/${name}/files` },
|
||||||
|
{ icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` },
|
||||||
|
{ icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` },
|
||||||
|
];
|
||||||
|
return <div className="space-y-6">
|
||||||
|
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
<Card><CardContent className="flex flex-wrap items-center justify-between gap-4 pt-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
|
||||||
|
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
|
||||||
|
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link>
|
||||||
|
</CardContent></Card>
|
||||||
|
<div className="flex flex-wrap gap-2" aria-label={t("space_label")}>
|
||||||
|
{(["lobby", "login"] as const).map((space) => <Button key={space} variant={name === space ? "default" : "outline"} aria-pressed={name === space} onClick={() => { if (space !== name) { if (dirty) setNextSpace(space); else setParams({ space }); } }}>{t(space)}</Button>)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
|
||||||
|
{status.loading && !data && <Loading />}
|
||||||
|
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
|
||||||
|
{data && <>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
|
||||||
|
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
|
||||||
|
</div>
|
||||||
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||||
|
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{links.map(({ icon: Icon, title, hint, to }) => <Link key={title} to={to} className="flex gap-3 rounded-lg border border-border bg-card p-4 hover:border-primary/40"><Icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" /><div className="min-w-0"><p className="text-sm font-medium">{t(title)}</p><p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p></div></Link>)}
|
||||||
|
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
|
||||||
|
{name === "lobby" && <BuilderAccess ready={data.ready} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -532,8 +532,8 @@ function ServerActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (server.system) {
|
if (server.system) {
|
||||||
// A system service carries a reserved name that every per-server route
|
// Staff customize system services in the dedicated lobby view.
|
||||||
// rejects, so offer no actions — just the honest label.
|
if (isAdmin) return <Link className="text-sm text-primary hover:underline" to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link>;
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||||
{ts("system_service")}
|
{ts("system_service")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import best.lolicon.felis.link.FelisApiClient;
|
|||||||
import best.lolicon.felis.link.LinkClient;
|
import best.lolicon.felis.link.LinkClient;
|
||||||
import best.lolicon.felis.link.LinkCode;
|
import best.lolicon.felis.link.LinkCode;
|
||||||
import best.lolicon.felis.link.LinkConfig;
|
import best.lolicon.felis.link.LinkConfig;
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
import best.lolicon.felis.link.LinkConfigLoader;
|
import best.lolicon.felis.link.LinkConfigLoader;
|
||||||
|
|
||||||
import com.loohp.limbo.events.EventHandler;
|
import com.loohp.limbo.events.EventHandler;
|
||||||
@@ -92,6 +93,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
|
|
||||||
private volatile Readiness readiness;
|
private volatile Readiness readiness;
|
||||||
private volatile LoginFlow flow;
|
private volatile LoginFlow flow;
|
||||||
|
private ExperienceConfig experience;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -146,6 +148,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
try {
|
try {
|
||||||
File props = new File(getDataFolder(), "felis-link.properties");
|
File props = new File(getDataFolder(), "felis-link.properties");
|
||||||
cfg = LinkConfigLoader.load(props.toPath());
|
cfg = LinkConfigLoader.load(props.toPath());
|
||||||
|
experience = ExperienceConfig.load(ExperienceConfig.PATH);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// Missing/half config: like the other Felis plugins, load un-crippled —
|
// Missing/half config: like the other Felis plugins, load un-crippled —
|
||||||
// readiness stays up, the login flow just never turns on.
|
// readiness stays up, the login flow just never turns on.
|
||||||
@@ -161,7 +164,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
}
|
}
|
||||||
String consoleUrl = "https://" + panelHost;
|
String consoleUrl = "https://" + panelHost;
|
||||||
String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER"));
|
String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER"));
|
||||||
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")) * 1000L;
|
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS") != null
|
||||||
|
? System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")
|
||||||
|
: Long.toString(experience.number("loginTimeoutSeconds", GateConfig.DEFAULT_TIMEOUT_SECONDS))) * 1000L;
|
||||||
this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg),
|
this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg),
|
||||||
consoleUrl, timeoutMillis, System::currentTimeMillis, LOG);
|
consoleUrl, timeoutMillis, System::currentTimeMillis, LOG);
|
||||||
LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl
|
LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl
|
||||||
@@ -187,7 +192,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
@Override
|
@Override
|
||||||
public LoginFlow.Seat player(UUID id) {
|
public LoginFlow.Seat player(UUID id) {
|
||||||
Player player = getServer().getPlayer(id);
|
Player player = getServer().getPlayer(id);
|
||||||
return player != null && player.isValid() ? new LimboSeat(player) : null;
|
return player != null && player.isValid() ? new LimboSeat(player, experience) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -222,7 +227,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** LimboSeat is one Limbo player as the flow sees them. */
|
/** LimboSeat is one Limbo player as the flow sees them. */
|
||||||
private record LimboSeat(Player player) implements LoginFlow.Seat {
|
private record LimboSeat(Player player, ExperienceConfig experience) implements LoginFlow.Seat {
|
||||||
@Override
|
@Override
|
||||||
public String name() {
|
public String name() {
|
||||||
return player.getName();
|
return player.getName();
|
||||||
@@ -235,7 +240,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void showCode(LinkCode code, String url) {
|
public void showCode(LinkCode code, String url) {
|
||||||
player.openBook(LoginBook.book(code.code(), url));
|
if (experience.flag("openBook", true)) {
|
||||||
|
player.openBook(LoginBook.book(code.code(), url, experience));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package best.lolicon.felis.limbo;
|
package best.lolicon.felis.limbo;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
|
import java.util.Map;
|
||||||
import net.kyori.adventure.inventory.Book;
|
import net.kyori.adventure.inventory.Book;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.event.ClickEvent;
|
import net.kyori.adventure.text.event.ClickEvent;
|
||||||
@@ -30,15 +32,20 @@ final class LoginBook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Book book(String code, String url) {
|
static Book book(String code, String url) {
|
||||||
Component page = Component.text("Felis 登录 / Login\n\n")
|
return book(code, url, new ExperienceConfig(Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Book book(String code, String url, ExperienceConfig settings) {
|
||||||
|
Component page = Component.text(settings.text("bookHeading", "Felis 登录 / Login") + "\n\n")
|
||||||
.append(Component.text("绑定码 / Code:\n"))
|
.append(Component.text("绑定码 / Code:\n"))
|
||||||
.append(Component.text(code + "\n\n").color(NamedTextColor.GOLD))
|
.append(Component.text(code + "\n\n").color(NamedTextColor.GOLD))
|
||||||
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
|
.append(Component.text(settings.text("bookAction", "▶ 点此打开登录页\n▶ Open login page") + "\n")
|
||||||
.color(NamedTextColor.AQUA)
|
.color(NamedTextColor.AQUA)
|
||||||
.clickEvent(ClickEvent.openUrl(url)))
|
.clickEvent(ClickEvent.openUrl(url)))
|
||||||
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
|
.append(Component.text("\n" + settings.text("bookHelp", "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there)."))
|
||||||
.color(NamedTextColor.GRAY));
|
.color(NamedTextColor.GRAY));
|
||||||
return Book.book(Component.text("Felis Login"), Component.text("Felis"), page);
|
return Book.book(Component.text(settings.text("bookTitle", "Felis Login")),
|
||||||
|
Component.text(settings.text("bookAuthor", "Felis")), page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** chatLine renders one chat line with § colour codes, its web addresses clickable. */
|
/** chatLine renders one chat line with § colour codes, its web addresses clickable. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package best.lolicon.felis.limbo;
|
package best.lolicon.felis.limbo;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
import best.lolicon.felis.link.Control;
|
import best.lolicon.felis.link.Control;
|
||||||
import best.lolicon.felis.link.ControlFrame;
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
import best.lolicon.felis.link.FelisApiClient;
|
import best.lolicon.felis.link.FelisApiClient;
|
||||||
@@ -24,6 +25,8 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -356,7 +359,22 @@ public final class LoginFlowTest {
|
|||||||
assertEq("lobby set", "hub", GateConfig.lobby(" hub "));
|
assertEq("lobby set", "hub", GateConfig.lobby(" hub "));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void loginBook() {
|
private static void loginBook() throws IOException {
|
||||||
|
Path file = Files.createTempFile("felis-experience", ".json");
|
||||||
|
ExperienceConfig settings;
|
||||||
|
try {
|
||||||
|
Files.writeString(file, "{\"bookTitle\":\"My Network\",\"bookHeading\":\"欢迎 / Welcome\"}");
|
||||||
|
settings = ExperienceConfig.load(file);
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
}
|
||||||
|
Book customized = LoginBook.book(CODE, CONSOLE + "/link", settings);
|
||||||
|
assertEq("custom book title", "My Network", plain(customized.title()));
|
||||||
|
assertTrue("custom book reads UTF-8 from the world volume", plain(customized.pages().get(0)).contains("欢迎 / Welcome"));
|
||||||
|
assertEq("custom book keeps the code", true, plain(customized.pages().get(0)).contains(CODE));
|
||||||
|
List<Component> customLinks = new ArrayList<>();
|
||||||
|
collectLinks(customized.pages().get(0), customLinks);
|
||||||
|
assertEq("custom book keeps the generated login URL", CONSOLE + "/link", customLinks.get(0).clickEvent().value());
|
||||||
Book book = LoginBook.book(CODE, CONSOLE + "/link");
|
Book book = LoginBook.book(CODE, CONSOLE + "/link");
|
||||||
assertEq("book title", "Felis Login", plain(book.title()));
|
assertEq("book title", "Felis Login", plain(book.title()));
|
||||||
Component page = book.pages().get(0);
|
Component page = book.pages().get(0);
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ dependencies {
|
|||||||
// plugin-message channel and never holds a felis-api token or talks to felis-api
|
// plugin-message channel and never holds a felis-api token or talks to felis-api
|
||||||
// directly. We enforce that physically here — the shared source root is on the
|
// directly. We enforce that physically here — the shared source root is on the
|
||||||
// path, but the include filter ships ONLY the paper package and the three codec
|
// path, but the include filter ships ONLY the paper package and the three codec
|
||||||
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
|
// files (Control + ControlFrame + Json) plus the local ExperienceConfig reader.
|
||||||
|
// FelisApiClient, LinkClient and the token
|
||||||
// config are not compiled in at all, so the lobby cannot reach the API even by
|
// config are not compiled in at all, so the lobby cannot reach the API even by
|
||||||
// mistake. If a codec class grows a new dependency, compilation fails loudly here
|
// mistake. If a codec class grows a new dependency, compilation fails loudly here
|
||||||
// rather than silently widening the lobby's reach.
|
// rather than silently widening the lobby's reach.
|
||||||
@@ -53,6 +54,7 @@ sourceSets {
|
|||||||
include 'best/lolicon/felis/link/Control.java'
|
include 'best/lolicon/felis/link/Control.java'
|
||||||
include 'best/lolicon/felis/link/ControlFrame.java'
|
include 'best/lolicon/felis/link/ControlFrame.java'
|
||||||
include 'best/lolicon/felis/link/Json.java'
|
include 'best/lolicon/felis/link/Json.java'
|
||||||
|
include 'best/lolicon/felis/link/ExperienceConfig.java'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package best.lolicon.felis.paper;
|
|||||||
|
|
||||||
import best.lolicon.felis.link.Control;
|
import best.lolicon.felis.link.Control;
|
||||||
import best.lolicon.felis.link.ControlFrame;
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
@@ -62,10 +65,16 @@ import java.util.List;
|
|||||||
public final class FelisPaperPlugin extends JavaPlugin {
|
public final class FelisPaperPlugin extends JavaPlugin {
|
||||||
|
|
||||||
private LobbyMenu menu;
|
private LobbyMenu menu;
|
||||||
|
private ExperienceConfig experience;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
saveDefaultConfig();
|
saveDefaultConfig();
|
||||||
|
try {
|
||||||
|
experience = ExperienceConfig.load(ExperienceConfig.PATH);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot load lobby settings", e);
|
||||||
|
}
|
||||||
if (!getConfig().getStringList("servers").isEmpty()) {
|
if (!getConfig().getStringList("servers").isEmpty()) {
|
||||||
getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
|
getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ public final class FelisPaperPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
|
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
|
||||||
// before this point get the rules here, later ones on their WorldLoadEvent.
|
// before this point get the rules here, later ones on their WorldLoadEvent.
|
||||||
LobbyGuard guard = new LobbyGuard(getLogger());
|
LobbyGuard guard = new LobbyGuard(getLogger(), experience);
|
||||||
getServer().getPluginManager().registerEvents(guard, this);
|
getServer().getPluginManager().registerEvents(guard, this);
|
||||||
getServer().getWorlds().forEach(guard::protect);
|
getServer().getWorlds().forEach(guard::protect);
|
||||||
|
|
||||||
@@ -125,6 +134,11 @@ public final class FelisPaperPlugin extends JavaPlugin {
|
|||||||
return FelisPaperPlugin.zh(player);
|
return FelisPaperPlugin.zh(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String menuTitle() {
|
||||||
|
return experience.text(zh() ? "menuTitleZh" : "menuTitleEn", zh() ? "Felis 服务器" : "Felis Servers");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean online() {
|
public boolean online() {
|
||||||
return player.isOnline();
|
return player.isOnline();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package best.lolicon.felis.paper;
|
package best.lolicon.felis.paper;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.event.ClickEvent;
|
import net.kyori.adventure.text.event.ClickEvent;
|
||||||
import net.kyori.adventure.text.event.HoverEvent;
|
import net.kyori.adventure.text.event.HoverEvent;
|
||||||
@@ -36,6 +37,7 @@ import org.bukkit.event.player.PlayerJoinEvent;
|
|||||||
import org.bukkit.event.vehicle.VehicleDestroyEvent;
|
import org.bukkit.event.vehicle.VehicleDestroyEvent;
|
||||||
import org.bukkit.event.world.WorldLoadEvent;
|
import org.bukkit.event.world.WorldLoadEvent;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ import java.util.logging.Logger;
|
|||||||
* Without it the lobby is a plain survival world: mobs at night, PvP, and every block
|
* Without it the lobby is a plain survival world: mobs at night, PvP, and every block
|
||||||
* broken or placed by a passer-by stays in the world volume for the next player.
|
* broken or placed by a passer-by stays in the world volume for the next player.
|
||||||
*
|
*
|
||||||
|
* <p>The following are defaults; felis-experience.json can adjust each behavior.
|
||||||
|
*
|
||||||
* <p><b>World.</b> Every world is made peaceful with natural spawning, PvP, mob
|
* <p><b>World.</b> Every world is made peaceful with natural spawning, PvP, mob
|
||||||
* griefing and TNT off, time frozen at noon and the weather clear, and inventories
|
* griefing and TNT off, time frozen at noon and the weather clear, and inventories
|
||||||
* kept. These are world rules (level.dat), so they are set here on enable and on
|
* kept. These are world rules (level.dat), so they are set here on enable and on
|
||||||
@@ -70,14 +74,20 @@ final class LobbyGuard implements Listener {
|
|||||||
/** Noon: the lobby is always lit. */
|
/** Noon: the lobby is always lit. */
|
||||||
private static final long NOON = 6000L;
|
private static final long NOON = 6000L;
|
||||||
|
|
||||||
static boolean guarded(Player player) {
|
boolean guarded(Player player) {
|
||||||
return !player.hasPermission(BUILD_PERMISSION);
|
return settings.flag("protectBuild", true) && !player.hasPermission(BUILD_PERMISSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Logger log;
|
private final Logger log;
|
||||||
|
private final ExperienceConfig settings;
|
||||||
|
|
||||||
LobbyGuard(Logger log) {
|
LobbyGuard(Logger log) {
|
||||||
|
this(log, new ExperienceConfig(Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
LobbyGuard(Logger log, ExperienceConfig settings) {
|
||||||
this.log = log;
|
this.log = log;
|
||||||
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,25 +104,27 @@ final class LobbyGuard implements Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void applyRules(World world) {
|
void applyRules(World world) {
|
||||||
world.setDifficulty(Difficulty.PEACEFUL);
|
world.setDifficulty(Difficulty.valueOf(settings.text("difficulty", "PEACEFUL")));
|
||||||
// Only a world with its own clock has a time of day to set; the nether and the
|
// Only a world with its own clock has a time of day to set; the nether and the
|
||||||
// end have none and refuse.
|
// end have none and refuse.
|
||||||
if (!world.isFixedTime()) {
|
if (settings.flag("freezeTime", true) && !world.isFixedTime()) {
|
||||||
world.setTime(NOON);
|
world.setTime(Math.floorMod(settings.number("time", NOON), 24000L));
|
||||||
}
|
}
|
||||||
|
if (settings.flag("clearWeather", true)) {
|
||||||
world.setStorm(false);
|
world.setStorm(false);
|
||||||
world.setThundering(false);
|
world.setThundering(false);
|
||||||
world.setGameRule(GameRules.PVP, false);
|
}
|
||||||
world.setGameRule(GameRules.SPAWN_MOBS, false);
|
world.setGameRule(GameRules.PVP, settings.flag("pvp", false));
|
||||||
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
|
world.setGameRule(GameRules.SPAWN_MOBS, settings.flag("spawnMobs", false));
|
||||||
world.setGameRule(GameRules.MOB_GRIEFING, false);
|
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, settings.flag("spawnMobs", false));
|
||||||
world.setGameRule(GameRules.TNT_EXPLODES, false);
|
world.setGameRule(GameRules.MOB_GRIEFING, settings.flag("mobGriefing", false));
|
||||||
world.setGameRule(GameRules.KEEP_INVENTORY, true);
|
world.setGameRule(GameRules.TNT_EXPLODES, settings.flag("tntExplodes", false));
|
||||||
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
|
world.setGameRule(GameRules.KEEP_INVENTORY, settings.flag("keepInventory", true));
|
||||||
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
|
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, settings.flag("immediateRespawn", true));
|
||||||
world.setGameRule(GameRules.ADVANCE_TIME, false);
|
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, settings.flag("showAdvancementMessages", false));
|
||||||
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
world.setGameRule(GameRules.ADVANCE_TIME, !settings.flag("freezeTime", true));
|
||||||
|
world.setGameRule(GameRules.ADVANCE_WEATHER, !settings.flag("clearWeather", true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
@@ -125,20 +137,30 @@ final class LobbyGuard implements Listener {
|
|||||||
@EventHandler
|
@EventHandler
|
||||||
public void onJoin(PlayerJoinEvent event) {
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
|
if (settings.flag("disableHunger", true)) {
|
||||||
player.setFoodLevel(20);
|
player.setFoodLevel(20);
|
||||||
if (guarded(player)) {
|
}
|
||||||
player.setGameMode(GameMode.ADVENTURE);
|
if (!player.hasPermission(BUILD_PERMISSION)) {
|
||||||
|
player.setGameMode(GameMode.valueOf(settings.text("gameMode", "ADVENTURE")));
|
||||||
|
if (settings.flag("teleportOnJoin", true)) {
|
||||||
player.teleport(player.getWorld().getSpawnLocation());
|
player.teleport(player.getWorld().getSpawnLocation());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (settings.flag("showWelcome", true)) {
|
||||||
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
|
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
|
||||||
}
|
}
|
||||||
|
if (settings.flag("openMenuOnJoin", false)) {
|
||||||
|
player.performCommand("menu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Component hint(boolean zh) {
|
Component hint(boolean zh) {
|
||||||
Component open = Component.text(zh ? "[打开服务器菜单]" : "[Open the server menu]",
|
Component open = Component.text(zh ? "[打开服务器菜单]" : "[Open the server menu]",
|
||||||
NamedTextColor.GREEN, TextDecoration.BOLD)
|
NamedTextColor.GREEN, TextDecoration.BOLD)
|
||||||
.clickEvent(ClickEvent.runCommand("/menu"))
|
.clickEvent(ClickEvent.runCommand("/menu"))
|
||||||
.hoverEvent(HoverEvent.showText(Component.text(zh ? "点击运行 /menu" : "Click to run /menu")));
|
.hoverEvent(HoverEvent.showText(Component.text(zh ? "点击运行 /menu" : "Click to run /menu")));
|
||||||
return Component.text(zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click ",
|
return Component.text(settings.text(zh ? "welcomeZh" : "welcomeEn",
|
||||||
|
zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click "),
|
||||||
NamedTextColor.GOLD)
|
NamedTextColor.GOLD)
|
||||||
.append(open)
|
.append(open)
|
||||||
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
|
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
|
||||||
@@ -152,15 +174,17 @@ final class LobbyGuard implements Listener {
|
|||||||
if (!(event.getEntity() instanceof Player player)) {
|
if (!(event.getEntity() instanceof Player player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (settings.flag("invulnerable", true)) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
|
}
|
||||||
|
if (settings.flag("voidRescue", true) && event.getCause() == EntityDamageEvent.DamageCause.VOID) {
|
||||||
player.teleport(player.getWorld().getSpawnLocation());
|
player.teleport(player.getWorld().getSpawnLocation());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.LOW)
|
@EventHandler(priority = EventPriority.LOW)
|
||||||
public void onHunger(FoodLevelChangeEvent event) {
|
public void onHunger(FoodLevelChangeEvent event) {
|
||||||
if (event.getEntity() instanceof Player) {
|
if (settings.flag("disableHunger", true) && event.getEntity() instanceof Player) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,15 +235,18 @@ final class LobbyGuard implements Listener {
|
|||||||
/** Fire spreads and burns with nobody behind it, so only a builder may start one. */
|
/** Fire spreads and burns with nobody behind it, so only a builder may start one. */
|
||||||
@EventHandler(priority = EventPriority.LOW)
|
@EventHandler(priority = EventPriority.LOW)
|
||||||
public void onIgnite(BlockIgniteEvent event) {
|
public void onIgnite(BlockIgniteEvent event) {
|
||||||
if (!(event.getIgnitingEntity() instanceof Player player) || guarded(player)) {
|
if (settings.flag("protectBuild", true) &&
|
||||||
|
(!(event.getIgnitingEntity() instanceof Player player) || guarded(player))) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.LOW)
|
@EventHandler(priority = EventPriority.LOW)
|
||||||
public void onBurn(BlockBurnEvent event) {
|
public void onBurn(BlockBurnEvent event) {
|
||||||
|
if (settings.flag("protectBuild", true)) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.LOW)
|
@EventHandler(priority = EventPriority.LOW)
|
||||||
public void onHit(EntityDamageByEntityEvent event) {
|
public void onHit(EntityDamageByEntityEvent event) {
|
||||||
@@ -258,7 +285,7 @@ final class LobbyGuard implements Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
|
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
|
||||||
private static boolean guardedCulprit(Entity culprit) {
|
private boolean guardedCulprit(Entity culprit) {
|
||||||
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
|
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
|
||||||
return guarded(shooter);
|
return guarded(shooter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
|
|||||||
/** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */
|
/** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */
|
||||||
boolean zh();
|
boolean zh();
|
||||||
|
|
||||||
|
default String menuTitle() {
|
||||||
|
return zh() ? "Felis 服务器" : "Felis Servers";
|
||||||
|
}
|
||||||
|
|
||||||
boolean online();
|
boolean online();
|
||||||
|
|
||||||
void send(ControlFrame frame);
|
void send(ControlFrame frame);
|
||||||
@@ -189,7 +193,7 @@ final class LobbyMenu implements Listener, PluginMessageListener {
|
|||||||
slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page");
|
slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
screen.open(holder, title(zh, p, pages), size, slots);
|
screen.open(holder, title(screen.menuTitle(), p, pages), size, slots);
|
||||||
// Ask the proxy for live status of every tile on this page; answers repaint them.
|
// Ask the proxy for live status of every tile on this page; answers repaint them.
|
||||||
for (String server : view) {
|
for (String server : view) {
|
||||||
screen.send(ControlFrame.statusQuery(server));
|
screen.send(ControlFrame.statusQuery(server));
|
||||||
@@ -286,7 +290,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
|
|||||||
// ---- text ----
|
// ---- text ----
|
||||||
|
|
||||||
static String title(boolean zh, int page, int pages) {
|
static String title(boolean zh, int page, int pages) {
|
||||||
String title = zh ? "Felis 服务器" : "Felis Servers";
|
return title(zh ? "Felis 服务器" : "Felis Servers", page, pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String title(String title, int page, int pages) {
|
||||||
if (pages > 1) {
|
if (pages > 1) {
|
||||||
title += " (" + (page + 1) + "/" + pages + ")";
|
title += " (" + (page + 1) + "/" + pages + ")";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
# channel to the Velocity proxy. It holds no felis-api token and never contacts
|
# channel to the Velocity proxy. It holds no felis-api token and never contacts
|
||||||
# felis-api directly.
|
# felis-api directly.
|
||||||
#
|
#
|
||||||
# There is nothing to configure. /menu asks the proxy which servers to show
|
# Player-facing settings are edited in the panel at /admin/lobby and stored in
|
||||||
|
# /data/felis-experience.json. This file does not hold those settings.
|
||||||
|
#
|
||||||
|
# /menu asks the proxy which servers to show
|
||||||
# (a ListRequest over felis:control) and the proxy answers from the same registry
|
# (a ListRequest over felis:control) and the proxy answers from the same registry
|
||||||
# it routes by, so a server created in the panel appears here on its own. Tiles are
|
# it routes by, so a server created in the panel appears here on its own. Tiles are
|
||||||
# paged 45 at a time. Status, ownership and autostart stay with the proxy and
|
# paged 45 at a time. Status, ownership and autostart stay with the proxy and
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package best.lolicon.felis.paper;
|
package best.lolicon.felis.paper;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ExperienceConfig;
|
||||||
import best.lolicon.felis.paper.Fakes.FakePlayer;
|
import best.lolicon.felis.paper.Fakes.FakePlayer;
|
||||||
|
import java.util.Map;
|
||||||
import best.lolicon.felis.paper.Fakes.FakeWorld;
|
import best.lolicon.felis.paper.Fakes.FakeWorld;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
@@ -82,6 +84,7 @@ public final class LobbyGuardTest {
|
|||||||
nobodyIsHurt();
|
nobodyIsHurt();
|
||||||
joining();
|
joining();
|
||||||
worldRules();
|
worldRules();
|
||||||
|
customization();
|
||||||
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
|
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +199,33 @@ public final class LobbyGuardTest {
|
|||||||
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
|
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void customization() {
|
||||||
|
LobbyGuard custom = new LobbyGuard(capturingLogger(), new ExperienceConfig(Map.of(
|
||||||
|
"protectBuild", false, "invulnerable", false, "disableHunger", false,
|
||||||
|
"teleportOnJoin", false, "gameMode", "SURVIVAL", "welcomeEn", "Hello, builders! ",
|
||||||
|
"difficulty", "HARD", "freezeTime", false, "clearWeather", false)));
|
||||||
|
FakePlayer player = new FakePlayer(false, world, Locale.US);
|
||||||
|
custom.onJoin(new PlayerJoinEvent(player.player, Component.text("joined")));
|
||||||
|
assertEq("custom mode", GameMode.SURVIVAL, player.gameMode);
|
||||||
|
assertEq("custom join does not teleport", 0, player.teleports.size());
|
||||||
|
assertEq("hunger remains enabled", 3, player.food);
|
||||||
|
assertEq("custom welcome keeps menu click", List.of("/menu"), commands(player.messages.get(0)));
|
||||||
|
assertEq("custom welcome", true, Fakes.text(player.messages.get(0)).startsWith("Hello, builders!"));
|
||||||
|
BlockBreakEvent build = new BlockBreakEvent(Fakes.block(Material.STONE), player.player);
|
||||||
|
custom.onBreak(build);
|
||||||
|
assertEq("building can be opened", false, build.isCancelled());
|
||||||
|
EntityDamageEvent damage = new EntityDamageEvent(player.player, DamageCause.FALL, HIT, 4);
|
||||||
|
custom.onDamage(damage);
|
||||||
|
assertEq("damage can be enabled", false, damage.isCancelled());
|
||||||
|
FoodLevelChangeEvent hunger = new FoodLevelChangeEvent(player.player, 2);
|
||||||
|
custom.onHunger(hunger);
|
||||||
|
assertEq("hunger can be enabled", false, hunger.isCancelled());
|
||||||
|
FakeWorld natural = new FakeWorld();
|
||||||
|
custom.protect(natural.world);
|
||||||
|
assertEq("custom difficulty, advancing time and weather", List.of("setDifficulty HARD"), natural.calls);
|
||||||
|
assertEq("custom menu keeps pagination", "My Hub (2/3)", LobbyMenu.title("My Hub", 1, 3));
|
||||||
|
}
|
||||||
|
|
||||||
private static void worldRules() {
|
private static void worldRules() {
|
||||||
FakeWorld overworld = new FakeWorld("world", false, false);
|
FakeWorld overworld = new FakeWorld("world", false, false);
|
||||||
WARNINGS.clear();
|
WARNINGS.clear();
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Player-facing settings on the persistent world volume, edited by the panel. */
|
||||||
|
public final class ExperienceConfig {
|
||||||
|
public static final Path PATH = Path.of("felis-experience.json");
|
||||||
|
private final Map<?, ?> values;
|
||||||
|
|
||||||
|
public ExperienceConfig(Map<?, ?> values) {
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExperienceConfig load(Path path) throws IOException {
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
return new ExperienceConfig(Map.of());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object value = Json.parse(Files.readString(path));
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
return new ExperienceConfig(map);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("expected an object");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IOException("invalid " + path + ": " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String text(String key, String fallback) {
|
||||||
|
return values.get(key) instanceof String value ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean flag(String key, boolean fallback) {
|
||||||
|
return values.get(key) instanceof Boolean value ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long number(String key, long fallback) {
|
||||||
|
return values.get(key) instanceof Number value ? value.longValue() : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user