From 31579bb466a5e0fd6576bd9244f4cd1a88008525 Mon Sep 17 00:00:00 2001 From: Macaron27 Date: Fri, 2 Oct 2026 09:42:49 +0200 Subject: [PATCH] Restructure into api/core/paper/bungee/velocity, ship jars in builds/, support Folia - Rename the common module to core (package com.friends.core). - builds/: every build syncs friends-paper/-velocity/-bungee/-api.jar there; the unused thin plugin jars are no longer built. - verifyBuilds (part of check): descriptor versions, Folia flag, each platform's own events only, relocated libraries, Java 8 bytecode for the Paper and API jars, no file-sync copies ("Foo 2.class") inside the jars. - Folia: the Paper jar declares folia-supported; the request-expiry timer moves from each platform's scheduler into FriendsRuntime (Folia has no Bukkit scheduler), and a failing run no longer cancels later ones. - Velocity's @Plugin version is generated from the build version. - API usage guide in api/README.md; README, e2e runner (folia- scenario, jars from builds/) and CI (build + verify, upload builds/*.jar) updated. - .gitignore: builds/, IDE and Claude Code files, test sources; untrack .vscode/, .claude/ and src/test (kept locally). Co-Authored-By: Claude Opus 5.5 --- .claude/known_errors.md | 68 --- .github/workflows/build.yml | 27 +- .gitignore | 27 +- .vscode/settings.json | 3 - README.md | 97 +--- api/README.md | 131 +++++ build.gradle.kts | 69 +++ bungee/build.gradle.kts | 2 +- .../java/com/friends/bungee/BungeeHooks.java | 6 +- .../com/friends/bungee/FriendsBungee.java | 20 +- .../com/friends/bungee/BungeeHooksTest.java | 117 ---- .../com/friends/common/BungeeChatTest.java | 89 --- .../com/friends/common/ChatSessionsTest.java | 73 --- .../com/friends/common/ConsistencyTest.java | 187 ------- .../com/friends/common/FriendsApiTest.java | 524 ------------------ .../java/com/friends/common/FriendsTest.java | 448 --------------- .../java/com/friends/common/MessagesTest.java | 38 -- .../com/friends/common/MultiProxyTest.java | 274 --------- .../java/com/friends/common/MysqlTest.java | 22 - .../com/friends/common/RedisNetworkTest.java | 188 ------- .../java/com/friends/common/SettingsTest.java | 69 --- .../java/com/friends/common/StorageTest.java | 133 ----- .../java/com/friends/common/TestSupport.java | 285 ---------- {common => core}/build.gradle.kts | 6 +- .../java/com/friends/core}/BungeeChat.java | 2 +- .../java/com/friends/core}/ChatSessions.java | 2 +- .../main/java/com/friends/core}/Event.java | 4 +- .../main/java/com/friends/core}/Friend.java | 2 +- .../java/com/friends/core}/FriendCommand.java | 6 +- .../main/java/com/friends/core}/Friends.java | 6 +- .../java/com/friends/core}/FriendsApi.java | 8 +- .../com/friends/core}/FriendsRuntime.java | 26 +- .../main/java/com/friends/core}/Hooks.java | 4 +- .../com/friends/core}/LuckPermsPrefix.java | 2 +- .../main/java/com/friends/core}/Messages.java | 2 +- .../main/java/com/friends/core}/Network.java | 2 +- .../main/java/com/friends/core}/Platform.java | 2 +- .../main/java/com/friends/core}/Presence.java | 2 +- .../java/com/friends/core}/RedisNetwork.java | 4 +- .../main/java/com/friends/core}/Request.java | 4 +- .../main/java/com/friends/core}/Settings.java | 2 +- .../main/java/com/friends/core}/Storage.java | 2 +- .../src/main/resources/config.yml | 0 paper/build.gradle.kts | 6 +- .../java/com/friends/paper/BukkitHooks.java | 6 +- .../java/com/friends/paper/PaperFriends.java | 17 +- paper/src/main/resources/plugin.yml | 2 + .../java/com/friends/paper/PaperApiTest.java | 208 ------- settings.gradle.kts | 2 +- tools/e2e/README.md | 3 +- tools/e2e/run.py | 18 +- tools/e2e/servers.py | 2 +- velocity/build.gradle.kts | 12 +- .../com/friends/velocity/FriendsVelocity.java | 20 +- .../com/friends/velocity/VelocityHooks.java | 6 +- .../com/friends/velocity/BuildConstants.java | 8 + .../friends/velocity/VelocityHooksTest.java | 86 --- 57 files changed, 378 insertions(+), 3003 deletions(-) delete mode 100644 .claude/known_errors.md delete mode 100644 .vscode/settings.json create mode 100644 api/README.md delete mode 100644 bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java delete mode 100644 common/src/test/java/com/friends/common/BungeeChatTest.java delete mode 100644 common/src/test/java/com/friends/common/ChatSessionsTest.java delete mode 100644 common/src/test/java/com/friends/common/ConsistencyTest.java delete mode 100644 common/src/test/java/com/friends/common/FriendsApiTest.java delete mode 100644 common/src/test/java/com/friends/common/FriendsTest.java delete mode 100644 common/src/test/java/com/friends/common/MessagesTest.java delete mode 100644 common/src/test/java/com/friends/common/MultiProxyTest.java delete mode 100644 common/src/test/java/com/friends/common/MysqlTest.java delete mode 100644 common/src/test/java/com/friends/common/RedisNetworkTest.java delete mode 100644 common/src/test/java/com/friends/common/SettingsTest.java delete mode 100644 common/src/test/java/com/friends/common/StorageTest.java delete mode 100644 common/src/test/java/com/friends/common/TestSupport.java rename {common => core}/build.gradle.kts (95%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/BungeeChat.java (99%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/ChatSessions.java (98%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Event.java (94%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Friend.java (96%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/FriendCommand.java (98%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Friends.java (99%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/FriendsApi.java (96%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/FriendsRuntime.java (76%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Hooks.java (95%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/LuckPermsPrefix.java (94%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Messages.java (99%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Network.java (98%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Platform.java (95%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Presence.java (94%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/RedisNetwork.java (99%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Request.java (78%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Settings.java (98%) rename {common/src/main/java/com/friends/common => core/src/main/java/com/friends/core}/Storage.java (99%) rename {common => core}/src/main/resources/config.yml (100%) delete mode 100644 paper/src/test/java/com/friends/paper/PaperApiTest.java create mode 100644 velocity/src/main/templates/com/friends/velocity/BuildConstants.java delete mode 100644 velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java diff --git a/.claude/known_errors.md b/.claude/known_errors.md deleted file mode 100644 index 6530502..0000000 --- a/.claude/known_errors.md +++ /dev/null @@ -1,68 +0,0 @@ -# Known errors & fixes - -- **Settings clobbered on write (old SqlFriendService):** `INSERT OR REPLACE INTO settings (player_uuid, x)` reset every - other column to its default, and is SQLite-only. Fix: dialect upserts that only touch the given columns - (`ON CONFLICT DO UPDATE` / `ON DUPLICATE KEY UPDATE`), plain `UPDATE` for settings. -- **Portable DDL:** MySQL 8 has no `CREATE INDEX IF NOT EXISTS`; SQLite has no inline `INDEX` in `CREATE TABLE`. - MySQL also rejects `DELETE ... WHERE x IN (SELECT ... same table)` (error 1093) — batch explicit deletes instead. -- **Velocity session races:** with `kick-existing-players`, the new session can load before the old one's - `DisconnectEvent`. Profiles are keyed by UUID but owned by the session (`Audience` identity); only the owner may drop it, - and a load that finishes after the player left is discarded. -- **Chat before the first backend:** at `PostLoginEvent` the client may not accept chat yet; send "pending requests" - on the first `ServerPostConnectEvent`. -- **Adventure 5:** `ClickEvent` is generic; read commands with `((ClickEvent.Payload.Text) e.payload()).value()`. -- **JUnit:** `@AfterEach` still runs when `@BeforeEach` aborts on an assumption — null-guard teardown. -- **sqlite-jdbc:** never relocate `org.sqlite` when shading (JNI symbol names). Java 25 has no 32-bit x86 port - (JEP 503), so those natives can be excluded from the jar. -- **Local MySQL on macOS:** the Unix socket path must be ≤ 103 chars; use a short `--socket` path and connect over TCP. -- **Cache vs SQL drift under concurrency:** "decide → update cache → queue SQL write" must be one atomic step, or two - threads (accept vs remove, two `/f best` toggles) queue writes in a different order than they changed the cache. - Fix: one lock around every state change; SQL writes queued inside it. Found by `ConsistencyTest` (random stress). -- **Don't apply an event after its write commits (thenRun):** with crossing accepts, a delayed second `Befriended` fired - after a later removal and re-linked the pair. Apply immediately under the lock instead. -- **Cross-proxy ordering:** publish events *through the SQL queue* (after the writes queued before them), otherwise a - remote proxy can delete a friendship before its insert lands. Crossing accepts on two proxies need a Redis - `SET NX PX` claim on the pair, or the loser's lagging insert resurrects a removed friendship. -- **Stale profile loads:** a friendship change for a player whose profile is still loading is missed by the load (the - write is queued behind it) — track loading players and re-read if something changed. -- **Detect races deterministically:** a `LaggingDb` executor (queue SQL tasks, run random FIFO batches) reproduces - write-timing bugs every run; random multi-threaded stress only catches them ~10% of the time. -- **Test clocks vs wall time:** RedisNetwork turns absolute expiry into a TTL with real time; tests using a fake clock - must set it to `Instant.now()` or keys expire instantly. -- **Name shadowing:** a local variable named `redis` shadows the `redis.clients...` package in fully-qualified names. -- **Jedis 8:** `JedisPooled` → `RedisClient.builder()`; `ssl(boolean)` is deprecated → `sslOptions(SslOptions.defaults())`. -- **GitHub Actions YAML:** an unquoted `run:` containing `: ` (e.g. `...:root: -D...`) is parsed as a mapping and the - whole workflow is rejected (run with 0 jobs, named after the file path). Quote such commands. -- **Bukkit loads the server's libraries first:** Paper 1.8.8 bundles sqlite-jdbc 3.7.2 (1.12.2: 3.21.0.1), so our - SQLite SQL must avoid UPSERT/TRUE/FALSE, set `connectionTestQuery`, and bind booleans with `setBoolean`. The - `testSqlite3_7_2` / `testSqlite3_21_0_1` tasks run the suites on those drivers. 3.7.2 rejects `journal_mode` as a - connection property (it returns a row): use `connectionInitSql`. 3.21.0.1 has no Apple Silicon native. -- **CraftBukkit's class rewriter (Commodore, 1.13+) can't read Java 25 classes before ~1.21:** "Fatal error trying to - convert" per class. Fix: Java 8 bootstrap plugin + embedded implementation jar loaded by a child URLClassLoader. -- **Paper 1.16.5 refuses Java > 16** at startup: `-DPaper.IgnoreJavaVersion=true` (it runs fine on Java 25). -- **bungeecord-chat:** `ChatColor` stopped being an enum (compile against 1.8, never `switch` on it); - `ComponentSerializer` moved to `bungeecord-serializer`; `ChatColor.of` (hex) only on 1.16+. -- **Hot-path messages:** MiniMessage parsing costs ~4 µs per message; join/leave notices go to every online friend, - so build them from prebuilt components (~20 ns) and cache fixed messages. -- **Gradle:** `project.version` inside `filesMatching { expand(...) }` is Task.project at execution time - (deprecated, breaks the configuration cache) — capture it during configuration. -- **E2E bot protocol ids:** minecraft-data lacks 26.2/26.3; minecraft.wiki's protocol page documents the latest - (26.3 = 777; known packs moved 0x0E -> 0x0F, play ids shifted). Paper 26.x enables the whitelist by default. - -- **Duplicated build outputs ("Foo 2.class"):** seen once in common/build (cause not confirmed; the repo is under - ~/Desktop, which macOS can sync). Gradle then fails with "Could not execute test class 'X 2' (wrong name: X)": - `./gradlew ::clean`; nothing tracked by git is affected. -- **Plugin code must never run on the database thread:** a continuation (`thenCompose`) of a `db(...)` future runs on - the DB thread, so a hook there blocks SQL, and a listener that joins an API future deadlocks. Hop first: - `thenComposeAsync(..., async)`. `FriendsApiTest.listenersRunOffTheDatabaseThread...` deadlocks without it. -- **`supplyAsync(x, ex).thenCompose(f)` can run `f` on the caller's thread** (if x finished first). For API actions - the whole action goes inside the `supplyAsync`, or a Bukkit async event could fire from the server thread. -- **Cancellable events outside the lock:** fire, then re-check under the lock. Post events ("...Added") come from the - locked commit, exactly once, delivered in order by one FriendsRuntime thread. -- **Offline cache vs a racing change:** put the cache entry *before* queuing its read; changes queue their write - before dropping entries, so any surviving entry was read after the write. Reversed, a stale list stays cached. -- **javac 25 `--release 8`** compiles against Java 25 jars (velocity-api) fine, but Gradle refuses such a dependency - for a Java 8 consumer: `java { disableAutoTargetJvm() }`. Published metadata still says JVM 8 (from `release`). -- **MockBukkit + Adventure 5:** paper-api 1.21.11's `BookMeta` implements Adventure 4's `Book`, sealed in Adventure 5 - (IncompatibleClassChangeError). Run Paper tests against the shadow jar (Adventure relocated), like a real server. - MockBukkit also subclasses the test plugin (can't be `final`) and ignores plugin.yml permission defaults. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19012ae..46ff978 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,24 +9,8 @@ permissions: contents: read jobs: - test: + build: runs-on: ubuntu-latest - services: - mysql: - image: mysql:8.4 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - MYSQL_DATABASE: friends_test - ports: ["3306:3306"] - options: >- - --health-cmd="mysqladmin ping -h 127.0.0.1" - --health-interval=5s --health-timeout=5s --health-retries=30 - redis: - image: redis:8 - ports: ["6379:6379"] - options: >- - --health-cmd="redis-cli ping" - --health-interval=5s --health-timeout=5s --health-retries=30 steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 @@ -34,12 +18,9 @@ jobs: distribution: temurin java-version: "25" - uses: gradle/actions/setup-gradle@v6 - # Runs every suite: SQLite, MySQL and real Redis (throwaway tables / key namespaces). - - run: "./gradlew build -Dfriends.mysql=127.0.0.1:3306/friends_test:root: -Dfriends.redis=127.0.0.1:6379" + # Test sources stay local (not in the repository): this compiles everything and checks the shipped jars. + - run: ./gradlew build - uses: actions/upload-artifact@v7 with: name: friends-plugins - path: | - velocity/build/libs/friends-velocity-*.jar - bungee/build/libs/friends-bungee-*.jar - paper/build/libs/friends-paper-[0-9]*.jar + path: builds/*.jar diff --git a/.gitignore b/.gitignore index 51e7908..ca44826 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,31 @@ +# Build outputs (the jars to ship are written to builds/) .gradle/ build/ +builds/ bin/ -.backup/ -*.db -*.db-shm -*.db-wal +out/ +target/ +*.log + +# IDEs and OS .idea/ *.iml +.vscode/ .DS_Store + +# Claude Code +.claude/ +claude.json +.claude.json +CLAUDE.local.md + +# Tests stay local (not published) +**/src/test/ __pycache__/ *.pyc + +# Local data and backups +.backup/ +*.db +*.db-shm +*.db-wal diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e0f15db..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "automatic" -} \ No newline at end of file diff --git a/README.md b/README.md index 15fe3e3..cde11d9 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # Friends -A Hypixel-style friends system for **Velocity**, **BungeeCord** and **Paper 1.8.8 → 26.3**, built for Java 25. - -Modules: -- `api` — the API for other plugins (Java 8, no dependencies), see [API for other plugins](#api-for-other-plugins). -- `common` — platform-agnostic core (rules, cache, storage, messages, multi-proxy network) + tests. -- `velocity`, `bungee` — proxy plugins (thin glue). Both support multi-proxy networks through Redis, even mixed. -- `paper` — standalone Paper servers, one jar for 1.8.8 to 26.x. +A Hypixel-style friends system for **Velocity**, **BungeeCord**, **Paper 1.8.8 → 26.3** and **Folia**, built for Java 25. + +Modules (one folder per part; a new platform gets its own top-level folder next to these): +- `api/` — the API for other plugins (Java 8, no dependencies): interfaces, data models, events. Usage guide: + [`api/README.md`](api/README.md). +- `core/` — platform-agnostic core: rules, cache, storage (SQLite/MySQL), messages, commands, multi-proxy network. +- `paper/` — Paper and Folia, one jar for 1.8.8 to 26.x. +- `bungee/`, `velocity/` — proxy plugins (thin glue). Both support multi-proxy networks through Redis, even mixed. +- `builds/` — the jars to ship, written by every build (not in git). - `tools/e2e` — end-to-end tests on real servers with bot players; `tools/e2e/probe` is a test plugin using the API. ## Build ```bash -./gradlew build # tests + velocity/build/libs/friends-velocity-0.5.0.jar, - # bungee/build/libs/friends-bungee-0.5.0.jar, paper/build/libs/friends-paper-0.5.0.jar +./gradlew build # tests, then builds/friends-paper.jar, friends-velocity.jar, friends-bungee.jar, friends-api.jar ``` Put the jar for your platform in `plugins/`. Every server/proxy must run **Java 25** (Velocity 4.2 and Paper 26.x @@ -25,6 +26,7 @@ flags to silence the JDK warning about SQLite's native library. | Platform | Tested | Notes | |---|---|---| | Paper 1.8.8, 1.12.2, 1.16.5, 1.20.6, 1.21.11, 26.3 | full friend flow with bot players | 1.16.5 needs `-DPaper.IgnoreJavaVersion=true` to start on Java 25 | +| Folia 1.21.11 | full friend flow with bot players (same jar as Paper) | regionized Paper: Friends uses no Bukkit scheduler | | BungeeCord 26.1 (build 2100) | players on 1.8.8 and 26.3 backends | | | Velocity 4.2.0 | players on 1.8.8 and 26.3 backends | | | BungeeCord + Velocity sharing MySQL + Redis | cross-proxy friend flow | | @@ -73,61 +75,9 @@ Use the plugin either on the proxies or on standalone Paper servers, not both. ## API for other plugins -`FriendsAPI` (artifact `com.friends:friends-api`) is the same on Paper, Velocity and BungeeCord: friend lists, -status, current server and pending requests, plus actions (request, accept, deny, remove, best friend, nickname, -status). Each platform also has six events (`com.friends.api.bukkit`, `.velocity`, `.bungee`). Every public method is -documented: `./gradlew :api:javadoc`. - -```bash -./gradlew :api:publishToMavenLocal -``` - -```kotlin -repositories { mavenLocal() } -dependencies { compileOnly("com.friends:friends-api:0.5.0") } // Friends provides it at runtime -``` - -Declare the dependency: `depend: [Friends]` in plugin.yml, `depends: [Friends]` in bungee.yml, -`@Dependency(id = "friends")` on Velocity. - -```java -FriendsAPI friends = FriendsAPI.get(); // on Paper also getServer().getServicesManager().load(FriendsAPI.class) - -boolean pals = friends.areFriends(alice, bob); // memory only: fine on the server thread -friends.loadFriends(someone) // offline players come from the database (cached 30 s) - .thenAccept(list -> ...); // completes on Friends' threads, not the server thread -friends.sendRequest(alice, bob).thenAccept(result -> { - if (result == Result.CANCELLED) ... // a plugin cancelled the event -}); - -@EventHandler -public void onRequest(FriendRequestSendEvent e) { // asynchronous: never on the server thread - if (isMuted(e.getPlayerId())) e.setCancelled(true); // Friends says nothing: tell the player yourself -} -``` - -| Event | Cancellable | Fired | -|---|---|---| -| `FriendRequestSendEvent` | yes | before a request is sent | -| `FriendAddEvent` | yes | before two players become friends (accept, or asking back) | -| `FriendAddedEvent` | no | after, exactly once per new friendship | -| `FriendRemoveEvent` | yes | before a friendship ends, once per friend (`/f removeall` too) | -| `FriendRemovedEvent` | no | after, exactly once | -| `FriendStatusChangeEvent` | yes | before `/status` changes | - -- **Threads.** Methods returning a value read memory and never block. Methods returning a `CompletableFuture` - return at once and do their work (database included) on Friends' threads. Events run on Friends' threads too - (Bukkit: asynchronous events): switch to the server thread before touching the world. -- **Commands and API actions fire the same events.** An action doesn't reply to the acting player (the `Result` says - what happened, e.g. `NOT_LOADED`, `ALREADY_FRIENDS`, `CANCELLED`); the other player is still notified. The acting - player must be online on this server/proxy. -- **Exactly once:** cancellable events fire outside Friends' lock and the action is checked again afterwards, so in - a rare race (one request accepted twice at the same moment) an action can still fail after its event. Rewards and - statistics belong in `FriendAddedEvent` / `FriendRemovedEvent`. -- **Where to listen:** on a network Friends runs on the proxy, so listen to its Velocity/BungeeCord events (each - fires once network-wide, on the proxy where the change happened). Bukkit events fire on standalone Paper servers. -- **Nothing throws:** null or invalid arguments give empty results, `false` or `Result.INVALID_ARGUMENT`; database - errors give `Result.ERROR`. Events and snapshots hold UUIDs and names, never `Player` objects. +`FriendsAPI` is the same on Paper, Velocity and BungeeCord: friend lists, status, current server and pending +requests, actions (request, accept, deny, remove, best friend, nickname, status) and six events per platform. See +[`api/README.md`](api/README.md) for setup, examples and the threading rules. ## How it works @@ -149,13 +99,16 @@ public void onRequest(FriendRequestSendEvent e) { // asynchronous: never on the ## Tests -`./gradlew build` runs everything that needs no services: SQLite, in-memory multi-proxy, randomized -cache-vs-database checks with a deliberately slow database, a concurrency stress test, chat conversion on the -newest and on the 1.8 BungeeCord chat API, and the SQLite suites on sqlite-jdbc 3.7.2 / 3.21.0.1 (the drivers -Paper 1.8.8 / 1.12.2 bundle). Add `-Dfriends.mysql=host:port/db:user:password` and/or `-Dfriends.redis=host:port` -to also run the MySQL and real-Redis suites (throwaway tables/key namespaces). The API has its own suite (results, -null/offline/invalid input, the offline cache under a paused database, cancelling, exactly-once events), and the -`paper` module runs the real plugin wiring on MockBukkit 4.116.3 against the shipped implementation jar (service, -asynchronous Bukkit events, cancelling); the proxies' events are tested on their own event APIs. +Test sources are kept locally and are not published in this repository. `./gradlew build` always runs +`verifyBuilds`, which opens the jars in `builds/` and checks their descriptors and versions, that each platform ships +only its own events, that bundled libraries are relocated, that the Paper and API jars are Java 8 bytecode, and that +no file-sync copies (`Foo 2.class`) slipped in. + +With the local suites present, `./gradlew build` also runs SQLite, in-memory multi-proxy, randomized cache-vs-database +checks with a deliberately slow database, a concurrency stress test, chat conversion on the newest and on the 1.8 +BungeeCord chat API, and the SQLite suites on sqlite-jdbc 3.7.2 / 3.21.0.1 (the drivers Paper 1.8.8 / 1.12.2 bundle). +Add `-Dfriends.mysql=host:port/db:user:password` and/or `-Dfriends.redis=host:port` to also run the MySQL and +real-Redis suites (throwaway tables/key namespaces). The `paper` module runs the real plugin wiring on MockBukkit +against the shipped implementation jar, and the proxies' events are tested on their own event APIs. End-to-end on real servers: see [`tools/e2e/README.md`](tools/e2e/README.md). diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..de2e483 --- /dev/null +++ b/api/README.md @@ -0,0 +1,131 @@ +# Friends API + +Lets other plugins read friend lists, presence and requests, act on behalf of players and react to friend events. +The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has six events +(`com.friends.api.bukkit`, `.velocity`, `.bungee`). The API is Java 8 bytecode with no dependencies of its own, so +any plugin can compile against it. Every public type and method has Javadoc (`./gradlew :api:javadoc`). + +## 1. Add the dependency + +Friends provides the API at runtime, so compile against it without shading it. + +Use the `friends-api.jar` from the GitHub release: + +```kotlin +dependencies { compileOnly(files("libs/friends-api.jar")) } +``` + +Or install it in your local Maven repository from this repository: + +```bash +./gradlew :api:publishToMavenLocal +``` + +```kotlin +repositories { mavenLocal() } +dependencies { compileOnly("com.friends:friends-api:0.5.0") } +``` + +Then make your plugin load after Friends: + +| Platform | Declaration | +|---|---| +| Paper | `depend: [Friends]` (or `softdepend`) in plugin.yml | +| BungeeCord | `depends: [Friends]` (or `softDepends`) in bungee.yml | +| Velocity | `@Plugin(..., dependencies = @Dependency(id = "friends"))` (`optional = true` for a soft dependency) | + +## 2. Get the API + +```java +FriendsAPI friends = FriendsAPI.get(); // throws IllegalStateException if Friends isn't enabled +Optional maybe = FriendsAPI.find(); // for soft dependencies +// Paper only, same instance: getServer().getServicesManager().load(FriendsAPI.class) +``` + +Fetch it again after Friends reloads instead of keeping it in a static field. + +## 3. Read (memory only, fine on the server thread) + +```java +boolean loaded = friends.isLoaded(player); // online here and loaded +List list = friends.getFriends(player); // empty unless loaded +boolean pals = friends.areFriends(alice, bob); // at least one of them must be loaded +Optional status = friends.getStatus(player); // online anywhere: ONLINE, AWAY, BUSY, OFFLINE (appears offline) +Optional server = friends.getServer(player); // backend server name, proxies only (empty on Paper) +List in = friends.getIncomingRequests(player); +List out = friends.getOutgoingRequests(player); +``` + +`Friend` gives `getUniqueId()`, `getName()`, `getSince()`, `isBestFriend()`, `getNickname()` and `getLastSeen()`. +`FriendRequest` gives `getSender()`, `getSenderName()`, `getTarget()`, `getTargetName()` and `getExpiresAt()`. + +## 4. Load and act (asynchronous) + +These methods return a `CompletableFuture` at once and do their work, database access included, on Friends' threads. + +```java +friends.loadFriends(anyone) // offline players come from the database (cached 30 s) + .thenAccept(list -> ...); + +friends.sendRequest(alice, bob).thenAccept(result -> { + if (result.isSuccess()) ... // SUCCESS (sent) or BECAME_FRIENDS (bob had asked alice) + else if (result == Result.CANCELLED) ... // a plugin cancelled the event + else ... // NOT_LOADED, ALREADY_FRIENDS, LIMIT_REACHED, ... (see Result) +}); +``` + +The actions are `sendRequest`, `acceptRequest`, `denyRequest`, `removeFriend`, `setBestFriend`, `setNickname` and +`setStatus`. Each one behaves like its command, events and limits included. The acting player must be loaded, and +gets no chat reply: the `Result` tells you what happened. The other player is still notified. + +## 5. Listen to events + +| Event | Cancellable | Fired | +|---|---|---| +| `FriendRequestSendEvent` | yes | before a request is sent | +| `FriendAddEvent` | yes | before two players become friends (accept, or asking back) | +| `FriendAddedEvent` | no | after, exactly once per new friendship | +| `FriendRemoveEvent` | yes | before a friendship ends, once per friend (`/f removeall` too) | +| `FriendRemovedEvent` | no | after, exactly once | +| `FriendStatusChangeEvent` | yes | before `/status` changes | + +Pair events have `getPlayerId()` / `getPlayerName()` (the acting player) and `getTargetId()` / `getTargetName()`. The +status event has `getOldStatus()` / `getNewStatus()`. `FriendAddedEvent` also has `getSince()`. + +Paper (`com.friends.api.bukkit`, asynchronous Bukkit events): + +```java +@EventHandler +public void onRequest(FriendRequestSendEvent e) { + if (isMuted(e.getPlayerId())) e.setCancelled(true); // Friends says nothing: tell the player yourself +} +``` + +BungeeCord (`com.friends.api.bungee`), registered with `getProxy().getPluginManager().registerListener(plugin, listener)`: + +```java +@EventHandler +public void onAdded(FriendAddedEvent e) { rewards.give(e.getPlayerId(), e.getTargetId()); } +``` + +Velocity (`com.friends.api.velocity`, `ResultedEvent`s), registered with `proxy.getEventManager().register(plugin, listener)`: + +```java +@Subscribe +public void onRemove(FriendRemoveEvent e) { + if (locked(e.getPlayerId())) e.setResult(ResultedEvent.GenericResult.denied()); +} +``` + +## Rules + +- **Threads.** Every method is thread-safe. Futures and events run on Friends' threads. Switch to the server thread + (on Bukkit, its scheduler) before touching the world, and never `join()` a Friends future on the server thread. +- **Commands and API actions fire the same events.** +- **Exactly once.** Cancellable events fire outside Friends' lock, and the action is checked again afterwards. In a + rare race (one request accepted twice at the same moment), an action can still fail after its event fired. Put + rewards and statistics in `FriendAddedEvent` / `FriendRemovedEvent`. +- **Where to listen.** On a network, Friends runs on the proxy: listen to its Velocity or BungeeCord events. Each + fires once network-wide, on the proxy where the change happened. The Bukkit events fire on standalone Paper servers. +- **Nothing throws.** Null or invalid arguments give empty results, `false` or `Result.INVALID_ARGUMENT`. Database + errors give `Result.ERROR`. Events and snapshots hold UUIDs and names, never `Player` objects. diff --git a/build.gradle.kts b/build.gradle.kts index 9a91df9..e4fa671 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream plugins { `java` @@ -54,6 +56,7 @@ subprojects { filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE } // both JDBC drivers } tasks.named("assemble") { dependsOn("shadowJar") } + tasks.named("jar") { enabled = false } // the thin jar isn't a plugin: only the shaded one ships val pluginVersion = project.version.toString() // read now: Task.project at execution time is deprecated tasks.named("processResources") { inputs.property("version", pluginVersion) @@ -61,3 +64,69 @@ subprojects { } } } + +// The jars to ship, one per platform plus the API: builds/friends-.jar. Synced, so stale or duplicated files go. +evaluationDependsOnChildren() +val builds = tasks.register("builds") { + description = "Collects the plugin and API jars into builds/" + group = "build" + into(layout.projectDirectory.dir("builds")) + mapOf(":api" to "jar", ":paper" to "pluginJar", ":bungee" to "shadowJar", ":velocity" to "shadowJar").forEach { (module, jar) -> + from(project(module).tasks.named(jar)) { rename { "friends-${module.removePrefix(":")}.jar" } } + } +} +tasks.assemble { dependsOn(builds) } + +// What a build-script slip (or a file-sync tool writing into build/) would silently break in the shipped jars. +val verifyBuilds = tasks.register("verifyBuilds") { + description = "Checks the jars in builds/" + group = "verification" + dependsOn(builds) + val dir = layout.projectDirectory.dir("builds").asFile + val expected = version.toString() + doLast { + fun read(name: String) = ZipFile(dir.resolve(name)).use { z -> + z.entries().asSequence().associate { it.name to z.getInputStream(it).readBytes() } + } + val api = read("friends-api.jar") + val paper = read("friends-paper.jar") + val bungee = read("friends-bungee.jar") + val velocity = read("friends-velocity.jar") + val paperImpl = ZipInputStream(paper.getValue("friends-paper-impl.jar").inputStream()).use { zin -> + generateSequence { zin.nextEntry }.associate { it.name to zin.readBytes() } + } + val jars = mapOf("api" to api, "paper" to paper, "paper-impl" to paperImpl, "bungee" to bungee, "velocity" to velocity) + val problems = mutableListOf() + fun expect(ok: Boolean, problem: String) { if (!ok) problems += problem } + fun Map.text(entry: String) = get(entry)?.decodeToString() ?: "" + + expect(dir.list()!!.sorted() == jars.keys.filter { it != "paper-impl" }.map { "friends-$it.jar" }.sorted(), "builds/ holds other files: ${dir.list()!!.sorted()}") + jars.forEach { (name, e) -> expect(e.keys.none { ' ' in it }, "$name: file-sync copies inside (e.g. \"Foo 2.class\"): ${e.keys.filter { ' ' in it }.take(3)}") } + expect(paper.text("plugin.yml").contains("\nversion: $expected\n"), "paper: plugin.yml is not version $expected") + expect(paper.text("plugin.yml").contains("\nfolia-supported: true\n"), "paper: plugin.yml doesn't declare Folia support") + expect(bungee.text("bungee.yml").contains("\nversion: $expected\n"), "bungee: bungee.yml is not version $expected") + expect(velocity.text("velocity-plugin.json").contains("\"version\":\"$expected\""), "velocity: velocity-plugin.json is not version $expected") + listOf(paper, bungee, velocity).forEach { expect("config.yml" in it, "a plugin jar has no config.yml") } + expect("com/friends/paper/FriendsBootstrap.class" in paper && "com/friends/paper/PaperFriends.class" in paperImpl, "paper: bootstrap or implementation missing") + // Each platform ships only its own events. + mapOf(paper to listOf("velocity", "bungee"), bungee to listOf("bukkit", "velocity"), velocity to listOf("bukkit", "bungee")).forEach { (jar, others) -> + others.forEach { other -> expect(jar.keys.none { it.startsWith("com/friends/api/$other/") }, "a jar contains com.friends.api.$other events") } + } + // Bundled libraries are relocated (unrelocated copies clash with other plugins); Adventure/SLF4J never ship as is. + val unrelocated = listOf("com/zaxxer/", "com/mysql/", "redis/clients/", "org/apache/commons/pool2/", "org/json/", "net/kyori/", "org/slf4j/", "com/google/gson/") + listOf("paper-impl", "bungee", "velocity").forEach { name -> + val leaked = jars.getValue(name).keys.filter { e -> unrelocated.any { e.startsWith(it) } } + expect(leaked.isEmpty(), "$name: unrelocated libraries: ${leaked.take(3)}") + } + expect(paperImpl.keys.none { it.startsWith("org/sqlite/") }, "paper: bundles sqlite-jdbc (every Paper has its own)") + // Java 8 bytecode where old servers' class rewriters (and plugins on any Java) read it. + fun major(b: ByteArray) = ((b[6].toInt() and 0xff) shl 8) or (b[7].toInt() and 0xff) + listOf("api" to api, "paper" to paper).forEach { (name, jar) -> + val newer = jar.filter { (e, b) -> e.endsWith(".class") && major(b) > 52 }.keys + expect(newer.isEmpty(), "$name: classes newer than Java 8: ${newer.take(3)}") + } + if (problems.isNotEmpty()) throw GradleException("builds/ is wrong:\n- " + problems.joinToString("\n- ")) + logger.lifecycle("builds/: ${dir.list()!!.sorted().joinToString()} verified (version $expected)") + } +} +tasks.check { dependsOn(verifyBuilds) } diff --git a/bungee/build.gradle.kts b/bungee/build.gradle.kts index 2f2cf9d..5c94c55 100644 --- a/bungee/build.gradle.kts +++ b/bungee/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } dependencies { - implementation(project(":common")) + implementation(project(":core")) compileOnly("net.md-5:bungeecord-api:1.21-R0.4") compileOnly("net.luckperms:api:5.5") diff --git a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java index 589611c..f8f3fb8 100644 --- a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java +++ b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java @@ -10,9 +10,9 @@ import com.friends.api.bungee.FriendRemovedEvent; import com.friends.api.bungee.FriendRequestSendEvent; import com.friends.api.bungee.FriendStatusChangeEvent; -import com.friends.common.Friend; -import com.friends.common.Hooks; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Friend; +import com.friends.core.Hooks; +import com.friends.core.Storage.PlayerRow; import net.md_5.bungee.api.plugin.Cancellable; import net.md_5.bungee.api.plugin.Event; diff --git a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java index 57889e2..9a8a21b 100644 --- a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java +++ b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java @@ -8,21 +8,20 @@ import java.util.List; import java.util.Objects; import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.friends.common.ChatSessions; -import com.friends.common.FriendCommand; -import com.friends.common.FriendsRuntime; -import com.friends.common.LuckPermsPrefix; -import com.friends.common.Network; -import com.friends.common.Platform.Online; -import com.friends.common.RedisNetwork; -import com.friends.common.Settings; -import com.friends.common.Storage; +import com.friends.core.ChatSessions; +import com.friends.core.FriendCommand; +import com.friends.core.FriendsRuntime; +import com.friends.core.LuckPermsPrefix; +import com.friends.core.Network; +import com.friends.core.Platform.Online; +import com.friends.core.RedisNetwork; +import com.friends.core.Settings; +import com.friends.core.Storage; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.CommandSender; @@ -74,7 +73,6 @@ public void onEnable() { plugins.registerCommand(this, new Cmd("fl", "list")); plugins.registerCommand(this, new Cmd("status", "status")); plugins.registerListener(this, this); - getProxy().getScheduler().schedule(this, runtime.friends::expireRequests, 1, 1, TimeUnit.SECONDS); for (ProxiedPlayer p : getProxy().getPlayers()) connect(p, () -> {}); // enabled while players are online } diff --git a/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java b/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java deleted file mode 100644 index 3c8be2d..0000000 --- a/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.friends.bungee; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Predicate; -import java.util.logging.Handler; -import java.util.logging.LogRecord; -import java.util.logging.Logger; - -import org.junit.jupiter.api.Test; - -import com.friends.api.Status; -import com.friends.api.bungee.FriendAddEvent; -import com.friends.api.bungee.FriendAddedEvent; -import com.friends.api.bungee.FriendEvent; -import com.friends.api.bungee.FriendRemoveEvent; -import com.friends.api.bungee.FriendRemovedEvent; -import com.friends.api.bungee.FriendRequestSendEvent; -import com.friends.api.bungee.FriendStatusChangeEvent; -import com.friends.common.Friend; -import com.friends.common.Storage.PlayerRow; - -import net.md_5.bungee.api.plugin.Cancellable; -import net.md_5.bungee.api.plugin.Event; -import net.md_5.bungee.event.EventBus; -import net.md_5.bungee.event.EventHandler; - -/** Through BungeeCord's real event bus (what PluginManager.callEvent posts to). */ -class BungeeHooksTest { - final PlayerRow alice = new PlayerRow(UUID.randomUUID(), "Alice", null, Instant.EPOCH); - final PlayerRow bob = new PlayerRow(UUID.randomUUID(), "Bob", null, Instant.EPOCH); - final Friend bobAsFriend = new Friend(bob.id(), "Bob", null, Instant.EPOCH, false, null, Instant.EPOCH); - final List logged = new CopyOnWriteArrayList<>(); - final Recorder listener = new Recorder(); - final BungeeHooks hooks; - - BungeeHooksTest() { - Logger log = Logger.getAnonymousLogger(); - log.setUseParentHandlers(false); - log.addHandler(new Handler() { - @Override public void publish(LogRecord r) { logged.add(r); } - @Override public void flush() {} - @Override public void close() {} - }); - EventBus bus = new EventBus(log); - bus.register(listener); - hooks = new BungeeHooks(bus::post); - } - - public static final class Recorder { - final List seen = new CopyOnWriteArrayList<>(); - volatile Predicate cancel = _ -> false; - volatile boolean explode; - - private void record(Event e) { - seen.add(e); - if (explode) throw new IllegalStateException("a broken listener"); - if (e instanceof Cancellable c && cancel.test(e)) c.setCancelled(true); - } - - @EventHandler public void on(FriendRequestSendEvent e) { record(e); } - @EventHandler public void on(FriendAddEvent e) { record(e); } - @EventHandler public void on(FriendAddedEvent e) { record(e); } - @EventHandler public void on(FriendRemoveEvent e) { record(e); } - @EventHandler public void on(FriendRemovedEvent e) { record(e); } - @EventHandler public void on(FriendStatusChangeEvent e) { record(e); } - } - - @Test - void listenersCanCancelAndSeeEveryField() { - assertTrue(hooks.requesting(alice, bob)); - assertTrue(hooks.befriending(bob, alice)); - assertTrue(hooks.unfriending(alice, bobAsFriend)); - assertTrue(hooks.statusChanging(alice, Status.ONLINE, Status.BUSY)); - listener.cancel = _ -> true; - assertFalse(hooks.requesting(alice, bob)); - assertFalse(hooks.befriending(bob, alice)); - assertFalse(hooks.unfriending(alice, bobAsFriend)); - assertFalse(hooks.statusChanging(alice, Status.ONLINE, Status.BUSY)); - - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields((FriendRequestSendEvent) listener.seen.get(0))); - assertEquals(List.of(bob.id(), "Bob", alice.id(), "Alice"), fields((FriendAddEvent) listener.seen.get(1))); - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields((FriendRemoveEvent) listener.seen.get(2))); - var status = (FriendStatusChangeEvent) listener.seen.get(3); - assertEquals(List.of(alice.id(), "Alice", Status.ONLINE, Status.BUSY), - List.of(status.getPlayerId(), status.getPlayerName(), status.getOldStatus(), status.getNewStatus())); - } - - @Test - void doneEventsAreCalled() { - Instant since = Instant.parse("2026-10-01T12:00:00Z"); - hooks.befriended(bob, alice, since); - hooks.unfriended(alice, bobAsFriend); - var added = (FriendAddedEvent) listener.seen.get(0); - assertEquals(List.of(bob.id(), "Bob", alice.id(), "Alice"), fields(added)); - assertEquals(since, added.getSince()); - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields((FriendRemovedEvent) listener.seen.get(1))); - } - - @Test - void aBrokenListenerIsLoggedAndDoesNotCancel() { - listener.explode = true; - assertTrue(hooks.requesting(alice, bob)); // no exception reaches Friends - assertEquals(1, logged.size()); - assertTrue(logged.getFirst().getThrown() instanceof IllegalStateException, logged::toString); - } - - private static List fields(FriendEvent e) { - return List.of(e.getPlayerId(), e.getPlayerName(), e.getTargetId(), e.getTargetName()); - } -} diff --git a/common/src/test/java/com/friends/common/BungeeChatTest.java b/common/src/test/java/com/friends/common/BungeeChatTest.java deleted file mode 100644 index cd835d3..0000000 --- a/common/src/test/java/com/friends/common/BungeeChatTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -import com.friends.api.Status; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextColor; -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.chat.BaseComponent; -import net.md_5.bungee.api.chat.ClickEvent; -import net.md_5.bungee.chat.ComponentSerializer; - -/** Runs on the newest bungeecord-chat ({@code test}) and on Spigot 1.8.8's ({@code testChat1_8}). */ -class BungeeChatTest { - - static List flatten(BaseComponent c) { - List out = new ArrayList<>(); - out.add(c); - if (c.getExtra() != null) c.getExtra().forEach(child -> out.addAll(flatten(child))); - return out; - } - - static List commands(BaseComponent c) { - return flatten(c).stream().filter(x -> x.getClickEvent() != null && x.getClickEvent().getAction() == ClickEvent.Action.RUN_COMMAND) - .map(x -> x.getClickEvent().getValue()).distinct().toList(); - } - - @Test - void textIsIdenticalToWhatAdventureRenders() { - for (Component message : List.of(Messages.help(), Messages.received(Messages.name("&b[MVP&c+&b] ", "Alice"), "Alice"), - Messages.removeAllConfirm(3), Messages.statusMenu(Status.AWAY), Messages.joined(Component.text("Bob")))) { - assertEquals(TestSupport.plain(message), BungeeChat.convert(message)[0].toPlainText()); - } - } - - @Test - void clickAndHoverEventsSurvive() { - BaseComponent request = BungeeChat.convert(Messages.received(Messages.name(null, "Alice"), "Alice"))[0]; - assertEquals(List.of("/f accept Alice", "/f deny Alice"), commands(request)); - assertTrue(flatten(request).stream().anyMatch(x -> x.getHoverEvent() != null), "hover text kept"); - - String json = ComponentSerializer.toString(request); - assertTrue(json.contains("/f accept Alice") && json.contains("run_command"), json); - } - - @Test - void coloursDecorationsAndRankPrefixesMap() { - BaseComponent line = BungeeChat.convert(Messages.LINE)[0]; - assertEquals(ChatColor.BLUE, line.getColor()); - assertTrue(line.isStrikethrough()); - - Friend best = new Friend(UUID.randomUUID(), "Bob", null, Instant.EPOCH, true, null, Instant.EPOCH); - BaseComponent entry = BungeeChat.convert(Messages.list(List.of(new Messages.Entry(best, Status.ONLINE, "lobby")), 1, 1, false, Instant.EPOCH))[0]; - assertTrue(flatten(entry).stream().anyMatch(x -> "Bob".equals(textOf(x)) && x.isBold()), "best friends are bold"); - - String legacy = BungeeChat.convert(Messages.name("&b[MVP&c+&b] ", "Alice"))[0].toLegacyText(); - assertTrue(legacy.contains("§b[MVP") && legacy.contains("§c+") && legacy.contains("Alice"), legacy); - } - - @Test - void hexColoursUseTheRuntimeWhenItCanElseTheNearestNamedColour() { - TextColor hex = TextColor.color(0x3366ff); - ChatColor mapped = BungeeChat.color(hex); - boolean runtimeHasHex; - try { - ChatColor.class.getMethod("of", String.class); - runtimeHasHex = true; - } catch (NoSuchMethodException e) { - runtimeHasHex = false; - } - if (runtimeHasHex) assertEquals("#3366ff", mapped.getName().toLowerCase()); - else assertEquals(BungeeChat.color(NamedTextColor.nearestTo(hex)), mapped); - assertEquals(ChatColor.GOLD, BungeeChat.color(NamedTextColor.GOLD)); - } - - private static String textOf(BaseComponent c) { - return c instanceof net.md_5.bungee.api.chat.TextComponent t ? t.getText() : null; - } -} diff --git a/common/src/test/java/com/friends/common/ChatSessionsTest.java b/common/src/test/java/com/friends/common/ChatSessionsTest.java deleted file mode 100644 index 0c69062..0000000 --- a/common/src/test/java/com/friends/common/ChatSessionsTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -import net.kyori.adventure.text.Component; -import net.md_5.bungee.api.chat.BaseComponent; - -class ChatSessionsTest { - /** Stands in for a platform player object (one per connection). */ - static final class FakePlayer { - final UUID id; - final String name; - String server; - final List received = new ArrayList<>(); - - FakePlayer(String name) { - this.id = TestSupport.uuid(name); - this.name = name; - } - } - - final ChatSessions sessions = new ChatSessions<>(new ChatSessions.Adapter<>() { - @Override public UUID id(FakePlayer p) { return p.id; } - @Override public String name(FakePlayer p) { return p.name; } - @Override public String server(FakePlayer p) { return p.server; } - @Override public String prefix(UUID id) { return "&a"; } - @Override public void send(FakePlayer p, BaseComponent[] message) { p.received.add(message); } - }); - - @Test - void aSessionKeepsOneAudienceAndReadsTheServerFresh() { - FakePlayer alice = new FakePlayer("Alice"); - var joined = sessions.join(alice); - alice.server = "lobby"; - var later = sessions.online(alice); - assertSame(joined.audience(), later.audience(), "the audience is the session identity"); - assertEquals("lobby", later.server()); - assertEquals("&a", later.prefix()); - assertEquals(later.audience(), sessions.player(alice.id).orElseThrow().audience()); - } - - @Test - void messagesArriveAsBungeeComponents() { - FakePlayer alice = new FakePlayer("Alice"); - sessions.join(alice).audience().sendMessage(Component.text("hi")); - assertEquals("hi", alice.received.getFirst()[0].toPlainText()); - } - - @Test - void aStaleSessionCannotRemoveTheNewOne() { - FakePlayer first = new FakePlayer("Alice"); - FakePlayer second = new FakePlayer("Alice"); // same account, new connection - var old = sessions.join(first); - var fresh = sessions.join(second); - assertNotSame(old.audience(), fresh.audience()); - - sessions.quit(first); - assertSame(fresh.audience(), sessions.player(second.id).orElseThrow().audience()); - assertNotSame(old.audience(), sessions.online(first).audience(), "a replaced session no longer owns the audience"); - - sessions.quit(second); - assertTrue(sessions.player(second.id).isEmpty()); - } -} diff --git a/common/src/test/java/com/friends/common/ConsistencyTest.java b/common/src/test/java/com/friends/common/ConsistencyTest.java deleted file mode 100644 index 7f8b3fc..0000000 --- a/common/src/test/java/com/friends/common/ConsistencyTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import com.friends.common.TestSupport.Harness; -import com.friends.common.TestSupport.Hub; -import com.friends.common.TestSupport.LaggingDb; -import com.friends.common.TestSupport.TestPlayer; - -/** - * Throws random command sequences (and concurrent ones) at the core, then checks the invariants the cache - * optimisation relies on: every online player's cached friends equal the database, and friendships are symmetric. - */ -class ConsistencyTest { - @TempDir - Path dir; - - static final List NAMES = List.of("Ann", "Ben", "Cat", "Dan", "Eve", "Fay", "Gus", "Hal", "Ivy", "Jon"); - - /** What must match between cache and database (lastSeen/name are refreshed lazily and may differ). */ - record Link(UUID friend, boolean best, String nickname) {} - - static Set links(List friends) { - return friends.stream().map(f -> new Link(f.id(), f.best(), f.nickname())).collect(Collectors.toSet()); - } - - static void assertConsistent(Storage storage, List proxies, Map online) throws Exception { - Map> db = new HashMap<>(); - for (String name : NAMES) { - UUID id = TestSupport.uuid(name); - db.put(id, storage.load(id).friends().stream().map(Friend::id).collect(Collectors.toSet())); - } - db.forEach((a, friends) -> friends.forEach(b -> - assertEquals(true, db.get(b).contains(a), "friendship " + a + " -> " + b + " is one-sided"))); - for (TestPlayer p : online.values()) { - List cached = proxies.stream().map(h -> h.friends.cachedFriends(p.id())).filter(l -> !l.isEmpty()) - .findFirst().orElse(List.of()); - assertEquals(links(storage.load(p.id()).friends()), links(cached), "cache drifted for " + p.online().name()); - } - } - - @ParameterizedTest(name = "{0} proxies, lagging db: {1}") - @CsvSource({"1, false, 1", "2, false, 1", "1, true, 1", "2, true, 1", "2, true, 2", "2, true, 3", "2, true, 4", "3, true, 5"}) - void randomCommandSequencesKeepCacheAndDatabaseInSync(int proxyCount, boolean lag, int seed) throws Exception { - Storage storage = Storage.sqlite(dir.resolve("friends.db")); - Hub hub = new Hub(); - List proxies = new ArrayList<>(); - List dbs = new ArrayList<>(); - for (int i = 0; i < proxyCount; i++) { - LaggingDb db = new LaggingDb(); - dbs.add(db); - Network network = proxyCount == 1 ? Network.LOCAL : hub.node("p" + i); - proxies.add(new Harness(storage, 5000, network, lag ? db : Runnable::run)); - } - Runnable drainAll = () -> { - while (dbs.stream().anyMatch(d -> !d.queue.isEmpty())) dbs.forEach(d -> { - while (!d.queue.isEmpty()) d.queue.poll().run(); - }); - hub.claims.clear(); // nothing in flight any more: like Redis' short claim TTL running out - }; - Map online = new HashMap<>(); - Map where = new HashMap<>(); - Random random = new Random(seed * 1000L + proxyCount + (lag ? 100 : 0)); - List crowd = NAMES.subList(0, 4); // few players, so the same pairs keep colliding - - for (int step = 0; step < 3000; step++) { - String name = crowd.get(random.nextInt(crowd.size())); - String other = crowd.get(random.nextInt(crowd.size())); - TestPlayer p = online.get(name); - if (p == null) { // offline players can only join - Harness h = proxies.get(random.nextInt(proxies.size())); - var o = new Platform.Online(TestSupport.uuid(name), name, null, "lobby", new TestSupport.Inbox()); - h.platform.online.put(o.id(), o); - var loaded = h.friends.connect(o); - drainAll.run(); - loaded.join(); - h.friends.greet(o); - online.put(name, new TestPlayer(o, (TestSupport.Inbox) o.audience())); - where.put(name, h); - continue; - } - Harness h = where.get(name); - String line = switch (random.nextInt(16)) { - case 0 -> null; - case 1, 2, 3, 4 -> "add " + other; - case 5, 6, 7, 8 -> "accept " + other; - case 9 -> "deny " + other; - case 10, 11 -> "remove " + other; - case 12 -> "best " + other; - case 13 -> random.nextBoolean() ? "nickname " + other + " N" + step % 7 : "nickname " + other; - case 14 -> random.nextInt(8) == 0 ? "removeall confirm" : "status " + List.of("online", "busy", "offline").get(random.nextInt(3)); - default -> "tick"; - }; - if (line == null) { - h.quit(p); - online.remove(name); - } else if (line.equals("tick")) { - Duration d = Duration.ofSeconds(random.nextInt(120)); - proxies.forEach(x -> { - x.clock.advance(d); - x.friends.expireRequests(); - }); - } else { - h.command.execute(p.online(), line.split(" ")); // not joined: with a lagging db it may still be queued - } - if (lag) dbs.forEach(d -> d.runSome(random)); - else hub.claims.clear(); - if (step % 100 == 0) { - drainAll.run(); - assertConsistent(storage, proxies, online); - } - } - drainAll.run(); - assertConsistent(storage, proxies, online); - storage.close(); - } - - @Test - void concurrentCommandsOnTheRealDatabaseThreadStayConsistent() throws Exception { - Storage storage = Storage.sqlite(dir.resolve("friends.db")); - ExecutorService db = Executors.newSingleThreadExecutor(); // like production: one ordered DB thread - Harness h = new Harness(storage, 5000, Network.LOCAL, db); - Map online = new HashMap<>(); - List crowd = NAMES.subList(0, 4); // few players = the same pairs collide constantly - for (String name : crowd) online.put(name, h.join(name)); - - ExecutorService pool = Executors.newFixedThreadPool(8); - List> commands = new ArrayList<>(); - Set errors = new HashSet<>(); - for (int t = 0; t < 8; t++) { - Random random = new Random(t); - pool.execute(() -> { - for (int i = 0; i < 2000; i++) { - TestPlayer p = online.get(crowd.get(random.nextInt(crowd.size()))); - String other = crowd.get(random.nextInt(crowd.size())); - String line = switch (random.nextInt(6)) { - case 0, 1 -> "add " + other; - case 2, 3 -> "accept " + other; - case 4 -> "remove " + other; - default -> "best " + other; - }; - var f = h.command.execute(p.online(), line.split(" ")); - synchronized (commands) { - commands.add(f); - } - } - }); - } - pool.shutdown(); - assertEquals(true, pool.awaitTermination(30, TimeUnit.SECONDS)); - CompletableFuture.allOf(commands.toArray(CompletableFuture[]::new)) - .exceptionally(e -> { - errors.add(e); - return null; - }).get(30, TimeUnit.SECONDS); - db.submit(() -> {}).get(30, TimeUnit.SECONDS); // drain queued writes - assertEquals(Set.of(), errors); - assertEquals(List.of(), online.values().stream().map(TestPlayer::last).filter(m -> m.contains("Something went wrong")).toList()); - - assertConsistent(storage, List.of(h), online); - db.shutdown(); - storage.close(); - } -} diff --git a/common/src/test/java/com/friends/common/FriendsApiTest.java b/common/src/test/java/com/friends/common/FriendsApiTest.java deleted file mode 100644 index e5cfe47..0000000 --- a/common/src/test/java/com/friends/common/FriendsApiTest.java +++ /dev/null @@ -1,524 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayDeque; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.Supplier; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import com.friends.api.FriendRequest; -import com.friends.api.Result; -import com.friends.api.Status; -import com.friends.common.Storage.PlayerRow; -import com.friends.common.TestSupport.Harness; - -class FriendsApiTest { - @TempDir - Path dir; - final GatedDb db = new GatedDb(); - final RecordingHooks hooks = new RecordingHooks(); - Harness h; - FriendsApi api; - - @BeforeEach - void setUp() throws Exception { - h = harness(5000); - } - - @AfterEach - void tearDown() { - if (h != null) h.close(); - } - - private Harness harness(int maxFriends) throws Exception { - if (h != null) h.close(); - h = new Harness(Storage.sqlite(dir.resolve("friends.db")), maxFriends, Network.LOCAL, db, Runnable::run, hooks); - api = h.api; - return h; - } - - private static Result result(CompletableFuture f) { - return f.join(); // direct executors: already complete - } - - private List friendNames(UUID id) { - return api.getFriends(id).stream().map(com.friends.api.Friend::getName).sorted().toList(); - } - - // --- reads --- - - @Test - void readsComeFromLoadedPlayersMemory() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.run(alice, "best Bob"); - h.run(alice, "nickname Bob Bobby"); - - assertTrue(api.isLoaded(alice.id())); - var friends = api.getFriends(alice.id()); - assertEquals(1, friends.size()); - var f = friends.getFirst(); - assertEquals(bob.id(), f.getUniqueId()); - assertEquals("Bob", f.getName()); - assertTrue(f.isBestFriend()); - assertEquals("Bobby", f.getNickname()); - assertEquals(h.clock.instant(), f.getSince()); - assertThrows(UnsupportedOperationException.class, () -> friends.add(f)); - assertTrue(api.areFriends(alice.id(), bob.id())); - assertTrue(api.areFriends(bob.id(), alice.id())); - assertFalse(api.getFriends(bob.id()).getFirst().isBestFriend()); // best and nickname are private to Alice - - UUID carol = TestSupport.uuid("Carol"); // never joined - assertFalse(api.isLoaded(carol)); - assertEquals(List.of(), api.getFriends(carol)); - assertFalse(api.areFriends(alice.id(), carol)); - - h.quit(bob); - assertFalse(api.isLoaded(bob.id())); - assertEquals(List.of(), api.getFriends(bob.id())); // not loaded: use loadFriends - assertTrue(api.areFriends(bob.id(), alice.id())); // Alice is loaded - } - - @Test - void statusAndServer() { - var alice = h.join("Alice"); - assertEquals(Optional.of(Status.ONLINE), api.getStatus(alice.id())); - assertEquals(Optional.of("lobby"), api.getServer(alice.id())); - - h.run(alice, "status busy"); - assertEquals(Optional.of(Status.BUSY), api.getStatus(alice.id())); - h.run(alice, "status offline"); - assertEquals(Optional.of(Status.OFFLINE), api.getStatus(alice.id())); - assertEquals(Optional.empty(), api.getServer(alice.id())); // appearing offline hides the server - - h.quit(alice); - assertEquals(Optional.empty(), api.getStatus(alice.id())); - assertEquals(Optional.empty(), api.getServer(alice.id())); - } - - @Test - void requestsAreListedUntilTheyExpire() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - - var expected = new FriendRequest(alice.id(), "Alice", bob.id(), "Bob", h.clock.instant().plus(Duration.ofMinutes(5))); - assertEquals(List.of(expected), api.getOutgoingRequests(alice.id())); - assertEquals(List.of(expected), api.getIncomingRequests(bob.id())); - assertEquals(List.of(), api.getIncomingRequests(alice.id())); - assertEquals(List.of(), api.getOutgoingRequests(bob.id())); - - h.clock.advance(Duration.ofMinutes(6)); - assertEquals(List.of(), api.getIncomingRequests(bob.id())); - assertEquals(List.of(), api.getOutgoingRequests(alice.id())); - } - - // --- actions --- - - @Test - void actionsWorkLikeCommandsWithoutReplyingToTheActor() throws Exception { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - alice.clear(); - bob.clear(); - - assertEquals(Result.SUCCESS, result(api.sendRequest(alice.id(), bob.id()))); - assertEquals(List.of(), alice.inbox().messages); // the result is the reply - assertTrue(bob.last().contains("Friend request from Alice"), bob.all()); // the target is still told - assertEquals(Result.ALREADY_REQUESTED, result(api.sendRequest(alice.id(), bob.id()))); - - assertEquals(Result.SUCCESS, result(api.acceptRequest(bob.id(), alice.id()))); - assertTrue(alice.last().contains("You are now friends with Bob"), alice.all()); - assertTrue(api.areFriends(alice.id(), bob.id())); - assertEquals(List.of(bob.id()), h.storage.load(alice.id()).friends().stream().map(Friend::id).toList()); - assertEquals(Result.ALREADY_FRIENDS, result(api.sendRequest(alice.id(), bob.id()))); - assertEquals(Result.NO_REQUEST, result(api.acceptRequest(bob.id(), alice.id()))); - - assertEquals(Result.SUCCESS, result(api.setBestFriend(alice.id(), bob.id(), true))); - assertEquals(Result.SUCCESS, result(api.setBestFriend(alice.id(), bob.id(), true))); // set, not toggle - assertTrue(api.getFriends(alice.id()).getFirst().isBestFriend()); - assertEquals(Result.SUCCESS, result(api.setBestFriend(alice.id(), bob.id(), false))); - assertFalse(api.getFriends(alice.id()).getFirst().isBestFriend()); - - assertEquals(Result.SUCCESS, result(api.setNickname(alice.id(), bob.id(), "Bobby"))); - assertEquals("Bobby", api.getFriends(alice.id()).getFirst().getNickname()); - assertEquals(Result.INVALID_ARGUMENT, result(api.setNickname(alice.id(), bob.id(), "no way!!"))); - assertEquals(Result.SUCCESS, result(api.setNickname(alice.id(), bob.id(), null))); - assertEquals(null, api.getFriends(alice.id()).getFirst().getNickname()); - - assertEquals(Result.SUCCESS, result(api.setStatus(alice.id(), Status.AWAY))); - assertEquals(Optional.of(Status.AWAY), api.getStatus(alice.id())); - - assertEquals(Result.SUCCESS, result(api.removeFriend(alice.id(), bob.id()))); - assertFalse(api.areFriends(alice.id(), bob.id())); - assertEquals(List.of(), h.storage.load(bob.id()).friends()); - assertEquals(Result.NOT_FRIENDS, result(api.removeFriend(alice.id(), bob.id()))); - assertEquals(Result.NOT_FRIENDS, result(api.setBestFriend(alice.id(), bob.id(), true))); - assertEquals(Result.NOT_FRIENDS, result(api.setNickname(alice.id(), bob.id(), "x"))); - - assertEquals(Result.SUCCESS, result(api.sendRequest(bob.id(), alice.id()))); - assertEquals(Result.SUCCESS, result(api.denyRequest(alice.id(), bob.id()))); - assertEquals(Result.NO_REQUEST, result(api.denyRequest(alice.id(), bob.id()))); - assertEquals(Result.NO_REQUEST, result(api.acceptRequest(alice.id(), bob.id()))); - assertEquals(List.of(), alice.inbox().messages.stream().map(TestSupport::plain) - .filter(m -> m.contains("declined") || m.contains("sent a friend request")).toList()); - } - - @Test - void askingBackMakesThemFriends() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - assertEquals(Result.SUCCESS, result(api.sendRequest(alice.id(), bob.id()))); - assertEquals(Result.BECAME_FRIENDS, result(api.sendRequest(bob.id(), alice.id()))); - assertTrue(api.areFriends(alice.id(), bob.id())); - } - - @Test - void targetsMayBeOfflineButActorsMustBeLoaded() { - var alice = h.join("Alice"); - var carol = h.join("Carol"); - h.quit(carol); - - assertEquals(Result.SUCCESS, result(api.sendRequest(alice.id(), carol.id()))); // joined before: known - assertEquals(Result.PLAYER_NOT_FOUND, result(api.sendRequest(alice.id(), UUID.randomUUID()))); - assertEquals(Result.SELF, result(api.sendRequest(alice.id(), alice.id()))); - - UUID offline = carol.id(); - UUID other = alice.id(); - List>> actions = List.of( - () -> api.sendRequest(offline, other), () -> api.acceptRequest(offline, other), - () -> api.denyRequest(offline, other), () -> api.removeFriend(offline, other), - () -> api.setBestFriend(offline, other, true), () -> api.setNickname(offline, other, "x"), - () -> api.setStatus(offline, Status.BUSY)); - for (var action : actions) assertEquals(Result.NOT_LOADED, result(action.get())); - } - - @Test - void friendLimitsApply() throws Exception { - harness(1); - var alice = h.join("Alice"); - var bob = h.join("Bob"); - var carol = h.join("Carol"); - h.befriend(alice, bob); - assertEquals(Result.LIMIT_REACHED, result(api.sendRequest(alice.id(), carol.id()))); - assertEquals(Result.TARGET_LIMIT_REACHED, result(api.sendRequest(carol.id(), alice.id()))); - } - - @Test - void nullsAndBadInputNeverThrow() { - UUID id = h.join("Alice").id(); - assertFalse(api.isLoaded(null)); - assertEquals(List.of(), api.getFriends(null)); - assertFalse(api.areFriends(null, id)); - assertFalse(api.areFriends(id, null)); - assertEquals(Optional.empty(), api.getStatus(null)); - assertEquals(Optional.empty(), api.getServer(null)); - assertEquals(List.of(), api.getIncomingRequests(null)); - assertEquals(List.of(), api.getOutgoingRequests(null)); - assertEquals(List.of(), api.loadFriends(null).join()); - - List>> actions = List.of( - () -> api.sendRequest(null, id), () -> api.sendRequest(id, null), - () -> api.acceptRequest(null, id), () -> api.acceptRequest(id, null), - () -> api.denyRequest(null, id), () -> api.denyRequest(id, null), - () -> api.removeFriend(null, id), () -> api.removeFriend(id, null), - () -> api.setBestFriend(null, id, true), () -> api.setBestFriend(id, null, true), - () -> api.setNickname(null, id, "x"), () -> api.setNickname(id, null, "x"), - () -> api.setStatus(null, Status.BUSY), () -> api.setStatus(id, null)); - for (var action : actions) assertEquals(Result.INVALID_ARGUMENT, result(action.get())); - } - - @Test - void databaseErrorsBecomeResultsNotExceptions() { - var alice = h.join("Alice"); - h.storage.close(); - assertEquals(Result.ERROR, result(api.sendRequest(alice.id(), UUID.randomUUID()))); // needs a lookup - var e = assertThrows(CompletionException.class, () -> api.loadFriends(UUID.randomUUID()).join()); - assertTrue(e.getCause() instanceof java.sql.SQLException, e::toString); - } - - @Test - void actionsAfterShutdownFailCleanly() { - ExecutorService async = Executors.newVirtualThreadPerTaskExecutor(); - var stopped = new FriendsApi(h.friends, h.platform, async); - UUID id = h.join("Alice").id(); - async.shutdown(); - assertEquals(Result.ERROR, result(stopped.setStatus(id, Status.BUSY))); - } - - // --- offline cache --- - - @Test - void offlineListsComeFromTheDatabaseThenTheCache() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.quit(bob); - - db.held = true; - var first = api.loadFriends(bob.id()); - var second = api.loadFriends(bob.id()); - assertEquals(1, db.queue.size()); // one read for both - db.release(); - assertEquals(List.of("Alice"), first.join().stream().map(com.friends.api.Friend::getName).toList()); - assertEquals(first.join(), second.join()); - - db.held = true; - var cached = api.loadFriends(bob.id()); - assertTrue(cached.isDone()); - assertEquals(0, db.queue.size()); - - h.clock.advance(Duration.ofSeconds(31)); // expired - api.loadFriends(bob.id()); - assertEquals(1, db.queue.size()); - db.release(); - } - - @Test - void aChangeDropsTheCachedListAndAReadItRacedIsNotKept() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.quit(bob); - api.loadFriends(bob.id()).join(); // cached - - h.clock.advance(Duration.ofSeconds(31)); - db.held = true; - // The removal lands right as the read is queued: the worst moment for the cache. - db.onQueue = () -> h.run(alice, "remove Bob"); - var raced = api.loadFriends(bob.id()); - assertEquals(2, db.queue.size()); // the read, then the removal's write - db.release(); - - assertEquals(1, raced.join().size()); // asked before the removal: read before its write - assertEquals(List.of(), api.loadFriends(bob.id()).join()); // the raced read was not cached - } - - @Test - void befriendingAnOfflinePlayerDropsTheirCachedList() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - h.quit(alice); - assertEquals(List.of(), api.loadFriends(alice.id()).join()); // cached: no friends - h.run(bob, "accept Alice"); - assertEquals(List.of("Bob"), api.loadFriends(alice.id()).join().stream().map(com.friends.api.Friend::getName).toList()); - } - - @Test - void loadedPlayersAreServedFromMemory() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - db.held = true; - var friends = api.loadFriends(alice.id()); - assertTrue(friends.isDone()); - assertEquals(0, db.queue.size()); - assertEquals(List.of("Bob"), friends.join().stream().map(com.friends.api.Friend::getName).toList()); - db.release(); - } - - // --- hooks (plugins' events) --- - - @Test - void pluginsCanCancelEveryAction() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - - hooks.veto = call -> call.startsWith("requesting"); - assertEquals(Result.CANCELLED, result(api.sendRequest(alice.id(), bob.id()))); - h.run(alice, "add Bob"); // commands too - assertEquals(List.of(), api.getIncomingRequests(bob.id())); - assertFalse(bob.all().contains("Friend request")); - - hooks.veto = call -> call.startsWith("befriending"); - assertEquals(Result.SUCCESS, result(api.sendRequest(alice.id(), bob.id()))); - assertEquals(Result.CANCELLED, result(api.acceptRequest(bob.id(), alice.id()))); - assertEquals(1, api.getIncomingRequests(bob.id()).size()); // still pending - assertFalse(api.areFriends(alice.id(), bob.id())); - hooks.veto = _ -> false; - assertEquals(Result.SUCCESS, result(api.acceptRequest(bob.id(), alice.id()))); - - hooks.veto = call -> call.startsWith("unfriending"); - assertEquals(Result.CANCELLED, result(api.removeFriend(alice.id(), bob.id()))); - assertTrue(api.areFriends(alice.id(), bob.id())); - - hooks.veto = call -> call.startsWith("status"); - assertEquals(Result.CANCELLED, result(api.setStatus(alice.id(), Status.BUSY))); - assertEquals(Optional.of(Status.ONLINE), api.getStatus(alice.id())); - hooks.calls.clear(); - assertEquals(Result.SUCCESS, result(api.setStatus(alice.id(), Status.ONLINE))); // no change: nobody is asked - assertEquals(List.of(), hooks.calls); - } - - @Test - void removeAllAsksAboutEachFriend() throws Exception { - var alice = h.join("Alice"); - for (String name : List.of("Bob", "Carol", "Dave")) h.befriend(alice, h.join(name)); - hooks.calls.clear(); - hooks.veto = call -> call.equals("unfriending Alice Carol"); - - h.run(alice, "removeall confirm"); - assertEquals(List.of("Carol"), friendNames(alice.id())); - assertEquals(List.of("Carol"), h.storage.load(alice.id()).friends().stream().map(Friend::name).toList()); - assertEquals(List.of("unfriended Alice Bob", "unfriended Alice Dave"), - hooks.calls.stream().filter(c -> c.startsWith("unfriended")).sorted().toList()); - assertTrue(alice.last().contains("Removed 2 friends"), alice.all()); - } - - @Test - void doneHooksFireOnceWhenTheSameRequestIsAcceptedTwiceAtOnce() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - AtomicBoolean started = new AtomicBoolean(); - AtomicReference> inner = new AtomicReference<>(); - // While plugins look at the first accept, a second one of the same request goes through. - hooks.during = call -> { - if (call.startsWith("befriending") && started.compareAndSet(false, true)) inner.set(api.acceptRequest(bob.id(), alice.id())); - }; - - assertEquals(Result.NO_REQUEST, result(api.acceptRequest(bob.id(), alice.id()))); // re-checked: already used - assertEquals(Result.SUCCESS, result(inner.get())); - assertEquals(1, hooks.calls.stream().filter(c -> c.equals("befriended Bob Alice")).count()); - assertEquals(1, api.getFriends(alice.id()).size()); - } - - @Test - void aRequestCrossingAnotherWhilePluginsDecideBecomesAFriendship() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - boolean[] crossed = {false}; - hooks.during = call -> { - if (call.equals("requesting Alice Bob") && !crossed[0]) { - crossed[0] = true; - assertEquals(Result.SUCCESS, result(api.sendRequest(bob.id(), alice.id()))); - } - }; - - assertEquals(Result.BECAME_FRIENDS, result(api.sendRequest(alice.id(), bob.id()))); - assertTrue(api.areFriends(alice.id(), bob.id())); - assertEquals(List.of("requesting Alice Bob", "requesting Bob Alice", "befriending Alice Bob", "befriended Alice Bob"), - hooks.calls); - } - - @Test - void listenersRunOffTheDatabaseThreadAndMayUseTheApi() throws Exception { - h.close(); - ExecutorService realDb = Executors.newSingleThreadExecutor(r -> new Thread(r, "test-db")); - ExecutorService async = Executors.newVirtualThreadPerTaskExecutor(); - h = new Harness(Storage.sqlite(dir.resolve("real.db")), 5000, Network.LOCAL, realDb, async, hooks); - api = h.api; - try { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - h.quit(alice); // offline requester: accepting reads her friend count on the database thread - List threads = new CopyOnWriteArrayList<>(); - hooks.during = call -> { - threads.add(Thread.currentThread().getName()); - api.loadFriends(alice.id()).join(); // would deadlock on the database thread - }; - - assertTimeoutPreemptively(Duration.ofSeconds(10), () -> assertEquals(Result.SUCCESS, api.acceptRequest(bob.id(), alice.id()).join())); - assertEquals(1, threads.size()); - assertNotEquals("test-db", threads.getFirst()); - } finally { - async.shutdown(); - realDb.shutdown(); - } - } - - /** Runs SQL inline, or queues it while {@code held} so a test can interleave reads and writes. */ - static final class GatedDb implements Executor { - final ArrayDeque queue = new ArrayDeque<>(); - boolean held; - Runnable onQueue; // runs once, right after the next task is queued - - @Override - public void execute(Runnable task) { - if (!held) { - task.run(); - return; - } - queue.add(task); - Runnable once = onQueue; - onQueue = null; - if (once != null) once.run(); - } - - void release() { - held = false; - while (!queue.isEmpty()) queue.poll().run(); - } - } - - /** Records every hook call ("requesting Alice Bob", "befriended Bob Alice", ...); vetoes what {@code veto} matches. */ - static final class RecordingHooks implements Hooks { - final List calls = new CopyOnWriteArrayList<>(); - volatile Predicate veto = _ -> false; - volatile Consumer during = _ -> {}; - - private boolean ask(String call) { - calls.add(call); - during.accept(call); - return !veto.test(call); - } - - @Override - public boolean requesting(PlayerRow from, PlayerRow to) { - return ask("requesting " + from.name() + " " + to.name()); - } - - @Override - public boolean befriending(PlayerRow player, PlayerRow sender) { - return ask("befriending " + player.name() + " " + sender.name()); - } - - @Override - public void befriended(PlayerRow player, PlayerRow sender, Instant since) { - calls.add("befriended " + player.name() + " " + sender.name()); - } - - @Override - public boolean unfriending(PlayerRow player, Friend friend) { - return ask("unfriending " + player.name() + " " + friend.name()); - } - - @Override - public void unfriended(PlayerRow player, Friend friend) { - calls.add("unfriended " + player.name() + " " + friend.name()); - } - - @Override - public boolean statusChanging(PlayerRow player, Status from, Status to) { - return ask("status " + player.name() + " " + from + " " + to); - } - } -} diff --git a/common/src/test/java/com/friends/common/FriendsTest.java b/common/src/test/java/com/friends/common/FriendsTest.java deleted file mode 100644 index 1e5608e..0000000 --- a/common/src/test/java/com/friends/common/FriendsTest.java +++ /dev/null @@ -1,448 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import com.friends.common.TestSupport.Harness; -import com.friends.common.TestSupport.Inbox; -import com.friends.common.TestSupport.TestPlayer; - -import net.kyori.adventure.text.format.TextDecoration; - -class FriendsTest { - @TempDir - Path dir; - Harness h; - - /** MysqlFriendsTest flips this to run the same scenarios against MySQL. */ - boolean mysql() { - return false; - } - - Harness harness(int maxFriends) throws Exception { - return new Harness(TestSupport.open(dir, mysql()), maxFriends); - } - - @BeforeEach - void setUp() throws Exception { - h = harness(5000); - } - - @AfterEach - void tearDown() { - if (h != null) h.close(); - } - - private static void assertContains(String haystack, String needle) { - assertTrue(haystack.contains(needle), () -> "expected <" + needle + "> in:\n" + haystack); - } - - @Test - void requestAndAcceptMakesBothFriends() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - - h.run(alice, "add Bob"); - assertContains(alice.last(), "You sent a friend request to Bob! They have 5 minutes to accept it!"); - assertContains(bob.last(), "Friend request from Alice"); - assertEquals(List.of("/f accept Alice", "/f deny Alice"), TestSupport.clicks(bob.lastComponent())); - - h.run(bob, "accept alice"); // names are case-insensitive - assertContains(bob.last(), "You are now friends with Alice"); - assertContains(alice.last(), "You are now friends with Bob"); - - h.run(alice, "list"); - assertContains(alice.last(), "Friends (Page 1 of 1)"); - assertContains(alice.last(), "Bob is in lobby"); - } - - @Test - void shorthandAndMutualRequestsAutoAccept() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "Bob"); // "/f Bob" == "/f add Bob" - h.run(bob, "add Alice"); - assertContains(bob.last(), "You are now friends with Alice"); - assertContains(alice.last(), "You are now friends with Bob"); - } - - @Test - void denyDropsTheRequest() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - h.run(bob, "deny Alice"); - assertContains(bob.last(), "Declined Alice's friend request!"); - h.run(bob, "accept Alice"); - assertContains(bob.last(), "You don't have a friend request from 'Alice'!"); - } - - @Test - void rejectsSelfUnknownDuplicateAndExistingFriends() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - - h.run(alice, "add alice"); - assertContains(alice.last(), "You can't add yourself as a friend!"); - h.run(alice, "add Nobody"); - assertContains(alice.last(), "Can't find a player by the name of 'Nobody'"); - h.run(alice, "add x"); // MiniMessage in player input is shown literally - assertContains(alice.last(), "Can't find a player by the name of 'x'"); - - h.run(alice, "add Bob"); - h.run(alice, "add Bob"); - assertContains(alice.last(), "You've already sent a friend request to Bob!"); - - h.run(bob, "accept Alice"); - h.run(alice, "add Bob"); - assertContains(alice.last(), "You're already friends with Bob!"); - } - - @Test - void requestsExpireAfterFiveMinutes() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - - h.clock.advance(Duration.ofMinutes(4)); - h.friends.expireRequests(); - h.run(bob, "requests"); - assertContains(bob.last(), "Friend Requests:"); - - h.clock.advance(Duration.ofMinutes(1)); - h.friends.expireRequests(); - assertContains(alice.last(), "Your friend request to Bob has expired."); - assertContains(bob.all(), "The friend request from Alice has expired."); - h.run(bob, "accept Alice"); - assertContains(bob.last(), "You don't have a friend request from 'Alice'!"); - } - - @Test - void expiryIsCheckedEvenBeforeTheSweepRuns() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - h.clock.advance(Duration.ofMinutes(6)); - h.run(bob, "accept Alice"); - assertContains(bob.last(), "You don't have a friend request from 'Alice'!"); - } - - @Test - void offlinePlayersCanBeAddedAndSeeTheRequestOnJoin() { - h.quit(h.join("Bob")); // Bob has played before - var alice = h.join("Alice"); - - h.run(alice, "add bob"); - assertContains(alice.last(), "You sent a friend request to Bob!"); - - var bob = h.join("Bob"); - assertContains(bob.all(), "You have 1 pending friend request."); - h.run(bob, "accept Alice"); - h.run(alice, "list"); - assertContains(alice.last(), "Bob is in lobby"); - } - - @Test - void acceptingWhileTheSenderIsOfflinePersists() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - h.quit(alice); - - h.run(bob, "accept Alice"); - assertContains(bob.last(), "You are now friends with Alice"); - h.run(bob, "list"); - assertContains(bob.last(), "Alice is currently offline"); - - alice = h.join("Alice"); - h.run(alice, "list"); - assertContains(alice.last(), "Bob is in lobby"); - } - - @Test - void friendshipsSurviveAFreshCoreOnTheSameDatabase() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.run(alice, "best Bob"); - - var restarted = new Harness(h.storage, 5000); - alice = restarted.join("Alice"); - restarted.run(alice, "list"); - assertContains(alice.last(), "Bob is currently offline"); - assertEquals(List.of("Bob"), restarted.friends.friendNames(alice.id())); - } - - @Test - void removeIsMutualAndPersisted() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - - h.run(alice, "remove bob"); - assertContains(alice.last(), "You removed Bob from your friends list!"); - assertEquals(List.of(), h.friends.friendNames(bob.id())); - - h.quit(bob); - bob = h.join("Bob"); - assertEquals(List.of(), h.friends.friendNames(bob.id())); - h.run(alice, "remove Bob"); - assertContains(alice.last(), "'Bob' isn't on your friends list!"); - } - - @Test - void joinAndLeaveNotificationsRespectToggleAndAppearOffline() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - - h.quit(bob); - assertEquals("Friend > Bob left.", alice.last()); - bob = h.join("Bob"); - assertEquals("Friend > Bob joined.", alice.last()); - - h.run(alice, "notifications"); - assertContains(alice.last(), "Disabled friend join/leave notifications."); - alice.clear(); - h.quit(bob); - bob = h.join("Bob"); - assertTrue(alice.inbox().messages.isEmpty()); - h.run(alice, "notifications"); - - h.run(bob, "status offline"); - assertContains(bob.last(), "Your status is now Appear Offline."); - h.run(alice, "list"); - assertContains(alice.last(), "Bob is currently offline"); - alice.clear(); - h.quit(bob); - assertTrue(alice.inbox().messages.isEmpty()); - } - - @Test - void statusIsShownToFriendsAndPersisted() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - - h.run(alice, "status away"); - h.run(bob, "list"); - assertContains(bob.last(), "Alice is away"); - - h.quit(alice); - alice = h.join("Alice"); - h.run(alice, "status"); - assertContains(alice.last(), "Your status is Away."); - h.run(alice, "status sleeping"); - assertContains(alice.last(), "Usage: /status [online|away|busy|offline]"); - } - - @Test - void listSortsBestThenOnlineThenOfflineAndPaginates() { - var alice = h.join("Alice"); - for (int i = 0; i < 25; i++) { - var f = h.join("F" + (i < 10 ? "0" : "") + i); - h.befriend(alice, f); - if (i % 2 == 1) { - h.clock.advance(Duration.ofMinutes(1)); - h.quit(f); // odd friends go offline; F23 is the most recently seen - } - } - h.run(alice, "best F24"); - h.run(alice, "best F01"); - - h.run(alice, "list"); - String page1 = alice.last(); - assertContains(page1, "Friends (Page 1 of 3)"); - String[] lines = page1.split("\n"); - assertEquals("F24 is in lobby", lines[2]); // best friends first (online ones first)... - assertEquals("F01 is currently offline", lines[3]); // ...even offline ones - assertEquals("F00 is in lobby", lines[4]); // then online by name - - h.run(alice, "list 99"); // clamps to the last page - String page3 = alice.last(); - assertContains(page3, "Friends (Page 3 of 3)"); - assertEquals(5 + 3, page3.split("\n").length); // 5 entries + header + 2 lines - assertContains(page3, "F03 is currently offline"); // least recently seen last - assertEquals(List.of("/f list 2"), TestSupport.clicks(alice.lastComponent())); - - h.run(alice, "list best"); - assertContains(alice.last(), "Best Friends (Page 1 of 1)"); - assertFalse(alice.last().contains("F00")); - } - - @Test - void bestFriendsAreBold() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.run(alice, "best Bob"); - assertContains(alice.last(), "Bob is now one of your best friends!"); - h.run(alice, "list"); - var entry = alice.lastComponent().children().get(4); // LINE, \n, header, \n, entry - assertTrue(entry.children().getFirst().hasDecoration(TextDecoration.BOLD)); - h.run(alice, "best Bob"); - assertContains(alice.last(), "Bob is no longer one of your best friends."); - } - - @Test - void nicknamesArePrivateValidatedAndPersisted() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - - h.run(alice, "nickname Bob Bobby B"); - assertContains(alice.last(), "You'll now see Bob as Bobby B."); - h.run(alice, "list"); - assertContains(alice.last(), "Bobby B is in lobby"); - h.run(bob, "list"); - assertContains(bob.last(), "Alice is in lobby"); - h.quit(bob); - assertEquals("Friend > Bobby B left.", alice.last()); - - h.run(alice, "nickname Bob x"); - assertContains(alice.last(), "Nicknames must be 1-16 letters"); - h.run(alice, "nickname Bob &cRed"); - assertContains(alice.last(), "Nicknames must be 1-16 letters"); - - h.quit(alice); - alice = h.join("Alice"); - h.run(alice, "list"); - assertContains(alice.last(), "Bobby B is currently offline"); - h.run(alice, "nick Bob"); - assertContains(alice.last(), "Cleared your nickname for Bob."); - } - - @Test - void removeAllKeepsBestFriendsAndNeedsConfirmation() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - var carol = h.join("Carol"); - var dave = h.join("Dave"); - h.befriend(alice, bob); - h.befriend(alice, carol); - h.befriend(alice, dave); - h.run(alice, "best Dave"); - - h.run(alice, "removeall"); - assertContains(alice.last(), "This will remove 2 friends (best friends are kept)."); - assertEquals(List.of("/f removeall confirm"), TestSupport.clicks(alice.lastComponent())); - assertEquals(3, h.friends.friendNames(alice.id()).size()); - - h.run(alice, "removeall confirm"); - assertContains(alice.last(), "Removed 2 friends"); - assertEquals(List.of("Dave"), h.friends.friendNames(alice.id())); - assertEquals(List.of(), h.friends.friendNames(bob.id())); - - h.quit(carol); - carol = h.join("Carol"); - assertEquals(List.of(), h.friends.friendNames(carol.id())); - } - - @Test - void friendLimitIsEnforcedOnBothSides() throws Exception { - if (h != null) h.close(); - h = harness(1); - var alice = h.join("Alice"); - var bob = h.join("Bob"); - var carol = h.join("Carol"); - h.befriend(alice, bob); - - h.run(alice, "add Carol"); - assertContains(alice.last(), "You can't have more than 1 friends!"); - h.run(carol, "add Alice"); - assertContains(carol.last(), "Alice has reached the maximum number of friends!"); - } - - @Test - void rankPrefixesAreRenderedFromLegacyCodes() { - var alice = h.join("Alice", "&b[MVP&c+&b] "); - var bob = h.join("Bob"); - h.run(alice, "add Bob"); - assertContains(bob.last(), "Friend request from [MVP+] Alice"); - } - - @Test - void anOldSessionDisconnectingDoesNotDropTheNewOne() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - - // kick-existing-players: the new session loads before the old one's disconnect fires - var inbox = new Inbox(); - var newSession = new TestPlayer(new Platform.Online(alice.id(), "Alice", null, "lobby", inbox), inbox); - h.platform.online.put(alice.id(), newSession.online()); // the proxy now maps Alice to the new session - h.friends.connect(newSession.online()).join(); - h.friends.disconnect(alice.online()); - - h.run(newSession, "list"); - assertContains(newSession.last(), "Bob is in lobby"); - } - - @Test - void aPlayerWhoLeavesWhileLoadingIsNotLeftOnline() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.befriend(alice, bob); - h.quit(bob); - - // Bob's connect is still loading when he disconnects: the proxy no longer knows him when it finishes. - var inbox = new Inbox(); - var ghost = new Platform.Online(bob.id(), "Bob", null, null, inbox); - h.friends.disconnect(ghost); - h.friends.connect(ghost).join(); - - h.run(alice, "list"); - assertContains(alice.last(), "Bob is currently offline"); - } - - @Test - void helpAndUsage() { - var alice = h.join("Alice"); - h.run(alice, ""); - assertContains(alice.last(), "Friend Commands:"); - h.run(alice, "add"); - assertContains(alice.last(), "Usage: /f add "); - h.run(alice, "list two"); - assertContains(alice.last(), "Usage: /f list [best] [page]"); - h.run(alice, "list"); - assertContains(alice.last(), "You don't have any friends yet!"); - h.run(alice, "requests"); - assertContains(alice.last(), "You don't have any pending friend requests."); - } - - @Test - void tabCompletion() { - var alice = h.join("Alice"); - var bob = h.join("Bob"); - h.join("Carol"); - - assertTrue(h.suggest(alice, "ad").contains("add")); - assertEquals(List.of("best", "Bob"), h.suggest(alice, "b")); - assertEquals(List.of("Bob", "Carol"), h.suggest(alice, "add", "")); - - h.run(bob, "add Alice"); - assertEquals(List.of("Bob"), h.suggest(alice, "accept", "")); - h.run(alice, "accept Bob"); - assertEquals(List.of("Carol"), h.suggest(alice, "add", "")); - assertEquals(List.of("Bob"), h.suggest(alice, "remove", "b")); - assertEquals(List.of("best"), h.suggest(alice, "list", "")); - } - - @Test - void databaseFailuresReplyWithAnErrorInsteadOfSilence() { - var alice = h.join("Alice"); - h.storage.close(); - h.run(alice, "add Ghost"); // offline lookup needs the database - assertContains(alice.last(), "Something went wrong, please try again later."); - } -} diff --git a/common/src/test/java/com/friends/common/MessagesTest.java b/common/src/test/java/com/friends/common/MessagesTest.java deleted file mode 100644 index d3e6dbd..0000000 --- a/common/src/test/java/com/friends/common/MessagesTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; - -import java.util.List; - -import org.junit.jupiter.api.Test; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; - -class MessagesTest { - static String legacy(Component c) { - return LegacyComponentSerializer.legacySection().serialize(c); - } - - @Test - void fastJoinAndLeaveMessagesLookExactlyLikeTheirTemplates() { - for (Component name : List.of(Messages.name(null, "Bob"), Messages.name("&b[VIP] ", "Bob"), Messages.name("[NoColour] ", "Bob"), - Component.text("Bobby"))) { - for (String verb : List.of("joined", "left")) { - Component template = MiniMessage.miniMessage().deserialize("Friend > " + verb + ".", - Placeholder.component("player", name)); - Component fast = verb.equals("joined") ? Messages.joined(name) : Messages.left(name); - assertEquals(legacy(template), legacy(fast), verb + " with " + legacy(name)); - } - } - } - - @Test - void fixedMessagesAreBuiltOnce() { - assertSame(Messages.help(), Messages.help()); - assertSame(Messages.noFriends(true), Messages.noFriends(true)); - } -} diff --git a/common/src/test/java/com/friends/common/MultiProxyTest.java b/common/src/test/java/com/friends/common/MultiProxyTest.java deleted file mode 100644 index 4b24b77..0000000 --- a/common/src/test/java/com/friends/common/MultiProxyTest.java +++ /dev/null @@ -1,274 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; - - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import com.friends.common.TestSupport.Harness; -import com.friends.common.TestSupport.Hub; -import com.friends.common.TestSupport.TestPlayer; - -/** Two (or three) proxies sharing one database and an in-memory stand-in for Redis. */ -class MultiProxyTest { - @TempDir - Path dir; - Storage storage; - Hub hub; - Harness p1; - Harness p2; - - @BeforeEach - void setUp() throws Exception { - storage = Storage.sqlite(dir.resolve("friends.db")); - hub = new Hub(); - p1 = proxy("p1"); - p2 = proxy("p2"); - } - - Harness proxy(String id) { - return new Harness(storage, 5000, hub.node(id), Runnable::run); - } - - @AfterEach - void tearDown() { - storage.close(); - } - - private static void assertContains(String haystack, String needle) { - assertTrue(haystack.contains(needle), () -> "expected <" + needle + "> in:\n" + haystack); - } - - @Test - void requestAcceptAndListAcrossProxies() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - - p1.run(alice, "add Bob"); - assertContains(alice.last(), "You sent a friend request to Bob!"); - assertContains(bob.last(), "Friend request from Alice"); - - p2.run(bob, "accept Alice"); - assertContains(bob.last(), "You are now friends with Alice"); - assertContains(alice.last(), "You are now friends with Bob"); - - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is in lobby"); - p2.run(bob, "list"); - assertContains(bob.last(), "Alice is in lobby"); - } - - @Test - void joinLeaveAndStatusReachFriendsOnOtherProxies() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - - p2.quit(bob); - assertEquals("Friend > Bob left.", alice.last()); - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is currently offline"); - - bob = p2.join("Bob"); - assertEquals("Friend > Bob joined.", alice.last()); - - p2.run(bob, "status busy"); - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is busy"); - } - - @Test - void serverSwitchesAreVisibleEverywhere() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - - var moved = new Platform.Online(bob.id(), "Bob", null, "bedwars-3", bob.inbox()); - p2.platform.online.put(bob.id(), moved); - p2.friends.moved(moved); - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is in bedwars-3"); - } - - @Test - void removingOnOneProxyUpdatesTheOther() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - - p1.run(alice, "remove Bob"); - assertEquals(List.of(), p2.friends.friendNames(bob.id())); - p2.run(bob, "list"); - assertContains(bob.last(), "You don't have any friends yet!"); - } - - @Test - void deniedRequestsDisappearEverywhere() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "deny Alice"); - p1.run(alice, "requests"); - assertContains(alice.last(), "You don't have any pending friend requests."); - } - - @Test - void mutualRequestsFromDifferentProxiesBecomeFriends() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "add Alice"); - assertContains(bob.last(), "You are now friends with Alice"); - assertEquals(List.of("Bob"), p1.friends.friendNames(alice.id())); - } - - @Test - void expiryIsAnnouncedOnceByEachPlayersOwnProxy() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - - p1.clock.advance(Duration.ofMinutes(6)); - p2.clock.advance(Duration.ofMinutes(6)); - p1.friends.expireRequests(); - p2.friends.expireRequests(); - assertEquals(1, alice.all().split("Your friend request to Bob has expired.", -1).length - 1); - assertEquals(1, bob.all().split("The friend request from Alice has expired.", -1).length - 1); - } - - @Test - void tabCompletionAndAddSeeTheWholeNetworkButNotInvisiblePlayers() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p2.join("Carol"); - assertEquals(List.of("Bob", "Carol"), p1.suggest(alice, "add", "")); - - p2.run(bob, "status offline"); - assertEquals(List.of("Carol"), p1.suggest(alice, "add", "")); - p1.run(alice, "add bob"); // still addable by name, like an offline player - assertContains(alice.last(), "You sent a friend request to Bob!"); - } - - @Test - void aProxyStartedLaterGetsTheSharedStateFromTheSnapshot() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - var carol = p1.join("Carol"); - p1.run(carol, "add Dave"); // Dave is unknown: nothing happens - p1.quit(p1.join("Dave")); // Dave has played before - p1.run(carol, "add Dave"); // request pending for an offline player - - var p3 = proxy("p3"); - var dave = p3.join("Dave"); - assertContains(dave.all(), "You have 1 pending friend request."); - p3.run(dave, "accept Carol"); - assertContains(carol.last(), "You are now friends with Dave"); - - p3.run(dave, "add Alice"); - p1.run(alice, "accept Dave"); - p3.run(dave, "list"); - assertContains(dave.last(), "Alice is in lobby"); - } - - @Test - void aLateLeaveFromTheOldProxyDoesNotHideAPlayerWhoMovedProxies() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - - hub.hold = true; // Bob moves p2 -> p1, but p2's "left" is delivered after p1's "joined" - p2.quit(bob); - hub.hold = false; - bob = p1.join("Bob"); - hub.release(); - - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is in lobby"); - var p3 = proxy("p3"); // and the shared state agrees - var carol = p3.join("Carol"); - assertTrue(p3.suggest(carol, "add", "").contains("Bob")); - } - - @Test - void aProxyGoingDownMarksItsPlayersOffline() { - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - p2.run(bob, "accept Alice"); - - p1.friends.event(new Event.ProxyDown("p2")); // what RedisNetwork delivers once p2's heartbeat expires - assertEquals("Friend > Bob left.", alice.last()); - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is currently offline"); - } - - @Test - void crossingAcceptsOnTwoProxiesCannotResurrectARemovedFriendship() throws Exception { - // Slow databases, and hold deliveries so each step is explicit. - var db1 = new TestSupport.LaggingDb(); - var db2 = new TestSupport.LaggingDb(); - var q1 = new Harness(storage, 5000, hub.node("q1"), db1); - var q2 = new Harness(storage, 5000, hub.node("q2"), db2); - var alice = connect(q1, db1, "Alice"); - var bob = connect(q2, db2, "Bob"); - - hub.hold = true; // both ask at once: neither proxy has seen the other's request yet - q1.command.execute(alice.online(), "add Bob".split(" ")); - q2.command.execute(bob.online(), "add Alice".split(" ")); - db1.drain(); - db2.drain(); - hub.release(); // now both requests are known everywhere - - q1.command.execute(alice.online(), "accept Bob".split(" ")); - q2.command.execute(bob.online(), "accept Alice".split(" ")); - db2.queue.poll().run(); // q2 decides first, but its insert stays stuck behind a slow disk - db1.drain(); // q1 decides too (unless q2's claim stops it) and its insert lands - hub.release(); - q1.command.execute(alice.online(), "remove Bob".split(" ")); // no-op unless q1 befriended - db1.drain(); - hub.release(); - db2.drain(); // q2's late insert lands last - hub.release(); - hub.hold = false; - - // Whoever won, both caches must agree with the database. - List stored = storage.load(alice.id()).friends().stream().map(Friend::name).toList(); - assertEquals(stored, q1.friends.friendNames(alice.id()), "Alice's cache vs database"); - assertEquals(storage.load(bob.id()).friends().stream().map(Friend::name).toList(), - q2.friends.friendNames(bob.id()), "Bob's cache vs database"); - } - - private static TestPlayer connect(Harness h, TestSupport.LaggingDb db, String name) { - var o = new Platform.Online(TestSupport.uuid(name), name, null, "lobby", new TestSupport.Inbox()); - h.platform.online.put(o.id(), o); - var loaded = h.friends.connect(o); - db.drain(); - loaded.join(); - return new TestPlayer(o, (TestSupport.Inbox) o.audience()); - } - - @Test - void resyncKeepsAndReannouncesThisProxysOwnPlayers() { - var alice = p1.join("Alice"); - p2.join("Bob"); - - hub.online.clear(); // e.g. Redis restarted and lost its data - p1.friends.resync(hub.snapshot()); - assertTrue(hub.online.containsKey(alice.id()), "p1 re-published Alice"); - assertTrue(p2.suggest(p2.join("Carol"), "add", "").contains("Alice")); - } -} diff --git a/common/src/test/java/com/friends/common/MysqlTest.java b/common/src/test/java/com/friends/common/MysqlTest.java deleted file mode 100644 index fd59a39..0000000 --- a/common/src/test/java/com/friends/common/MysqlTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.friends.common; - -import org.junit.jupiter.api.Nested; - -/** Re-runs the storage and core suites on MySQL/MariaDB; skipped unless -Dfriends.mysql is set. */ -class MysqlTest { - @Nested - class StorageOnMysql extends StorageTest { - @Override - boolean mysql() { - return true; - } - } - - @Nested - class CoreOnMysql extends FriendsTest { - @Override - boolean mysql() { - return true; - } - } -} diff --git a/common/src/test/java/com/friends/common/RedisNetworkTest.java b/common/src/test/java/com/friends/common/RedisNetworkTest.java deleted file mode 100644 index 9467e86..0000000 --- a/common/src/test/java/com/friends/common/RedisNetworkTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -import com.friends.api.Status; -import com.friends.common.Storage.PlayerRow; -import com.friends.common.TestSupport.Harness; - -import redis.clients.jedis.Jedis; -import redis.clients.jedis.RedisClient; -import redis.clients.jedis.args.ClientType; -import redis.clients.jedis.params.ClientKillParams; - -/** Real-Redis checks; the Redis-backed ones need -Dfriends.redis=host:port (they use a throwaway namespace). */ -class RedisNetworkTest { - @TempDir - Path dir; - final String namespace = "friends-test-" + UUID.randomUUID().toString().substring(0, 8); - final List cleanup = new ArrayList<>(); - Storage storage; - - @AfterEach - void tearDown() throws Exception { - for (AutoCloseable c : cleanup.reversed()) c.close(); - if (storage != null) storage.close(); - } - - @Test - void everyEventSurvivesTheWireFormat() { - Instant t = Instant.ofEpochMilli(1_790_000_000_123L); - PlayerRow a = new PlayerRow(TestSupport.uuid("A"), "A", "&b[VIP] ", t); - PlayerRow b = new PlayerRow(TestSupport.uuid("B"), "B", null, t); - Presence p = new Presence("A", "&b[VIP] ", "p1", null, Status.AWAY); - List events = List.of( - new Event.Joined(a.id(), p), - new Event.Updated(a.id(), p.withServer("lobby")), - new Event.Left(a.id(), "p1", t), - new Event.RequestSent(new Request(a, b, t)), - new Event.RequestDenied(a.id(), b.id()), - new Event.Befriended(a, b, t), - new Event.Unfriended(a.id(), List.of(b.id())), - new Event.ProxyDown("p1")); - assertEquals(Event.class.getPermittedSubclasses().length, events.size(), "cover every event type"); - for (Event e : events) { - assertEquals(e, RedisNetwork.GSON.fromJson(RedisNetwork.GSON.toJson(e), e.getClass())); - } - } - - // --- against a real Redis --- - - String[] redisAddress() { - String spec = System.getProperty("friends.redis"); - assumeTrue(spec != null && !spec.isBlank(), "set -Dfriends.redis=host:port to run Redis tests"); - return spec.split(":", 2); - } - - RedisNetwork network(String proxy) throws Exception { - String[] hp = redisAddress(); - var n = new RedisNetwork(hp[0], Integer.parseInt(hp[1]), null, 0, false, namespace, proxy, - Duration.ofMillis(100), Duration.ofMillis(500), LoggerFactory.getLogger("redis-" + proxy)); - cleanup.add(n); - return n; - } - - Harness proxy(RedisNetwork network) throws Exception { - if (storage == null) storage = Storage.sqlite(dir.resolve("friends.db")); - var h = new Harness(storage, 5000, network, Runnable::run); - h.clock.now = Instant.now(); // Redis TTLs are computed from wall-clock time, like in production - return h; - } - - RedisClient client() { - String[] hp = redisAddress(); - RedisClient c = RedisClient.create(hp[0], Integer.parseInt(hp[1])); - cleanup.add(c); - return c; - } - - /** Events cross proxies asynchronously: retry the assertion for a few seconds. */ - static void eventually(Runnable assertion) throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (true) { - try { - assertion.run(); - return; - } catch (AssertionError e) { - if (System.nanoTime() > deadline) throw e; - Thread.sleep(25); - } - } - } - - private static void assertContains(String haystack, String needle) { - assertTrue(haystack.contains(needle), () -> "expected <" + needle + "> in:\n" + haystack); - } - - @Test - void friendsAcrossTwoProxies() throws Exception { - var p1 = proxy(network("p1")); - var p2 = proxy(network("p2")); - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - eventually(() -> assertTrue(p1.suggest(alice, "add", "").contains("Bob"))); - - p1.run(alice, "add Bob"); - eventually(() -> assertContains(bob.all(), "Friend request from Alice")); - p2.run(bob, "accept Alice"); - eventually(() -> assertContains(alice.all(), "You are now friends with Bob")); - p1.run(alice, "list"); - assertContains(alice.last(), "Bob is in lobby"); - - p2.quit(bob); - eventually(() -> assertEquals("Friend > Bob left.", alice.last())); - } - - @Test - void onlyOneProxyCanClaimAPairAtATime() throws Exception { - var n1 = network("p1"); - var n2 = network("p2"); - UUID a = TestSupport.uuid("A"); - UUID b = TestSupport.uuid("B"); - assertTrue(n1.claim(a, b)); - assertTrue(!n2.claim(b, a), "same pair, either order"); - assertTrue(n2.claim(a, TestSupport.uuid("C"))); - eventually(() -> assertTrue(n2.claim(b, a), "claims expire after 2s")); // eventually() waits up to 5s - } - - @Test - void aLateProxyStartsFromTheSnapshot() throws Exception { - var p1 = proxy(network("p1")); - var alice = p1.join("Alice"); - p1.quit(p1.join("Dave")); - p1.run(alice, "add Dave"); - eventually(() -> assertEquals(1, client().keys(namespace + ":request:*").size())); - - var p3 = proxy(network("p3")); // start() waits for the first snapshot - var dave = p3.join("Dave"); - assertContains(dave.all(), "You have 1 pending friend request."); - assertTrue(p3.friends.onlineNames().contains("Alice")); - } - - @Test - void aProxyThatStopsHeartbeatingIsReaped() throws Exception { - var p1 = proxy(network("p1")); - var net2 = network("p2"); - var p2 = proxy(net2); - var alice = p1.join("Alice"); - var bob = p2.join("Bob"); - p1.run(alice, "add Bob"); - eventually(() -> assertContains(bob.all(), "Friend request from Alice")); - p2.run(bob, "accept Alice"); - eventually(() -> assertContains(alice.all(), "You are now friends with Bob")); - - net2.heartbeat.shutdownNow(); // p2 "freezes": no Left events, its heartbeat key expires - eventually(() -> assertEquals("Friend > Bob left.", alice.last())); - assertTrue(client().hget(namespace + ":online", bob.id().toString()) == null); - } - - @Test - void afterLosingTheSubscriptionAndTheDataAProxyReannouncesItsPlayers() throws Exception { - var p1 = proxy(network("p1")); - var alice = p1.join("Alice"); - var redis = client(); - eventually(() -> assertTrue(redis.hexists(namespace + ":online", alice.id().toString()))); - - redis.del(namespace + ":online"); // e.g. Redis restarted without persistence - String[] hp = redisAddress(); - try (var admin = new Jedis(hp[0], Integer.parseInt(hp[1]))) { // drop every subscription - admin.clientKill(ClientKillParams.clientKillParams() - .type(ClientType.PUBSUB)); - } - eventually(() -> assertTrue(redis.hexists(namespace + ":online", alice.id().toString()))); - } -} diff --git a/common/src/test/java/com/friends/common/SettingsTest.java b/common/src/test/java/com/friends/common/SettingsTest.java deleted file mode 100644 index 1580c62..0000000 --- a/common/src/test/java/com/friends/common/SettingsTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Duration; -import java.util.Map; - -import org.junit.jupiter.api.Test; - -class SettingsTest { - static Settings.Source source(Map values) { - return new Settings.Source() { - @Override public String string(String path, String def) { return (String) values.getOrDefault(path, def); } - @Override public int number(String path, int def) { return (int) values.getOrDefault(path, def); } - @Override public boolean flag(String path, boolean def) { return (boolean) values.getOrDefault(path, def); } - }; - } - - @Test - void defaultsMatchTheShippedConfig() { - Settings s = Settings.read(source(Map.of())); - assertEquals("sqlite", s.storage()); - assertEquals("friends.db", s.sqliteFile()); - assertFalse(s.usesMysql()); - assertFalse(s.redis().enabled()); - assertEquals("friends", s.redis().namespace()); - assertEquals(Duration.ofMinutes(5), s.requestExpiry()); - assertEquals(5000, s.maxFriends()); - } - - @Test - void readsEverySection() { - Settings s = Settings.read(source(Map.of("storage.type", "MySQL", "mysql.host", "db", "mysql.port", 3307, - "redis.enabled", true, "redis.proxy-id", "p1", "request-expiry-minutes", 2, "max-friends", 100))); - assertTrue(s.usesMysql()); - assertEquals("db", s.mysql().host()); - assertEquals(3307, s.mysql().port()); - assertTrue(s.redis().enabled()); - assertEquals("p1", s.redis().proxyId()); - assertEquals(Duration.ofMinutes(2), s.requestExpiry()); - assertEquals(100, s.maxFriends()); - } - - @Test - void rejectsUnknownStorageAndClampsNonsense() { - assertThrows(IllegalArgumentException.class, () -> Settings.read(source(Map.of("storage.type", "postgres")))); - Settings s = Settings.read(source(Map.of("request-expiry-minutes", 0, "max-friends", -5))); - assertEquals(Duration.ofMinutes(1), s.requestExpiry()); - assertEquals(1, s.maxFriends()); - } - - @Test - void redisNeedsMysql() { - Settings s = Settings.read(source(Map.of("redis.enabled", true))); - assertThrows(java.io.IOException.class, () -> RedisNetwork.open(s, org.slf4j.LoggerFactory.getLogger("t"))); - assertEquals(Network.LOCAL, uncheckedOpen(Settings.read(source(Map.of())))); - } - - private static Network uncheckedOpen(Settings s) { - try { - return RedisNetwork.open(s, org.slf4j.LoggerFactory.getLogger("t")); - } catch (java.io.IOException e) { - throw new AssertionError(e); - } - } -} diff --git a/common/src/test/java/com/friends/common/StorageTest.java b/common/src/test/java/com/friends/common/StorageTest.java deleted file mode 100644 index 75d97ac..0000000 --- a/common/src/test/java/com/friends/common/StorageTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Path; -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import com.friends.api.Status; - -class StorageTest { - @TempDir - Path dir; - Storage storage; - - final UUID a = TestSupport.uuid("Alice"); - final UUID b = TestSupport.uuid("Bob"); - final Instant t0 = Instant.parse("2026-01-01T00:00:00Z"); - - boolean mysql() { - return false; - } - - @BeforeEach - void setUp() throws Exception { - storage = TestSupport.open(dir, mysql()); - } - - @AfterEach - void tearDown() { - if (storage != null) storage.close(); - } - - @Test - void findsPlayersCaseInsensitivelyPreferringTheNewestHolderOfAName() throws Exception { - storage.savePlayer(a, "Steve", "&a", t0); - storage.savePlayer(b, "steve", null, t0.plusSeconds(60)); // name reused after a rename - var found = storage.player("STEVE").orElseThrow(); - assertEquals(b, found.id()); - assertEquals("steve", found.name()); - assertNull(found.prefix()); - assertTrue(storage.player("nobody").isEmpty()); - } - - @Test - void savingAPlayerKeepsTheirSettings() throws Exception { - storage.savePlayer(a, "Alice", null, t0); - storage.setNotifications(a, false); - storage.setStatus(a, Status.BUSY); - storage.savePlayer(a, "Alice2", "&b", t0.plusSeconds(5)); // the old INSERT OR REPLACE wiped these - - var loaded = storage.load(a); - assertFalse(loaded.notifications()); - assertEquals(Status.BUSY, loaded.status()); - assertEquals("Alice2", storage.player("alice2").orElseThrow().name()); - } - - @Test - void unknownPlayersLoadWithDefaults() throws Exception { - var loaded = storage.load(a); - assertTrue(loaded.notifications()); - assertEquals(Status.ONLINE, loaded.status()); - assertEquals(List.of(), loaded.friends()); - } - - @Test - void friendshipsAreStoredPerDirectionWithPrivateFlags() throws Exception { - storage.savePlayer(a, "Alice", "&c", t0); - storage.savePlayer(b, "Bob", null, t0.plusSeconds(30)); - storage.addFriendship(a, b, t0); - storage.addFriendship(a, b, t0); // idempotent - storage.setBest(a, b, true); - storage.setNickname(a, b, "Bobby"); - - Friend bobForAlice = storage.load(a).friends().getFirst(); - assertEquals(b, bobForAlice.id()); - assertEquals("Bob", bobForAlice.name()); - assertEquals(t0, bobForAlice.since()); - assertEquals(t0.plusSeconds(30), bobForAlice.lastSeen()); - assertTrue(bobForAlice.best()); - assertEquals("Bobby", bobForAlice.nickname()); - - Friend aliceForBob = storage.load(b).friends().getFirst(); - assertEquals("&c", aliceForBob.prefix()); - assertFalse(aliceForBob.best()); - assertNull(aliceForBob.nickname()); - assertEquals(1, storage.countFriends(a)); - - storage.setNickname(a, b, null); - assertNull(storage.load(a).friends().getFirst().nickname()); - } - - @Test - void removingDeletesBothDirections() throws Exception { - UUID c = TestSupport.uuid("Carol"); - storage.addFriendship(a, b, t0); - storage.addFriendship(a, c, t0); - storage.removeFriendships(a, List.of(b, c)); - assertEquals(0, storage.countFriends(a)); - assertEquals(0, storage.countFriends(b)); - assertEquals(0, storage.countFriends(c)); - } - - @Test - void sqliteRunsInWalMode() throws Exception { - org.junit.jupiter.api.Assumptions.assumeFalse(mysql()); - boolean nativeDriver = (boolean) Class.forName("org.sqlite.SQLiteJDBCLoader").getMethod("isNativeMode").invoke(null); - org.junit.jupiter.api.Assumptions.assumeTrue(nativeDriver, "sqlite-jdbc's pure-Java fallback (old 3.7.2) has no WAL"); - try (var c = java.sql.DriverManager.getConnection("jdbc:sqlite:" + dir.resolve("friends.db").toAbsolutePath()); - var rs = c.createStatement().executeQuery("PRAGMA journal_mode")) { - rs.next(); - assertEquals("wal", rs.getString(1).toLowerCase()); - } - } - - @Test - void touchOnlyBumpsLastSeen() throws Exception { - storage.savePlayer(a, "Alice", "&c", t0); - storage.touch(a, t0.plusSeconds(99)); - var row = storage.player("alice").orElseThrow(); - assertEquals(t0.plusSeconds(99), row.lastSeen()); - assertEquals("&c", row.prefix()); - } -} diff --git a/common/src/test/java/com/friends/common/TestSupport.java b/common/src/test/java/com/friends/common/TestSupport.java deleted file mode 100644 index ff7e727..0000000 --- a/common/src/test/java/com/friends/common/TestSupport.java +++ /dev/null @@ -1,285 +0,0 @@ -package com.friends.common; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executor; - -import org.slf4j.LoggerFactory; - -import com.friends.common.Platform.Online; - -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; - -final class TestSupport { - private TestSupport() {} - - /** SQLite in {@code dir}, or MySQL when run with -Dfriends.mysql=host:port/db:user:password. */ - static Storage open(Path dir, boolean mysql) throws SQLException { - if (!mysql) return Storage.sqlite(dir.resolve("friends.db")); - String spec = System.getProperty("friends.mysql"); - assumeTrue(spec != null && !spec.isBlank(), "set -Dfriends.mysql=host:port/db:user:password to run MySQL tests"); - String[] hostDb = spec.split(":", 3); // host, port/db, user:password - String[] portDb = hostDb[1].split("/", 2); - String[] userPass = hostDb[2].split(":", 2); - String url = "jdbc:mysql://%s:%s/%s?sslMode=DISABLED".formatted(hostDb[0], portDb[0], portDb[1]); - try (var c = DriverManager.getConnection(url, userPass[0], userPass.length > 1 ? userPass[1] : ""); - var st = c.createStatement()) { - st.execute("DROP TABLE IF EXISTS friends_friendships, friends_players"); - } - return Storage.mysql(hostDb[0], Integer.parseInt(portDb[0]), portDb[1], userPass[0], - userPass.length > 1 ? userPass[1] : "", false); - } - - static UUID uuid(String name) { - return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)); - } - - static String plain(Component c) { - return PlainTextComponentSerializer.plainText().serialize(c); - } - - /** Every click command in a component tree. */ - static List clicks(Component c) { - List out = new ArrayList<>(); - if (c.clickEvent() != null && c.clickEvent().action() == ClickEvent.Action.RUN_COMMAND) { - out.add(((ClickEvent.Payload.Text) c.clickEvent().payload()).value()); - } - c.children().forEach(child -> out.addAll(clicks(child))); - return out; - } - - static final class MutableClock extends Clock { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - - void advance(Duration d) { - now = now.plus(d); - } - - @Override public Instant instant() { return now; } - @Override public ZoneId getZone() { return ZoneOffset.UTC; } - @Override public Clock withZone(ZoneId zone) { return this; } - } - - static final class Inbox implements Audience { - final List messages = new CopyOnWriteArrayList<>(); - - @Override - public void sendMessage(Component message) { - messages.add(message); - } - } - - /** - * A single-threaded "slow database": SQL tasks queue up and run in random FIFO batches between commands, so any - * logic that depends on when a write actually lands (rather than on queue order) shows up deterministically. - */ - static final class LaggingDb implements Executor { - final ArrayDeque queue = new ArrayDeque<>(); - - @Override - public void execute(Runnable task) { - queue.add(task); - } - - void drain() { - while (!queue.isEmpty()) queue.poll().run(); - } - - void runSome(java.util.Random random) { - for (int n = random.nextInt(3); n > 0 && !queue.isEmpty(); n--) queue.poll().run(); // 0-2 tasks: writes pile up - } - } - - static final class FakePlatform implements Platform { - final Map online = new ConcurrentHashMap<>(); - - @Override - public Optional player(UUID id) { - return Optional.ofNullable(online.get(id)); - } - } - - /** - * In-memory stand-in for Redis: shared state with the same semantics as {@link RedisNetwork#publish}, and - * synchronous fan-out to the other proxies. {@code hold} queues deliveries so tests can reorder them. - */ - static final class Hub { - final Map online = new ConcurrentHashMap<>(); - final Map requests = new ConcurrentHashMap<>(); - final Set> claims = ConcurrentHashMap.newKeySet(); // like Redis' 2s claim keys; tests clear it - final List nodes = new CopyOnWriteArrayList<>(); - final List held = new ArrayList<>(); - boolean hold; - - Node node(String proxy) { - return new Node(proxy); - } - - void release() { - List batch = List.copyOf(held); - held.clear(); - batch.forEach(Runnable::run); - } - - Network.Snapshot snapshot() { - return new Network.Snapshot(Map.copyOf(online), List.copyOf(requests.values())); - } - - final class Node implements Network { - final String proxy; - Listener listener; - - Node(String proxy) { - this.proxy = proxy; - } - - @Override public String proxyId() { return proxy; } - - @Override - public boolean claim(UUID a, UUID b) { - return claims.add(Set.of(a, b)); - } - - @Override - public void start(Listener listener) { - this.listener = listener; - nodes.add(this); - listener.resync(snapshot()); - } - - @Override - public void publish(Event event) { - switch (event) { - case Event.Joined(UUID id, Presence p) -> online.put(id, p); - case Event.Updated(UUID id, Presence p) -> online.put(id, p); - case Event.Left(UUID id, String from, Instant _) -> online.computeIfPresent(id, (_, p) -> p.proxy().equals(from) ? null : p); - case Event.RequestSent(Request r) -> requests.put(r.key(), r); - case Event.RequestDenied(UUID from, UUID to) -> requests.remove(new Request.Key(from, to)); - case Event.Befriended(var a, var b, Instant _) -> { - requests.remove(new Request.Key(a.id(), b.id())); - requests.remove(new Request.Key(b.id(), a.id())); - } - case Event.Unfriended _, Event.ProxyDown _ -> {} - } - for (Node n : nodes) { - if (n == this) continue; - Runnable delivery = () -> n.listener.event(event); - if (hold) held.add(delivery); else delivery.run(); - } - } - - @Override - public void close() { - nodes.remove(this); - } - } - } - - record TestPlayer(Online online, Inbox inbox) { - UUID id() { - return online.id(); - } - - String last() { - return inbox.messages.isEmpty() ? "" : plain(inbox.messages.getLast()); - } - - Component lastComponent() { - return inbox.messages.getLast(); - } - - String all() { - return String.join("\n", inbox.messages.stream().map(TestSupport::plain).toList()); - } - - void clear() { - inbox.messages.clear(); - } - } - - /** Wires the core like the Velocity plugin does, but with a direct DB executor and a controllable clock. */ - static final class Harness implements AutoCloseable { - final MutableClock clock = new MutableClock(); - final FakePlatform platform = new FakePlatform(); - final Storage storage; - final Friends friends; - final FriendCommand command; - final FriendsApi api; - - Harness(Storage storage, int maxFriends) { - this(storage, maxFriends, Network.LOCAL, Runnable::run); - } - - /** One proxy: its own players, clock and caches, sharing {@code storage} (and {@code network}) with others. */ - Harness(Storage storage, int maxFriends, Network network, Executor db) { - this(storage, maxFriends, network, db, Runnable::run, Hooks.NONE); - } - - /** With {@code async} = Runnable::run, hooks (plugins) are asked on the calling thread: fully deterministic. */ - Harness(Storage storage, int maxFriends, Network network, Executor db, Executor async, Hooks hooks) { - this.storage = storage; - this.friends = new Friends(storage, platform, network, db, async, clock, Duration.ofMinutes(5), maxFriends, - hooks, LoggerFactory.getLogger(Harness.class)); - this.api = new FriendsApi(friends, platform, async); - this.command = new FriendCommand(friends); - friends.start(); - } - - TestPlayer join(String name) { - return join(name, null); - } - - TestPlayer join(String name, String prefix) { - Online online = new Online(uuid(name), name, prefix, "lobby", new Inbox()); - platform.online.put(online.id(), online); - friends.connect(online).join(); - friends.greet(online); - return new TestPlayer(online, (Inbox) online.audience()); - } - - void quit(TestPlayer p) { - friends.disconnect(p.online()); - platform.online.remove(p.id()); - } - - void run(TestPlayer p, String line) { - command.execute(p.online(), line.isEmpty() ? new String[0] : line.split(" ")).join(); - } - - List suggest(TestPlayer p, String... args) { - return command.suggest(p.online(), args); - } - - void befriend(TestPlayer a, TestPlayer b) { - run(a, "add " + b.online().name()); - run(b, "accept " + a.online().name()); - } - - @Override - public void close() { - storage.close(); - } - } -} diff --git a/common/build.gradle.kts b/core/build.gradle.kts similarity index 95% rename from common/build.gradle.kts rename to core/build.gradle.kts index e97fdb6..d361ef5 100644 --- a/common/build.gradle.kts +++ b/core/build.gradle.kts @@ -50,8 +50,8 @@ for (version in listOf("3.7.2", "3.21.0.1")) { testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath.filter { !it.name.startsWith("sqlite-jdbc-") } + driver filter { - includeTestsMatching("com.friends.common.StorageTest") - includeTestsMatching("com.friends.common.FriendsTest") + includeTestsMatching("com.friends.core.StorageTest") + includeTestsMatching("com.friends.core.FriendsTest") } jvmArgs("--enable-native-access=ALL-UNNAMED") // 3.21.0.1 has no Apple Silicon native (Linux x86_64/aarch64 are fine); 3.7.2 falls back to pure Java. @@ -69,7 +69,7 @@ val testChat18 = tasks.register("testChat1_8") { group = "verification" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath.filter { !it.name.startsWith("bungeecord-") } + chat18 - filter { includeTestsMatching("com.friends.common.BungeeChatTest") } + filter { includeTestsMatching("com.friends.core.BungeeChatTest") } } tasks.check { dependsOn(testChat18) } diff --git a/common/src/main/java/com/friends/common/BungeeChat.java b/core/src/main/java/com/friends/core/BungeeChat.java similarity index 99% rename from common/src/main/java/com/friends/common/BungeeChat.java rename to core/src/main/java/com/friends/core/BungeeChat.java index 648d336..1c0a2e1 100644 --- a/common/src/main/java/com/friends/common/BungeeChat.java +++ b/core/src/main/java/com/friends/core/BungeeChat.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; diff --git a/common/src/main/java/com/friends/common/ChatSessions.java b/core/src/main/java/com/friends/core/ChatSessions.java similarity index 98% rename from common/src/main/java/com/friends/common/ChatSessions.java rename to core/src/main/java/com/friends/core/ChatSessions.java index 6c1d32a..1819771 100644 --- a/common/src/main/java/com/friends/common/ChatSessions.java +++ b/core/src/main/java/com/friends/core/ChatSessions.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.util.Map; import java.util.Optional; diff --git a/common/src/main/java/com/friends/common/Event.java b/core/src/main/java/com/friends/core/Event.java similarity index 94% rename from common/src/main/java/com/friends/common/Event.java rename to core/src/main/java/com/friends/core/Event.java index ce27cb9..3ced783 100644 --- a/common/src/main/java/com/friends/common/Event.java +++ b/core/src/main/java/com/friends/core/Event.java @@ -1,10 +1,10 @@ -package com.friends.common; +package com.friends.core; import java.time.Instant; import java.util.List; import java.util.UUID; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Storage.PlayerRow; /** * A change other proxies must know about. The proxy where it happens applies it, then publishes it; every other diff --git a/common/src/main/java/com/friends/common/Friend.java b/core/src/main/java/com/friends/core/Friend.java similarity index 96% rename from common/src/main/java/com/friends/common/Friend.java rename to core/src/main/java/com/friends/core/Friend.java index 9a26b18..9905c83 100644 --- a/common/src/main/java/com/friends/common/Friend.java +++ b/core/src/main/java/com/friends/core/Friend.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.time.Instant; import java.util.UUID; diff --git a/common/src/main/java/com/friends/common/FriendCommand.java b/core/src/main/java/com/friends/core/FriendCommand.java similarity index 98% rename from common/src/main/java/com/friends/common/FriendCommand.java rename to core/src/main/java/com/friends/core/FriendCommand.java index d526d84..16dfe79 100644 --- a/common/src/main/java/com/friends/common/FriendCommand.java +++ b/core/src/main/java/com/friends/core/FriendCommand.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -10,8 +10,8 @@ import com.friends.api.Result; import com.friends.api.Status; -import com.friends.common.Friends.Target; -import com.friends.common.Platform.Online; +import com.friends.core.Friends.Target; +import com.friends.core.Platform.Online; /** Parses {@code /friend ...} arguments. {@code /fl} and {@code /status} arrive as "list"/"status". */ public final class FriendCommand { diff --git a/common/src/main/java/com/friends/common/Friends.java b/core/src/main/java/com/friends/core/Friends.java similarity index 99% rename from common/src/main/java/com/friends/common/Friends.java rename to core/src/main/java/com/friends/core/Friends.java index acac560..b80dbf1 100644 --- a/common/src/main/java/com/friends/common/Friends.java +++ b/core/src/main/java/com/friends/core/Friends.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -26,8 +26,8 @@ import com.friends.api.Result; import com.friends.api.Status; -import com.friends.common.Platform.Online; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Platform.Online; +import com.friends.core.Storage.PlayerRow; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; diff --git a/common/src/main/java/com/friends/common/FriendsApi.java b/core/src/main/java/com/friends/core/FriendsApi.java similarity index 96% rename from common/src/main/java/com/friends/common/FriendsApi.java rename to core/src/main/java/com/friends/core/FriendsApi.java index 0884fd3..47ca593 100644 --- a/common/src/main/java/com/friends/common/FriendsApi.java +++ b/core/src/main/java/com/friends/core/FriendsApi.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -15,8 +15,8 @@ import com.friends.api.FriendsAPI; import com.friends.api.Result; import com.friends.api.Status; -import com.friends.common.Friends.Target; -import com.friends.common.Platform.Online; +import com.friends.core.Friends.Target; +import com.friends.core.Platform.Online; import net.kyori.adventure.audience.Audience; @@ -128,7 +128,7 @@ private Optional presence(UUID player) { return player == null ? Optional.empty() : Optional.ofNullable(friends.presenceOf(player)); } - private static List views(List list) { + private static List views(List list) { return list.stream() .map(f -> new Friend(f.id(), f.name(), f.since(), f.best(), f.nickname(), f.lastSeen())) .toList(); diff --git a/common/src/main/java/com/friends/common/FriendsRuntime.java b/core/src/main/java/com/friends/core/FriendsRuntime.java similarity index 76% rename from common/src/main/java/com/friends/common/FriendsRuntime.java rename to core/src/main/java/com/friends/core/FriendsRuntime.java index 575e075..2be0d86 100644 --- a/common/src/main/java/com/friends/common/FriendsRuntime.java +++ b/core/src/main/java/com/friends/core/FriendsRuntime.java @@ -1,18 +1,22 @@ -package com.friends.common; +package com.friends.core; import java.time.Clock; import java.time.Instant; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import com.friends.api.FriendsProvider; import com.friends.api.Status; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Storage.PlayerRow; -/** The core wired up (one ordered DB thread, network, commands, hooks); every platform plugin is glue around this. */ +/** + * The core wired up (one ordered DB thread, network, commands, hooks, request expiry); every platform plugin is glue + * around this. + */ public final class FriendsRuntime implements AutoCloseable { public final Friends friends; public final FriendCommand command; @@ -20,20 +24,33 @@ public final class FriendsRuntime implements AutoCloseable { private final ExecutorService db = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("friends-db").daemon().factory()); private final ExecutorService async = Executors.newVirtualThreadPerTaskExecutor(); // asks plugins, never on db private final ExecutorService done = Executors.newSingleThreadExecutor(Thread.ofVirtual().name("friends-events").factory()); + // Our own timer, not the platform's: Folia has no Bukkit scheduler, and every platform then behaves the same. + private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("friends-timer").daemon().factory()); private final Storage storage; private final Network network; private final Logger log; /** Takes ownership of {@code storage} and {@code network} (closed by {@link #close}); serves {@code FriendsAPI.get()}. */ public FriendsRuntime(Settings settings, Storage storage, Network network, Platform platform, Hooks hooks, Logger log) { + this(settings, storage, network, platform, hooks, log, Clock.systemUTC()); + } + + FriendsRuntime(Settings settings, Storage storage, Network network, Platform platform, Hooks hooks, Logger log, Clock clock) { this.storage = storage; this.network = network; this.log = log; - this.friends = new Friends(storage, platform, network, db, async, Clock.systemUTC(), settings.requestExpiry(), + this.friends = new Friends(storage, platform, network, db, async, clock, settings.requestExpiry(), settings.maxFriends(), inOrder(hooks), log); this.command = new FriendCommand(friends); this.api = new FriendsApi(friends, platform, async); friends.start(); + timer.scheduleWithFixedDelay(() -> { + try { + friends.expireRequests(); + } catch (RuntimeException e) { // an escaping exception would cancel every later run + log.error("Friends: expiring requests failed", e); + } + }, 1, 1, TimeUnit.SECONDS); FriendsProvider.register(api); } @@ -41,6 +58,7 @@ public FriendsRuntime(Settings settings, Storage storage, Network network, Platf @Override public void close() { FriendsProvider.unregister(api); // other plugins must not keep using (or pinning) a stopped instance + timer.close(); // waits for a running expiry: it may still queue writes async.shutdown(); // running actions may still queue writes: let them finish before the database thread stops try { if (!async.awaitTermination(5, TimeUnit.SECONDS)) log.warn("Friends: gave up waiting for plugins' event listeners"); diff --git a/common/src/main/java/com/friends/common/Hooks.java b/core/src/main/java/com/friends/core/Hooks.java similarity index 95% rename from common/src/main/java/com/friends/common/Hooks.java rename to core/src/main/java/com/friends/core/Hooks.java index c386b78..67c18c3 100644 --- a/common/src/main/java/com/friends/common/Hooks.java +++ b/core/src/main/java/com/friends/core/Hooks.java @@ -1,9 +1,9 @@ -package com.friends.common; +package com.friends.core; import java.time.Instant; import com.friends.api.Status; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Storage.PlayerRow; /** * Lets other plugins veto and observe changes made on this proxy/server: each platform turns these into its own diff --git a/common/src/main/java/com/friends/common/LuckPermsPrefix.java b/core/src/main/java/com/friends/core/LuckPermsPrefix.java similarity index 94% rename from common/src/main/java/com/friends/common/LuckPermsPrefix.java rename to core/src/main/java/com/friends/core/LuckPermsPrefix.java index ff06e00..4c38baf 100644 --- a/common/src/main/java/com/friends/common/LuckPermsPrefix.java +++ b/core/src/main/java/com/friends/core/LuckPermsPrefix.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.util.UUID; diff --git a/common/src/main/java/com/friends/common/Messages.java b/core/src/main/java/com/friends/core/Messages.java similarity index 99% rename from common/src/main/java/com/friends/common/Messages.java rename to core/src/main/java/com/friends/core/Messages.java index 3c1041c..5c5a2f1 100644 --- a/common/src/main/java/com/friends/common/Messages.java +++ b/core/src/main/java/com/friends/core/Messages.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import static net.kyori.adventure.text.Component.newline; import static net.kyori.adventure.text.Component.text; diff --git a/common/src/main/java/com/friends/common/Network.java b/core/src/main/java/com/friends/core/Network.java similarity index 98% rename from common/src/main/java/com/friends/common/Network.java rename to core/src/main/java/com/friends/core/Network.java index 8211bdd..75f3919 100644 --- a/common/src/main/java/com/friends/common/Network.java +++ b/core/src/main/java/com/friends/core/Network.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.util.List; import java.util.Map; diff --git a/common/src/main/java/com/friends/common/Platform.java b/core/src/main/java/com/friends/core/Platform.java similarity index 95% rename from common/src/main/java/com/friends/common/Platform.java rename to core/src/main/java/com/friends/core/Platform.java index 9f0e89c..c73ad85 100644 --- a/common/src/main/java/com/friends/common/Platform.java +++ b/core/src/main/java/com/friends/core/Platform.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.util.Optional; import java.util.UUID; diff --git a/common/src/main/java/com/friends/common/Presence.java b/core/src/main/java/com/friends/core/Presence.java similarity index 94% rename from common/src/main/java/com/friends/common/Presence.java rename to core/src/main/java/com/friends/core/Presence.java index bf00b31..ef86df4 100644 --- a/common/src/main/java/com/friends/common/Presence.java +++ b/core/src/main/java/com/friends/core/Presence.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import com.friends.api.Status; diff --git a/common/src/main/java/com/friends/common/RedisNetwork.java b/core/src/main/java/com/friends/core/RedisNetwork.java similarity index 99% rename from common/src/main/java/com/friends/common/RedisNetwork.java rename to core/src/main/java/com/friends/core/RedisNetwork.java index 10c2592..f2ea94f 100644 --- a/common/src/main/java/com/friends/common/RedisNetwork.java +++ b/core/src/main/java/com/friends/core/RedisNetwork.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.io.IOException; import java.time.Duration; @@ -19,7 +19,7 @@ import org.slf4j.Logger; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Storage.PlayerRow; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; diff --git a/common/src/main/java/com/friends/common/Request.java b/core/src/main/java/com/friends/core/Request.java similarity index 78% rename from common/src/main/java/com/friends/common/Request.java rename to core/src/main/java/com/friends/core/Request.java index 84dc9c0..5286ffb 100644 --- a/common/src/main/java/com/friends/common/Request.java +++ b/core/src/main/java/com/friends/core/Request.java @@ -1,9 +1,9 @@ -package com.friends.common; +package com.friends.core; import java.time.Instant; import java.util.UUID; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Storage.PlayerRow; /** A pending friend request. */ public record Request(PlayerRow from, PlayerRow to, Instant expires) { diff --git a/common/src/main/java/com/friends/common/Settings.java b/core/src/main/java/com/friends/core/Settings.java similarity index 98% rename from common/src/main/java/com/friends/common/Settings.java rename to core/src/main/java/com/friends/core/Settings.java index 51a5117..8cdd741 100644 --- a/common/src/main/java/com/friends/common/Settings.java +++ b/core/src/main/java/com/friends/core/Settings.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.nio.file.Path; import java.sql.SQLException; diff --git a/common/src/main/java/com/friends/common/Storage.java b/core/src/main/java/com/friends/core/Storage.java similarity index 99% rename from common/src/main/java/com/friends/common/Storage.java rename to core/src/main/java/com/friends/core/Storage.java index 0fbbb15..68ec632 100644 --- a/common/src/main/java/com/friends/common/Storage.java +++ b/core/src/main/java/com/friends/core/Storage.java @@ -1,4 +1,4 @@ -package com.friends.common; +package com.friends.core; import java.nio.file.Path; import java.sql.Connection; diff --git a/common/src/main/resources/config.yml b/core/src/main/resources/config.yml similarity index 100% rename from common/src/main/resources/config.yml rename to core/src/main/resources/config.yml diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index a31f4d8..a558a53 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -13,7 +13,7 @@ configurations.testImplementation { extendsFrom(mockServer) } val testRuntime: Configuration = configurations.create("testRuntime") { extendsFrom(mockServer) } dependencies { - implementation(project(":common")) + implementation(project(":core")) // The oldest supported API: anything that compiles here exists on every Paper from 1.8.8 to 26.x. compileOnly("org.github.paperspigot:paperspigot-api:1.8.8-R0.1-SNAPSHOT") "bootstrapCompileOnly"("org.github.paperspigot:paperspigot-api:1.8.8-R0.1-SNAPSHOT") @@ -35,7 +35,7 @@ dependencies { "mockServer"(platform("org.junit:junit-bom:6.1.3")) "mockServer"("org.junit.jupiter:junit-jupiter") "testRuntime"(project(":api")) // the bootstrap layer's part - "testRuntime"("org.xerial:sqlite-jdbc:3.53.4.0") // servers bring their own (common's version) + "testRuntime"("org.xerial:sqlite-jdbc:3.53.4.0") // servers bring their own (core's version) "testRuntime"("org.junit.platform:junit-platform-launcher") } @@ -79,7 +79,7 @@ val pluginJar = tasks.register("pluginJar") { exclude("META-INF/**", "com/friends/api/velocity/**", "com/friends/api/bungee/**") // the proxies' events } from(tasks.processResources) { include("plugin.yml") } - from(project(":common").file("src/main/resources/config.yml")) + from(project(":core").file("src/main/resources/config.yml")) from(tasks.shadowJar) { rename { "friends-paper-impl.jar" } } // No NMS/CraftBukkit use: tell Paper 1.20.5+ not to spend startup time remapping this jar. manifest { attributes("paperweight-mappings-namespace" to "mojang") } diff --git a/paper/src/main/java/com/friends/paper/BukkitHooks.java b/paper/src/main/java/com/friends/paper/BukkitHooks.java index 9a56a3d..4c8b9b6 100644 --- a/paper/src/main/java/com/friends/paper/BukkitHooks.java +++ b/paper/src/main/java/com/friends/paper/BukkitHooks.java @@ -13,9 +13,9 @@ import com.friends.api.bukkit.FriendRemovedEvent; import com.friends.api.bukkit.FriendRequestSendEvent; import com.friends.api.bukkit.FriendStatusChangeEvent; -import com.friends.common.Friend; -import com.friends.common.Hooks; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Friend; +import com.friends.core.Hooks; +import com.friends.core.Storage.PlayerRow; /** The core's hooks as Bukkit events, all asynchronous: hooks never run on the server thread. */ final class BukkitHooks implements Hooks { diff --git a/paper/src/main/java/com/friends/paper/PaperFriends.java b/paper/src/main/java/com/friends/paper/PaperFriends.java index 5f1f88c..ae1ac04 100644 --- a/paper/src/main/java/com/friends/paper/PaperFriends.java +++ b/paper/src/main/java/com/friends/paper/PaperFriends.java @@ -23,14 +23,14 @@ import org.slf4j.LoggerFactory; import com.friends.api.FriendsAPI; -import com.friends.common.ChatSessions; -import com.friends.common.FriendCommand; -import com.friends.common.FriendsRuntime; -import com.friends.common.LuckPermsPrefix; -import com.friends.common.Network; -import com.friends.common.Platform.Online; -import com.friends.common.Settings; -import com.friends.common.Storage; +import com.friends.core.ChatSessions; +import com.friends.core.FriendCommand; +import com.friends.core.FriendsRuntime; +import com.friends.core.LuckPermsPrefix; +import com.friends.core.Network; +import com.friends.core.Platform.Online; +import com.friends.core.Settings; +import com.friends.core.Storage; import net.md_5.bungee.api.chat.BaseComponent; @@ -84,7 +84,6 @@ public void enable() { (_, e) -> { if (e instanceof PlayerJoinEvent join) join(join.getPlayer()); }, plugin); events.registerEvent(PlayerQuitEvent.class, this, EventPriority.MONITOR, (_, e) -> { if (e instanceof PlayerQuitEvent quit) leave(quit.getPlayer()); }, plugin); - plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, runtime.friends::expireRequests, 20L, 20L); // Also FriendsAPI.get(). Bukkit drops the service by itself when the plugin disables. plugin.getServer().getServicesManager().register(FriendsAPI.class, runtime.api, plugin, ServicePriority.Normal); for (Player p : plugin.getServer().getOnlinePlayers()) join(p); // enabled while players are online (/reload) diff --git a/paper/src/main/resources/plugin.yml b/paper/src/main/resources/plugin.yml index 0f56c99..c301a45 100644 --- a/paper/src/main/resources/plugin.yml +++ b/paper/src/main/resources/plugin.yml @@ -5,6 +5,8 @@ version: ${version} api-version: "1.13" description: Hypixel-style friends system softdepend: [LuckPerms] +# Folia (regionized Paper): Friends runs off the server threads and uses no Bukkit scheduler. +folia-supported: true commands: friend: description: Friends commands diff --git a/paper/src/test/java/com/friends/paper/PaperApiTest.java b/paper/src/test/java/com/friends/paper/PaperApiTest.java deleted file mode 100644 index b19fe42..0000000 --- a/paper/src/test/java/com/friends/paper/PaperApiTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.friends.paper; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.io.IOException; -import java.io.InputStream; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; -import java.util.function.BooleanSupplier; -import java.util.function.Predicate; - -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.plugin.java.JavaPlugin; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.MockBukkit; -import org.mockbukkit.mockbukkit.ServerMock; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import com.friends.api.FriendsAPI; -import com.friends.api.Result; -import com.friends.api.Status; -import com.friends.api.bukkit.FriendAddEvent; -import com.friends.api.bukkit.FriendAddedEvent; -import com.friends.api.bukkit.FriendRemoveEvent; -import com.friends.api.bukkit.FriendRemovedEvent; -import com.friends.api.bukkit.FriendRequestSendEvent; -import com.friends.api.bukkit.FriendStatusChangeEvent; - -/** The real Paper wiring ({@link PaperFriends}) on a MockBukkit server: API registration and Bukkit events. */ -class PaperApiTest { - ServerMock server; - TestPlugin plugin; - FriendsAPI api; - final Recorder events = new Recorder(); - - /** Runs {@link PaperFriends} like {@code FriendsBootstrap} does, minus the embedded jar (not final: MockBukkit subclasses it). */ - public static class TestPlugin extends JavaPlugin { - PaperFriends friends; - - @Override - public void onEnable() { - friends = new PaperFriends(this); - friends.enable(); - } - - @Override - public void onDisable() { - friends.disable(); - } - } - - /** Records every Friends event (and the thread it ran on); cancels those {@code cancel} matches. */ - public static final class Recorder implements Listener { - final List seen = new CopyOnWriteArrayList<>(); - final List onServerThread = new CopyOnWriteArrayList<>(); - volatile Predicate cancel = _ -> false; - volatile Runnable during = () -> {}; - - private void record(Event e) { - seen.add(e); - onServerThread.add(MockBukkit.getMock().isPrimaryThread()); - during.run(); - if (e instanceof Cancellable c && cancel.test(e)) c.setCancelled(true); - } - - @EventHandler public void on(FriendRequestSendEvent e) { record(e); } - @EventHandler public void on(FriendAddEvent e) { record(e); } - @EventHandler public void on(FriendAddedEvent e) { record(e); } - @EventHandler public void on(FriendRemoveEvent e) { record(e); } - @EventHandler public void on(FriendRemovedEvent e) { record(e); } - @EventHandler public void on(FriendStatusChangeEvent e) { record(e); } - - List of(Class type) { - return seen.stream().filter(type::isInstance).map(type::cast).toList(); - } - } - - @BeforeEach - void setUp() throws IOException { - server = MockBukkit.mock(); - try (InputStream yml = PaperApiTest.class.getResourceAsStream("/plugin.yml")) { - plugin = MockBukkit.loadWith(TestPlugin.class, yml); - } - server.getPluginManager().registerEvents(events, plugin); - api = FriendsAPI.get(); - } - - @AfterEach - void tearDown() { - if (MockBukkit.isMocked()) MockBukkit.unmock(); - } - - private PlayerMock join(String name) { - PlayerMock p = server.addPlayer(name); - p.addAttachment(plugin, "friends.use", true); // MockBukkit ignores plugin.yml's "default: true" - await(() -> api.isLoaded(p.getUniqueId())); - return p; - } - - private static T get(CompletableFuture f) { - try { - return f.get(10, TimeUnit.SECONDS); - } catch (Exception e) { - throw new AssertionError(e); - } - } - - private static void await(BooleanSupplier condition) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (!condition.getAsBoolean()) { - if (System.nanoTime() > deadline) fail("timed out"); - Thread.onSpinWait(); - } - } - - @Test - void theApiIsAServiceAndGoesAwayOnDisable() { - assertSame(api, server.getServicesManager().load(FriendsAPI.class)); - MockBukkit.unmock(); // disables the plugin - assertEquals(Optional.empty(), FriendsAPI.find()); - } - - @Test - void eventsFireAsynchronouslyAndCanCancelEveryAction() { - assertTrue(server.isPrimaryThread()); // the test calls the API from the server thread - PlayerMock alice = join("Alice"); - PlayerMock bob = join("Bob"); - - events.cancel = e -> e instanceof FriendRequestSendEvent; - assertEquals(Result.CANCELLED, get(api.sendRequest(alice.getUniqueId(), bob.getUniqueId()))); - assertEquals(List.of(), api.getIncomingRequests(bob.getUniqueId())); - - events.cancel = e -> e instanceof FriendAddEvent; - assertEquals(Result.SUCCESS, get(api.sendRequest(alice.getUniqueId(), bob.getUniqueId()))); - FriendRequestSendEvent sent = events.of(FriendRequestSendEvent.class).getLast(); - assertEquals(alice.getUniqueId(), sent.getPlayerId()); - assertEquals("Alice", sent.getPlayerName()); - assertEquals(bob.getUniqueId(), sent.getTargetId()); - assertEquals("Bob", sent.getTargetName()); - assertEquals(Result.CANCELLED, get(api.acceptRequest(bob.getUniqueId(), alice.getUniqueId()))); - assertFalse(api.areFriends(alice.getUniqueId(), bob.getUniqueId())); - - events.cancel = _ -> false; - assertEquals(Result.SUCCESS, get(api.acceptRequest(bob.getUniqueId(), alice.getUniqueId()))); - await(() -> events.of(FriendAddedEvent.class).size() == 1); - FriendAddedEvent added = events.of(FriendAddedEvent.class).getFirst(); - assertEquals(bob.getUniqueId(), added.getPlayerId()); - assertEquals(alice.getUniqueId(), added.getTargetId()); - assertEquals(api.getFriends(alice.getUniqueId()).getFirst().getSince(), added.getSince()); - - events.cancel = e -> e instanceof FriendStatusChangeEvent; - assertEquals(Result.CANCELLED, get(api.setStatus(alice.getUniqueId(), Status.BUSY))); - assertEquals(Optional.of(Status.ONLINE), api.getStatus(alice.getUniqueId())); - FriendStatusChangeEvent status = events.of(FriendStatusChangeEvent.class).getLast(); - assertEquals(Status.ONLINE, status.getOldStatus()); - assertEquals(Status.BUSY, status.getNewStatus()); - - events.cancel = e -> e instanceof FriendRemoveEvent; - assertEquals(Result.CANCELLED, get(api.removeFriend(alice.getUniqueId(), bob.getUniqueId()))); - events.cancel = _ -> false; - assertEquals(Result.SUCCESS, get(api.removeFriend(alice.getUniqueId(), bob.getUniqueId()))); - await(() -> events.of(FriendRemovedEvent.class).size() == 1); - assertEquals(bob.getUniqueId(), events.of(FriendRemovedEvent.class).getFirst().getTargetId()); - - assertTrue(events.seen.stream().allMatch(Event::isAsynchronous)); - assertEquals(List.of(), events.onServerThread.stream().filter(b -> b).toList()); // never on the server thread - assertEquals(1, events.of(FriendAddedEvent.class).size()); // exactly once - } - - @Test - void commandsFireTheSameEvents() { - PlayerMock alice = join("Alice"); - PlayerMock bob = join("Bob"); - alice.performCommand("f add Bob"); - await(() -> api.getIncomingRequests(bob.getUniqueId()).size() == 1); - assertEquals(1, events.of(FriendRequestSendEvent.class).size()); - - events.cancel = e -> e instanceof FriendAddEvent; - bob.performCommand("f accept Alice"); - await(() -> events.of(FriendAddEvent.class).size() == 1); - events.cancel = _ -> false; - bob.performCommand("f accept Alice"); - await(() -> api.areFriends(alice.getUniqueId(), bob.getUniqueId())); - await(() -> events.of(FriendAddedEvent.class).size() == 1); - } - - @Test - void listenersMayUseTheApi() { - PlayerMock alice = join("Alice"); - PlayerMock bob = join("Bob"); - List sizes = new CopyOnWriteArrayList<>(); - events.during = () -> sizes.add(get(api.loadFriends(bob.getUniqueId())).size()); // no deadlock, no server thread - assertEquals(Result.SUCCESS, get(api.sendRequest(alice.getUniqueId(), bob.getUniqueId()))); - assertEquals(List.of(0), sizes); - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 116edee..42dea22 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,4 @@ rootProject.name = "friends-plugin" -include("api", "common", "velocity", "bungee", "paper") +include("api", "core", "velocity", "bungee", "paper") include("probe") project(":probe").projectDir = file("tools/e2e/probe") diff --git a/tools/e2e/README.md b/tools/e2e/README.md index 135c91e..75990b1 100644 --- a/tools/e2e/README.md +++ b/tools/e2e/README.md @@ -9,6 +9,7 @@ notifications, remove. 2. Put the server jars in one folder (not included; running Paper means accepting the [Minecraft EULA](https://aka.ms/MinecraftEULA), which the scripts write to `eula.txt`): - `paper-.jar` from (e.g. 1.8.8, 1.12.2, 1.16.5, 1.20.6, 1.21.11, 26.3) + - `folia-.jar` from (e.g. 1.21.11) - `BungeeCord.jar` from - `velocity.jar` from 3. Run (needs Python 3 with PyYAML, Java 25): @@ -19,7 +20,7 @@ python3 tools/e2e/run.py /path/to/jars paper-26.3 bungee-26.3 FRIENDS_MYSQL=127.0.0.1:3306/friends:root: FRIENDS_REDIS=127.0.0.1:6379 python3 tools/e2e/run.py /path/to/jars mixed ``` -Scenarios: `paper-`, `load-` (enables and answers the console only), `bungee[-]`, +Scenarios: `paper-`, `folia-` (the Paper jar on Folia), `load-` (enables and answers the console only), `bungee[-]`, `velocity[-]` (proxy plugin, players and a Paper backend of that version, default 1.8.8), `mixed` (Alice through BungeeCord, Bob through Velocity, sharing MySQL and Redis), `api-` / `api-bungee` / `api-velocity` (another plugin, [`probe`](probe), using the API: its events, one cancelled, reads and actions from its diff --git a/tools/e2e/run.py b/tools/e2e/run.py index 28c5057..3f07df9 100644 --- a/tools/e2e/run.py +++ b/tools/e2e/run.py @@ -2,8 +2,9 @@ python3 tools/e2e/run.py [scenario ...] - holds the downloaded servers: paper-.jar, BungeeCord.jar, velocity.jar (see README). + holds the downloaded servers: paper-.jar, folia-.jar, BungeeCord.jar, velocity.jar (see README). Scenarios: paper- (full friend flow with bot players: 1.8.8, 1.12.2, 1.16.5, 1.20.6, 1.21.11, 26.3), +folia- (the same flow, same Paper jar, on Folia; e.g. 1.21.11), load- (plugin enables and answers the console), bungee[-] / velocity[-] (the proxy plugin with players and a Paper backend of that version, default 1.8.8), mixed (BungeeCord + Velocity sharing MySQL and Redis; needs FRIENDS_MYSQL=host:port/db:user:password and FRIENDS_REDIS=host:port), api- / api-bungee / api-velocity (another @@ -21,8 +22,7 @@ import servers # noqa: E402 ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -VERSION = "0.5.0" -PLUGIN = {m: os.path.join(ROOT, m, "build", "libs", f"friends-{m}-{VERSION}.jar") for m in ("paper", "bungee", "velocity")} +PLUGIN = {m: os.path.join(ROOT, "builds", f"friends-{m}.jar") for m in ("paper", "bungee", "velocity")} PROBE = os.path.join(ROOT, "tools", "e2e", "probe", "build", "libs", "friends-probe.jar") PROTOCOL = {"1.8.8": 47, "1.12.2": 340, "1.16.5": 754, "1.20.6": 766, "1.21.11": 774, "26.3": 777} PAUSE = 0.4 # keeps well under Spigot's chat spam filter @@ -82,9 +82,10 @@ def mysql_settings(): "mysql.database": database, "mysql.user": user, "mysql.password": password} -def scenario_paper(jars, version): - port = 25700 + list(PROTOCOL).index(version) - s = servers.paper(os.path.join(jars, f"e2e-paper-{version}"), os.path.join(jars, f"paper-{version}.jar"), port, PLUGIN["paper"]) +def scenario_paper(jars, version, kind="paper"): + """`kind` "folia": the same plugin jar on Folia (regionized Paper, no Bukkit scheduler).""" + port = (25700 if kind == "paper" else 25770) + list(PROTOCOL).index(version) + s = servers.paper(os.path.join(jars, f"e2e-{kind}-{version}"), os.path.join(jars, f"{kind}-{version}.jar"), port, PLUGIN["paper"]) if version == "1.12.2" and platform.system() == "Darwin" and platform.machine() == "arm64": # Paper 1.12.2's own sqlite-jdbc (3.21.0.1) has no Apple Silicon native; Linux is fine. Use MySQL here. servers.plugin_config(s.folder, **mysql_settings()) @@ -229,8 +230,9 @@ def main(): for name in wanted: start = time.time() try: - if name.startswith("paper-"): - scenario_paper(jars, name[6:]) + if name.startswith(("paper-", "folia-")): + kind, _, version = name.partition("-") + scenario_paper(jars, version, kind) elif name.startswith("api-"): scenario_api(jars, name[4:]) elif name.startswith("load-"): diff --git a/tools/e2e/servers.py b/tools/e2e/servers.py index 8395eb2..83a6430 100644 --- a/tools/e2e/servers.py +++ b/tools/e2e/servers.py @@ -146,7 +146,7 @@ def plugin_config(folder, **values): for sub in ("Friends", "friends"): path = os.path.join(folder, "plugins", sub) os.makedirs(path, exist_ok=True) - with open(os.path.join(os.path.dirname(__file__), "..", "..", "common", "src", "main", "resources", "config.yml")) as f: + with open(os.path.join(os.path.dirname(__file__), "..", "..", "core", "src", "main", "resources", "config.yml")) as f: config = yaml.safe_load(f) for key, value in values.items(): node = config diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 67d1e75..a88f2ab 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -3,10 +3,18 @@ plugins { id("com.gradleup.shadow") } -// Project version (root build) must match @Plugin(version) in FriendsVelocity. +// @Plugin(version) must be a compile-time constant: generate it from the project version (one source of truth). +val generateConstants = tasks.register("generateConstants") { + val pluginVersion = project.version.toString() + inputs.property("version", pluginVersion) + from("src/main/templates") + into(layout.buildDirectory.dir("generated/sources/templates")) + expand("version" to pluginVersion) +} +sourceSets.main { java.srcDir(generateConstants) } dependencies { - implementation(project(":common")) + implementation(project(":core")) compileOnly("com.velocitypowered:velocity-api:4.2.0") annotationProcessor("com.velocitypowered:velocity-api:4.2.0") // generates velocity-plugin.json compileOnly("net.luckperms:api:5.5") diff --git a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java index ff97770..cb13078 100644 --- a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java +++ b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java @@ -5,7 +5,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; -import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -19,14 +18,14 @@ import org.spongepowered.configurate.ConfigurationNode; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; -import com.friends.common.FriendCommand; -import com.friends.common.FriendsRuntime; -import com.friends.common.LuckPermsPrefix; -import com.friends.common.Network; -import com.friends.common.Platform; -import com.friends.common.RedisNetwork; -import com.friends.common.Settings; -import com.friends.common.Storage; +import com.friends.core.FriendCommand; +import com.friends.core.FriendsRuntime; +import com.friends.core.LuckPermsPrefix; +import com.friends.core.Network; +import com.friends.core.Platform; +import com.friends.core.RedisNetwork; +import com.friends.core.Settings; +import com.friends.core.Storage; import com.google.inject.Inject; import com.velocitypowered.api.command.CommandManager; import com.velocitypowered.api.command.SimpleCommand; @@ -47,7 +46,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -@Plugin(id = "friends", name = "Friends", version = "0.5.0", +@Plugin(id = "friends", name = "Friends", version = BuildConstants.VERSION, description = "Hypixel-style friends system", dependencies = @Dependency(id = "luckperms", optional = true)) public final class FriendsVelocity { @@ -86,7 +85,6 @@ public void onInit(ProxyInitializeEvent event) { commands.register(commands.metaBuilder("friend").aliases("f", "friends").plugin(this).build(), new Cmd(runtime.command, platform, null, commandThreads)); commands.register(commands.metaBuilder("fl").plugin(this).build(), new Cmd(runtime.command, platform, "list", commandThreads)); commands.register(commands.metaBuilder("status").plugin(this).build(), new Cmd(runtime.command, platform, "status", commandThreads)); - proxy.getScheduler().buildTask(this, runtime.friends::expireRequests).repeat(Duration.ofSeconds(1)).schedule(); } @Subscribe diff --git a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java index d71c363..2b5b6f8 100644 --- a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java +++ b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java @@ -11,9 +11,9 @@ import com.friends.api.velocity.FriendRemovedEvent; import com.friends.api.velocity.FriendRequestSendEvent; import com.friends.api.velocity.FriendStatusChangeEvent; -import com.friends.common.Friend; -import com.friends.common.Hooks; -import com.friends.common.Storage.PlayerRow; +import com.friends.core.Friend; +import com.friends.core.Hooks; +import com.friends.core.Storage.PlayerRow; import com.velocitypowered.api.event.ResultedEvent; /** The core's hooks as Velocity events. Hooks run on Friends' threads, so waiting for listeners blocks nothing of Velocity's. */ diff --git a/velocity/src/main/templates/com/friends/velocity/BuildConstants.java b/velocity/src/main/templates/com/friends/velocity/BuildConstants.java new file mode 100644 index 0000000..e89055a --- /dev/null +++ b/velocity/src/main/templates/com/friends/velocity/BuildConstants.java @@ -0,0 +1,8 @@ +package com.friends.velocity; + +/** Generated by the build (velocity/src/main/templates): {@code @Plugin(version)} needs a compile-time constant. */ +final class BuildConstants { + static final String VERSION = "${version}"; + + private BuildConstants() {} +} diff --git a/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java b/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java deleted file mode 100644 index 60a42e2..0000000 --- a/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.friends.velocity; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Predicate; - -import org.junit.jupiter.api.Test; - -import com.friends.api.Status; -import com.friends.api.velocity.FriendAddEvent; -import com.friends.api.velocity.FriendAddedEvent; -import com.friends.api.velocity.FriendEvent; -import com.friends.api.velocity.FriendRemoveEvent; -import com.friends.api.velocity.FriendRemovedEvent; -import com.friends.api.velocity.FriendRequestSendEvent; -import com.friends.api.velocity.FriendStatusChangeEvent; -import com.friends.common.Friend; -import com.friends.common.Storage.PlayerRow; -import com.velocitypowered.api.event.ResultedEvent; -import com.velocitypowered.api.event.ResultedEvent.GenericResult; - -class VelocityHooksTest { - final PlayerRow alice = new PlayerRow(UUID.randomUUID(), "Alice", null, Instant.EPOCH); - final PlayerRow bob = new PlayerRow(UUID.randomUUID(), "Bob", null, Instant.EPOCH); - final Friend bobAsFriend = new Friend(bob.id(), "Bob", null, Instant.EPOCH, false, null, Instant.EPOCH); - final List fired = new CopyOnWriteArrayList<>(); - volatile Predicate deny = _ -> false; - - /** Like Velocity's EventManager.fire: listeners run on another thread, the future completes after them. */ - @SuppressWarnings("unchecked") - final VelocityHooks hooks = new VelocityHooks(event -> CompletableFuture.supplyAsync(() -> { - try { - Thread.sleep(20); // a slow listener: the verdict must be read after it - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - fired.add(event); - if (deny.test(event)) ((ResultedEvent) event).setResult(GenericResult.denied()); - return event; - })); - - @Test - void vetoesWaitForListenersAndMapEveryField() { - assertTrue(hooks.requesting(alice, bob)); - assertTrue(hooks.befriending(bob, alice)); - assertTrue(hooks.unfriending(alice, bobAsFriend)); - assertTrue(hooks.statusChanging(alice, Status.ONLINE, Status.BUSY)); - deny = _ -> true; - assertFalse(hooks.requesting(alice, bob)); - assertFalse(hooks.befriending(bob, alice)); - assertFalse(hooks.unfriending(alice, bobAsFriend)); - assertFalse(hooks.statusChanging(alice, Status.ONLINE, Status.BUSY)); - - var sent = (FriendRequestSendEvent) fired.get(0); - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields(sent)); - assertEquals(List.of(bob.id(), "Bob", alice.id(), "Alice"), fields((FriendAddEvent) fired.get(1))); - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields((FriendRemoveEvent) fired.get(2))); - var status = (FriendStatusChangeEvent) fired.get(3); - assertEquals(List.of(alice.id(), "Alice", Status.ONLINE, Status.BUSY), - List.of(status.getPlayerId(), status.getPlayerName(), status.getOldStatus(), status.getNewStatus())); - assertTrue(sent.getResult().isAllowed()); - } - - @Test - void doneEventsAreFiredAndAwaited() { - Instant since = Instant.parse("2026-10-01T12:00:00Z"); - hooks.befriended(bob, alice, since); - hooks.unfriended(alice, bobAsFriend); - assertEquals(2, fired.size()); // both delivered before returning - var added = (FriendAddedEvent) fired.get(0); - assertEquals(List.of(bob.id(), "Bob", alice.id(), "Alice"), fields(added)); - assertEquals(since, added.getSince()); - assertEquals(List.of(alice.id(), "Alice", bob.id(), "Bob"), fields((FriendRemovedEvent) fired.get(1))); - } - - private static List fields(FriendEvent e) { - return List.of(e.getPlayerId(), e.getPlayerName(), e.getTargetId(), e.getTargetName()); - } -}