From 93f143f5b6ad6fe461cd0e7dce2eab2f6b84e6d5 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Fri, 26 Jun 2026 23:32:40 +0900 Subject: [PATCH] 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. --- plugins/README.md | 224 ++++++++++++++ plugins/fabric/build.gradle | 44 +++ .../fabric/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + plugins/fabric/gradlew | 248 +++++++++++++++ plugins/fabric/gradlew.bat | 82 +++++ plugins/fabric/settings.gradle | 12 + .../lolicon/felis/fabric/FelisFabricMod.java | 93 ++++++ .../fabric/src/main/resources/fabric.mod.json | 19 ++ plugins/forge/build.gradle | 39 +++ .../forge/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + plugins/forge/gradlew | 248 +++++++++++++++ plugins/forge/gradlew.bat | 82 +++++ plugins/forge/settings.gradle | 11 + .../lolicon/felis/forge/FelisForgeMod.java | 98 ++++++ .../src/main/resources/META-INF/mods.toml | 25 ++ plugins/forge/src/main/resources/pack.mcmeta | 6 + plugins/neoforge/build.gradle | 41 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + plugins/neoforge/gradlew | 248 +++++++++++++++ plugins/neoforge/gradlew.bat | 82 +++++ plugins/neoforge/settings.gradle | 11 + .../felis/neoforge/FelisNeoForgeMod.java | 102 ++++++ .../src/main/resources/META-INF/mods.toml | 25 ++ .../neoforge/src/main/resources/pack.mcmeta | 6 + plugins/paper/build.gradle | 56 ++++ plugins/paper/settings.gradle | 1 + .../lolicon/felis/paper/FelisPaperPlugin.java | 291 ++++++++++++++++++ .../best/lolicon/felis/paper/MenuHolder.java | 59 ++++ plugins/paper/src/main/resources/config.yml | 16 + plugins/paper/src/main/resources/plugin.yml | 13 + .../java/best/lolicon/felis/link/Control.java | 188 +++++++++++ .../best/lolicon/felis/link/ControlFrame.java | 179 +++++++++++ .../lolicon/felis/link/FelisApiClient.java | 221 +++++++++++++ .../java/best/lolicon/felis/link/Json.java | 228 ++++++++++++++ .../best/lolicon/felis/link/LinkClient.java | 124 ++++++++ .../best/lolicon/felis/link/LinkCode.java | 30 ++ .../best/lolicon/felis/link/LinkConfig.java | 57 ++++ .../lolicon/felis/link/LinkConfigLoader.java | 86 ++++++ .../lolicon/felis/link/LinkException.java | 39 +++ .../best/lolicon/felis/link/MenuStatus.java | 93 ++++++ .../best/lolicon/felis/link/ServerView.java | 123 ++++++++ .../felis/link/ControlRoundTripTest.java | 149 +++++++++ plugins/velocity/build.gradle | 44 +++ plugins/velocity/settings.gradle | 1 + .../felis/velocity/ControlChannel.java | 207 +++++++++++++ .../felis/velocity/FelisVelocityConfig.java | 91 ++++++ .../felis/velocity/FelisVelocityPlugin.java | 256 +++++++++++++++ .../lolicon/felis/velocity/MotdResponder.java | 76 +++++ .../felis/velocity/ServerRegistry.java | 150 +++++++++ .../lolicon/felis/velocity/WaitingRouter.java | 281 +++++++++++++++++ 53 files changed, 4832 insertions(+) create mode 100644 plugins/README.md create mode 100644 plugins/fabric/build.gradle create mode 100644 plugins/fabric/gradle/wrapper/gradle-wrapper.jar create mode 100644 plugins/fabric/gradle/wrapper/gradle-wrapper.properties create mode 100644 plugins/fabric/gradlew create mode 100644 plugins/fabric/gradlew.bat create mode 100644 plugins/fabric/settings.gradle create mode 100644 plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java create mode 100644 plugins/fabric/src/main/resources/fabric.mod.json create mode 100644 plugins/forge/build.gradle create mode 100644 plugins/forge/gradle/wrapper/gradle-wrapper.jar create mode 100644 plugins/forge/gradle/wrapper/gradle-wrapper.properties create mode 100644 plugins/forge/gradlew create mode 100644 plugins/forge/gradlew.bat create mode 100644 plugins/forge/settings.gradle create mode 100644 plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java create mode 100644 plugins/forge/src/main/resources/META-INF/mods.toml create mode 100644 plugins/forge/src/main/resources/pack.mcmeta create mode 100644 plugins/neoforge/build.gradle create mode 100644 plugins/neoforge/gradle/wrapper/gradle-wrapper.jar create mode 100644 plugins/neoforge/gradle/wrapper/gradle-wrapper.properties create mode 100644 plugins/neoforge/gradlew create mode 100644 plugins/neoforge/gradlew.bat create mode 100644 plugins/neoforge/settings.gradle create mode 100644 plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java create mode 100644 plugins/neoforge/src/main/resources/META-INF/mods.toml create mode 100644 plugins/neoforge/src/main/resources/pack.mcmeta create mode 100644 plugins/paper/build.gradle create mode 100644 plugins/paper/settings.gradle create mode 100644 plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java create mode 100644 plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java create mode 100644 plugins/paper/src/main/resources/config.yml create mode 100644 plugins/paper/src/main/resources/plugin.yml create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/Control.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/Json.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/LinkClient.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/LinkCode.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfig.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfigLoader.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/LinkException.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java create mode 100644 plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java create mode 100644 plugins/velocity/build.gradle create mode 100644 plugins/velocity/settings.gradle create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..b0af72b --- /dev/null +++ b/plugins/README.md @@ -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 ` and body `{"mc_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.` → 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. diff --git a/plugins/fabric/build.gradle b/plugins/fabric/build.gradle new file mode 100644 index 0000000..eaacc38 --- /dev/null +++ b/plugins/fabric/build.gradle @@ -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' +} diff --git a/plugins/fabric/gradle/wrapper/gradle-wrapper.jar b/plugins/fabric/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/plugins/fabric/gradle/wrapper/gradle-wrapper.properties b/plugins/fabric/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b8fb7d2 --- /dev/null +++ b/plugins/fabric/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/plugins/fabric/gradlew b/plugins/fabric/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/plugins/fabric/gradlew @@ -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" "$@" diff --git a/plugins/fabric/gradlew.bat b/plugins/fabric/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/plugins/fabric/gradlew.bat @@ -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% diff --git a/plugins/fabric/settings.gradle b/plugins/fabric/settings.gradle new file mode 100644 index 0000000..411983a --- /dev/null +++ b/plugins/fabric/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'felis-fabric' diff --git a/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java b/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java new file mode 100644 index 0000000..3885acb --- /dev/null +++ b/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java @@ -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 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."))); + } + }); + } +} diff --git a/plugins/fabric/src/main/resources/fabric.mod.json b/plugins/fabric/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..5ed2fc9 --- /dev/null +++ b/plugins/fabric/src/main/resources/fabric.mod.json @@ -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": "*" + } +} diff --git a/plugins/forge/build.gradle b/plugins/forge/build.gradle new file mode 100644 index 0000000..0d4a8d7 --- /dev/null +++ b/plugins/forge/build.gradle @@ -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' +} diff --git a/plugins/forge/gradle/wrapper/gradle-wrapper.jar b/plugins/forge/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/plugins/forge/gradle/wrapper/gradle-wrapper.properties b/plugins/forge/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b8fb7d2 --- /dev/null +++ b/plugins/forge/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/plugins/forge/gradlew b/plugins/forge/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/plugins/forge/gradlew @@ -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" "$@" diff --git a/plugins/forge/gradlew.bat b/plugins/forge/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/plugins/forge/gradlew.bat @@ -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% diff --git a/plugins/forge/settings.gradle b/plugins/forge/settings.gradle new file mode 100644 index 0000000..ba82fca --- /dev/null +++ b/plugins/forge/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +rootProject.name = 'felis-forge' diff --git a/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java b/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java new file mode 100644 index 0000000..a5ab0cd --- /dev/null +++ b/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java @@ -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 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."))); + } + }); + } +} diff --git a/plugins/forge/src/main/resources/META-INF/mods.toml b/plugins/forge/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..fb6c772 --- /dev/null +++ b/plugins/forge/src/main/resources/META-INF/mods.toml @@ -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" diff --git a/plugins/forge/src/main/resources/pack.mcmeta b/plugins/forge/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..6b78a17 --- /dev/null +++ b/plugins/forge/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Felis Link", + "pack_format": 15 + } +} diff --git a/plugins/neoforge/build.gradle b/plugins/neoforge/build.gradle new file mode 100644 index 0000000..b5a4d8c --- /dev/null +++ b/plugins/neoforge/build.gradle @@ -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' +} diff --git a/plugins/neoforge/gradle/wrapper/gradle-wrapper.jar b/plugins/neoforge/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/plugins/neoforge/gradle/wrapper/gradle-wrapper.properties b/plugins/neoforge/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..680d395 --- /dev/null +++ b/plugins/neoforge/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/plugins/neoforge/gradlew b/plugins/neoforge/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/plugins/neoforge/gradlew @@ -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" "$@" diff --git a/plugins/neoforge/gradlew.bat b/plugins/neoforge/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/plugins/neoforge/gradlew.bat @@ -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% diff --git a/plugins/neoforge/settings.gradle b/plugins/neoforge/settings.gradle new file mode 100644 index 0000000..4817da8 --- /dev/null +++ b/plugins/neoforge/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/releases' + } + } +} + +rootProject.name = 'felis-neoforge' diff --git a/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java b/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java new file mode 100644 index 0000000..1404c33 --- /dev/null +++ b/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java @@ -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. + * + *

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 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."))); + } + }); + } +} diff --git a/plugins/neoforge/src/main/resources/META-INF/mods.toml b/plugins/neoforge/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..618e313 --- /dev/null +++ b/plugins/neoforge/src/main/resources/META-INF/mods.toml @@ -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" diff --git a/plugins/neoforge/src/main/resources/pack.mcmeta b/plugins/neoforge/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..d464183 --- /dev/null +++ b/plugins/neoforge/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Felis Link", + "pack_format": 22 + } +} diff --git a/plugins/paper/build.gradle b/plugins/paper/build.gradle new file mode 100644 index 0000000..9fc6cd3 --- /dev/null +++ b/plugins/paper/build.gradle @@ -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' +} diff --git a/plugins/paper/settings.gradle b/plugins/paper/settings.gradle new file mode 100644 index 0000000..295b4df --- /dev/null +++ b/plugins/paper/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'felis-paper' diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java new file mode 100644 index 0000000..77d1c36 --- /dev/null +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java @@ -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}. + * + *

Pure UI face. 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. + * + *

Flow. 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 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 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 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; + } +} diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java new file mode 100644 index 0000000..2d7298f --- /dev/null +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/MenuHolder.java @@ -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. + * + *

It holds two things: the ordered list of server names (the list index is + * 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 servers; // index = slot + private final Map latest = new HashMap<>(); + private Inventory inventory; + + MenuHolder(List servers) { + this.servers = servers; + } + + /** servers returns the tile order; the list index is the inventory slot. */ + List 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; + } +} diff --git a/plugins/paper/src/main/resources/config.yml b/plugins/paper/src/main/resources/config.yml new file mode 100644 index 0000000..0cd4e01 --- /dev/null +++ b/plugins/paper/src/main/resources/config.yml @@ -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 diff --git a/plugins/paper/src/main/resources/plugin.yml b/plugins/paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..ca0fd9f --- /dev/null +++ b/plugins/paper/src/main/resources/plugin.yml @@ -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 diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java new file mode 100644 index 0000000..e8d2621 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java @@ -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. + * + *

Frames are encoded as raw UTF-8 JSON bytes, 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. + * + *

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; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java new file mode 100644 index 0000000..465239d --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java @@ -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. + * + *

There are six frame types, discriminated by {@link #type()}: + *

    + *
  • Upstream (lobby → velocity): {@link #WAKE_REQUEST} and + * {@link #CLAIM_REQUEST} carry {@code player}+{@code server}; + * {@link #STATUS_QUERY} carries {@code server}.
  • + *
  • Downstream (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.
  • + *
+ * + *

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

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

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) + "}"; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java new file mode 100644 index 0000000..20c7c5f --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -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. + * + *

Every call authenticates with {@code Authorization: Bearer } + * 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: + *

    + *
  • {@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.
  • + *
  • {@code serverByHost} → 404 means the host maps to no server.
  • + *
+ */ +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 listServers() throws LinkException { + Map obj = getObject("/api/v1/servers", 200); + Object arr = obj.get("servers"); + List 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.} 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 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 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 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); + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java b/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java new file mode 100644 index 0000000..000b901 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java @@ -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. + * + *

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 object() { + Map 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 array() { + List 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); + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/LinkClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkClient.java new file mode 100644 index 0000000..251e823 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkClient.java @@ -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. + * + *

Contract (authoritative, mirrored from {@code internal/api}): + *

    + *
  • {@code POST {apiBaseUrl}/api/v1/internal/account/link/code}
  • + *
  • header {@code Authorization: Bearer } (constant-time + * compared server-side; an empty token fails closed)
  • + *
  • request body {@code {"mc_uuid":""}}
  • + *
  • success: HTTP 201 with {@code {"code","expires_at"}}
  • + *
  • failure: the {@code {"error":{"code","message"}}} envelope
  • + *
+ * + *

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 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); + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/LinkCode.java b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkCode.java new file mode 100644 index 0000000..121fa62 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkCode.java @@ -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; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfig.java b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfig.java new file mode 100644 index 0000000..7cbe81d --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfig.java @@ -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 never 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; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfigLoader.java b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfigLoader.java new file mode 100644 index 0000000..43dfe8a --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkConfigLoader.java @@ -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(); + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/LinkException.java b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkException.java new file mode 100644 index 0000000..01559cc --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/LinkException.java @@ -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 not 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; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java b/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java new file mode 100644 index 0000000..5d6ab00 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/MenuStatus.java @@ -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 + * not {@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. + * + *

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

{@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; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java new file mode 100644 index 0000000..bab10ea --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java @@ -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. + * + *

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

{@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; + } +} diff --git a/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java new file mode 100644 index 0000000..f25be87 --- /dev/null +++ b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java @@ -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. + * + *

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 by construction, 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. + * + *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java + * shared/test/best/lolicon/felis/link/ControlRoundTripTest.java && java -cp + * 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)); + } +} diff --git a/plugins/velocity/build.gradle b/plugins/velocity/build.gradle new file mode 100644 index 0000000..d4696c8 --- /dev/null +++ b/plugins/velocity/build.gradle @@ -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' +} diff --git a/plugins/velocity/settings.gradle b/plugins/velocity/settings.gradle new file mode 100644 index 0000000..9680ccf --- /dev/null +++ b/plugins/velocity/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'felis-velocity' diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java new file mode 100644 index 0000000..bd0e72b --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java @@ -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}. + * + *

Anti-spoof (spec §14). 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. + * + *

Threading. {@code felis:control} frames arrive on a Velocity event + * thread, but every felis-api call below blocks on HTTP. So each handler does the + * 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. + * + *

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(); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java new file mode 100644 index 0000000..6cb42e3 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java @@ -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}). + * + *

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; + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java new file mode 100644 index 0000000..7fb0fc8 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -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: + * + *

    + *
  • {@code /link} — mints a one-time account-link code from the player's + * online-mode-verified UUID (the first leg of §10), unchanged.
  • + *
  • Domain-autostart routing — 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).
  • + *
+ * + *

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 ? "" : 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 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 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 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 ? "" : 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 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 ? "" : value, NamedTextColor.WHITE)); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java new file mode 100644 index 0000000..407a4bb --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java @@ -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. + * + *

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 own 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 vh = event.getConnection().getVirtualHost(); + if (vh.isEmpty()) { + return; // no SRV host → leave the proxy's own MOTD + } + Optional 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; + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java new file mode 100644 index 0000000..a50f3a9 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java @@ -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}. + * + *

{@link #refresh(Collection)} is called only on a successful 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. + * + *

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 byName = new ConcurrentHashMap<>(); + private final Map 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 servers) { + Set 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 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.} to its current view. */ + Optional 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 registered(String name) { + return proxy.getServer(name); + } + + Collection 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); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java new file mode 100644 index 0000000..d6ef447 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -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: + * + *

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

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

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 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 host = virtualHost(player); + if (host.isEmpty()) { + return; // direct connect / no SRV host → leave default routing + } + Optional targetOpt = registry.resolveByHost(host.get()); + if (targetOpt.isEmpty()) { + return; // host is not a felis subdomain → leave default routing + } + ServerView target = targetOpt.get(); + Optional backend = registry.registered(target.name()); + if (target.ready() && backend.isPresent()) { + event.setInitialServer(backend.get()); // ready → straight in + return; + } + + Optional 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 readyCache = new HashMap<>(); // one status poll per distinct server + for (Map.Entry e : new ArrayList<>(waiting.entrySet())) { + UUID id = e.getKey(); + Waiter w = e.getValue(); + Optional 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 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 lobby() { + return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer); + } + + private static Optional 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); + } +}