feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods
Server-side integration plugins: the Velocity proxy plugin plus Fabric, Forge, NeoForge, and Paper mods with a shared module. Gradle build output is not tracked.
This commit is contained in:
53 files changed
+4832
No files matched your search
@@ -0,0 +1,224 @@
|
|||||||
|
# Felis server-side plugins
|
||||||
|
|
||||||
|
These are the in-cluster and edge plugins for Felis. Every module **except the
|
||||||
|
lobby** ships the in-game first leg of the §10 account-link flow: a player who is already online
|
||||||
|
(so Mojang has verified their UUID) runs `/link`; the plugin asks felis-api to
|
||||||
|
mint a one-time code for that UUID and shows it in chat. The player then enters
|
||||||
|
the code on the web panel → **Account** page (the second leg), which binds the
|
||||||
|
code to their logged-in account. The web side is already built.
|
||||||
|
|
||||||
|
The **Velocity** module additionally carries the §11 domain-autostart routing
|
||||||
|
loop — recognizing each server's subdomain, registering backends dynamically,
|
||||||
|
waking a sleeping target and holding the player until it is ready. It is a full
|
||||||
|
proxy plugin, not just `/link`; see **[Velocity routing](#velocity-routing-§11)**
|
||||||
|
below. The Fabric / Forge / NeoForge mods are `/link`-only.
|
||||||
|
|
||||||
|
The **Paper** module is different in kind: it is the §12 lobby UI face. It ships
|
||||||
|
**no** `/link` and holds **no** felis-api token — it only paints the `/menu`
|
||||||
|
(and `/server`) chest GUI and speaks the `felis:control` plugin-message channel
|
||||||
|
to Velocity, which is the only side that ever talks to felis-api. See
|
||||||
|
**[Lobby menu](#lobby-menu-§12)** below.
|
||||||
|
|
||||||
|
| Module | Platform | Target | Jar |
|
||||||
|
| ------------------ | --------------------------- | ----------------------------------- | ---------------------------- |
|
||||||
|
| `velocity/` | Velocity proxy plugin | velocity-api 3.3.0-SNAPSHOT | `felis-velocity-0.2.0.jar` |
|
||||||
|
| `fabric/` | Fabric server mod | MC 1.20.1 / fabric-loader 0.16.x | `felis-fabric-0.1.0.jar` |
|
||||||
|
| `forge/` | Forge server mod | MC 1.20.1 / Forge 47.3.0 | `felis-forge-0.1.0.jar` |
|
||||||
|
| `neoforge/` | NeoForge server mod | MC 1.20.4 / NeoForge 20.4.251 | `felis-neoforge-0.1.0.jar` |
|
||||||
|
| `paper/` | Paper server plugin (lobby) | paper-api 1.21.4-R0.1-SNAPSHOT | `felis-paper-0.1.0.jar` |
|
||||||
|
| `shared/` | *(not built on its own)* | — | source compiled into each |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Each platform is an **independent** Gradle build with its own `settings.gradle`,
|
||||||
|
not one root project mixing loader plugins (the loader Gradle plugins have
|
||||||
|
conflicting Gradle-version requirements — see below). The platform-neutral link
|
||||||
|
core lives in `shared/src/main/java` and is pulled into every module via:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
sourceSets { main { java { srcDir '../shared/src/main/java' } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The core (`best.lolicon.felis.link`) has **zero third-party dependencies** — it
|
||||||
|
uses the JDK's `java.net.http.HttpClient` and a small hand-written JSON parser —
|
||||||
|
so there is nothing to shade and each jar is self-contained.
|
||||||
|
|
||||||
|
- `LinkClient` — `POST {apiBaseUrl}/api/v1/internal/account/link/code` with
|
||||||
|
`Authorization: Bearer <service-token>` and body `{"mc_uuid":"<uuid>"}`;
|
||||||
|
`201 → {code, expires_at}`, otherwise the `{error:{code,message}}` envelope.
|
||||||
|
- `LinkConfigLoader` — reads `FELIS_API_BASE_URL` / `FELIS_SERVICE_TOKEN` (env
|
||||||
|
wins) or a `felis-link.properties` file written as a commented template on
|
||||||
|
first run. **The API URL and service token are deployment inputs and are never
|
||||||
|
compiled in.**
|
||||||
|
|
||||||
|
Threading: the command runs on the server thread; the HTTP call is dispatched to
|
||||||
|
a daemon single-thread executor and the reply is hopped back onto the server
|
||||||
|
thread, so a slow felis-api never stalls the tick loop. If config is missing the
|
||||||
|
plugin loads but never registers `/link`, so the server runs un-crippled.
|
||||||
|
|
||||||
|
All three mods use **official Mojang mappings**, so the MC class/method names are
|
||||||
|
identical across Fabric/Forge/NeoForge and the command handler is uniform; only
|
||||||
|
the `@Mod`/event-bus/config-dir glue differs per loader.
|
||||||
|
|
||||||
|
## Velocity routing (§11)
|
||||||
|
|
||||||
|
Velocity sits on the player-facing edge, off-cluster, so it is where
|
||||||
|
domain-autostart routing lives. Beyond `/link`, the Velocity plugin recognizes
|
||||||
|
each felis server by its subdomain, registers backends into Velocity's dynamic
|
||||||
|
server registry, and decides — per join — whether to send the player straight in,
|
||||||
|
wake a sleeping server and park them, or ask them to reconnect. It drives §9 wake
|
||||||
|
and §11 routing over the felis-api **internal** face (service-token auth), and
|
||||||
|
additionally terminates the `felis:control` plugin-message channel that backs the
|
||||||
|
§12 lobby menu — translating each lobby frame into the same wake/claim/status
|
||||||
|
calls, against the player's connection-derived identity rather than anything the
|
||||||
|
lobby claims. See **[Lobby menu](#lobby-menu-§12)** below.
|
||||||
|
|
||||||
|
Two preconditions gate routing, **each fails safe** (routing turns off, `/link`
|
||||||
|
keeps working):
|
||||||
|
|
||||||
|
- **online mode** — `online-mode=true` in `velocity.toml`. The autostartPolicy
|
||||||
|
and allowlist gates trust Mojang-verified UUIDs; under offline mode the plugin
|
||||||
|
refuses to route on spoofable identities and logs an error.
|
||||||
|
- **root-domain** — the deployment zone (e.g. `mc.example.net`). This is the only
|
||||||
|
place the zone enters the proxy and is **never compiled in**; without it,
|
||||||
|
host-based routing has nothing to match and stays off.
|
||||||
|
|
||||||
|
What it does when routing is active:
|
||||||
|
|
||||||
|
| Surface | Behavior |
|
||||||
|
| ------- | -------- |
|
||||||
|
| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. Addresses are registered *unresolved* (a sleeping backend's Service DNS may not resolve yet). |
|
||||||
|
| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.<root-domain>` → server. **Ready** → send straight in. **Not ready + lobby** → park in the lobby, wake, and transfer when ready. **Not ready + no lobby** → disconnect with a "reconnect shortly" message, still firing the wake so the reconnect lands faster. |
|
||||||
|
| Waiting queue | One scheduled drain every 2 s polls status once per distinct waited-on server; a waiter drops out on transfer, on the player leaving, or after a 120 s timeout. |
|
||||||
|
| Wake gate | The wake is `POST /api/v1/internal/servers/{name}/wake` keyed on the player's online-mode UUID. **403** (policy refused) tells the player and stops; **429** (wake already in flight) keeps waiting. |
|
||||||
|
| Server-list ping (`ProxyPingEvent`) | Answers from the cached lifecycle view with a phase-aware MOTD (online / starting / sleeping) — **read-only, never wakes** anything. Mirroring each backend's own MOTD by background-pinging ready servers is a later slice. |
|
||||||
|
| Join report (`ServerConnectedEvent`) | Reports real joins to a felis backend via `POST …/join-event`, so the reaper sees activity and the player is auto-added to the server allowlist. |
|
||||||
|
| `/felis`, `/felis list` | Operator status: online-mode, root-domain, lobby, and the known server set with phase/ready. |
|
||||||
|
|
||||||
|
Velocity-only config keys (read from the same `felis-link.properties` / env as
|
||||||
|
`/link`; env wins):
|
||||||
|
|
||||||
|
| Key | Env | Meaning |
|
||||||
|
| --- | --- | ------- |
|
||||||
|
| `root-domain` | `FELIS_ROOT_DOMAIN` | Routing zone, e.g. `mc.example.net`. Unset → routing off. |
|
||||||
|
| `lobby-server` | `FELIS_LOBBY_SERVER` | A `velocity.toml` static server to park players in while a backend wakes. Unset → players are asked to reconnect instead. Its name must not collide with a felis server name. |
|
||||||
|
|
||||||
|
## Lobby menu (§12)
|
||||||
|
|
||||||
|
The `paper/` module is the lobby's player-facing face for §27 scenario 10
|
||||||
|
(`/menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect`). It runs
|
||||||
|
on the Paper lobby server and gives players a chest GUI instead of a command
|
||||||
|
line: `/menu` (alias `/server`) opens a grid of one tile per configured server,
|
||||||
|
and clicking a tile wakes, claims, or joins that backend.
|
||||||
|
|
||||||
|
**Pure UI face.** The lobby holds no felis-api token, opens no HTTP connection,
|
||||||
|
and keeps no waiting queue. Every action it takes is a single frame on the
|
||||||
|
`felis:control` plugin-message channel; every piece of state it shows arrives as
|
||||||
|
a frame on the same channel. Velocity (the `ControlChannel`, above) is the only
|
||||||
|
side that talks to felis-api. This is enforced **physically** by the build, not
|
||||||
|
just by convention: the module's `sourceSets` include-filter compiles in only the
|
||||||
|
paper package plus the three codec classes, so the lobby jar contains exactly
|
||||||
|
five classes —
|
||||||
|
|
||||||
|
```
|
||||||
|
best/lolicon/felis/link/Control.class (channel framing)
|
||||||
|
best/lolicon/felis/link/ControlFrame.class (the frame model)
|
||||||
|
best/lolicon/felis/link/Json.class (codec)
|
||||||
|
best/lolicon/felis/paper/FelisPaperPlugin.class
|
||||||
|
best/lolicon/felis/paper/MenuHolder.class
|
||||||
|
```
|
||||||
|
|
||||||
|
— and **no** `FelisApiClient`, `LinkClient`, or token-config class. If a codec
|
||||||
|
class ever grew a dependency on the API client, compilation would fail here
|
||||||
|
rather than silently widen the lobby's reach.
|
||||||
|
|
||||||
|
**Frames.** Upstream (lobby → velocity) carries `WakeRequest`, `ClaimRequest`,
|
||||||
|
and `StatusQuery`; downstream (velocity → lobby) carries `StatusUpdate`,
|
||||||
|
`TransferReady`, and `Error`. Opening the menu paints a grey "loading" tile per
|
||||||
|
server and fires a `StatusQuery` for each; the proxy answers with `StatusUpdate`
|
||||||
|
frames that repaint each tile by phase + ownership.
|
||||||
|
|
||||||
|
**Anti-spoof (§14).** The `player` field a lobby puts in a frame is **not**
|
||||||
|
trusted. Velocity derives the acting player and UUID from the `ServerConnection`
|
||||||
|
the plugin message arrived on, and the server-side autostartPolicy / ownership
|
||||||
|
gates authorize against that verified identity. The frame's `server` field is the
|
||||||
|
trusted payload — it only names *which* tile was clicked. A fully compromised
|
||||||
|
lobby therefore cannot act as another player or reach the API directly.
|
||||||
|
|
||||||
|
**Button rules** (the tile a click sends depends on the last `StatusUpdate`):
|
||||||
|
|
||||||
|
| Tile state | Label | Frame sent |
|
||||||
|
| ---------- | ----- | ---------- |
|
||||||
|
| ownerless + stopped (`claimable`) | **Claim & Start** | `ClaimRequest{server}` |
|
||||||
|
| owned + running (`ready`) | **Join** | `WakeRequest{server}` |
|
||||||
|
| owned + stopped | **Wake** | `WakeRequest{server}` |
|
||||||
|
|
||||||
|
"Join" and "Wake" are the **same** upstream frame (`WakeRequest`) — only the
|
||||||
|
label differs; the proxy treats a wake of an already-running owned server as a
|
||||||
|
join. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` /
|
||||||
|
`already_claimed` → a friendly message), which is the only place a claim/quota/
|
||||||
|
policy failure surfaces to the player; readiness arrives as `TransferReady` just
|
||||||
|
before the proxy Connects them.
|
||||||
|
|
||||||
|
> **Status.** This slice is **code-complete and compile-verified** (paper jar
|
||||||
|
> builds green on a Java-21 toolchain; the velocity end compiles the full shared
|
||||||
|
> tree; the wire codec round-trips). It is **not** live-verified — there is no
|
||||||
|
> running Paper + Velocity + real players in this environment — so §27 scenario 10
|
||||||
|
> stays **FAIL (live-unverified)** in the spec matrix until it can be exercised
|
||||||
|
> end-to-end on a real deployment.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The platforms need different Gradle versions (a real, measured constraint, not a
|
||||||
|
preference):
|
||||||
|
|
||||||
|
| Module | Gradle | Why |
|
||||||
|
| ----------- | ----------- | --------------------------------------------------------------- |
|
||||||
|
| `velocity` | 9.5.1 (system) | plain `java` plugin — no loader Gradle plugin |
|
||||||
|
| `fabric` | 8.8 (wrapper) | loom 1.7.4 uses `Problems.forNamespace`, removed in Gradle 9 |
|
||||||
|
| `forge` | 8.8 (wrapper) | ForgeGradle 6 is Gradle-8-only |
|
||||||
|
| `neoforge` | 8.14 (wrapper) | NeoGradle 7.1.38 requires Gradle API ≥ 8.14 |
|
||||||
|
| `paper` | 9.5.1 (system), **JDK 21 toolchain** | plain `java` plugin, but paper-api 1.21.4 is published for Java 21, so it declares a `JavaLanguageVersion.of(21)` toolchain — Gradle picks a detected JDK 21 to compile regardless of which JDK runs Gradle |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Velocity — system Gradle is fine
|
||||||
|
gradle -p plugins/velocity build
|
||||||
|
|
||||||
|
# Paper — system Gradle too, but it compiles on a Java-21 toolchain (see table)
|
||||||
|
gradle -p plugins/paper build
|
||||||
|
|
||||||
|
# Fabric / Forge / NeoForge — use the per-module wrapper
|
||||||
|
plugins/fabric/gradlew -p plugins/fabric build
|
||||||
|
plugins/forge/gradlew -p plugins/forge build
|
||||||
|
plugins/neoforge/gradlew -p plugins/neoforge build
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires JDK 17 — **except `paper`, which needs a Java-21 toolchain available to
|
||||||
|
Gradle** (paper-api 1.21.4 is a Java-21 artifact; the rest of the suite is Java
|
||||||
|
17). The first build of each mod downloads and remaps/decompiles Minecraft, so it
|
||||||
|
takes a few minutes; subsequent builds are fast. Jars land in each module's
|
||||||
|
`build/libs/`.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
Drop the matching jar into the server/proxy mods or plugins directory, start
|
||||||
|
once to generate `config/felis-link.properties` (or `plugins/felis-link/…` on
|
||||||
|
Velocity), then set `api-base-url` and `service-token` — or provide
|
||||||
|
`FELIS_API_BASE_URL` and `FELIS_SERVICE_TOKEN` in the environment, which take
|
||||||
|
precedence. The service token is the same one felis-api compares for its
|
||||||
|
internal endpoints; treat it as a secret.
|
||||||
|
|
||||||
|
On **Velocity**, also set `root-domain` (and optionally `lobby-server`) in the
|
||||||
|
same file to turn on §11 routing, and make sure `online-mode=true` in
|
||||||
|
`velocity.toml` — without either, the proxy still serves `/link` but routing
|
||||||
|
stays off (see **[Velocity routing](#velocity-routing-§11)**). The config dir is
|
||||||
|
`plugins/felis-link/` because the plugin id is `felis-link` (kept stable across
|
||||||
|
the 0.1 → 0.2 jar so existing config carries over).
|
||||||
|
|
||||||
|
On the **Paper lobby** there is no token to set, because the lobby never talks to
|
||||||
|
felis-api. Drop `felis-paper-…jar` into `plugins/`, start once to generate
|
||||||
|
`plugins/FelisPaper/config.yml`, and list the felis server names (the CRD
|
||||||
|
`metadata.name`, not the display title) you want as tiles under `servers:`. The
|
||||||
|
lobby must sit behind the same Velocity proxy as the backends — it reaches the
|
||||||
|
control plane only through the proxy's `felis:control` terminus — so it needs no
|
||||||
|
`api-base-url` and no `service-token` of its own.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id 'fabric-loom' version '1.7.4'
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'best.lolicon.felis'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
// MC 1.20.1 is a Java-17 line — the ceiling this JDK can build.
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = 'Fabric'
|
||||||
|
url = 'https://maven.fabricmc.net/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
minecraft 'com.mojang:minecraft:1.20.1'
|
||||||
|
// Official Mojang mappings keep MC class/method names identical to the
|
||||||
|
// Forge/NeoForge modules, so the command handlers stay near-uniform.
|
||||||
|
mappings loom.officialMojangMappings()
|
||||||
|
modImplementation 'net.fabricmc:fabric-loader:0.16.5'
|
||||||
|
// fabric-command-api-v2 (CommandRegistrationCallback) ships in fabric-api.
|
||||||
|
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero-dependency link core is compiled straight into the remapped jar.
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java {
|
||||||
|
srcDir '../shared/src/main/java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = 'Fabric'
|
||||||
|
url = 'https://maven.fabricmc.net/'
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'felis-fabric'
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package best.lolicon.felis.fabric;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.LinkClient;
|
||||||
|
import best.lolicon.felis.link.LinkCode;
|
||||||
|
import best.lolicon.felis.link.LinkConfig;
|
||||||
|
import best.lolicon.felis.link.LinkConfigLoader;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import net.fabricmc.api.DedicatedServerModInitializer;
|
||||||
|
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||||
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link
|
||||||
|
* flow. A server-side {@code /link} command takes the player's already-verified
|
||||||
|
* UUID, asks felis-api for a one-time code, and shows it in chat; the player then
|
||||||
|
* redeems it on the web panel. The HTTP call is pushed onto a daemon I/O thread
|
||||||
|
* and the reply is hopped back onto the server thread, so a slow felis-api never
|
||||||
|
* stalls the tick loop. Failures collapse to a generic chat line with details
|
||||||
|
* confined to the server log.
|
||||||
|
*/
|
||||||
|
public final class FelisFabricMod implements DedicatedServerModInitializer {
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "felis-link-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private LinkClient linkClient;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInitializeServer() {
|
||||||
|
try {
|
||||||
|
LinkConfig config = LinkConfigLoader.load(
|
||||||
|
FabricLoader.getInstance().getConfigDir().resolve("felis-link.properties"));
|
||||||
|
this.linkClient = new LinkClient(config);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.error("Felis link disabled: {}", e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CommandRegistrationCallback.EVENT.register(
|
||||||
|
(dispatcher, registry, environment) -> register(dispatcher));
|
||||||
|
LOGGER.info("Felis link ready; /link is registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
|
dispatcher.register(Commands.literal("link").executes(ctx -> {
|
||||||
|
CommandSourceStack source = ctx.getSource();
|
||||||
|
ServerPlayer player;
|
||||||
|
try {
|
||||||
|
player = source.getPlayerOrException();
|
||||||
|
} catch (CommandSyntaxException e) {
|
||||||
|
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
requestAndReply(source.getServer(), player);
|
||||||
|
return 1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
|
UUID uuid = player.getUUID();
|
||||||
|
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||||
|
io.submit(() -> {
|
||||||
|
try {
|
||||||
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Your link code: " + code.code()
|
||||||
|
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||||
|
} catch (LinkException e) {
|
||||||
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "felis-link",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"name": "Felis Link",
|
||||||
|
"description": "In-game /link command: mints a one-time account-link code from felis-api.",
|
||||||
|
"authors": ["Felis"],
|
||||||
|
"license": "MIT",
|
||||||
|
"environment": "server",
|
||||||
|
"entrypoints": {
|
||||||
|
"server": ["best.lolicon.felis.fabric.FelisFabricMod"]
|
||||||
|
},
|
||||||
|
"depends": {
|
||||||
|
"fabricloader": ">=0.15.0",
|
||||||
|
"minecraft": "~1.20.1",
|
||||||
|
"java": ">=17",
|
||||||
|
"fabric-api": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
plugins {
|
||||||
|
id 'net.minecraftforge.gradle' version '[6.0,6.2)'
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'best.lolicon.felis'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
// MC 1.20.1 / Forge 47.x is a Java-17 line.
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
minecraft {
|
||||||
|
// Official Mojang mappings — same MC names as the Fabric/NeoForge modules.
|
||||||
|
mappings channel: 'official', version: '1.20.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
minecraft 'net.minecraftforge:forge:1.20.1-47.3.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero-dependency link core is compiled in and reobfuscated with the mod.
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java {
|
||||||
|
srcDir '../shared/src/main/java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven {
|
||||||
|
name = 'MinecraftForge'
|
||||||
|
url = 'https://maven.minecraftforge.net/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'felis-forge'
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package best.lolicon.felis.forge;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.LinkClient;
|
||||||
|
import best.lolicon.felis.link.LinkCode;
|
||||||
|
import best.lolicon.felis.link.LinkConfig;
|
||||||
|
import best.lolicon.felis.link.LinkConfigLoader;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
|
import net.minecraftforge.event.RegisterCommandsEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
import net.minecraftforge.fml.loading.FMLPaths;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisForgeMod is the Forge server-side leg of the §10 account-link flow. On
|
||||||
|
* {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a
|
||||||
|
* one-time code from felis-api for the player's already-verified UUID. As on the
|
||||||
|
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped
|
||||||
|
* back onto the server thread; failures collapse to a generic chat line with
|
||||||
|
* details kept to the server log. If config is missing the mod stays loaded but
|
||||||
|
* never registers the command, so the proxy/server runs un-crippled.
|
||||||
|
*/
|
||||||
|
@Mod("felis_link")
|
||||||
|
public final class FelisForgeMod {
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "felis-link-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private LinkClient linkClient;
|
||||||
|
|
||||||
|
public FelisForgeMod() {
|
||||||
|
try {
|
||||||
|
LinkConfig config = LinkConfigLoader.load(
|
||||||
|
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties"));
|
||||||
|
this.linkClient = new LinkClient(config);
|
||||||
|
MinecraftForge.EVENT_BUS.register(this);
|
||||||
|
LOGGER.info("Felis link ready; /link will be registered.");
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.error("Felis link disabled: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onRegisterCommands(RegisterCommandsEvent event) {
|
||||||
|
register(event.getDispatcher());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
|
dispatcher.register(Commands.literal("link").executes(ctx -> {
|
||||||
|
CommandSourceStack source = ctx.getSource();
|
||||||
|
ServerPlayer player;
|
||||||
|
try {
|
||||||
|
player = source.getPlayerOrException();
|
||||||
|
} catch (CommandSyntaxException e) {
|
||||||
|
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
requestAndReply(source.getServer(), player);
|
||||||
|
return 1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
|
UUID uuid = player.getUUID();
|
||||||
|
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||||
|
io.submit(() -> {
|
||||||
|
try {
|
||||||
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Your link code: " + code.code()
|
||||||
|
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||||
|
} catch (LinkException e) {
|
||||||
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
modLoader = "javafml"
|
||||||
|
loaderVersion = "[47,)"
|
||||||
|
license = "MIT"
|
||||||
|
issueTrackerURL = "https://example.invalid/felis"
|
||||||
|
|
||||||
|
[[mods]]
|
||||||
|
modId = "felis_link"
|
||||||
|
version = "0.1.0"
|
||||||
|
displayName = "Felis Link"
|
||||||
|
description = "In-game /link command: mints a one-time account-link code from felis-api."
|
||||||
|
authors = "Felis"
|
||||||
|
|
||||||
|
[[dependencies.felis_link]]
|
||||||
|
modId = "forge"
|
||||||
|
mandatory = true
|
||||||
|
versionRange = "[47,)"
|
||||||
|
ordering = "NONE"
|
||||||
|
side = "SERVER"
|
||||||
|
|
||||||
|
[[dependencies.felis_link]]
|
||||||
|
modId = "minecraft"
|
||||||
|
mandatory = true
|
||||||
|
versionRange = "[1.20.1,1.20.2)"
|
||||||
|
ordering = "NONE"
|
||||||
|
side = "SERVER"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"description": "Felis Link",
|
||||||
|
"pack_format": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
plugins {
|
||||||
|
id 'net.neoforged.gradle.userdev' version '7.1.38'
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'best.lolicon.felis'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
// NeoForge 20.4 (MC 1.20.4) is a Java-17 line. The Gradle daemon runs on JDK 17,
|
||||||
|
// so toolchain 17 resolves to the running JVM with no auto-provisioning.
|
||||||
|
java {
|
||||||
|
toolchain.languageVersion = JavaLanguageVersion.of(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = 'NeoForged'
|
||||||
|
url = 'https://maven.neoforged.net/releases'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// NeoForge bundles MC at Mojang mappings — same MC class/method names as the
|
||||||
|
// Fabric and Forge modules, so the command handler below is uniform with them.
|
||||||
|
implementation 'net.neoforged:neoforge:20.4.251'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero-dependency link core is compiled in. NeoForge runs Mojang mappings at
|
||||||
|
// runtime, so there is no reobf step — the jar straight out of `build` is the deliverable.
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java {
|
||||||
|
srcDir '../shared/src/main/java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven {
|
||||||
|
name = 'NeoForged'
|
||||||
|
url = 'https://maven.neoforged.net/releases'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'felis-neoforge'
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package best.lolicon.felis.neoforge;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.LinkClient;
|
||||||
|
import best.lolicon.felis.link.LinkCode;
|
||||||
|
import best.lolicon.felis.link.LinkConfig;
|
||||||
|
import best.lolicon.felis.link.LinkConfigLoader;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.bus.api.IEventBus;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.common.Mod;
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisNeoForgeMod is the NeoForge server-side leg of the §10 account-link flow. On
|
||||||
|
* {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a
|
||||||
|
* one-time code from felis-api for the player's already-verified UUID. As on the
|
||||||
|
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped
|
||||||
|
* back onto the server thread; failures collapse to a generic chat line with details
|
||||||
|
* kept to the server log. If config is missing the mod stays loaded but never
|
||||||
|
* registers the command, so the server runs un-crippled.
|
||||||
|
*
|
||||||
|
* <p>NeoForge constructs the mod with the mod event bus injected; the command event
|
||||||
|
* fires on the game bus ({@link NeoForge#EVENT_BUS}), which is what we subscribe to.
|
||||||
|
*/
|
||||||
|
@Mod("felis_link")
|
||||||
|
public final class FelisNeoForgeMod {
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "felis-link-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private LinkClient linkClient;
|
||||||
|
|
||||||
|
public FelisNeoForgeMod(IEventBus modEventBus) {
|
||||||
|
try {
|
||||||
|
LinkConfig config = LinkConfigLoader.load(
|
||||||
|
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties"));
|
||||||
|
this.linkClient = new LinkClient(config);
|
||||||
|
NeoForge.EVENT_BUS.register(this);
|
||||||
|
LOGGER.info("Felis link ready; /link will be registered.");
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.error("Felis link disabled: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onRegisterCommands(RegisterCommandsEvent event) {
|
||||||
|
register(event.getDispatcher());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
|
dispatcher.register(Commands.literal("link").executes(ctx -> {
|
||||||
|
CommandSourceStack source = ctx.getSource();
|
||||||
|
ServerPlayer player;
|
||||||
|
try {
|
||||||
|
player = source.getPlayerOrException();
|
||||||
|
} catch (CommandSyntaxException e) {
|
||||||
|
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
requestAndReply(source.getServer(), player);
|
||||||
|
return 1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
|
UUID uuid = player.getUUID();
|
||||||
|
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||||
|
io.submit(() -> {
|
||||||
|
try {
|
||||||
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Your link code: " + code.code()
|
||||||
|
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||||
|
} catch (LinkException e) {
|
||||||
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
|
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||||
|
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
modLoader = "javafml"
|
||||||
|
loaderVersion = "[1,)"
|
||||||
|
license = "MIT"
|
||||||
|
issueTrackerURL = "https://example.invalid/felis"
|
||||||
|
|
||||||
|
[[mods]]
|
||||||
|
modId = "felis_link"
|
||||||
|
version = "0.1.0"
|
||||||
|
displayName = "Felis Link"
|
||||||
|
description = "In-game /link command: mints a one-time account-link code from felis-api."
|
||||||
|
authors = "Felis"
|
||||||
|
|
||||||
|
[[dependencies.felis_link]]
|
||||||
|
modId = "neoforge"
|
||||||
|
type = "required"
|
||||||
|
versionRange = "[20.4,)"
|
||||||
|
ordering = "NONE"
|
||||||
|
side = "SERVER"
|
||||||
|
|
||||||
|
[[dependencies.felis_link]]
|
||||||
|
modId = "minecraft"
|
||||||
|
type = "required"
|
||||||
|
versionRange = "[1.20.4,1.20.5)"
|
||||||
|
ordering = "NONE"
|
||||||
|
side = "SERVER"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"description": "Felis Link",
|
||||||
|
"pack_format": 22
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'best.lolicon.felis'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
// Paper 1.21 runs on Java 21, and its API is published as a Java-21 artifact, so
|
||||||
|
// this one module needs a Java-21 toolchain (the rest of the suite is 17). The
|
||||||
|
// toolchain block makes that requirement explicit and self-enforcing: Gradle uses a
|
||||||
|
// detected JDK 21 to compile, regardless of which JDK runs Gradle, and fails loudly
|
||||||
|
// if none is present. The shared codec is plain Java-17 source, which 21 compiles
|
||||||
|
// forward-compatibly.
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = 'papermc'
|
||||||
|
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// paper-api is compile-only (the server provides it at runtime). It pulls in
|
||||||
|
// Bukkit + Adventure, which is all the lobby face needs.
|
||||||
|
compileOnly 'io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lobby is a PURE UI face (spec §12): it speaks only the felis:control
|
||||||
|
// 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
|
||||||
|
// path, but the include filter ships ONLY the paper package and the three codec
|
||||||
|
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
|
||||||
|
// 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
|
||||||
|
// rather than silently widening the lobby's reach.
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java {
|
||||||
|
srcDir '../shared/src/main/java'
|
||||||
|
include 'best/lolicon/felis/paper/**'
|
||||||
|
include 'best/lolicon/felis/link/Control.java'
|
||||||
|
include 'best/lolicon/felis/link/ControlFrame.java'
|
||||||
|
include 'best/lolicon/felis/link/Json.java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'felis-paper'
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package best.lolicon.felis.paper;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.Control;
|
||||||
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.bukkit.plugin.messaging.PluginMessageListener;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and
|
||||||
|
* {@code /server}) chest GUI players use to pick, wake or claim a backend without
|
||||||
|
* ever touching the command line. It is the player-facing end of the §27 scenario-10
|
||||||
|
* path — {@code /menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
|
||||||
|
*
|
||||||
|
* <p><b>Pure UI face.</b> This plugin deliberately holds no felis-api token, opens no
|
||||||
|
* HTTP connection, and keeps no waiting queue. Every action it takes is a single
|
||||||
|
* {@link ControlFrame} written to the {@code felis:control} plugin-message channel,
|
||||||
|
* and every piece of state it shows arrives as a frame on the same channel. The
|
||||||
|
* Velocity proxy ({@code ControlChannel}) is the only thing that talks to felis-api,
|
||||||
|
* and it derives the acting player's identity from the backend connection rather than
|
||||||
|
* anything this lobby sends (spec §14) — so even a fully compromised lobby cannot act
|
||||||
|
* as another player or reach the API directly. The build enforces this physically:
|
||||||
|
* only the channel codec ({@code Control}/{@code ControlFrame}/{@code Json}) is
|
||||||
|
* compiled in from the shared core; {@code FelisApiClient} and the token config are
|
||||||
|
* not on the lobby's classpath at all.
|
||||||
|
*
|
||||||
|
* <p><b>Flow.</b> Opening the menu paints a "loading" tile per configured server and
|
||||||
|
* fires a {@code StatusQuery} for each; the proxy answers with {@code StatusUpdate}
|
||||||
|
* frames that repaint each tile by phase + ownership. Clicking a tile sends a
|
||||||
|
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
|
||||||
|
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
|
||||||
|
* of a running owned server and the "Wake" of a stopped owned one), then closes the
|
||||||
|
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
|
||||||
|
* the only place claim/quota/policy failures surface — and readiness arrives as
|
||||||
|
* {@code TransferReady} just before the proxy Connects them.
|
||||||
|
*/
|
||||||
|
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
|
||||||
|
|
||||||
|
private static final Component MENU_TITLE =
|
||||||
|
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
|
||||||
|
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
|
||||||
|
|
||||||
|
/** Server names to show as tiles, in display order; loaded from config. */
|
||||||
|
private final List<String> servers = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
saveDefaultConfig();
|
||||||
|
servers.clear();
|
||||||
|
servers.addAll(getConfig().getStringList("servers"));
|
||||||
|
|
||||||
|
// Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
|
||||||
|
// the proxy; incoming receives StatusUpdate/TransferReady/Error back.
|
||||||
|
getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL);
|
||||||
|
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
|
||||||
|
getServer().getPluginManager().registerEvents(this, this);
|
||||||
|
|
||||||
|
getLogger().info("felis-paper enabled: " + servers.size()
|
||||||
|
+ " server tile(s), felis:control open. Pure UI face — no felis-api token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- commands: /menu and /server both open the GUI ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!(sender instanceof Player)) {
|
||||||
|
sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
openMenu((Player) sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openMenu(Player player) {
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"No servers are configured yet — ask an operator to set up felis-paper.",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int shown = Math.min(servers.size(), MAX_TILES);
|
||||||
|
List<String> view = new ArrayList<>(servers.subList(0, shown));
|
||||||
|
MenuHolder holder = new MenuHolder(view);
|
||||||
|
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
|
||||||
|
holder.setInventory(inv);
|
||||||
|
for (int i = 0; i < shown; i++) {
|
||||||
|
inv.setItem(i, loadingTile(view.get(i)));
|
||||||
|
}
|
||||||
|
player.openInventory(inv);
|
||||||
|
// Ask the proxy for live status of every tile; answers repaint them.
|
||||||
|
for (String server : view) {
|
||||||
|
sendUpstream(player, ControlFrame.statusQuery(server));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- click: a tile is a button, never an item to pick up ----
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
Inventory top = event.getView().getTopInventory();
|
||||||
|
if (!(top.getHolder() instanceof MenuHolder)) {
|
||||||
|
return; // not our GUI
|
||||||
|
}
|
||||||
|
// Every slot in our GUI is a button: cancel unconditionally so nothing can be
|
||||||
|
// taken out, even on clicks in empty slots or the player's own inventory.
|
||||||
|
event.setCancelled(true);
|
||||||
|
if (event.getClickedInventory() != top) {
|
||||||
|
return; // click landed in the player's inventory, not a tile
|
||||||
|
}
|
||||||
|
if (!(event.getWhoClicked() instanceof Player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = (Player) event.getWhoClicked();
|
||||||
|
MenuHolder holder = (MenuHolder) top.getHolder();
|
||||||
|
int slot = event.getSlot();
|
||||||
|
if (slot < 0 || slot >= holder.servers().size()) {
|
||||||
|
return; // padding slot
|
||||||
|
}
|
||||||
|
String server = holder.servers().get(slot);
|
||||||
|
ControlFrame state = holder.latest(server);
|
||||||
|
if (state == null) {
|
||||||
|
return; // still loading — no status yet, so we don't know which frame to send
|
||||||
|
}
|
||||||
|
// Claimable (ownerless + stopped) → Claim & Start; everything else → Wake
|
||||||
|
// (which the proxy treats as Join when the owned server is already running).
|
||||||
|
if (state.claimable()) {
|
||||||
|
sendUpstream(player, ControlFrame.claimRequest(player.getName(), server));
|
||||||
|
} else {
|
||||||
|
sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server));
|
||||||
|
}
|
||||||
|
player.closeInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryDrag(InventoryDragEvent event) {
|
||||||
|
// A drag can deposit into or sweep across our tiles without ever firing a
|
||||||
|
// single InventoryClickEvent on them, so the click guard alone is not enough:
|
||||||
|
// cancel any drag that touches our GUI so a tile can never be grabbed or smeared.
|
||||||
|
if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- downstream: felis:control frames from the proxy ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
|
||||||
|
if (!Control.CHANNEL.equals(channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ControlFrame frame;
|
||||||
|
try {
|
||||||
|
frame = Control.decode(message);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (frame.type()) {
|
||||||
|
case ControlFrame.STATUS_UPDATE:
|
||||||
|
applyStatus(player, frame);
|
||||||
|
break;
|
||||||
|
case ControlFrame.ERROR:
|
||||||
|
// The proxy already sanitizes transport faults; this is the only place
|
||||||
|
// a claim/quota/policy refusal becomes visible to the player.
|
||||||
|
player.sendMessage(Component.text("⚠ " + errorText(frame), NamedTextColor.RED));
|
||||||
|
break;
|
||||||
|
case ControlFrame.TRANSFER_READY:
|
||||||
|
// The proxy performs the actual Connect; just make sure a stale menu is
|
||||||
|
// not left open over the join.
|
||||||
|
closeIfMenu(player);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Upstream-only types (Wake/Claim/StatusQuery) are never expected back.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyStatus(Player player, ControlFrame frame) {
|
||||||
|
Inventory top = player.getOpenInventory().getTopInventory();
|
||||||
|
if (!(top.getHolder() instanceof MenuHolder)) {
|
||||||
|
return; // the player closed the menu before the answer arrived
|
||||||
|
}
|
||||||
|
MenuHolder holder = (MenuHolder) top.getHolder();
|
||||||
|
int slot = holder.servers().indexOf(frame.server());
|
||||||
|
if (slot < 0) {
|
||||||
|
return; // a server we are not showing
|
||||||
|
}
|
||||||
|
holder.put(frame.server(), frame);
|
||||||
|
top.setItem(slot, tile(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeIfMenu(Player player) {
|
||||||
|
if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) {
|
||||||
|
player.closeInventory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering ----
|
||||||
|
|
||||||
|
private ItemStack tile(ControlFrame f) {
|
||||||
|
Material material;
|
||||||
|
String action;
|
||||||
|
NamedTextColor color;
|
||||||
|
if (f.claimable()) {
|
||||||
|
material = Material.GOLD_BLOCK;
|
||||||
|
action = "Claim & Start";
|
||||||
|
color = NamedTextColor.GOLD;
|
||||||
|
} else if (f.ready()) {
|
||||||
|
material = Material.LIME_CONCRETE;
|
||||||
|
action = "Join";
|
||||||
|
color = NamedTextColor.GREEN;
|
||||||
|
} else {
|
||||||
|
material = Material.RED_CONCRETE;
|
||||||
|
action = "Wake";
|
||||||
|
color = NamedTextColor.RED;
|
||||||
|
}
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.displayName(Component.text(action + " · " + f.server(), color)
|
||||||
|
.decoration(TextDecoration.ITALIC, false));
|
||||||
|
List<Component> lore = new ArrayList<>();
|
||||||
|
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
||||||
|
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
|
||||||
|
meta.lore(lore);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack loadingTile(String server) {
|
||||||
|
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
|
||||||
|
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)));
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component line(String key, String value) {
|
||||||
|
return Component.text(key + ": ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(value, NamedTextColor.WHITE))
|
||||||
|
.decoration(TextDecoration.ITALIC, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String errorText(ControlFrame f) {
|
||||||
|
String code = f.code();
|
||||||
|
if (code != null) {
|
||||||
|
switch (code) {
|
||||||
|
case "not_linked":
|
||||||
|
return "Link your account first — run /link, then finish on the web panel.";
|
||||||
|
case "quota_exceeded":
|
||||||
|
return "You've reached your server quota.";
|
||||||
|
case "already_claimed":
|
||||||
|
return "That server was just claimed by someone else.";
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.message() != null && !f.message().isEmpty()
|
||||||
|
? f.message()
|
||||||
|
: (code != null ? code : "Request failed — please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private void sendUpstream(Player player, ControlFrame frame) {
|
||||||
|
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int invSize(int count) {
|
||||||
|
int rows = Math.max(1, (count + 8) / 9);
|
||||||
|
return Math.min(rows, 6) * 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package best.lolicon.felis.paper;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
|
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuHolder is the identity and state carried by a {@code /menu} inventory. Bukkit
|
||||||
|
* lets an {@link InventoryHolder} ride along with an {@link Inventory}, which is how
|
||||||
|
* {@link FelisPaperPlugin} tells "this is our GUI" from any other open chest — a click
|
||||||
|
* or a downstream frame only acts when {@code inventory.getHolder() instanceof
|
||||||
|
* MenuHolder}. Tying the state to the inventory instance (rather than a per-player map
|
||||||
|
* on the plugin) means it is garbage-collected with the menu and never leaks across
|
||||||
|
* reopen.
|
||||||
|
*
|
||||||
|
* <p>It holds two things: the ordered list of server names (the list index <em>is</em>
|
||||||
|
* the slot, so a click slot maps straight to a server) and the latest
|
||||||
|
* {@link ControlFrame} seen for each, so a click knows whether to send a Claim or a
|
||||||
|
* Wake without re-querying.
|
||||||
|
*/
|
||||||
|
final class MenuHolder implements InventoryHolder {
|
||||||
|
|
||||||
|
private final List<String> servers; // index = slot
|
||||||
|
private final Map<String, ControlFrame> latest = new HashMap<>();
|
||||||
|
private Inventory inventory;
|
||||||
|
|
||||||
|
MenuHolder(List<String> servers) {
|
||||||
|
this.servers = servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** servers returns the tile order; the list index is the inventory slot. */
|
||||||
|
List<String> servers() {
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** latest is the most recent StatusUpdate for a server, or null if none yet. */
|
||||||
|
ControlFrame latest(String server) {
|
||||||
|
return latest.get(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** put records the latest StatusUpdate for a server. */
|
||||||
|
void put(String server, ControlFrame frame) {
|
||||||
|
latest.put(server, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setInventory(Inventory inventory) {
|
||||||
|
this.inventory = inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() {
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# felis-paper — the lobby UI face (spec §12).
|
||||||
|
#
|
||||||
|
# This plugin is a PURE UI face: it speaks only the felis:control plugin-message
|
||||||
|
# channel to the Velocity proxy. It holds no felis-api token and never contacts
|
||||||
|
# felis-api directly. The list below is only which server tiles to show in the
|
||||||
|
# /menu GUI; the proxy (and felis-api behind it) stay the source of truth for
|
||||||
|
# status, ownership and autostart — every tile is filled in by a live StatusQuery
|
||||||
|
# over felis:control when the menu opens, and acting on a tile sends a Wake or
|
||||||
|
# Claim frame that the proxy authorizes against the player's verified identity.
|
||||||
|
#
|
||||||
|
# Replace the examples below with the names of your felis servers (the CRD
|
||||||
|
# metadata.name / the server's felis name, not its display title). Up to 54 are
|
||||||
|
# shown. An empty list makes /menu say there is nothing to show.
|
||||||
|
servers:
|
||||||
|
- smp
|
||||||
|
- creative
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name: FelisPaper
|
||||||
|
version: 0.1.0
|
||||||
|
main: best.lolicon.felis.paper.FelisPaperPlugin
|
||||||
|
api-version: '1.21'
|
||||||
|
authors: [Felis]
|
||||||
|
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel.
|
||||||
|
commands:
|
||||||
|
menu:
|
||||||
|
description: Open the Felis server menu.
|
||||||
|
usage: /menu
|
||||||
|
server:
|
||||||
|
description: Open the Felis server menu (alias of /menu).
|
||||||
|
usage: /server
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control is the {@code felis:control} plugin-message codec (spec §12): it turns a
|
||||||
|
* {@link ControlFrame} into the raw bytes a plugin message carries, and back. Both
|
||||||
|
* the Velocity proxy and the felis-paper lobby source-share this class, so the wire
|
||||||
|
* format has exactly one definition and the two ends cannot drift.
|
||||||
|
*
|
||||||
|
* <p>Frames are encoded as <b>raw UTF-8 JSON bytes</b>, not via
|
||||||
|
* {@code DataOutputStream.writeUTF}. The namespaced channel hands the listener the
|
||||||
|
* exact byte array on both ends — Velocity's {@code PluginMessageEvent.getData()}
|
||||||
|
* and Bukkit's {@code PluginMessageListener} — so there is no length-prefix framing
|
||||||
|
* to agree on, and the 64 KB ceiling {@code writeUTF} imposes is avoided.
|
||||||
|
*
|
||||||
|
* <p>The codec reuses the package-private {@link Json} reader (which is why this
|
||||||
|
* lives in {@code best.lolicon.felis.link}) and a tiny hand-rolled writer, keeping
|
||||||
|
* the shared core dependency-free. Decoding is strict on structure (a non-object or
|
||||||
|
* a missing/unknown {@code type} throws {@link IllegalArgumentException}) and
|
||||||
|
* tolerant on fields (absent fields degrade to null/zero, per {@link ControlFrame}).
|
||||||
|
*/
|
||||||
|
public final class Control {
|
||||||
|
|
||||||
|
/** The plugin-message channel both ends register (spec §12). */
|
||||||
|
public static final String CHANNEL = "felis:control";
|
||||||
|
|
||||||
|
private Control() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* encode renders {@code frame} as the channel's raw UTF-8 JSON bytes. Only the
|
||||||
|
* fields its {@code type} defines are written, so a round-trip through
|
||||||
|
* {@link #decode(byte[])} reproduces an equal frame.
|
||||||
|
*/
|
||||||
|
public static byte[] encode(ControlFrame frame) {
|
||||||
|
StringBuilder sb = new StringBuilder(96);
|
||||||
|
sb.append("{\"type\":");
|
||||||
|
jsonString(sb, frame.type());
|
||||||
|
switch (frame.type()) {
|
||||||
|
case ControlFrame.WAKE_REQUEST:
|
||||||
|
case ControlFrame.CLAIM_REQUEST:
|
||||||
|
case ControlFrame.TRANSFER_READY:
|
||||||
|
kv(sb, "player", frame.player());
|
||||||
|
kv(sb, "server", frame.server());
|
||||||
|
break;
|
||||||
|
case ControlFrame.STATUS_QUERY:
|
||||||
|
kv(sb, "server", frame.server());
|
||||||
|
break;
|
||||||
|
case ControlFrame.STATUS_UPDATE:
|
||||||
|
kv(sb, "server", frame.server());
|
||||||
|
kv(sb, "phase", frame.phase());
|
||||||
|
kvBool(sb, "ready", frame.ready());
|
||||||
|
kvInt(sb, "playersOnline", frame.playersOnline());
|
||||||
|
kvInt(sb, "playersMax", frame.playersMax());
|
||||||
|
kvBool(sb, "claimable", frame.claimable());
|
||||||
|
break;
|
||||||
|
case ControlFrame.ERROR:
|
||||||
|
kv(sb, "code", frame.code());
|
||||||
|
kv(sb, "message", frame.message());
|
||||||
|
// server is optional on Error: only emit it when the refusal is
|
||||||
|
// server-scoped, so a bare Error frame stays minimal.
|
||||||
|
if (frame.server() != null) {
|
||||||
|
kv(sb, "server", frame.server());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("control: cannot encode unknown frame type '" + frame.type() + "'");
|
||||||
|
}
|
||||||
|
sb.append('}');
|
||||||
|
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* decode parses raw channel bytes back into a {@link ControlFrame}. A malformed
|
||||||
|
* body, a non-object root, a missing {@code type}, or an unrecognized
|
||||||
|
* {@code type} all throw {@link IllegalArgumentException} — the caller treats a
|
||||||
|
* bad frame as a dropped message, never a crash.
|
||||||
|
*/
|
||||||
|
public static ControlFrame decode(byte[] data) {
|
||||||
|
Object root;
|
||||||
|
try {
|
||||||
|
root = Json.parse(new String(data, StandardCharsets.UTF_8));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new IllegalArgumentException("control: malformed frame", e);
|
||||||
|
}
|
||||||
|
if (!(root instanceof Map)) {
|
||||||
|
throw new IllegalArgumentException("control: frame is not a JSON object");
|
||||||
|
}
|
||||||
|
Map<?, ?> o = (Map<?, ?>) root;
|
||||||
|
String type = str(o, "type");
|
||||||
|
if (type == null) {
|
||||||
|
throw new IllegalArgumentException("control: frame missing 'type'");
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case ControlFrame.WAKE_REQUEST:
|
||||||
|
return ControlFrame.wakeRequest(str(o, "player"), str(o, "server"));
|
||||||
|
case ControlFrame.CLAIM_REQUEST:
|
||||||
|
return ControlFrame.claimRequest(str(o, "player"), str(o, "server"));
|
||||||
|
case ControlFrame.STATUS_QUERY:
|
||||||
|
return ControlFrame.statusQuery(str(o, "server"));
|
||||||
|
case ControlFrame.STATUS_UPDATE:
|
||||||
|
return ControlFrame.statusUpdate(str(o, "server"), str(o, "phase"), bool(o, "ready"),
|
||||||
|
intval(o, "playersOnline"), intval(o, "playersMax"), bool(o, "claimable"));
|
||||||
|
case ControlFrame.TRANSFER_READY:
|
||||||
|
return ControlFrame.transferReady(str(o, "player"), str(o, "server"));
|
||||||
|
case ControlFrame.ERROR:
|
||||||
|
return ControlFrame.error(str(o, "code"), str(o, "message"), str(o, "server"));
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("control: unknown frame type '" + type + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON writer (every field after "type" is preceded by a comma) ----
|
||||||
|
|
||||||
|
private static void kv(StringBuilder sb, String key, String value) {
|
||||||
|
sb.append(",\"").append(key).append("\":");
|
||||||
|
if (value == null) {
|
||||||
|
sb.append("null");
|
||||||
|
} else {
|
||||||
|
jsonString(sb, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void kvBool(StringBuilder sb, String key, boolean value) {
|
||||||
|
sb.append(",\"").append(key).append("\":").append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void kvInt(StringBuilder sb, String key, int value) {
|
||||||
|
sb.append(",\"").append(key).append("\":").append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void jsonString(StringBuilder sb, String s) {
|
||||||
|
sb.append('"');
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '"':
|
||||||
|
sb.append("\\\"");
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
sb.append("\\\\");
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
sb.append("\\n");
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
sb.append("\\r");
|
||||||
|
break;
|
||||||
|
case '\t':
|
||||||
|
sb.append("\\t");
|
||||||
|
break;
|
||||||
|
case '\b':
|
||||||
|
sb.append("\\b");
|
||||||
|
break;
|
||||||
|
case '\f':
|
||||||
|
sb.append("\\f");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON readers (mirror ServerView's tolerant coercion) ----
|
||||||
|
|
||||||
|
private static String str(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof String ? (String) v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean bool(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof Boolean && (Boolean) v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intval(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
// Json parses every number as Double; the player counts are int32 server-side.
|
||||||
|
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlFrame is one message on the {@code felis:control} plugin-message channel
|
||||||
|
* (spec §12): the wire contract between the felis-paper lobby and the Velocity
|
||||||
|
* proxy. The lobby is a pure UI face — it holds no felis-api token and maintains no
|
||||||
|
* queue — so every lobby action travels to Velocity as one of these frames, and
|
||||||
|
* Velocity answers with one back. {@link Control} encodes/decodes them; this class
|
||||||
|
* is just the immutable value, source-shared into both the velocity and paper jars
|
||||||
|
* so the two ends can never drift on field names.
|
||||||
|
*
|
||||||
|
* <p>There are six frame types, discriminated by {@link #type()}:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Upstream</b> (lobby → velocity): {@link #WAKE_REQUEST} and
|
||||||
|
* {@link #CLAIM_REQUEST} carry {@code player}+{@code server};
|
||||||
|
* {@link #STATUS_QUERY} carries {@code server}.</li>
|
||||||
|
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
|
||||||
|
* projection; {@link #TRANSFER_READY} tells the lobby a parked player's
|
||||||
|
* backend is up; {@link #ERROR} reports a refusal.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The {@code player} field is informational only on the upstream frames:
|
||||||
|
* Velocity derives the real identity from the {@code ServerConnection} the message
|
||||||
|
* arrived on, never from this field, so a compromised backend cannot act as another
|
||||||
|
* player (spec §14). The lobby still fills it in for symmetry and logging.
|
||||||
|
*
|
||||||
|
* <p>The spec sketches {@code StatusUpdate} as {@code {server,phase,players,
|
||||||
|
* claimable}}; this refines {@code players} into {@link #ready()} +
|
||||||
|
* {@link #playersOnline()} + {@link #playersMax()}, mapping the frame 1:1 onto the
|
||||||
|
* {@code GET …/menu} endpoint so the GUI can render both the phase button and a
|
||||||
|
* "3/20" player count from a single frame.
|
||||||
|
*
|
||||||
|
* <p>Accessors degrade to {@code null}/{@code 0}/{@code false} for fields absent on
|
||||||
|
* a given type, mirroring {@link ServerView}'s tolerant philosophy: a frame is read
|
||||||
|
* for the fields its type defines and no others.
|
||||||
|
*/
|
||||||
|
public final class ControlFrame {
|
||||||
|
|
||||||
|
/** Upstream: park-and-wake a server the player may already own (player, server). */
|
||||||
|
public static final String WAKE_REQUEST = "WakeRequest";
|
||||||
|
/** Upstream: claim an ownerless server, then wake it (player, server). */
|
||||||
|
public static final String CLAIM_REQUEST = "ClaimRequest";
|
||||||
|
/** Upstream: ask for a fresh {@link #STATUS_UPDATE} for one server (server). */
|
||||||
|
public static final String STATUS_QUERY = "StatusQuery";
|
||||||
|
/** Downstream: the tile projection (server, phase, ready, players, claimable). */
|
||||||
|
public static final String STATUS_UPDATE = "StatusUpdate";
|
||||||
|
/** Downstream: a parked player's backend is ready; the lobby may release them (player, server). */
|
||||||
|
public static final String TRANSFER_READY = "TransferReady";
|
||||||
|
/** Downstream: a refusal (code, message, optional server). */
|
||||||
|
public static final String ERROR = "Error";
|
||||||
|
|
||||||
|
private final String type;
|
||||||
|
private final String player;
|
||||||
|
private final String server;
|
||||||
|
private final String phase;
|
||||||
|
private final boolean ready;
|
||||||
|
private final int playersOnline;
|
||||||
|
private final int playersMax;
|
||||||
|
private final boolean claimable;
|
||||||
|
private final String code;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
private ControlFrame(String type, String player, String server, String phase, boolean ready,
|
||||||
|
int playersOnline, int playersMax, boolean claimable, String code, String message) {
|
||||||
|
this.type = type;
|
||||||
|
this.player = player;
|
||||||
|
this.server = server;
|
||||||
|
this.phase = phase;
|
||||||
|
this.ready = ready;
|
||||||
|
this.playersOnline = playersOnline;
|
||||||
|
this.playersMax = playersMax;
|
||||||
|
this.claimable = claimable;
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
|
||||||
|
|
||||||
|
public static ControlFrame wakeRequest(String player, String server) {
|
||||||
|
return new ControlFrame(WAKE_REQUEST, player, server, null, false, 0, 0, false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ControlFrame claimRequest(String player, String server) {
|
||||||
|
return new ControlFrame(CLAIM_REQUEST, player, server, null, false, 0, 0, false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ControlFrame statusQuery(String server) {
|
||||||
|
return new ControlFrame(STATUS_QUERY, null, server, null, false, 0, 0, false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ControlFrame statusUpdate(String server, String phase, boolean ready,
|
||||||
|
int playersOnline, int playersMax, boolean claimable) {
|
||||||
|
return new ControlFrame(STATUS_UPDATE, null, server, phase, ready, playersOnline, playersMax, claimable, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ControlFrame transferReady(String player, String server) {
|
||||||
|
return new ControlFrame(TRANSFER_READY, player, server, null, false, 0, 0, false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** error reports a refusal; {@code server} is optional (null when not server-scoped). */
|
||||||
|
public static ControlFrame error(String code, String message, String server) {
|
||||||
|
return new ControlFrame(ERROR, null, server, null, false, 0, 0, false, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accessors ----
|
||||||
|
|
||||||
|
public String type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String player() {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String server() {
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String phase() {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean ready() {
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersOnline() {
|
||||||
|
return playersOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersMax() {
|
||||||
|
return playersMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean claimable() {
|
||||||
|
return claimable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String message() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof ControlFrame)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ControlFrame f = (ControlFrame) o;
|
||||||
|
return ready == f.ready
|
||||||
|
&& playersOnline == f.playersOnline
|
||||||
|
&& playersMax == f.playersMax
|
||||||
|
&& claimable == f.claimable
|
||||||
|
&& Objects.equals(type, f.type)
|
||||||
|
&& Objects.equals(player, f.player)
|
||||||
|
&& Objects.equals(server, f.server)
|
||||||
|
&& Objects.equals(phase, f.phase)
|
||||||
|
&& Objects.equals(code, f.code)
|
||||||
|
&& Objects.equals(message, f.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ControlFrame{" + new String(Control.encode(this), java.nio.charset.StandardCharsets.UTF_8) + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
|
||||||
|
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
|
||||||
|
* drives domain-autostart routing: it lists the registrable servers, resolves a
|
||||||
|
* connecting virtual host to its server, polls a server's lifecycle status, pulls
|
||||||
|
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
|
||||||
|
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
|
||||||
|
* stack, so it compiles straight into each loader jar with nothing to shade.
|
||||||
|
*
|
||||||
|
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
|
||||||
|
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
|
||||||
|
* status, so the proxy can branch on it without parsing human text. The two that
|
||||||
|
* matter for routing:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
|
||||||
|
* not enqueue the player); 429 means a wake is already cooling down
|
||||||
|
* ("already waking, keep waiting"), not a failure.</li>
|
||||||
|
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class FelisApiClient {
|
||||||
|
private final LinkConfig config;
|
||||||
|
private final HttpClient http;
|
||||||
|
|
||||||
|
public FelisApiClient(LinkConfig config) {
|
||||||
|
this.config = Objects.requireNonNull(config, "config");
|
||||||
|
this.http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(config.timeout())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */
|
||||||
|
public List<ServerView> listServers() throws LinkException {
|
||||||
|
Map<?, ?> obj = getObject("/api/v1/servers", 200);
|
||||||
|
Object arr = obj.get("servers");
|
||||||
|
List<ServerView> out = new ArrayList<>();
|
||||||
|
if (arr instanceof List) {
|
||||||
|
for (Object e : (List<?>) arr) {
|
||||||
|
if (e instanceof Map) {
|
||||||
|
out.add(ServerView.fromJson((Map<?, ?>) e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
|
||||||
|
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
|
||||||
|
* statusCode 404 so the caller can distinguish "unknown host" from a transport
|
||||||
|
* fault.
|
||||||
|
*/
|
||||||
|
public ServerView serverByHost(String host) throws LinkException {
|
||||||
|
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** serverStatus reads one server's current lifecycle view (internal status). */
|
||||||
|
public ServerView serverStatus(String name) throws LinkException {
|
||||||
|
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wake pulls the domain-autostart lever for {@code name} on behalf of the
|
||||||
|
* joining player (spec §9.1, §14). The reply (202) carries the current phase
|
||||||
|
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
|
||||||
|
* or 429 (cooldown) arrives as a LinkException the caller branches on.
|
||||||
|
*/
|
||||||
|
public ServerView wake(String name, UUID mcUuid) throws LinkException {
|
||||||
|
Objects.requireNonNull(name, "name");
|
||||||
|
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||||
|
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||||
|
return ServerView.fromJson(postObject("/api/v1/internal/servers/" + name + "/wake", body, 202));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reportJoin tells felis-api a real player joined {@code name} (spec §7
|
||||||
|
* /internal/.../join-event): it bumps last_active_at against the reaper and
|
||||||
|
* auto-appends the UUID to the allowlist. Expects 204.
|
||||||
|
*/
|
||||||
|
public void reportJoin(String name, UUID mcUuid) throws LinkException {
|
||||||
|
Objects.requireNonNull(name, "name");
|
||||||
|
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||||
|
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||||
|
HttpResponse<String> res = send(post("/api/v1/internal/servers/" + name + "/join-event", body));
|
||||||
|
int status = res.statusCode();
|
||||||
|
if (status != 204 && status != 200) {
|
||||||
|
throw parseError(status, res.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* claim takes ownership of an ownerless server on behalf of a player driving the
|
||||||
|
* felis-paper lobby menu (spec §9.3, §12). It is the first of the menu's two
|
||||||
|
* rules — claim asserts ownership and quota; the autostartPolicy gate is enforced
|
||||||
|
* separately by the {@link #wake} that follows. Identity is the verified
|
||||||
|
* online-mode UUID Velocity derived from the connection, never a client-supplied
|
||||||
|
* value. Expects 200; the refusal cases surface as branchable LinkExceptions:
|
||||||
|
* 412 {@code not_linked}, 403 {@code quota_exceeded}, 409 {@code already_claimed},
|
||||||
|
* 404 unknown server.
|
||||||
|
*/
|
||||||
|
public void claim(String name, UUID mcUuid) throws LinkException {
|
||||||
|
Objects.requireNonNull(name, "name");
|
||||||
|
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||||
|
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||||
|
Map<?, ?> res = postObject("/api/v1/internal/servers/" + name + "/claim", body, 200);
|
||||||
|
Object claimed = res.get("claimed");
|
||||||
|
if (!(claimed instanceof Boolean) || !((Boolean) claimed)) {
|
||||||
|
// A 200 that doesn't affirm the claim is a contract breach, not a refusal —
|
||||||
|
// every refusal (412/403/409/404) already threw above. Fail loud rather
|
||||||
|
// than wake a server the caller doesn't actually own.
|
||||||
|
throw new LinkException(200, "bad_response", "claim returned 200 without claimed=true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* menuStatus reads the lobby menu projection of one server (spec §12,
|
||||||
|
* {@code GET …/menu}): the §11 lifecycle view plus the ownership-derived
|
||||||
|
* {@code claimable} flag the lobby needs to choose a button. Velocity calls this
|
||||||
|
* for a {@code StatusQuery} and forwards the result downstream as a
|
||||||
|
* {@code StatusUpdate} frame. Expects 200; 404 means the server is unknown.
|
||||||
|
*/
|
||||||
|
public MenuStatus menuStatus(String name) throws LinkException {
|
||||||
|
Objects.requireNonNull(name, "name");
|
||||||
|
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + name + "/menu", 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- transport ----
|
||||||
|
|
||||||
|
private Map<?, ?> getObject(String path, int expect) throws LinkException {
|
||||||
|
HttpRequest req = base(path).GET().build();
|
||||||
|
return expectObject(send(req), expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> postObject(String path, String body, int expect) throws LinkException {
|
||||||
|
return expectObject(send(post(path, body)), expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequest post(String path, String body) {
|
||||||
|
return base(path)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequest.Builder base(String path) {
|
||||||
|
return HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.apiBaseUrl() + path))
|
||||||
|
.timeout(config.timeout())
|
||||||
|
.header("Authorization", "Bearer " + config.serviceToken())
|
||||||
|
.header("Accept", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> send(HttpRequest req) throws LinkException {
|
||||||
|
try {
|
||||||
|
return http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new LinkException(0, "transport_error",
|
||||||
|
"could not reach felis-api: " + e.getMessage(), e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new LinkException(0, "interrupted", "felis-api request interrupted", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> expectObject(HttpResponse<String> res, int expect) throws LinkException {
|
||||||
|
int status = res.statusCode();
|
||||||
|
if (status != expect) {
|
||||||
|
throw parseError(status, res.body());
|
||||||
|
}
|
||||||
|
Object root;
|
||||||
|
try {
|
||||||
|
root = Json.parse(res.body());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new LinkException(status, "bad_response", "malformed body from felis-api", e);
|
||||||
|
}
|
||||||
|
if (!(root instanceof Map)) {
|
||||||
|
throw new LinkException(status, "bad_response", "expected a JSON object from felis-api");
|
||||||
|
}
|
||||||
|
return (Map<?, ?>) root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseError mirrors LinkClient: extract the stable {"error":{"code","message"}}
|
||||||
|
// envelope when present, else fall back to the HTTP status. A separate copy here
|
||||||
|
// keeps the routing client independent of LinkClient's private internals.
|
||||||
|
private LinkException parseError(int status, String text) {
|
||||||
|
String code = "error";
|
||||||
|
String message = "felis-api returned HTTP " + status;
|
||||||
|
try {
|
||||||
|
Object root = Json.parse(text);
|
||||||
|
if (root instanceof Map) {
|
||||||
|
Object err = ((Map<?, ?>) root).get("error");
|
||||||
|
if (err instanceof Map) {
|
||||||
|
Object c = ((Map<?, ?>) err).get("code");
|
||||||
|
Object m = ((Map<?, ?>) err).get("message");
|
||||||
|
if (c instanceof String && !((String) c).isEmpty()) {
|
||||||
|
code = (String) c;
|
||||||
|
}
|
||||||
|
if (m instanceof String && !((String) m).isEmpty()) {
|
||||||
|
message = (String) m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Non-JSON error body (proxy 502, plain text, etc.): keep the fallback.
|
||||||
|
}
|
||||||
|
return new LinkException(status, code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Json is a minimal, dependency-free JSON reader. It exists so the shared link
|
||||||
|
* core stays zero-dependency: the {@code srcDir}-sharing build model compiles
|
||||||
|
* this source straight into every platform jar, so pulling in Gson/Jackson would
|
||||||
|
* force shading the parser into four loaders. It parses the small, well-formed
|
||||||
|
* bodies the felis-api internal face returns — a flat success object, or the
|
||||||
|
* nested {@code {"error":{"code","message"}}} envelope — and nothing more exotic
|
||||||
|
* is required.
|
||||||
|
*
|
||||||
|
* <p>The parser is package-private and intentionally strict: callers wrap a parse
|
||||||
|
* failure as a "bad response from felis-api" condition rather than guessing.
|
||||||
|
*/
|
||||||
|
final class Json {
|
||||||
|
private final String s;
|
||||||
|
private int i;
|
||||||
|
|
||||||
|
private Json(String s) {
|
||||||
|
this.s = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** parse reads a single JSON value from text, rejecting trailing garbage. */
|
||||||
|
static Object parse(String text) {
|
||||||
|
Json p = new Json(text);
|
||||||
|
p.ws();
|
||||||
|
Object v = p.value();
|
||||||
|
p.ws();
|
||||||
|
if (p.i < p.s.length()) {
|
||||||
|
throw p.err("trailing content");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object value() {
|
||||||
|
if (i >= s.length()) {
|
||||||
|
throw err("unexpected end of input");
|
||||||
|
}
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '{':
|
||||||
|
return object();
|
||||||
|
case '[':
|
||||||
|
return array();
|
||||||
|
case '"':
|
||||||
|
return string();
|
||||||
|
case 't':
|
||||||
|
case 'f':
|
||||||
|
return bool();
|
||||||
|
case 'n':
|
||||||
|
return nul();
|
||||||
|
default:
|
||||||
|
return number();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> object() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
expect('{');
|
||||||
|
ws();
|
||||||
|
if (peek() == '}') {
|
||||||
|
i++;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
ws();
|
||||||
|
String key = string();
|
||||||
|
ws();
|
||||||
|
expect(':');
|
||||||
|
ws();
|
||||||
|
m.put(key, value());
|
||||||
|
ws();
|
||||||
|
char c = next();
|
||||||
|
if (c == '}') {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
if (c != ',') {
|
||||||
|
throw err("expected ',' or '}' in object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Object> array() {
|
||||||
|
List<Object> l = new ArrayList<>();
|
||||||
|
expect('[');
|
||||||
|
ws();
|
||||||
|
if (peek() == ']') {
|
||||||
|
i++;
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
ws();
|
||||||
|
l.add(value());
|
||||||
|
ws();
|
||||||
|
char c = next();
|
||||||
|
if (c == ']') {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
if (c != ',') {
|
||||||
|
throw err("expected ',' or ']' in array");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String string() {
|
||||||
|
expect('"');
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
while (true) {
|
||||||
|
if (i >= s.length()) {
|
||||||
|
throw err("unterminated string");
|
||||||
|
}
|
||||||
|
char c = s.charAt(i++);
|
||||||
|
if (c == '"') {
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
if (c == '\\') {
|
||||||
|
if (i >= s.length()) {
|
||||||
|
throw err("unterminated escape");
|
||||||
|
}
|
||||||
|
char e = s.charAt(i++);
|
||||||
|
switch (e) {
|
||||||
|
case '"':
|
||||||
|
sb.append('"');
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
sb.append('\\');
|
||||||
|
break;
|
||||||
|
case '/':
|
||||||
|
sb.append('/');
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
sb.append('\b');
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
sb.append('\f');
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
sb.append('\n');
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
sb.append('\r');
|
||||||
|
break;
|
||||||
|
case 't':
|
||||||
|
sb.append('\t');
|
||||||
|
break;
|
||||||
|
case 'u':
|
||||||
|
if (i + 4 > s.length()) {
|
||||||
|
throw err("truncated unicode escape");
|
||||||
|
}
|
||||||
|
sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16));
|
||||||
|
i += 4;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw err("invalid escape '\\" + e + "'");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object number() {
|
||||||
|
int start = i;
|
||||||
|
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
String num = s.substring(start, i);
|
||||||
|
if (num.isEmpty()) {
|
||||||
|
throw err("invalid value");
|
||||||
|
}
|
||||||
|
return Double.parseDouble(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean bool() {
|
||||||
|
if (s.startsWith("true", i)) {
|
||||||
|
i += 4;
|
||||||
|
return Boolean.TRUE;
|
||||||
|
}
|
||||||
|
if (s.startsWith("false", i)) {
|
||||||
|
i += 5;
|
||||||
|
return Boolean.FALSE;
|
||||||
|
}
|
||||||
|
throw err("invalid literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object nul() {
|
||||||
|
if (s.startsWith("null", i)) {
|
||||||
|
i += 4;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err("invalid literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ws() {
|
||||||
|
while (i < s.length()) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private char peek() {
|
||||||
|
return i < s.length() ? s.charAt(i) : '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private char next() {
|
||||||
|
return i < s.length() ? s.charAt(i++) : '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expect(char c) {
|
||||||
|
if (i >= s.length() || s.charAt(i) != c) {
|
||||||
|
throw err("expected '" + c + "'");
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IllegalArgumentException err(String msg) {
|
||||||
|
return new IllegalArgumentException("json: " + msg + " at index " + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkClient calls the felis-api internal face to mint an account-link code for
|
||||||
|
* an already-authenticated Minecraft player (spec §10). It is platform-agnostic —
|
||||||
|
* Velocity (proxy) and the Fabric/Forge/NeoForge server mods all drive the same
|
||||||
|
* client — and depends only on the JDK, so this single source file compiles
|
||||||
|
* straight into each plugin jar with nothing to shade.
|
||||||
|
*
|
||||||
|
* <p>Contract (authoritative, mirrored from {@code internal/api}):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code POST {apiBaseUrl}/api/v1/internal/account/link/code}</li>
|
||||||
|
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
|
||||||
|
* compared server-side; an empty token fails closed)</li>
|
||||||
|
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
|
||||||
|
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
|
||||||
|
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The UUID must come from the platform's authenticated player identity, never
|
||||||
|
* from user input — the whole security model of the flow is that the in-game side
|
||||||
|
* proves the UUID before a code is ever minted.
|
||||||
|
*/
|
||||||
|
public final class LinkClient {
|
||||||
|
private static final String PATH = "/api/v1/internal/account/link/code";
|
||||||
|
|
||||||
|
private final LinkConfig config;
|
||||||
|
private final HttpClient http;
|
||||||
|
|
||||||
|
public LinkClient(LinkConfig config) {
|
||||||
|
this.config = Objects.requireNonNull(config, "config");
|
||||||
|
this.http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(config.timeout())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** requestCode mints a one-time link code for the given verified UUID. */
|
||||||
|
public LinkCode requestCode(UUID mcUuid) throws LinkException {
|
||||||
|
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||||
|
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.apiBaseUrl() + PATH))
|
||||||
|
.timeout(config.timeout())
|
||||||
|
.header("Authorization", "Bearer " + config.serviceToken())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> res;
|
||||||
|
try {
|
||||||
|
res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new LinkException(0, "transport_error",
|
||||||
|
"could not reach felis-api: " + e.getMessage(), e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new LinkException(0, "interrupted", "link request interrupted", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = res.statusCode();
|
||||||
|
String text = res.body();
|
||||||
|
if (status == 201) {
|
||||||
|
return parseCode(status, text);
|
||||||
|
}
|
||||||
|
throw parseError(status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinkCode parseCode(int status, String text) throws LinkException {
|
||||||
|
Object root;
|
||||||
|
try {
|
||||||
|
root = Json.parse(text);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new LinkException(status, "bad_response",
|
||||||
|
"malformed success body from felis-api", e);
|
||||||
|
}
|
||||||
|
if (!(root instanceof Map)) {
|
||||||
|
throw new LinkException(status, "bad_response",
|
||||||
|
"expected a JSON object from felis-api");
|
||||||
|
}
|
||||||
|
Map<?, ?> obj = (Map<?, ?>) root;
|
||||||
|
Object code = obj.get("code");
|
||||||
|
if (!(code instanceof String) || ((String) code).isEmpty()) {
|
||||||
|
throw new LinkException(status, "bad_response",
|
||||||
|
"success body missing 'code'");
|
||||||
|
}
|
||||||
|
Object exp = obj.get("expires_at");
|
||||||
|
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinkException parseError(int status, String text) {
|
||||||
|
String code = "error";
|
||||||
|
String message = "felis-api returned HTTP " + status;
|
||||||
|
try {
|
||||||
|
Object root = Json.parse(text);
|
||||||
|
if (root instanceof Map) {
|
||||||
|
Object err = ((Map<?, ?>) root).get("error");
|
||||||
|
if (err instanceof Map) {
|
||||||
|
Object c = ((Map<?, ?>) err).get("code");
|
||||||
|
Object m = ((Map<?, ?>) err).get("message");
|
||||||
|
if (c instanceof String && !((String) c).isEmpty()) {
|
||||||
|
code = (String) c;
|
||||||
|
}
|
||||||
|
if (m instanceof String && !((String) m).isEmpty()) {
|
||||||
|
message = (String) m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Non-JSON error body (proxy 502, plain text, etc.): keep the
|
||||||
|
// HTTP-status fallback message rather than guessing.
|
||||||
|
}
|
||||||
|
return new LinkException(status, code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkCode is the one-time account-link code the internal face mints for a
|
||||||
|
* verified Minecraft UUID (spec §10). {@code expiresAt} is the raw RFC 3339
|
||||||
|
* timestamp string the server returned (or {@code null} if it was omitted); the
|
||||||
|
* plugins surface it to the player as an opaque "valid for a few minutes" hint
|
||||||
|
* rather than reformatting it, so the in-game side stays agnostic to the exact
|
||||||
|
* expiry policy the server enforces.
|
||||||
|
*/
|
||||||
|
public final class LinkCode {
|
||||||
|
private final String code;
|
||||||
|
private final String expiresAt;
|
||||||
|
|
||||||
|
public LinkCode(String code, String expiresAt) {
|
||||||
|
this.code = Objects.requireNonNull(code, "code");
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** expiresAt is the raw RFC 3339 expiry string, or null if the server omitted it. */
|
||||||
|
public String expiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkConfig is the immutable configuration a {@link LinkClient} needs to reach
|
||||||
|
* the felis-api internal face. Both the base URL and the service token are
|
||||||
|
* deployment inputs — operator config or a Secret-injected environment variable —
|
||||||
|
* and are <em>never</em> compiled in. Keeping them out of source is what lets the
|
||||||
|
* tree stay domain- and credential-free; each platform's config loader is
|
||||||
|
* responsible for sourcing them.
|
||||||
|
*/
|
||||||
|
public final class LinkConfig {
|
||||||
|
private final String apiBaseUrl;
|
||||||
|
private final String serviceToken;
|
||||||
|
private final Duration timeout;
|
||||||
|
|
||||||
|
public LinkConfig(String apiBaseUrl, String serviceToken, Duration timeout) {
|
||||||
|
this.apiBaseUrl = stripTrailingSlash(Objects.requireNonNull(apiBaseUrl, "apiBaseUrl"));
|
||||||
|
this.serviceToken = Objects.requireNonNull(serviceToken, "serviceToken");
|
||||||
|
this.timeout = Objects.requireNonNull(timeout, "timeout");
|
||||||
|
if (this.apiBaseUrl.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("apiBaseUrl is empty");
|
||||||
|
}
|
||||||
|
if (this.serviceToken.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("serviceToken is empty");
|
||||||
|
}
|
||||||
|
if (this.timeout.isZero() || this.timeout.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("timeout must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkConfig(String apiBaseUrl, String serviceToken) {
|
||||||
|
this(apiBaseUrl, serviceToken, Duration.ofSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String apiBaseUrl() {
|
||||||
|
return apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String serviceToken() {
|
||||||
|
return serviceToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration timeout() {
|
||||||
|
return timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripTrailingSlash(String u) {
|
||||||
|
String t = u.trim();
|
||||||
|
while (t.endsWith("/")) {
|
||||||
|
t = t.substring(0, t.length() - 1);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkConfigLoader resolves a {@link LinkConfig} the same way on every platform:
|
||||||
|
* environment variables ({@code FELIS_API_BASE_URL}, {@code FELIS_SERVICE_TOKEN})
|
||||||
|
* win, falling back to a {@code felis-link.properties} file in the platform's
|
||||||
|
* config directory. Neither the API base URL nor the service token is ever
|
||||||
|
* compiled in — this loader is the single seam each loader's entrypoint calls, so
|
||||||
|
* the source tree stays domain- and credential-free. On first run it writes a
|
||||||
|
* commented template and then reports the values as missing, so an operator gets
|
||||||
|
* a file to fill in rather than a silent half-configured plugin.
|
||||||
|
*/
|
||||||
|
public final class LinkConfigLoader {
|
||||||
|
public static final String ENV_URL = "FELIS_API_BASE_URL";
|
||||||
|
public static final String ENV_TOKEN = "FELIS_SERVICE_TOKEN";
|
||||||
|
private static final String KEY_URL = "api-base-url";
|
||||||
|
private static final String KEY_TOKEN = "service-token";
|
||||||
|
|
||||||
|
private LinkConfigLoader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load resolves config for the given properties file path, writing a template
|
||||||
|
* if the file does not yet exist.
|
||||||
|
*
|
||||||
|
* @throws IOException if the file cannot be read/created, or if neither the
|
||||||
|
* environment nor the file supplies both required values.
|
||||||
|
*/
|
||||||
|
public static LinkConfig load(Path propertiesFile) throws IOException {
|
||||||
|
Properties props = new Properties();
|
||||||
|
if (Files.exists(propertiesFile)) {
|
||||||
|
try (InputStream in = Files.newInputStream(propertiesFile)) {
|
||||||
|
props.load(in);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeTemplate(propertiesFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
String url = firstNonBlank(System.getenv(ENV_URL), props.getProperty(KEY_URL));
|
||||||
|
String token = firstNonBlank(System.getenv(ENV_TOKEN), props.getProperty(KEY_TOKEN));
|
||||||
|
|
||||||
|
if (isBlank(url) || isBlank(token)) {
|
||||||
|
throw new IOException("set " + ENV_URL + "/" + ENV_TOKEN
|
||||||
|
+ " or fill in " + propertiesFile + " (" + KEY_URL + ", " + KEY_TOKEN + ")");
|
||||||
|
}
|
||||||
|
return new LinkConfig(url, token, Duration.ofSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeTemplate(Path file) throws IOException {
|
||||||
|
Path parent = file.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
String template =
|
||||||
|
"# Felis link configuration.\n"
|
||||||
|
+ "# Both values are normally injected via environment variables\n"
|
||||||
|
+ "# (" + ENV_URL + ", " + ENV_TOKEN + "); this file is the fallback.\n"
|
||||||
|
+ "#\n"
|
||||||
|
+ "# " + KEY_URL + ": base URL of the felis-api internal face, e.g.\n"
|
||||||
|
+ "# http://felis-api.felis.svc.cluster.local:8080\n"
|
||||||
|
+ KEY_URL + "=\n"
|
||||||
|
+ "#\n"
|
||||||
|
+ "# " + KEY_TOKEN + ": the internal service token (keep this secret).\n"
|
||||||
|
+ KEY_TOKEN + "=\n";
|
||||||
|
try (OutputStream out = Files.newOutputStream(file)) {
|
||||||
|
out.write(template.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String a, String b) {
|
||||||
|
return !isBlank(a) ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String s) {
|
||||||
|
return s == null || s.trim().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkException is thrown when a link-code request does not succeed. It carries
|
||||||
|
* the HTTP status (0 for a transport/timeout failure that produced no response)
|
||||||
|
* and the server's stable {@code error.code} when one was returned, so callers
|
||||||
|
* can branch on a machine-readable cause without parsing human text. The message
|
||||||
|
* is safe to log server-side; the plugins deliberately do <em>not</em> echo it to
|
||||||
|
* the player, surfacing a generic "try again" line instead so nothing internal
|
||||||
|
* leaks into chat.
|
||||||
|
*/
|
||||||
|
public final class LinkException extends Exception {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final int statusCode;
|
||||||
|
private final String errorCode;
|
||||||
|
|
||||||
|
public LinkException(int statusCode, String errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkException(int statusCode, String errorCode, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** statusCode is the HTTP status, or 0 if the request never completed. */
|
||||||
|
public int statusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errorCode is the server's stable error.code, or null when unavailable. */
|
||||||
|
public String errorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuStatus is the proxy-side mirror of the felis-api lobby menu projection
|
||||||
|
* ({@code GET /api/v1/internal/servers/{name}/menu}, spec §12). It is deliberately
|
||||||
|
* <em>not</em> {@link ServerView}: the menu endpoint adds one field the §11 lifecycle
|
||||||
|
* views never carry — {@link #claimable()}, derived from ownership (an ownerless
|
||||||
|
* server can be claimed) — and drops the routing-only fields (subdomain, endpoint
|
||||||
|
* address, desiredState) the GUI has no use for. Keeping it a separate type means a
|
||||||
|
* future change to either contract can't silently corrupt the other.
|
||||||
|
*
|
||||||
|
* <p>Velocity reads this for a {@code StatusQuery} and projects it onto a
|
||||||
|
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
|
||||||
|
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
|
||||||
|
* (Claim & Start / Join / Wake) and {@code playersOnline}/{@code
|
||||||
|
* playersMax} render the "3/20" count.
|
||||||
|
*
|
||||||
|
* <p>{@link #fromJson(Map)} is tolerant in the same way as {@link ServerView}: an
|
||||||
|
* absent field degrades to null/zero/false rather than throwing, so a partial body
|
||||||
|
* can never crash the proxy's event thread.
|
||||||
|
*/
|
||||||
|
public final class MenuStatus {
|
||||||
|
private final String name;
|
||||||
|
private final String phase;
|
||||||
|
private final boolean ready;
|
||||||
|
private final int playersOnline;
|
||||||
|
private final int playersMax;
|
||||||
|
private final boolean claimable;
|
||||||
|
|
||||||
|
public MenuStatus(String name, String phase, boolean ready,
|
||||||
|
int playersOnline, int playersMax, boolean claimable) {
|
||||||
|
this.name = name;
|
||||||
|
this.phase = phase;
|
||||||
|
this.ready = ready;
|
||||||
|
this.playersOnline = playersOnline;
|
||||||
|
this.playersMax = playersMax;
|
||||||
|
this.claimable = claimable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fromJson builds a status from a parsed menu object, tolerating absent fields. */
|
||||||
|
public static MenuStatus fromJson(Map<?, ?> o) {
|
||||||
|
return new MenuStatus(
|
||||||
|
str(o, "name"),
|
||||||
|
str(o, "phase"),
|
||||||
|
bool(o, "ready"),
|
||||||
|
intval(o, "playersOnline"),
|
||||||
|
intval(o, "playersMax"),
|
||||||
|
bool(o, "claimable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String phase() {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean ready() {
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersOnline() {
|
||||||
|
return playersOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersMax() {
|
||||||
|
return playersMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** claimable is true when the server has no owner yet (the menu's `Claim & Start`). */
|
||||||
|
public boolean claimable() {
|
||||||
|
return claimable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof String ? (String) v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean bool(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof Boolean && (Boolean) v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intval(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
// Json parses every number as Double; the player counts are int32 server-side.
|
||||||
|
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
|
||||||
|
* MinecraftServer (the {@code ServerInfo} the internal face emits for
|
||||||
|
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
|
||||||
|
* status/wake replies). It is an immutable, dependency-free value object so the
|
||||||
|
* shared link core stays zero-dependency and source-shareable across all four
|
||||||
|
* loaders.
|
||||||
|
*
|
||||||
|
* <p>The name deliberately avoids {@code ServerInfo}: Velocity already owns
|
||||||
|
* {@code com.velocitypowered.api.proxy.server.ServerInfo} (name + address), and
|
||||||
|
* the routing code juggles both at once. {@code ServerView} is the felis lifecycle
|
||||||
|
* record; {@code ServerInfo} is Velocity's registration handle.
|
||||||
|
*
|
||||||
|
* <p>{@link #fromJson(Map)} is tolerant: the wake reply carries only a subset of
|
||||||
|
* the fields ({@code name}, {@code desiredState}, {@code phase}, {@code ready}),
|
||||||
|
* so every accessor degrades to a null/zero default rather than throwing when a
|
||||||
|
* field is absent. Routing decisions are driven off {@link #ready()} and
|
||||||
|
* {@link #phase()}, which the relevant endpoints always populate.
|
||||||
|
*/
|
||||||
|
public final class ServerView {
|
||||||
|
private final String name;
|
||||||
|
private final String subdomain;
|
||||||
|
private final String phase;
|
||||||
|
private final boolean ready;
|
||||||
|
private final String autostartPolicy;
|
||||||
|
private final String desiredState;
|
||||||
|
private final String endpointMode;
|
||||||
|
private final String endpointAddress;
|
||||||
|
private final int playersOnline;
|
||||||
|
private final int playersMax;
|
||||||
|
|
||||||
|
public ServerView(String name, String subdomain, String phase, boolean ready,
|
||||||
|
String autostartPolicy, String desiredState, String endpointMode,
|
||||||
|
String endpointAddress, int playersOnline, int playersMax) {
|
||||||
|
this.name = name;
|
||||||
|
this.subdomain = subdomain;
|
||||||
|
this.phase = phase;
|
||||||
|
this.ready = ready;
|
||||||
|
this.autostartPolicy = autostartPolicy;
|
||||||
|
this.desiredState = desiredState;
|
||||||
|
this.endpointMode = endpointMode;
|
||||||
|
this.endpointAddress = endpointAddress;
|
||||||
|
this.playersOnline = playersOnline;
|
||||||
|
this.playersMax = playersMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */
|
||||||
|
public static ServerView fromJson(Map<?, ?> o) {
|
||||||
|
return new ServerView(
|
||||||
|
str(o, "name"),
|
||||||
|
str(o, "subdomain"),
|
||||||
|
str(o, "phase"),
|
||||||
|
bool(o, "ready"),
|
||||||
|
str(o, "autostartPolicy"),
|
||||||
|
str(o, "desiredState"),
|
||||||
|
str(o, "endpointMode"),
|
||||||
|
str(o, "endpointAddress"),
|
||||||
|
intval(o, "playersOnline"),
|
||||||
|
intval(o, "playersMax"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String subdomain() {
|
||||||
|
return subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String phase() {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ready is the authoritative "RCON-confirmed up" gate the proxy routes on. */
|
||||||
|
public boolean ready() {
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String autostartPolicy() {
|
||||||
|
return autostartPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String desiredState() {
|
||||||
|
return desiredState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String endpointMode() {
|
||||||
|
return endpointMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** endpointAddress is the {@code host[:port]} the proxy registers as a backend. */
|
||||||
|
public String endpointAddress() {
|
||||||
|
return endpointAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersOnline() {
|
||||||
|
return playersOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int playersMax() {
|
||||||
|
return playersMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof String ? (String) v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean bool(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
return v instanceof Boolean && (Boolean) v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intval(Map<?, ?> o, String key) {
|
||||||
|
Object v = o.get(key);
|
||||||
|
// Json parses every number as Double; the player counts are int32 server-side.
|
||||||
|
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package best.lolicon.felis.link;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlRoundTripTest is a hermetic, dependency-free check of the {@code
|
||||||
|
* felis:control} codec (spec §12). It lives outside {@code src/main/java} so it
|
||||||
|
* never ships in a module jar, and it has no test framework: a failed assertion
|
||||||
|
* throws and the process exits non-zero.
|
||||||
|
*
|
||||||
|
* <p>Because the velocity and paper jars source-share the very {@link Control} and
|
||||||
|
* {@link ControlFrame} this test exercises, a passing encode→decode round-trip
|
||||||
|
* proves wire compatibility <em>by construction</em>, not merely that the code
|
||||||
|
* compiles — the one part of the Java plugin layer that can be verified above
|
||||||
|
* "compiles" without a live proxy/lobby. So it asserts field-level equality for
|
||||||
|
* every frame type, the {@code type} discriminator on the wire, {@code Error} with
|
||||||
|
* its optional {@code server} both present and absent, and that a malformed or
|
||||||
|
* unknown frame is rejected, not silently mis-decoded.
|
||||||
|
*
|
||||||
|
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
|
||||||
|
* shared/test/best/lolicon/felis/link/ControlRoundTripTest.java && java -cp <out>
|
||||||
|
* best.lolicon.felis.link.ControlRoundTripTest}.
|
||||||
|
*/
|
||||||
|
public final class ControlRoundTripTest {
|
||||||
|
|
||||||
|
private static int checks;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
roundTripsEveryFrameType();
|
||||||
|
wireCarriesTypeDiscriminator();
|
||||||
|
wireCarriesRefinedStatusFields();
|
||||||
|
errorOmitsServerWhenAbsentButRoundTrips();
|
||||||
|
escapesAwkwardStrings();
|
||||||
|
rejectsMalformedAndUnknownFrames();
|
||||||
|
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every factory frame must survive encode→decode as an equal frame, so the two
|
||||||
|
// ends read back exactly what the other wrote.
|
||||||
|
private static void roundTripsEveryFrameType() {
|
||||||
|
roundTrip(ControlFrame.wakeRequest("Notch", "survival"));
|
||||||
|
roundTrip(ControlFrame.claimRequest("Notch", "creative"));
|
||||||
|
roundTrip(ControlFrame.statusQuery("survival"));
|
||||||
|
roundTrip(ControlFrame.statusUpdate("survival", "Running", true, 3, 20, false));
|
||||||
|
roundTrip(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 20, true));
|
||||||
|
roundTrip(ControlFrame.transferReady("Notch", "survival"));
|
||||||
|
roundTrip(ControlFrame.error("quota_exceeded", "server quota exhausted", "survival"));
|
||||||
|
roundTrip(ControlFrame.error("not_linked", "link your account first", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The discriminator the dispatch switch keys on must appear verbatim on the wire.
|
||||||
|
private static void wireCarriesTypeDiscriminator() {
|
||||||
|
assertContains(ControlFrame.wakeRequest("p", "s"), "\"type\":\"WakeRequest\"");
|
||||||
|
assertContains(ControlFrame.claimRequest("p", "s"), "\"type\":\"ClaimRequest\"");
|
||||||
|
assertContains(ControlFrame.statusQuery("s"), "\"type\":\"StatusQuery\"");
|
||||||
|
assertContains(ControlFrame.statusUpdate("s", "Running", true, 1, 2, false), "\"type\":\"StatusUpdate\"");
|
||||||
|
assertContains(ControlFrame.transferReady("p", "s"), "\"type\":\"TransferReady\"");
|
||||||
|
assertContains(ControlFrame.error("c", "m", null), "\"type\":\"Error\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusUpdate refines the spec's "players" into ready + online + max; the GUI
|
||||||
|
// renders all three, so all three must survive the round-trip with exact values.
|
||||||
|
private static void wireCarriesRefinedStatusFields() {
|
||||||
|
ControlFrame f = decode(ControlFrame.statusUpdate("survival", "Running", true, 7, 40, false));
|
||||||
|
assertEq("server", "survival", f.server());
|
||||||
|
assertEq("phase", "Running", f.phase());
|
||||||
|
assertEq("ready", true, f.ready());
|
||||||
|
assertEq("playersOnline", 7, f.playersOnline());
|
||||||
|
assertEq("playersMax", 40, f.playersMax());
|
||||||
|
assertEq("claimable", false, f.claimable());
|
||||||
|
// And the booleans flip independently of one another.
|
||||||
|
ControlFrame g = decode(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 8, true));
|
||||||
|
assertEq("ready(false)", false, g.ready());
|
||||||
|
assertEq("claimable(true)", true, g.claimable());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error's optional server: absent → not on the wire and decodes to null;
|
||||||
|
// present → on the wire and decodes back. Both round-trip to an equal frame.
|
||||||
|
private static void errorOmitsServerWhenAbsentButRoundTrips() {
|
||||||
|
ControlFrame bare = ControlFrame.error("not_linked", "link first", null);
|
||||||
|
String wire = new String(Control.encode(bare), StandardCharsets.UTF_8);
|
||||||
|
if (wire.contains("\"server\"")) {
|
||||||
|
throw new AssertionError("bare Error must not carry a server key: " + wire);
|
||||||
|
}
|
||||||
|
checks++;
|
||||||
|
assertEq("bare Error server", null, decode(bare).server());
|
||||||
|
|
||||||
|
ControlFrame scoped = ControlFrame.error("quota_exceeded", "no room", "survival");
|
||||||
|
assertContains(scoped, "\"server\":\"survival\"");
|
||||||
|
assertEq("scoped Error server", "survival", decode(scoped).server());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player names and error messages can carry quotes/backslashes/newlines; the
|
||||||
|
// hand-rolled writer must escape them so the reader recovers the original.
|
||||||
|
private static void escapesAwkwardStrings() {
|
||||||
|
String nasty = "a\"b\\c\nd\te";
|
||||||
|
ControlFrame f = ControlFrame.error("bad", nasty, "ser\"ver");
|
||||||
|
ControlFrame back = decode(f);
|
||||||
|
assertEq("escaped message", nasty, back.message());
|
||||||
|
assertEq("escaped server", "ser\"ver", back.server());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bad frame is a dropped message, never a crash or a silent mis-decode.
|
||||||
|
private static void rejectsMalformedAndUnknownFrames() {
|
||||||
|
assertRejected("not json at all".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertRejected("[1,2,3]".getBytes(StandardCharsets.UTF_8)); // root is not an object
|
||||||
|
assertRejected("{\"player\":\"p\"}".getBytes(StandardCharsets.UTF_8)); // missing type
|
||||||
|
assertRejected("{\"type\":\"Bogus\"}".getBytes(StandardCharsets.UTF_8)); // unknown type
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- harness ----
|
||||||
|
|
||||||
|
private static ControlFrame decode(ControlFrame f) {
|
||||||
|
return Control.decode(Control.encode(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTrip(ControlFrame f) {
|
||||||
|
ControlFrame back = decode(f);
|
||||||
|
if (!f.equals(back)) {
|
||||||
|
throw new AssertionError("round-trip changed the frame:\n in: " + f + "\n out: " + back);
|
||||||
|
}
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertContains(ControlFrame f, String needle) {
|
||||||
|
String wire = new String(Control.encode(f), StandardCharsets.UTF_8);
|
||||||
|
if (!wire.contains(needle)) {
|
||||||
|
throw new AssertionError("wire " + wire + " is missing " + needle);
|
||||||
|
}
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertEq(String what, Object want, Object got) {
|
||||||
|
if (want == null ? got != null : !want.equals(got)) {
|
||||||
|
throw new AssertionError(what + " = " + got + ", want " + want);
|
||||||
|
}
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertRejected(byte[] data) {
|
||||||
|
try {
|
||||||
|
Control.decode(data);
|
||||||
|
} catch (IllegalArgumentException expected) {
|
||||||
|
checks++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("expected rejection of: " + new String(data, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'best.lolicon.felis'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a
|
||||||
|
// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than
|
||||||
|
// provisioning a separate toolchain.
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = 'papermc'
|
||||||
|
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// velocity-api is compile-only (the proxy provides it at runtime); the
|
||||||
|
// annotation processor turns @Plugin into the generated velocity-plugin.json.
|
||||||
|
compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
|
||||||
|
annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The platform-agnostic link core lives in ../shared and is compiled straight
|
||||||
|
// into this jar. The core has zero third-party dependencies, so source-sharing
|
||||||
|
// keeps every loader self-contained with nothing to shade.
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java {
|
||||||
|
srcDir '../shared/src/main/java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'felis-velocity'
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.Control;
|
||||||
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
|
import best.lolicon.felis.link.FelisApiClient;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
import best.lolicon.felis.link.MenuStatus;
|
||||||
|
|
||||||
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
|
import com.velocitypowered.api.event.connection.PluginMessageEvent;
|
||||||
|
import com.velocitypowered.api.proxy.Player;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import com.velocitypowered.api.proxy.ServerConnection;
|
||||||
|
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
|
||||||
|
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlChannel is the proxy end of the {@code felis:control} plugin-message channel
|
||||||
|
* (spec §12): the bridge between the felis-paper lobby's {@code /menu} GUI and the
|
||||||
|
* routing core. The lobby is a pure UI face — it holds no felis-api token and keeps
|
||||||
|
* no queue — so every menu action arrives here as a {@link ControlFrame}, this class
|
||||||
|
* drives the felis-api internal endpoints and the shared waiting queue, and answers
|
||||||
|
* downstream with another frame. It is the §27 scenario-10 path: {@code /menu →
|
||||||
|
* plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
|
||||||
|
*
|
||||||
|
* <p><b>Anti-spoof (spec §14).</b> The acting identity is taken from the
|
||||||
|
* {@link ServerConnection} the message arrived on — {@code source.getPlayer()} — and
|
||||||
|
* never from the frame's {@code player} field, so a compromised backend cannot drive
|
||||||
|
* an action as another player. A frame whose source is not a backend server (e.g. a
|
||||||
|
* client) is consumed and dropped. The frame's {@code server} field is data, not
|
||||||
|
* identity: it names which backend the player asked for, and the autostartPolicy /
|
||||||
|
* ownership gates server-side decide whether this UUID may act on it.
|
||||||
|
*
|
||||||
|
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
|
||||||
|
* thread, but every felis-api call below blocks on HTTP. So each handler does the
|
||||||
|
* non-blocking work synchronously — decode, derive identity, and
|
||||||
|
* {@code setResult(handled())} to stop the frame being forwarded — then hands the
|
||||||
|
* blocking call to {@link FelisVelocityPlugin#async}, sending any downstream frame
|
||||||
|
* from inside that callback. {@code setResult} must run before the handler returns;
|
||||||
|
* it cannot be set from the async hop.
|
||||||
|
*
|
||||||
|
* <p>The three upstream frames map onto the menu's buttons (spec §12): a
|
||||||
|
* {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
|
||||||
|
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
|
||||||
|
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
|
||||||
|
* the wake applies the autostartPolicy gate — and parks on success. Refusals come
|
||||||
|
* back as {@code Error}; readiness as {@code TransferReady} just before the proxy
|
||||||
|
* Connects the player (via {@link WaitingRouter.MenuTransferListener}).
|
||||||
|
*/
|
||||||
|
public final class ControlChannel implements WaitingRouter.MenuTransferListener {
|
||||||
|
|
||||||
|
/** The namespaced channel both ends register; shared with the codec's name. */
|
||||||
|
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from(Control.CHANNEL);
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Logger log;
|
||||||
|
private final FelisApiClient api;
|
||||||
|
private final WaitingRouter router;
|
||||||
|
private final FelisVelocityPlugin plugin;
|
||||||
|
|
||||||
|
ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api,
|
||||||
|
WaitingRouter router, FelisVelocityPlugin plugin) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.log = log;
|
||||||
|
this.api = api;
|
||||||
|
this.router = router;
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* register opens the channel and wires this instance as the waiting queue's
|
||||||
|
* menu-transfer listener. The caller still registers it as an event subscriber.
|
||||||
|
*/
|
||||||
|
void register() {
|
||||||
|
proxy.getChannelRegistrar().register(CHANNEL);
|
||||||
|
router.setMenuTransferListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onPluginMessage(PluginMessageEvent event) {
|
||||||
|
if (!CHANNEL.getId().equals(event.getIdentifier().getId())) {
|
||||||
|
return; // not ours → leave Velocity's default handling alone
|
||||||
|
}
|
||||||
|
// We own this channel end to end: a felis:control frame is never relayed to
|
||||||
|
// the other side, whatever its source. Consume it before doing anything else.
|
||||||
|
event.setResult(PluginMessageEvent.ForwardResult.handled());
|
||||||
|
|
||||||
|
// Identity comes from the connection, never the frame (spec §14). A frame from
|
||||||
|
// anything but a backend server (e.g. a client) is not a legitimate lobby
|
||||||
|
// action — drop it.
|
||||||
|
if (!(event.getSource() instanceof ServerConnection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ServerConnection source = (ServerConnection) event.getSource();
|
||||||
|
Player player = source.getPlayer();
|
||||||
|
|
||||||
|
ControlFrame frame;
|
||||||
|
try {
|
||||||
|
frame = Control.decode(event.getData());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.debug("Felis: dropping malformed felis:control frame from {}: {}",
|
||||||
|
source.getServerInfo().getName(), e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (frame.type()) {
|
||||||
|
case ControlFrame.STATUS_QUERY:
|
||||||
|
handleStatusQuery(source, frame.server());
|
||||||
|
break;
|
||||||
|
case ControlFrame.WAKE_REQUEST:
|
||||||
|
handleWake(source, player, frame.server());
|
||||||
|
break;
|
||||||
|
case ControlFrame.CLAIM_REQUEST:
|
||||||
|
handleClaim(source, player, frame.server());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Downstream-only types (StatusUpdate/TransferReady/Error) are not
|
||||||
|
// actionable arriving upstream; a well-behaved lobby never sends them.
|
||||||
|
log.debug("Felis: ignoring non-actionable felis:control frame '{}' from {}",
|
||||||
|
frame.type(), player.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A StatusQuery refreshes one tile: read the menu projection and answer with a
|
||||||
|
// StatusUpdate, or an Error if felis-api refuses (e.g. 404 unknown server).
|
||||||
|
private void handleStatusQuery(ServerConnection source, String server) {
|
||||||
|
if (isBlank(server)) {
|
||||||
|
return; // nothing to look up
|
||||||
|
}
|
||||||
|
plugin.async(() -> {
|
||||||
|
try {
|
||||||
|
MenuStatus s = api.menuStatus(server);
|
||||||
|
send(source, ControlFrame.statusUpdate(
|
||||||
|
s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable()));
|
||||||
|
} catch (LinkException e) {
|
||||||
|
send(source, errorFrame(e, server));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A WakeRequest is the menu's Join/Wake button on a server the player owns: wake
|
||||||
|
// it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread
|
||||||
|
// and reports its own refusals to the player; nothing to await here.
|
||||||
|
private void handleWake(ServerConnection source, Player player, String server) {
|
||||||
|
if (isBlank(server)) {
|
||||||
|
send(source, ControlFrame.error("bad_request", "wake without a server", null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.enqueueFromMenu(player, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ClaimRequest is the menu's Claim & Start on an ownerless server: the two-rule
|
||||||
|
// split. Claim first (ownership + quota); only on success wake-and-park (the
|
||||||
|
// autostartPolicy gate). A claim refusal answers with Error and never wakes.
|
||||||
|
private void handleClaim(ServerConnection source, Player player, String server) {
|
||||||
|
if (isBlank(server)) {
|
||||||
|
send(source, ControlFrame.error("bad_request", "claim without a server", null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UUID id = player.getUniqueId();
|
||||||
|
plugin.async(() -> {
|
||||||
|
try {
|
||||||
|
api.claim(server, id);
|
||||||
|
} catch (LinkException e) {
|
||||||
|
send(source, errorFrame(e, server));
|
||||||
|
return; // claim refused → do not wake a server the player doesn't own
|
||||||
|
}
|
||||||
|
// Owned now → run the second rule. enqueueFromMenu spawns its own async
|
||||||
|
// hop for the wake, which is fine from here.
|
||||||
|
router.enqueueFromMenu(player, server);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onReady fires when a menu-parked player's backend goes ready, just before the
|
||||||
|
* proxy Connects them. The lobby uses {@code TransferReady} to react (close the
|
||||||
|
* menu / show "joining"); the actual move is the proxy's Connect, not this frame.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onReady(Player player, String serverName) {
|
||||||
|
player.getCurrentServer().ifPresent(sc ->
|
||||||
|
send(sc, ControlFrame.transferReady(player.getUsername(), serverName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(ServerConnection connection, ControlFrame frame) {
|
||||||
|
connection.sendPluginMessage(CHANNEL, Control.encode(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorFrame turns a LinkException into a downstream Error. felis-api's structured
|
||||||
|
// errors carry user-safe text (the same {code,message} the external API returns),
|
||||||
|
// but a transport failure (statusCode 0) carries internal IO detail — host names,
|
||||||
|
// refused ports — that must not reach a player's screen, so it is generalized.
|
||||||
|
private static ControlFrame errorFrame(LinkException e, String server) {
|
||||||
|
String code = e.errorCode() != null ? e.errorCode() : "error";
|
||||||
|
String message = e.statusCode() == 0
|
||||||
|
? "felis is temporarily unavailable — please try again."
|
||||||
|
: e.getMessage();
|
||||||
|
return ControlFrame.error(code, message, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String s) {
|
||||||
|
return s == null || s.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.LinkConfig;
|
||||||
|
import best.lolicon.felis.link.LinkConfigLoader;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisVelocityConfig extends the shared link config with the two inputs only the
|
||||||
|
* full proxy needs: the {@code root-domain} the deployment serves (the zone
|
||||||
|
* subdomains are carved from) and the {@code lobby-server} waiters are parked in
|
||||||
|
* while their backend wakes. It reuses {@link LinkConfigLoader} for the API base
|
||||||
|
* URL + service token (and its first-run template), so {@code /link} keeps working
|
||||||
|
* exactly as before; these extra keys are read from the same properties file (or
|
||||||
|
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOBBY_SERVER}).
|
||||||
|
*
|
||||||
|
* <p>Both extras are optional at load time and the routing layer degrades rather
|
||||||
|
* than crashing: a missing {@code root-domain} disables routing (with a clear log
|
||||||
|
* line) while {@code /link} still runs, and a missing {@code lobby-server} means
|
||||||
|
* the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect
|
||||||
|
* shortly" message instead of dropping the player onto a not-yet-ready backend.
|
||||||
|
* The root domain is the only place the deployment zone enters the proxy — it is
|
||||||
|
* never compiled in (CI red line).
|
||||||
|
*/
|
||||||
|
final class FelisVelocityConfig {
|
||||||
|
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
|
||||||
|
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
|
||||||
|
private static final String KEY_ROOT_DOMAIN = "root-domain";
|
||||||
|
private static final String KEY_LOBBY = "lobby-server";
|
||||||
|
|
||||||
|
private final LinkConfig linkConfig;
|
||||||
|
private final String rootDomain; // null → routing disabled
|
||||||
|
private final String lobbyServer; // null → no lobby to park waiters in
|
||||||
|
|
||||||
|
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) {
|
||||||
|
this.linkConfig = linkConfig;
|
||||||
|
this.rootDomain = rootDomain;
|
||||||
|
this.lobbyServer = lobbyServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FelisVelocityConfig load(Path file) throws IOException {
|
||||||
|
LinkConfig link = LinkConfigLoader.load(file); // url + token (required) + template + validate
|
||||||
|
Properties props = new Properties();
|
||||||
|
if (Files.exists(file)) {
|
||||||
|
try (InputStream in = Files.newInputStream(file)) {
|
||||||
|
props.load(in);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
|
||||||
|
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
|
||||||
|
return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinkConfig linkConfig() {
|
||||||
|
return linkConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rootDomain is the deployment zone, or null when routing should stay disabled. */
|
||||||
|
String rootDomain() {
|
||||||
|
return rootDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean routingEnabled() {
|
||||||
|
return rootDomain != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lobbyServer is the velocity.toml server name waiters are parked in, or null. */
|
||||||
|
String lobbyServer() {
|
||||||
|
return lobbyServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String a, String b) {
|
||||||
|
if (a != null && !a.trim().isEmpty()) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = s.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.FelisApiClient;
|
||||||
|
import best.lolicon.felis.link.LinkClient;
|
||||||
|
import best.lolicon.felis.link.LinkCode;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
import best.lolicon.felis.link.ServerView;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.mojang.brigadier.Command;
|
||||||
|
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||||
|
import com.velocitypowered.api.command.BrigadierCommand;
|
||||||
|
import com.velocitypowered.api.command.CommandManager;
|
||||||
|
import com.velocitypowered.api.command.CommandMeta;
|
||||||
|
import com.velocitypowered.api.command.CommandSource;
|
||||||
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
|
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
|
||||||
|
import com.velocitypowered.api.plugin.Plugin;
|
||||||
|
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
||||||
|
import com.velocitypowered.api.proxy.Player;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
|
||||||
|
* domain-autostart routing). Velocity sits on the player-facing edge, off-cluster,
|
||||||
|
* and is where two responsibilities naturally live:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>{@code /link}</b> — mints a one-time account-link code from the player's
|
||||||
|
* online-mode-verified UUID (the first leg of §10), unchanged.</li>
|
||||||
|
* <li><b>Domain-autostart routing</b> — recognizes each server's subdomain,
|
||||||
|
* registers backends dynamically, routes joins, wakes a sleeping target and
|
||||||
|
* holds the player in a lobby until it is ready, and reports real joins so
|
||||||
|
* the reaper and allowlist see them (§11, driving §9).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Routing has two hard preconditions, each fails safe: the proxy must run in
|
||||||
|
* online mode (verified UUIDs are the whole basis of the autostartPolicy and
|
||||||
|
* allowlist gates — under offline mode routing is refused while {@code /link}
|
||||||
|
* keeps working), and a {@code root-domain} must be configured (the only place the
|
||||||
|
* deployment zone enters the proxy; never compiled in). With routing active the
|
||||||
|
* {@code felis:control} plugin-message channel (spec §12) is also opened: the
|
||||||
|
* felis-paper lobby's {@code /menu} drives the same waiting queue through
|
||||||
|
* {@link ControlChannel}, deriving identity from the backend connection rather than
|
||||||
|
* the frame so a compromised lobby cannot act as another player (spec §14).
|
||||||
|
*/
|
||||||
|
@Plugin(
|
||||||
|
id = "felis-link",
|
||||||
|
name = "Felis Velocity",
|
||||||
|
version = "0.2.0",
|
||||||
|
description = "In-game /link plus domain-autostart routing: recognizes server subdomains, "
|
||||||
|
+ "registers backends, wakes sleeping servers and holds players until ready.",
|
||||||
|
authors = {"Felis"}
|
||||||
|
)
|
||||||
|
public final class FelisVelocityPlugin {
|
||||||
|
private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
|
||||||
|
private static final Duration WAIT_POLL = Duration.ofSeconds(2);
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Logger logger;
|
||||||
|
private final Path dataDirectory;
|
||||||
|
|
||||||
|
private FelisVelocityConfig config;
|
||||||
|
private LinkClient linkClient;
|
||||||
|
private FelisApiClient apiClient;
|
||||||
|
private ServerRegistry registry;
|
||||||
|
private boolean onlineMode;
|
||||||
|
private boolean routingActive;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public FelisVelocityPlugin(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.logger = logger;
|
||||||
|
this.dataDirectory = dataDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onProxyInitialize(ProxyInitializeEvent event) {
|
||||||
|
try {
|
||||||
|
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Felis disabled: {}", e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.linkClient = new LinkClient(config.linkConfig());
|
||||||
|
registerLinkCommand();
|
||||||
|
registerFelisCommand();
|
||||||
|
|
||||||
|
this.onlineMode = proxy.getConfiguration().isOnlineMode();
|
||||||
|
if (!onlineMode) {
|
||||||
|
logger.error("Felis routing DISABLED: the proxy is in offline mode (online-mode=false). "
|
||||||
|
+ "Domain autostart and the allowlist trust Mojang-verified UUIDs; refusing to route on "
|
||||||
|
+ "spoofable identities. /link remains available. Set online-mode=true to enable routing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!config.routingEnabled()) {
|
||||||
|
logger.warn("Felis routing DISABLED: no root-domain configured. Add 'root-domain=' to "
|
||||||
|
+ "felis-link.properties (or set " + FelisVelocityConfig.ENV_ROOT_DOMAIN + ") to enable "
|
||||||
|
+ "host-based routing. /link remains available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiClient = new FelisApiClient(config.linkConfig());
|
||||||
|
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
|
||||||
|
WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
|
||||||
|
MotdResponder motd = new MotdResponder(registry);
|
||||||
|
proxy.getEventManager().register(this, router);
|
||||||
|
proxy.getEventManager().register(this, motd);
|
||||||
|
|
||||||
|
// The felis:control face (spec §12): the felis-paper lobby's /menu drives the
|
||||||
|
// same waiting queue through this channel. Opened only with routing active —
|
||||||
|
// it depends on the same online-mode + root-domain guards, and its claim/wake
|
||||||
|
// identity is the verified UUID off the backend connection (spec §14).
|
||||||
|
ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, this);
|
||||||
|
control.register();
|
||||||
|
proxy.getEventManager().register(this, control);
|
||||||
|
|
||||||
|
// Prime registrations immediately, then keep them fresh; drain the queue often.
|
||||||
|
refreshRegistrations();
|
||||||
|
repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
|
||||||
|
repeating(WAIT_POLL, router::tick);
|
||||||
|
|
||||||
|
this.routingActive = true;
|
||||||
|
if (config.lobbyServer() == null) {
|
||||||
|
logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be "
|
||||||
|
+ "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue.");
|
||||||
|
}
|
||||||
|
logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.",
|
||||||
|
config.rootDomain(), config.lobbyServer() == null ? "<none>" : config.lobbyServer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */
|
||||||
|
void async(Runnable task) {
|
||||||
|
proxy.getScheduler().buildTask(this, task).schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void repeating(Duration interval, Runnable task) {
|
||||||
|
proxy.getScheduler().buildTask(this, task).delay(interval).repeat(interval).schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshRegistrations() {
|
||||||
|
try {
|
||||||
|
List<ServerView> servers = apiClient.listServers();
|
||||||
|
registry.refresh(servers);
|
||||||
|
} catch (LinkException e) {
|
||||||
|
// Keep existing registrations on a control-plane blip (spec §11): a
|
||||||
|
// transient failure must never deregister live backends.
|
||||||
|
logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.",
|
||||||
|
e.statusCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /link (spec §10 first leg) ----
|
||||||
|
|
||||||
|
private void registerLinkCommand() {
|
||||||
|
CommandManager commands = proxy.getCommandManager();
|
||||||
|
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("link")
|
||||||
|
.executes(ctx -> {
|
||||||
|
CommandSource source = ctx.getSource();
|
||||||
|
if (!(source instanceof Player)) {
|
||||||
|
source.sendMessage(Component.text("/link can only be run by a player.", NamedTextColor.RED));
|
||||||
|
return Command.SINGLE_SUCCESS;
|
||||||
|
}
|
||||||
|
requestAndReply((Player) source);
|
||||||
|
return Command.SINGLE_SUCCESS;
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
CommandMeta meta = commands.metaBuilder("link").plugin(this).build();
|
||||||
|
commands.register(meta, new BrigadierCommand(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestAndReply(Player player) {
|
||||||
|
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
|
||||||
|
async(() -> {
|
||||||
|
try {
|
||||||
|
LinkCode code = linkClient.requestCode(player.getUniqueId());
|
||||||
|
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
|
||||||
|
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
|
||||||
|
NamedTextColor.GRAY));
|
||||||
|
} catch (LinkException e) {
|
||||||
|
logger.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
|
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"Couldn't get a link code right now. Please try again in a moment.",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /felis (operator status) ----
|
||||||
|
|
||||||
|
private void registerFelisCommand() {
|
||||||
|
CommandManager commands = proxy.getCommandManager();
|
||||||
|
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("felis")
|
||||||
|
.executes(ctx -> {
|
||||||
|
sendSummary(ctx.getSource());
|
||||||
|
return Command.SINGLE_SUCCESS;
|
||||||
|
})
|
||||||
|
.then(BrigadierCommand.literalArgumentBuilder("list")
|
||||||
|
.executes(ctx -> {
|
||||||
|
sendList(ctx.getSource());
|
||||||
|
return Command.SINGLE_SUCCESS;
|
||||||
|
}))
|
||||||
|
.build();
|
||||||
|
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
|
||||||
|
commands.register(meta, new BrigadierCommand(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendSummary(CommandSource source) {
|
||||||
|
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
||||||
|
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
||||||
|
if (!routingActive) {
|
||||||
|
source.sendMessage(Component.text(
|
||||||
|
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.sendMessage(field("root-domain", config.rootDomain()));
|
||||||
|
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
|
||||||
|
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendList(CommandSource source) {
|
||||||
|
if (!routingActive) {
|
||||||
|
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Collection<ServerView> servers = registry.all();
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
|
||||||
|
for (ServerView v : servers) {
|
||||||
|
String phase = v.phase() == null ? "?" : v.phase();
|
||||||
|
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
|
||||||
|
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
|
||||||
|
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component field(String key, String value) {
|
||||||
|
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ServerView;
|
||||||
|
|
||||||
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
|
import com.velocitypowered.api.event.proxy.ProxyPingEvent;
|
||||||
|
import com.velocitypowered.api.proxy.server.ServerPing;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MotdResponder answers the server-list ping for a felis subdomain from cache, so
|
||||||
|
* a player sees the right server in their list — and whether it is awake — without
|
||||||
|
* the ping itself waking anything (spec §11 "ping MOTD:只读缓存,后台刷新"). The
|
||||||
|
* cache it reads is the {@link ServerRegistry}'s lifecycle view, refreshed in the
|
||||||
|
* background by the plugin's registration poll; the ping handler never touches the
|
||||||
|
* backend or the wake lever.
|
||||||
|
*
|
||||||
|
* <p>This is the read-only, phase-aware subset of the responsibility: the MOTD is
|
||||||
|
* synthesized from the server's lifecycle (online / starting / sleeping) and its
|
||||||
|
* cached player counts. Mirroring each backend's <em>own</em> MOTD string (by
|
||||||
|
* pinging ready servers in the background and caching the result) is a richer
|
||||||
|
* variant deferred to a later slice; nothing here ever pings a sleeping backend.
|
||||||
|
*/
|
||||||
|
public final class MotdResponder {
|
||||||
|
private final ServerRegistry registry;
|
||||||
|
|
||||||
|
MotdResponder(ServerRegistry registry) {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onProxyPing(ProxyPingEvent event) {
|
||||||
|
Optional<InetSocketAddress> vh = event.getConnection().getVirtualHost();
|
||||||
|
if (vh.isEmpty()) {
|
||||||
|
return; // no SRV host → leave the proxy's own MOTD
|
||||||
|
}
|
||||||
|
Optional<ServerView> viewOpt = registry.resolveByHost(vh.get().getHostString());
|
||||||
|
if (viewOpt.isEmpty()) {
|
||||||
|
return; // not a felis subdomain → leave the proxy's own MOTD
|
||||||
|
}
|
||||||
|
ServerView v = viewOpt.get();
|
||||||
|
|
||||||
|
ServerPing.Builder b = event.getPing().asBuilder();
|
||||||
|
b.description(Component.text("« " + v.name() + " » ", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(statusLine(v), statusColor(v))));
|
||||||
|
if (v.ready()) {
|
||||||
|
b.onlinePlayers(v.playersOnline());
|
||||||
|
b.maximumPlayers(Math.max(v.playersMax(), v.playersOnline()));
|
||||||
|
}
|
||||||
|
event.setPing(b.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusLine(ServerView v) {
|
||||||
|
if (v.ready()) {
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
if ("Running".equals(v.desiredState())) {
|
||||||
|
return "starting…";
|
||||||
|
}
|
||||||
|
return "sleeping — join to wake";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedTextColor statusColor(ServerView v) {
|
||||||
|
if (v.ready()) {
|
||||||
|
return NamedTextColor.GREEN;
|
||||||
|
}
|
||||||
|
if ("Running".equals(v.desiredState())) {
|
||||||
|
return NamedTextColor.YELLOW;
|
||||||
|
}
|
||||||
|
return NamedTextColor.GRAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.ServerView;
|
||||||
|
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import com.velocitypowered.api.proxy.server.RegisteredServer;
|
||||||
|
import com.velocitypowered.api.proxy.server.ServerInfo;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerRegistry mirrors the felis server set into Velocity's dynamic server
|
||||||
|
* registry and indexes it for host-based routing (spec §11). It owns two things:
|
||||||
|
* the felis lifecycle views keyed by name + subdomain, and the registration of
|
||||||
|
* each server's backend address as a Velocity {@link RegisteredServer}.
|
||||||
|
*
|
||||||
|
* <p>{@link #refresh(Collection)} is called only on a <em>successful</em> fetch
|
||||||
|
* from felis-api, so a name's absence is a genuine removal. On an API failure the
|
||||||
|
* caller skips the refresh entirely and the previous registrations survive
|
||||||
|
* untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must
|
||||||
|
* never deregister live backends out from under connected players.
|
||||||
|
*
|
||||||
|
* <p>Only felis-managed servers live in this registry; servers defined statically
|
||||||
|
* in {@code velocity.toml} (notably the lobby) are never added here and so are
|
||||||
|
* never deregistered by a refresh. Static server names must therefore not collide
|
||||||
|
* with felis server names.
|
||||||
|
*/
|
||||||
|
final class ServerRegistry {
|
||||||
|
private static final int DEFAULT_PORT = 25565;
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Logger log;
|
||||||
|
private final String rootDomain;
|
||||||
|
|
||||||
|
private final Map<String, ServerView> byName = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, String> subdomainToName = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
ServerRegistry(ProxyServer proxy, Logger log, String rootDomain) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.log = log;
|
||||||
|
this.rootDomain = rootDomain.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** refresh reconciles registrations against a freshly fetched server list. */
|
||||||
|
void refresh(Collection<ServerView> servers) {
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (ServerView v : servers) {
|
||||||
|
String name = v.name();
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(name);
|
||||||
|
byName.put(name, v);
|
||||||
|
String sub = v.subdomain();
|
||||||
|
if (sub != null && !sub.isEmpty()) {
|
||||||
|
subdomainToName.put(sub.toLowerCase(Locale.ROOT), name);
|
||||||
|
}
|
||||||
|
ensureRegistered(v);
|
||||||
|
}
|
||||||
|
// Drop servers that vanished from a successful fetch (iterate a snapshot so
|
||||||
|
// deregister can mutate byName underneath us).
|
||||||
|
for (String name : new ArrayList<>(byName.keySet())) {
|
||||||
|
if (!seen.contains(name)) {
|
||||||
|
deregister(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subdomainToName.values().removeIf(n -> !seen.contains(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureRegistered(ServerView v) {
|
||||||
|
String addr = v.endpointAddress();
|
||||||
|
if (addr == null || addr.isEmpty()) {
|
||||||
|
return; // no backend address yet (server never started) → nothing to register
|
||||||
|
}
|
||||||
|
InetSocketAddress target = parseAddress(addr);
|
||||||
|
Optional<RegisteredServer> existing = proxy.getServer(v.name());
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
if (existing.get().getServerInfo().getAddress().equals(target)) {
|
||||||
|
return; // already registered at this address
|
||||||
|
}
|
||||||
|
proxy.unregisterServer(existing.get().getServerInfo()); // address changed → re-register
|
||||||
|
}
|
||||||
|
proxy.registerServer(new ServerInfo(v.name(), target));
|
||||||
|
log.info("Felis: registered backend {} -> {}", v.name(), addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deregister(String name) {
|
||||||
|
byName.remove(name);
|
||||||
|
proxy.getServer(name).ifPresent(rs -> {
|
||||||
|
proxy.unregisterServer(rs.getServerInfo());
|
||||||
|
log.info("Felis: deregistered backend {}", name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resolveByHost maps {@code subdomain.<root_domain>} to its current view. */
|
||||||
|
Optional<ServerView> resolveByHost(String host) {
|
||||||
|
if (host == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String h = host.toLowerCase(Locale.ROOT);
|
||||||
|
String suffix = "." + rootDomain;
|
||||||
|
if (!h.endsWith(suffix)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String sub = h.substring(0, h.length() - suffix.length());
|
||||||
|
String name = subdomainToName.get(sub);
|
||||||
|
return name == null ? Optional.empty() : Optional.ofNullable(byName.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerView view(String name) {
|
||||||
|
return name == null ? null : byName.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isManaged(String name) {
|
||||||
|
return name != null && byName.containsKey(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RegisteredServer> registered(String name) {
|
||||||
|
return proxy.getServer(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection<ServerView> all() {
|
||||||
|
return new ArrayList<>(byName.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** parseAddress splits {@code host[:port]} into an unresolved socket address. */
|
||||||
|
static InetSocketAddress parseAddress(String addr) {
|
||||||
|
int idx = addr.lastIndexOf(':');
|
||||||
|
if (idx > 0 && idx < addr.length() - 1) {
|
||||||
|
try {
|
||||||
|
int port = Integer.parseInt(addr.substring(idx + 1));
|
||||||
|
// Unresolved: the backend's DNS (a K8s Service) may not resolve yet
|
||||||
|
// while the server is asleep; Velocity resolves at connect time.
|
||||||
|
return InetSocketAddress.createUnresolved(addr.substring(0, idx), port);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// not host:port → fall through to the default Minecraft port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InetSocketAddress.createUnresolved(addr, DEFAULT_PORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package best.lolicon.felis.velocity;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.FelisApiClient;
|
||||||
|
import best.lolicon.felis.link.LinkException;
|
||||||
|
import best.lolicon.felis.link.ServerView;
|
||||||
|
|
||||||
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
|
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
|
||||||
|
import com.velocitypowered.api.event.player.ServerConnectedEvent;
|
||||||
|
import com.velocitypowered.api.proxy.Player;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import com.velocitypowered.api.proxy.server.RegisteredServer;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WaitingRouter implements the §11 domain-autostart routing loop and its waiting
|
||||||
|
* queue. It resolves the virtual host a player connected with to a felis server
|
||||||
|
* and decides what happens next:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* host has no felis subdomain → leave Velocity's default routing alone
|
||||||
|
* server ready + registered → set it as the initial server (straight in)
|
||||||
|
* server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
|
||||||
|
* server not ready, no lobby → refuse cleanly ("reconnect shortly"), wake
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>The queue is drained by {@link #tick()}, scheduled by the plugin on the async
|
||||||
|
* pool. Each tick polls felis-api once per distinct waited-on server and, when one
|
||||||
|
* reports ready, transfers everyone waiting on it. A waiter drops out when it times
|
||||||
|
* out, when the player leaves the proxy, or on a successful transfer.
|
||||||
|
*
|
||||||
|
* <p>The wake is gated server-side by autostartPolicy keyed on the player's
|
||||||
|
* online-mode UUID: a 403 means this player may not start the server (we tell them
|
||||||
|
* and stop), a 429 means a wake is already in flight (we keep waiting). Real joins
|
||||||
|
* to a felis backend are reported back so the reaper sees activity and the player
|
||||||
|
* is auto-added to the allowlist.
|
||||||
|
*/
|
||||||
|
public final class WaitingRouter {
|
||||||
|
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Logger log;
|
||||||
|
private final FelisApiClient api;
|
||||||
|
private final ServerRegistry registry;
|
||||||
|
private final FelisVelocityPlugin plugin;
|
||||||
|
private final String lobbyServer; // may be null → no lobby
|
||||||
|
|
||||||
|
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Notified just before a menu-originated waiter is transferred, so the lobby's
|
||||||
|
// felis:control face can tell the player's GUI the backend is ready. Null until
|
||||||
|
// the ControlChannel is wired in at proxy init; set once, read on the tick pool.
|
||||||
|
private volatile MenuTransferListener menuListener;
|
||||||
|
|
||||||
|
WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry,
|
||||||
|
FelisVelocityPlugin plugin, String lobbyServer) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.log = log;
|
||||||
|
this.api = api;
|
||||||
|
this.registry = registry;
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.lobbyServer = lobbyServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setMenuTransferListener wires the felis:control face so a menu-driven wait can
|
||||||
|
* notify the lobby when its backend is ready. Called once at proxy init.
|
||||||
|
*/
|
||||||
|
void setMenuTransferListener(MenuTransferListener listener) {
|
||||||
|
this.menuListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enqueueFromMenu parks a player who is already on the proxy (sitting in the
|
||||||
|
* lobby) on a server they asked for through the felis-paper {@code /menu}, then
|
||||||
|
* wakes it and lets {@link #tick()} transfer them when ready — the same shared
|
||||||
|
* waiting queue used by host-based autostart routing (spec §12, §27 scenario 10).
|
||||||
|
* It differs from initial-server routing only in origin: the player drove it from
|
||||||
|
* a GUI button rather than a connecting virtual host, so the waiter is flagged to
|
||||||
|
* fire {@link MenuTransferListener} on transfer.
|
||||||
|
*/
|
||||||
|
void enqueueFromMenu(Player player, String serverName) {
|
||||||
|
wakeAndWait(player, serverName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
Optional<String> host = virtualHost(player);
|
||||||
|
if (host.isEmpty()) {
|
||||||
|
return; // direct connect / no SRV host → leave default routing
|
||||||
|
}
|
||||||
|
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
|
||||||
|
if (targetOpt.isEmpty()) {
|
||||||
|
return; // host is not a felis subdomain → leave default routing
|
||||||
|
}
|
||||||
|
ServerView target = targetOpt.get();
|
||||||
|
Optional<RegisteredServer> backend = registry.registered(target.name());
|
||||||
|
if (target.ready() && backend.isPresent()) {
|
||||||
|
event.setInitialServer(backend.get()); // ready → straight in
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RegisteredServer> lobby = lobby();
|
||||||
|
if (lobby.isEmpty()) {
|
||||||
|
// Nowhere to hold the player while the backend wakes: refuse cleanly so
|
||||||
|
// they reconnect onto a ready server, rather than dropping them onto a
|
||||||
|
// backend that is still starting. Still fire the wake so the reconnect
|
||||||
|
// lands faster.
|
||||||
|
player.disconnect(Component.text(
|
||||||
|
"« " + target.name() + " » is starting up — please reconnect in a moment.",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
fireWake(player.getUniqueId(), target.name());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setInitialServer(lobby.get()); // park in lobby
|
||||||
|
wakeAndWait(player, target.name(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onServerConnected(ServerConnectedEvent event) {
|
||||||
|
String name = event.getServer().getServerInfo().getName();
|
||||||
|
if (!registry.isManaged(name)) {
|
||||||
|
return; // lobby / static server → not a felis backend, nothing to report
|
||||||
|
}
|
||||||
|
UUID id = event.getPlayer().getUniqueId();
|
||||||
|
plugin.async(() -> {
|
||||||
|
try {
|
||||||
|
api.reportJoin(name, id);
|
||||||
|
} catch (LinkException e) {
|
||||||
|
log.debug("Felis: join-event {} failed (status={}): {}", name, e.statusCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tick drains the waiting queue; the plugin schedules it on the async pool. */
|
||||||
|
void tick() {
|
||||||
|
if (waiting.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Map<String, Boolean> readyCache = new HashMap<>(); // one status poll per distinct server
|
||||||
|
for (Map.Entry<UUID, Waiter> e : new ArrayList<>(waiting.entrySet())) {
|
||||||
|
UUID id = e.getKey();
|
||||||
|
Waiter w = e.getValue();
|
||||||
|
Optional<Player> po = proxy.getPlayer(id);
|
||||||
|
if (po.isEmpty()) {
|
||||||
|
waiting.remove(id); // player left the proxy
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Player player = po.get();
|
||||||
|
if (now > w.deadlineMillis) {
|
||||||
|
waiting.remove(id);
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"« " + w.serverName + " » is taking longer than expected to start. "
|
||||||
|
+ "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Boolean ready = readyCache.get(w.serverName);
|
||||||
|
if (ready == null) {
|
||||||
|
try {
|
||||||
|
ready = api.serverStatus(w.serverName).ready();
|
||||||
|
} catch (LinkException ex) {
|
||||||
|
ready = Boolean.FALSE; // transient → keep waiting until the deadline
|
||||||
|
}
|
||||||
|
readyCache.put(w.serverName, ready);
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Optional<RegisteredServer> backend = registry.registered(w.serverName);
|
||||||
|
if (backend.isEmpty()) {
|
||||||
|
continue; // ready but not yet registered → next tick
|
||||||
|
}
|
||||||
|
waiting.remove(id);
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
|
||||||
|
// Tell a menu-driven lobby its tile is live before we pull the player off
|
||||||
|
// it; the proxy still performs the actual Connect just below.
|
||||||
|
MenuTransferListener listener = menuListener;
|
||||||
|
if (w.fromMenu && listener != null) {
|
||||||
|
listener.onReady(player, w.serverName);
|
||||||
|
}
|
||||||
|
transfer(player, w.serverName, backend.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void wakeAndWait(Player player, String serverName, boolean fromMenu) {
|
||||||
|
UUID id = player.getUniqueId();
|
||||||
|
plugin.async(() -> {
|
||||||
|
try {
|
||||||
|
api.wake(serverName, id);
|
||||||
|
} catch (LinkException e) {
|
||||||
|
switch (e.statusCode()) {
|
||||||
|
case 403:
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
|
||||||
|
return; // policy gate refused → do not enqueue
|
||||||
|
case 429:
|
||||||
|
break; // a wake is already in flight → fall through to waiting
|
||||||
|
default:
|
||||||
|
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"Couldn't start « " + serverName + " » right now. Try again shortly.",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"Starting « " + serverName + " » — you'll be moved in automatically.",
|
||||||
|
NamedTextColor.GRAY));
|
||||||
|
waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fireWake(UUID id, String serverName) {
|
||||||
|
plugin.async(() -> {
|
||||||
|
try {
|
||||||
|
api.wake(serverName, id);
|
||||||
|
} catch (LinkException e) {
|
||||||
|
if (e.statusCode() != 429 && e.statusCode() != 403) {
|
||||||
|
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void transfer(Player player, String serverName, RegisteredServer backend) {
|
||||||
|
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
|
||||||
|
if (err != null || (result != null && !result.isSuccessful())) {
|
||||||
|
player.sendMessage(Component.text(
|
||||||
|
"Couldn't connect you to « " + serverName + " ». Please try again.",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<RegisteredServer> lobby() {
|
||||||
|
return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<String> virtualHost(Player player) {
|
||||||
|
return player.getVirtualHost()
|
||||||
|
.map(InetSocketAddress::getHostString)
|
||||||
|
.map(s -> s.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Waiter {
|
||||||
|
final String serverName;
|
||||||
|
final long deadlineMillis;
|
||||||
|
final boolean fromMenu; // true → notify the felis:control face on transfer
|
||||||
|
|
||||||
|
Waiter(String serverName, long deadlineMillis, boolean fromMenu) {
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.deadlineMillis = deadlineMillis;
|
||||||
|
this.fromMenu = fromMenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuTransferListener bridges the shared waiting queue to the felis:control face
|
||||||
|
* without {@link WaitingRouter} depending on the wire codec: it is told a
|
||||||
|
* menu-originated player's backend is ready, and the implementation owns encoding
|
||||||
|
* and sending the {@code TransferReady} frame. The proxy still performs the
|
||||||
|
* Connect itself ({@link #transfer}); this is only the lobby-UI notification.
|
||||||
|
*/
|
||||||
|
interface MenuTransferListener {
|
||||||
|
void onReady(Player player, String serverName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user