From 380c00a9d50df80c2eb833f4298b6b493cb46e74 Mon Sep 17 00:00:00 2001 From: Macaron27 Date: Thu, 1 Oct 2026 20:30:36 +0200 Subject: [PATCH] Add a public API for other plugins, with events on Paper, Velocity and BungeeCord New `api` module (com.friends:friends-api, Java 8, no dependencies): FriendsAPI (reads from memory, async actions returning a Result), Friend/FriendRequest snapshots, Status, and six events per platform: four cancellable "before" events (request, add, remove, status) and two exactly-once "after" events. - Java 8 because on Paper the API sits next to the bootstrap, where other plugins' class loaders can see it and Paper's class rewriter can read it. - Core: commands return a Result, accept a name or a UUID, ask plugins outside the lock and re-check after; hooks never run on the database thread. - Offline players' friend lists are cached for 30 s, dropped on any friendship change; a read that races a change is never kept. - Nulls and bad input never throw (empty, false or INVALID_ARGUMENT). - Tests: FriendsApiTest, MockBukkit on the shipped Paper jar, proxy event tests, and api-* e2e scenarios with a probe plugin on real servers. - Version 0.5.0. Co-Authored-By: Claude Opus 5.5 --- .claude/known_errors.md | 18 + README.md | 73 ++- api/build.gradle.kts | 44 ++ api/src/main/java/com/friends/api/Friend.java | 111 ++++ .../java/com/friends/api/FriendRequest.java | 95 ++++ .../main/java/com/friends/api/FriendsAPI.java | 215 +++++++ .../java/com/friends/api/FriendsProvider.java | 27 + api/src/main/java/com/friends/api/Result.java | 42 ++ api/src/main/java/com/friends/api/Status.java | 13 + .../friends/api/bukkit/FriendAddEvent.java | 69 +++ .../friends/api/bukkit/FriendAddedEvent.java | 57 ++ .../com/friends/api/bukkit/FriendEvent.java | 72 +++ .../friends/api/bukkit/FriendRemoveEvent.java | 68 +++ .../api/bukkit/FriendRemovedEvent.java | 44 ++ .../api/bukkit/FriendRequestSendEvent.java | 68 +++ .../api/bukkit/FriendStatusChangeEvent.java | 113 ++++ .../friends/api/bungee/FriendAddEvent.java | 48 ++ .../friends/api/bungee/FriendAddedEvent.java | 35 ++ .../com/friends/api/bungee/FriendEvent.java | 70 +++ .../friends/api/bungee/FriendRemoveEvent.java | 47 ++ .../api/bungee/FriendRemovedEvent.java | 21 + .../api/bungee/FriendRequestSendEvent.java | 47 ++ .../api/bungee/FriendStatusChangeEvent.java | 91 +++ .../friends/api/velocity/FriendAddEvent.java | 50 ++ .../api/velocity/FriendAddedEvent.java | 35 ++ .../com/friends/api/velocity/FriendEvent.java | 68 +++ .../api/velocity/FriendRemoveEvent.java | 49 ++ .../api/velocity/FriendRemovedEvent.java | 21 + .../api/velocity/FriendRequestSendEvent.java | 49 ++ .../api/velocity/FriendStatusChangeEvent.java | 91 +++ build.gradle.kts | 2 +- bungee/build.gradle.kts | 6 + .../java/com/friends/bungee/BungeeHooks.java | 62 +++ .../com/friends/bungee/FriendsBungee.java | 2 +- .../com/friends/bungee/BungeeHooksTest.java | 117 ++++ common/build.gradle.kts | 2 + .../com/friends/common/FriendCommand.java | 27 +- .../main/java/com/friends/common/Friends.java | 338 +++++++---- .../java/com/friends/common/FriendsApi.java | 140 +++++ .../com/friends/common/FriendsRuntime.java | 44 +- .../main/java/com/friends/common/Hooks.java | 42 ++ .../java/com/friends/common/Messages.java | 1 + .../java/com/friends/common/Presence.java | 2 + .../main/java/com/friends/common/Status.java | 6 - .../main/java/com/friends/common/Storage.java | 25 +- .../com/friends/common/BungeeChatTest.java | 2 + .../com/friends/common/ConsistencyTest.java | 2 +- .../com/friends/common/FriendsApiTest.java | 524 ++++++++++++++++++ .../com/friends/common/RedisNetworkTest.java | 1 + .../java/com/friends/common/StorageTest.java | 2 + .../java/com/friends/common/TestSupport.java | 13 +- paper/build.gradle.kts | 28 + .../java/com/friends/paper/BukkitHooks.java | 62 +++ .../java/com/friends/paper/PaperFriends.java | 6 +- .../java/com/friends/paper/PaperApiTest.java | 208 +++++++ settings.gradle.kts | 4 +- tools/e2e/README.md | 4 +- tools/e2e/probe/build.gradle.kts | 27 + .../java/com/friends/probe/BungeeProbe.java | 60 ++ .../java/com/friends/probe/PaperProbe.java | 68 +++ .../main/java/com/friends/probe/Probe.java | 42 ++ .../java/com/friends/probe/VelocityProbe.java | 68 +++ tools/e2e/probe/src/main/resources/bungee.yml | 4 + tools/e2e/probe/src/main/resources/plugin.yml | 5 + .../src/main/resources/velocity-plugin.json | 2 + tools/e2e/run.py | 64 ++- velocity/build.gradle.kts | 9 + .../com/friends/velocity/FriendsVelocity.java | 21 +- .../com/friends/velocity/VelocityHooks.java | 62 +++ .../friends/velocity/VelocityHooksTest.java | 86 +++ 70 files changed, 3891 insertions(+), 150 deletions(-) create mode 100644 api/build.gradle.kts create mode 100644 api/src/main/java/com/friends/api/Friend.java create mode 100644 api/src/main/java/com/friends/api/FriendRequest.java create mode 100644 api/src/main/java/com/friends/api/FriendsAPI.java create mode 100644 api/src/main/java/com/friends/api/FriendsProvider.java create mode 100644 api/src/main/java/com/friends/api/Result.java create mode 100644 api/src/main/java/com/friends/api/Status.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendAddEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendAddedEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendRemovedEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendRequestSendEvent.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendStatusChangeEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendAddEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendAddedEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendRemovedEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendRequestSendEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendStatusChangeEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendAddEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendAddedEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendRemovedEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendRequestSendEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendStatusChangeEvent.java create mode 100644 bungee/src/main/java/com/friends/bungee/BungeeHooks.java create mode 100644 bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java create mode 100644 common/src/main/java/com/friends/common/FriendsApi.java create mode 100644 common/src/main/java/com/friends/common/Hooks.java delete mode 100644 common/src/main/java/com/friends/common/Status.java create mode 100644 common/src/test/java/com/friends/common/FriendsApiTest.java create mode 100644 paper/src/main/java/com/friends/paper/BukkitHooks.java create mode 100644 paper/src/test/java/com/friends/paper/PaperApiTest.java create mode 100644 tools/e2e/probe/build.gradle.kts create mode 100644 tools/e2e/probe/src/main/java/com/friends/probe/BungeeProbe.java create mode 100644 tools/e2e/probe/src/main/java/com/friends/probe/PaperProbe.java create mode 100644 tools/e2e/probe/src/main/java/com/friends/probe/Probe.java create mode 100644 tools/e2e/probe/src/main/java/com/friends/probe/VelocityProbe.java create mode 100644 tools/e2e/probe/src/main/resources/bungee.yml create mode 100644 tools/e2e/probe/src/main/resources/plugin.yml create mode 100644 tools/e2e/probe/src/main/resources/velocity-plugin.json create mode 100644 velocity/src/main/java/com/friends/velocity/VelocityHooks.java create mode 100644 velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java diff --git a/.claude/known_errors.md b/.claude/known_errors.md index 46a3dd1..6530502 100644 --- a/.claude/known_errors.md +++ b/.claude/known_errors.md @@ -48,3 +48,21 @@ (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/README.md b/README.md index 783250f..15fe3e3 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,17 @@ 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. -- `tools/e2e` — end-to-end tests on real servers with bot players. +- `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.4.0.jar, - # bungee/build/libs/friends-bungee-0.4.0.jar, paper/build/libs/friends-paper-0.4.0.jar +./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 ``` Put the jar for your platform in `plugins/`. Every server/proxy must run **Java 25** (Velocity 4.2 and Paper 26.x @@ -70,6 +71,64 @@ messages, status, current server, `/f list` and tab-completion then see the whol 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. + ## How it works - Each online player's friends and settings are loaded with one query at login (the login waits for it on @@ -81,7 +140,8 @@ Use the plugin either on the proxies or on standalone Paper servers, not both. - Chat on BungeeCord and Paper goes out as BungeeCord components, the one chat API every version has; the core uses Adventure (bundled and relocated where the platform lacks it). - Paper jar: a Java 8 bootstrap loads the Java 25 plugin from an embedded jar, so CraftBukkit's class rewriter - (1.13+, which can't read Java 25 class files before ~1.21) never sees it. It skips SQLite (every Paper bundles its + (1.13+, which can't read Java 25 class files before ~1.21) never sees it. The API (Java 8) sits next to the + bootstrap, where other plugins' class loaders can see it. It skips SQLite (every Paper bundles its own; the SQL stays portable to SQLite 3.7.2, Paper 1.8.8's) and Redis, so it is 3.4 MB, and tells Paper 1.20.5+ not to remap it. - Tables are `friends_players` and `friends_friendships`. Data from the old Bungee/Paper tables @@ -93,6 +153,9 @@ Use the plugin either on the proxies or on standalone Paper servers, not both. 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). +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. End-to-end on real servers: see [`tools/e2e/README.md`](tools/e2e/README.md). diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..9e55739 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + `java-library` + `maven-publish` +} + +// Java 8 bytecode: on Paper these classes live in the bootstrap jar (see FriendsBootstrap), which every CraftBukkit +// class rewriter must be able to read, and plugins built for any Java version can compile against them. +base.archivesName = "friends-api" + +java { + withSourcesJar() + withJavadocJar() + disableAutoTargetJvm() // velocity-api is Java 25 bytecode: we only reference it, so don't let Gradle refuse it +} + +tasks.compileJava { + options.release = 8 + options.compilerArgs.addAll(listOf("-Xlint:all,-options", "-Werror")) +} + +tasks.javadoc { + // Every public type and member must be documented: a missing @param or comment fails the build. + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("Xdoclint:all", true) + addBooleanOption("Werror", true) + addStringOption("-release", "8") + } +} + +dependencies { + // Events extend each platform's event type; the oldest supported APIs, like the plugins themselves. + compileOnly("org.github.paperspigot:paperspigot-api:1.8.8-R0.1-SNAPSHOT") + compileOnly("com.velocitypowered:velocity-api:4.2.0") + compileOnly("net.md-5:bungeecord-api:1.21-R0.4") +} + +publishing { + publications { + create("maven") { + artifactId = "friends-api" + from(components["java"]) + } + } +} diff --git a/api/src/main/java/com/friends/api/Friend.java b/api/src/main/java/com/friends/api/Friend.java new file mode 100644 index 0000000..376d640 --- /dev/null +++ b/api/src/main/java/com/friends/api/Friend.java @@ -0,0 +1,111 @@ +package com.friends.api; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * One friend, as seen by the player who owns the list. Immutable snapshot: it doesn't change when the friendship + * does, ask {@link FriendsAPI} again for fresh data. Best friend and nickname are private to the owner. + */ +public final class Friend { + private final UUID uniqueId; + private final String name; + private final Instant since; + private final boolean bestFriend; + private final String nickname; + private final Instant lastSeen; + + /** + * Created by Friends; plugins have no reason to build one. + * + * @param uniqueId the friend's UUID + * @param name the friend's last known name + * @param since when they became friends + * @param bestFriend whether the owner marked them as a best friend + * @param nickname the owner's nickname for them, or null + * @param lastSeen when the friend last joined or left + */ + public Friend(UUID uniqueId, String name, Instant since, boolean bestFriend, String nickname, Instant lastSeen) { + this.uniqueId = uniqueId; + this.name = name; + this.since = since; + this.bestFriend = bestFriend; + this.nickname = nickname; + this.lastSeen = lastSeen; + } + + /** + * The friend's UUID. + * + * @return never null + */ + public UUID getUniqueId() { + return uniqueId; + } + + /** + * The friend's name the last time Friends saw them. + * + * @return never null + */ + public String getName() { + return name; + } + + /** + * When they became friends. + * + * @return never null + */ + public Instant getSince() { + return since; + } + + /** + * Whether the owner marked this friend as a best friend. + * + * @return true for a best friend + */ + public boolean isBestFriend() { + return bestFriend; + } + + /** + * The nickname the owner gave this friend (only the owner sees it). + * + * @return the nickname, or null if none + */ + public String getNickname() { + return nickname; + } + + /** + * When the friend last joined or left (for an online friend: when they joined). + * + * @return never null + */ + public Instant getLastSeen() { + return lastSeen; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Friend)) return false; + Friend f = (Friend) o; + return bestFriend == f.bestFriend && uniqueId.equals(f.uniqueId) && name.equals(f.name) && since.equals(f.since) + && Objects.equals(nickname, f.nickname) && lastSeen.equals(f.lastSeen); + } + + @Override + public int hashCode() { + return Objects.hash(uniqueId, name, since, bestFriend, nickname, lastSeen); + } + + @Override + public String toString() { + return "Friend{" + name + " " + uniqueId + ", since=" + since + ", best=" + bestFriend + ", nickname=" + nickname + + ", lastSeen=" + lastSeen + "}"; + } +} diff --git a/api/src/main/java/com/friends/api/FriendRequest.java b/api/src/main/java/com/friends/api/FriendRequest.java new file mode 100644 index 0000000..89cca52 --- /dev/null +++ b/api/src/main/java/com/friends/api/FriendRequest.java @@ -0,0 +1,95 @@ +package com.friends.api; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** A pending friend request. Immutable snapshot. */ +public final class FriendRequest { + private final UUID sender; + private final String senderName; + private final UUID target; + private final String targetName; + private final Instant expiresAt; + + /** + * Created by Friends; plugins have no reason to build one. + * + * @param sender UUID of the player who sent it + * @param senderName their name + * @param target UUID of the player who received it + * @param targetName their name + * @param expiresAt when it expires + */ + public FriendRequest(UUID sender, String senderName, UUID target, String targetName, Instant expiresAt) { + this.sender = sender; + this.senderName = senderName; + this.target = target; + this.targetName = targetName; + this.expiresAt = expiresAt; + } + + /** + * Who sent the request. + * + * @return the sender's UUID, never null + */ + public UUID getSender() { + return sender; + } + + /** + * The sender's name. + * + * @return never null + */ + public String getSenderName() { + return senderName; + } + + /** + * Who received the request. + * + * @return the target's UUID, never null + */ + public UUID getTarget() { + return target; + } + + /** + * The target's name. + * + * @return never null + */ + public String getTargetName() { + return targetName; + } + + /** + * When the request expires (it can't be accepted after that). + * + * @return never null + */ + public Instant getExpiresAt() { + return expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FriendRequest)) return false; + FriendRequest r = (FriendRequest) o; + return sender.equals(r.sender) && senderName.equals(r.senderName) && target.equals(r.target) + && targetName.equals(r.targetName) && expiresAt.equals(r.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(sender, senderName, target, targetName, expiresAt); + } + + @Override + public String toString() { + return "FriendRequest{" + senderName + " -> " + targetName + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/api/src/main/java/com/friends/api/FriendsAPI.java b/api/src/main/java/com/friends/api/FriendsAPI.java new file mode 100644 index 0000000..b242869 --- /dev/null +++ b/api/src/main/java/com/friends/api/FriendsAPI.java @@ -0,0 +1,215 @@ +package com.friends.api; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Friends' API for other plugins: read friend lists, presence and requests, and act on behalf of players. The same + * API exists on Paper, Velocity and BungeeCord; each platform also fires its own events (packages + * {@code com.friends.api.bukkit}, {@code .velocity} and {@code .bungee}). + * + *

Getting it

+ * Call {@link #get()} once Friends is enabled: declare a dependency on {@code Friends} ({@code depend} or + * {@code softdepend} in plugin.yml / bungee.yml, {@code @Dependency(id = "friends")} on Velocity). On Paper it is also + * a Bukkit service: {@code getServer().getServicesManager().load(FriendsAPI.class)}. Don't cache it across a reload + * of Friends. + * + *

Threads

+ * Every method is thread-safe. Methods returning a value read memory only: they never block or touch the database, so + * they are fine on the server thread. Methods returning a {@link CompletableFuture} return at once and do their work + * (and any database access) on Friends' threads, where the future completes (unless it is already complete, e.g. + * {@link #loadFriends} of a loaded player): switch back to the server thread (e.g. Bukkit's scheduler) before using + * the world or the Bukkit API from a callback. Never {@code join()} such a future on the server thread. + * + *

What is cached

+ * Players online on this server (or proxy) are loaded: their friends and settings live in memory, so every + * read about them is free. Status, current server and pending requests are known network-wide. Friends of other + * players come from the database through {@link #loadFriends}. + * + *

Invalid input

+ * Nothing here throws: a null or invalid argument gives an empty result, {@code false} or + * {@link Result#INVALID_ARGUMENT}. Futures of actions never complete exceptionally (database errors give + * {@link Result#ERROR}). + * + *

Actions

+ * Actions behave like the matching command, events and limits included, with one difference: the acting player gets + * no chat reply (use the {@link Result}). Other players are still notified ("Friend request from...", "You are now + * friends with..."). The acting player must be loaded, else {@link Result#NOT_LOADED}. + */ +public interface FriendsAPI { + + /** + * The running API. + * + * @return the API + * @throws IllegalStateException if Friends is not enabled (your plugin doesn't depend on it, or loads first) + */ + static FriendsAPI get() { + FriendsAPI api = FriendsProvider.instance; + if (api == null) { + throw new IllegalStateException("Friends is not enabled: declare a dependency on Friends, or use FriendsAPI.find()"); + } + return api; + } + + /** + * The running API, if Friends is enabled. For soft dependencies. + * + * @return the API, or empty if Friends is not enabled + */ + static Optional find() { + return Optional.ofNullable(FriendsProvider.instance); + } + + // --- Reads: memory only, never block --- + + /** + * Whether the player's friends are in memory here, i.e. they are online on this server (proxy) and loaded. + * + * @param player a player's UUID + * @return true if loaded; false if offline, online elsewhere, still loading, or null + */ + boolean isLoaded(UUID player); + + /** + * A loaded player's friends, in no particular order. For other players use {@link #loadFriends}. + * + * @param player a player's UUID + * @return an unmodifiable snapshot; empty if the player has no friends or is not {@linkplain #isLoaded loaded} + */ + List getFriends(UUID player); + + /** + * Whether two players are friends, from memory: at least one of them must be {@linkplain #isLoaded loaded} (for + * example, both are online on this server). Otherwise use {@link #loadFriends}. + * + * @param a a player's UUID + * @param b another player's UUID + * @return true if they are friends; false if not, if neither is loaded, or if an argument is null + */ + boolean areFriends(UUID a, UUID b); + + /** + * The status of a player online anywhere on the network. + * + * @param player a player's UUID + * @return their status ({@link Status#OFFLINE} while they appear offline), or empty if they are not online + */ + Optional getStatus(UUID player); + + /** + * The backend server a player is on (proxies only). + * + * @param player a player's UUID + * @return the server name; empty on Paper, while connecting, if offline, or if they appear offline + */ + Optional getServer(UUID player); + + /** + * Pending requests the player received (network-wide). + * + * @param player a player's UUID + * @return an unmodifiable snapshot, possibly empty + */ + List getIncomingRequests(UUID player); + + /** + * Pending requests the player sent (network-wide). + * + * @param player a player's UUID + * @return an unmodifiable snapshot, possibly empty + */ + List getOutgoingRequests(UUID player); + + // --- Async: return at once, complete on Friends' threads --- + + /** + * Any player's friends: from memory if they are loaded, else from the database. Offline players' lists are + * cached for up to 30 seconds (and dropped as soon as a friendship of theirs changes), so repeated calls are + * cheap. + * + * @param player a player's UUID + * @return their friends (unmodifiable, no particular order; empty if none, never joined, or null); completes + * exceptionally only if the database fails, which Friends logs + */ + CompletableFuture> loadFriends(UUID player); + + /** + * {@code /f add}: sends a friend request, or makes them friends if the target had already asked the sender. + * Fires the request (or add) event, which can cancel it. + * + * @param sender a loaded player + * @param target any player who has joined before + * @return {@link Result#SUCCESS} (sent), {@link Result#BECAME_FRIENDS}, or why not: {@link Result#NOT_LOADED}, + * {@link Result#SELF}, {@link Result#PLAYER_NOT_FOUND}, {@link Result#ALREADY_FRIENDS}, + * {@link Result#ALREADY_REQUESTED}, {@link Result#LIMIT_REACHED}, {@link Result#TARGET_LIMIT_REACHED}, + * {@link Result#CANCELLED}, {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} + */ + CompletableFuture sendRequest(UUID sender, UUID target); + + /** + * {@code /f accept}: accepts a pending request. Fires the add event, which can cancel it (the request then stays + * pending). + * + * @param player a loaded player who received a request + * @param sender who sent it + * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#NO_REQUEST}, + * {@link Result#LIMIT_REACHED}, {@link Result#TARGET_LIMIT_REACHED}, {@link Result#CANCELLED}, + * {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} + */ + CompletableFuture acceptRequest(UUID player, UUID sender); + + /** + * {@code /f deny}: declines a pending request. + * + * @param player a loaded player who received a request + * @param sender who sent it + * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#NO_REQUEST}, + * {@link Result#INVALID_ARGUMENT} + */ + CompletableFuture denyRequest(UUID player, UUID sender); + + /** + * {@code /f remove}: ends a friendship (for both sides). Fires the remove event, which can cancel it. + * + * @param player a loaded player + * @param friend one of their friends + * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#NOT_FRIENDS}, + * {@link Result#CANCELLED}, {@link Result#INVALID_ARGUMENT} + */ + CompletableFuture removeFriend(UUID player, UUID friend); + + /** + * {@code /f best}: marks or unmarks a best friend (private to {@code player}). + * + * @param player a loaded player + * @param friend one of their friends + * @param best true to mark, false to unmark + * @return {@link Result#SUCCESS} (also if it already was so), or {@link Result#NOT_LOADED}, + * {@link Result#NOT_FRIENDS}, {@link Result#INVALID_ARGUMENT} + */ + CompletableFuture setBestFriend(UUID player, UUID friend, boolean best); + + /** + * {@code /f nickname}: sets the nickname {@code player} sees for a friend. + * + * @param player a loaded player + * @param friend one of their friends + * @param nickname 1 to 16 letters, digits, spaces or underscores; null clears it + * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#NOT_FRIENDS}, + * {@link Result#INVALID_ARGUMENT} + */ + CompletableFuture setNickname(UUID player, UUID friend, String nickname); + + /** + * {@code /status}: sets the status friends see. Fires the status change event, which can cancel it. + * + * @param player a loaded player + * @param status the new status ({@link Status#OFFLINE} = appear offline) + * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#CANCELLED}, + * {@link Result#INVALID_ARGUMENT} + */ + CompletableFuture setStatus(UUID player, Status status); +} diff --git a/api/src/main/java/com/friends/api/FriendsProvider.java b/api/src/main/java/com/friends/api/FriendsProvider.java new file mode 100644 index 0000000..bdacffd --- /dev/null +++ b/api/src/main/java/com/friends/api/FriendsProvider.java @@ -0,0 +1,27 @@ +package com.friends.api; + +/** Holds the running {@link FriendsAPI}. Only Friends itself calls this; other plugins use {@link FriendsAPI#get()}. */ +public final class FriendsProvider { + static volatile FriendsAPI instance; + + private FriendsProvider() {} + + /** + * Makes {@code api} the one {@link FriendsAPI#get()} returns. Called by Friends when it enables. + * + * @param api the running API + */ + public static synchronized void register(FriendsAPI api) { + instance = api; + } + + /** + * Forgets {@code api} if it is the registered one, so a disabled Friends (and its class loader) isn't kept alive. + * Called by Friends when it disables. + * + * @param api the API being shut down + */ + public static synchronized void unregister(FriendsAPI api) { + if (instance == api) instance = null; + } +} diff --git a/api/src/main/java/com/friends/api/Result.java b/api/src/main/java/com/friends/api/Result.java new file mode 100644 index 0000000..4ae2e84 --- /dev/null +++ b/api/src/main/java/com/friends/api/Result.java @@ -0,0 +1,42 @@ +package com.friends.api; + +/** Outcome of a {@link FriendsAPI} action. */ +public enum Result { + /** Done: the request was sent, accepted or denied, the friend removed, or the setting changed. */ + SUCCESS, + /** {@link FriendsAPI#sendRequest}: the target had already asked the sender, so they are now friends. */ + BECAME_FRIENDS, + /** The acting player is not online on this server (proxy), or their data is still loading. */ + NOT_LOADED, + /** The target player has never joined. */ + PLAYER_NOT_FOUND, + /** The player targeted themselves. */ + SELF, + /** They are already friends. */ + ALREADY_FRIENDS, + /** The sender already has a pending request to the target. */ + ALREADY_REQUESTED, + /** No pending request from that player (never sent, already answered or expired). */ + NO_REQUEST, + /** They are not friends. */ + NOT_FRIENDS, + /** The acting player has reached the friend limit ({@code max-friends}). */ + LIMIT_REACHED, + /** The target has reached the friend limit ({@code max-friends}). */ + TARGET_LIMIT_REACHED, + /** A null or invalid argument (for nicknames: 1 to 16 letters, digits, spaces or underscores). */ + INVALID_ARGUMENT, + /** A plugin cancelled the action's event. */ + CANCELLED, + /** A database error (Friends logs it). */ + ERROR; + + /** + * Whether the action happened. + * + * @return true for {@link #SUCCESS} and {@link #BECAME_FRIENDS} + */ + public boolean isSuccess() { + return this == SUCCESS || this == BECAME_FRIENDS; + } +} diff --git a/api/src/main/java/com/friends/api/Status.java b/api/src/main/java/com/friends/api/Status.java new file mode 100644 index 0000000..40df9c6 --- /dev/null +++ b/api/src/main/java/com/friends/api/Status.java @@ -0,0 +1,13 @@ +package com.friends.api; + +/** Online status a player shows their friends, like Hypixel's {@code /status}. */ +public enum Status { + /** Online and available (the default). */ + ONLINE, + /** Online, away from keyboard. */ + AWAY, + /** Online, do not disturb. */ + BUSY, + /** Online but appearing offline: friends see them offline and get no join/leave messages. */ + OFFLINE +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendAddEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendAddEvent.java new file mode 100644 index 0000000..f1c4a95 --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendAddEvent.java @@ -0,0 +1,69 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; + +/** + * Two players are about to become friends: {@code player} accepted {@code target}'s request (or asked back while one + * was pending). Cancel to prevent it; the request then stays pending. + * + *

To react to the friendship itself (rewards, statistics), listen to {@link FriendAddedEvent}, which fires exactly + * once per friendship that actually starts. + */ +public final class FriendAddEvent extends FriendEvent implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player accepting + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + */ + public FriendAddEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendAddedEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendAddedEvent.java new file mode 100644 index 0000000..ae2d213 --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendAddedEvent.java @@ -0,0 +1,57 @@ +package com.friends.api.bukkit; + +import java.time.Instant; +import java.util.UUID; + +import org.bukkit.event.HandlerList; + +/** + * Two players just became friends. Fired exactly once per new friendship, after the change is applied (the database + * write is queued). Not cancellable: see {@link FriendAddEvent}. + */ +public final class FriendAddedEvent extends FriendEvent { + private static final HandlerList HANDLERS = new HandlerList(); + private final Instant since; + + /** + * Created by Friends. + * + * @param playerId the player who accepted + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + * @param since when they became friends + */ + public FriendAddedEvent(UUID playerId, String playerName, UUID targetId, String targetName, Instant since) { + super(playerId, playerName, targetId, targetName); + this.since = since; + } + + /** + * When they became friends. + * + * @return never null + */ + public Instant getSince() { + return since; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendEvent.java new file mode 100644 index 0000000..f29512c --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendEvent.java @@ -0,0 +1,72 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.Event; + +/** + * Base of Friends' Bukkit events about two players: {@code player} acts on {@code target}. Fired by standalone + * Paper servers (on a network, Friends runs on the proxy and fires that platform's events). + * + *

Always asynchronous: listeners run on Friends' threads, never on the server thread, so schedule onto it + * ({@code Bukkit.getScheduler().runTask}) before using the world or most of the Bukkit API. Events hold UUIDs and + * names, never {@code Player} objects; look players up with {@code Bukkit.getPlayer(uuid)} if needed. + */ +public abstract class FriendEvent extends Event { + private final UUID playerId; + private final String playerName; + private final UUID targetId; + private final String targetName; + + /** + * Created by Friends. + * + * @param playerId the acting player + * @param playerName their name + * @param targetId the other player + * @param targetName their name + */ + protected FriendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(true); + this.playerId = playerId; + this.playerName = playerName; + this.targetId = targetId; + this.targetName = targetName; + } + + /** + * The acting player. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The acting player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The other player. + * + * @return their UUID, never null + */ + public UUID getTargetId() { + return targetId; + } + + /** + * The other player's name. + * + * @return never null + */ + public String getTargetName() { + return targetName; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java new file mode 100644 index 0000000..5a8c9c0 --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java @@ -0,0 +1,68 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; + +/** + * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: + * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * + *

To react to the removal itself, listen to {@link FriendRemovedEvent}. + */ +public final class FriendRemoveEvent extends FriendEvent implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player removing a friend + * @param playerName their name + * @param targetId the friend being removed + * @param targetName their name + */ + public FriendRemoveEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendRemovedEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendRemovedEvent.java new file mode 100644 index 0000000..a342e4a --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendRemovedEvent.java @@ -0,0 +1,44 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.HandlerList; + +/** + * A friendship just ended (for both sides). Fired exactly once per friendship, after the change is applied (the + * database write is queued). Not cancellable: see {@link FriendRemoveEvent}. + */ +public final class FriendRemovedEvent extends FriendEvent { + private static final HandlerList HANDLERS = new HandlerList(); + + /** + * Created by Friends. + * + * @param playerId the player who removed the friend + * @param playerName their name + * @param targetId the removed friend + * @param targetName their name + */ + public FriendRemovedEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendRequestSendEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendRequestSendEvent.java new file mode 100644 index 0000000..d6c107c --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendRequestSendEvent.java @@ -0,0 +1,68 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; + +/** + * A player is about to send a friend request (command or API). Cancel to block it. + * + *

Seen at {@code MONITOR} it is about to happen, unless a simultaneous change gets there first (then the action + * fails its re-check). Not fired when the target had already asked: that is a {@link FriendAddEvent}. + */ +public final class FriendRequestSendEvent extends FriendEvent implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player sending the request + * @param playerName their name + * @param targetId the player who would receive it + * @param targetName their name + */ + public FriendRequestSendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bukkit/FriendStatusChangeEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendStatusChangeEvent.java new file mode 100644 index 0000000..1e79813 --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendStatusChangeEvent.java @@ -0,0 +1,113 @@ +package com.friends.api.bukkit; + +import java.util.UUID; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import com.friends.api.Status; + +/** + * A player is about to change the status their friends see ({@code /status} or the API). Cancel to keep the old one. + * Asynchronous, like every Friends event (see {@link FriendEvent}). + */ +public final class FriendStatusChangeEvent extends Event implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private final UUID playerId; + private final String playerName; + private final Status oldStatus; + private final Status newStatus; + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player + * @param playerName their name + * @param oldStatus their current status + * @param newStatus the status they are switching to + */ + public FriendStatusChangeEvent(UUID playerId, String playerName, Status oldStatus, Status newStatus) { + super(true); + this.playerId = playerId; + this.playerName = playerName; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + } + + /** + * The player changing status. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The status before the change. + * + * @return never null + */ + public Status getOldStatus() { + return oldStatus; + } + + /** + * The status after the change. + * + * @return never null + */ + public Status getNewStatus() { + return newStatus; + } + + /** + * Whether a plugin cancelled the change. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the change. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendAddEvent.java b/api/src/main/java/com/friends/api/bungee/FriendAddEvent.java new file mode 100644 index 0000000..88003e8 --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendAddEvent.java @@ -0,0 +1,48 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +import net.md_5.bungee.api.plugin.Cancellable; + +/** + * Two players are about to become friends: {@code player} accepted {@code target}'s request (or asked back while one + * was pending). Cancel to prevent it; the request then stays pending. + * + *

To react to the friendship itself (rewards, statistics), listen to {@link FriendAddedEvent}, which fires exactly + * once per friendship that actually starts. + */ +public final class FriendAddEvent extends FriendEvent implements Cancellable { + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player accepting + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + */ + public FriendAddEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendAddedEvent.java b/api/src/main/java/com/friends/api/bungee/FriendAddedEvent.java new file mode 100644 index 0000000..55784de --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendAddedEvent.java @@ -0,0 +1,35 @@ +package com.friends.api.bungee; + +import java.time.Instant; +import java.util.UUID; + +/** + * Two players just became friends. Fired exactly once per new friendship, after the change is applied (the database + * write is queued). Not cancellable: see {@link FriendAddEvent}. + */ +public final class FriendAddedEvent extends FriendEvent { + private final Instant since; + + /** + * Created by Friends. + * + * @param playerId the player who accepted + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + * @param since when they became friends + */ + public FriendAddedEvent(UUID playerId, String playerName, UUID targetId, String targetName, Instant since) { + super(playerId, playerName, targetId, targetName); + this.since = since; + } + + /** + * When they became friends. + * + * @return never null + */ + public Instant getSince() { + return since; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendEvent.java b/api/src/main/java/com/friends/api/bungee/FriendEvent.java new file mode 100644 index 0000000..8427ce3 --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendEvent.java @@ -0,0 +1,70 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +import net.md_5.bungee.api.plugin.Event; + +/** + * Base of Friends' BungeeCord events about two players: {@code player} acts on {@code target}. + * + *

Called on Friends' threads, never on BungeeCord's network threads. On a multi-proxy network an event fires only + * on the proxy where the change happens, so once network-wide. Events hold UUIDs and names, never + * {@code ProxiedPlayer} objects; look players up with {@code ProxyServer.getInstance().getPlayer(uuid)} if needed. + */ +public abstract class FriendEvent extends Event { + private final UUID playerId; + private final String playerName; + private final UUID targetId; + private final String targetName; + + /** + * Created by Friends. + * + * @param playerId the acting player + * @param playerName their name + * @param targetId the other player + * @param targetName their name + */ + protected FriendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + this.playerId = playerId; + this.playerName = playerName; + this.targetId = targetId; + this.targetName = targetName; + } + + /** + * The acting player. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The acting player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The other player. + * + * @return their UUID, never null + */ + public UUID getTargetId() { + return targetId; + } + + /** + * The other player's name. + * + * @return never null + */ + public String getTargetName() { + return targetName; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java new file mode 100644 index 0000000..edec53f --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java @@ -0,0 +1,47 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +import net.md_5.bungee.api.plugin.Cancellable; + +/** + * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: + * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * + *

To react to the removal itself, listen to {@link FriendRemovedEvent}. + */ +public final class FriendRemoveEvent extends FriendEvent implements Cancellable { + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player removing a friend + * @param playerName their name + * @param targetId the friend being removed + * @param targetName their name + */ + public FriendRemoveEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendRemovedEvent.java b/api/src/main/java/com/friends/api/bungee/FriendRemovedEvent.java new file mode 100644 index 0000000..202fab9 --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendRemovedEvent.java @@ -0,0 +1,21 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +/** + * A friendship just ended (for both sides). Fired exactly once per friendship, after the change is applied (the + * database write is queued). Not cancellable: see {@link FriendRemoveEvent}. + */ +public final class FriendRemovedEvent extends FriendEvent { + /** + * Created by Friends. + * + * @param playerId the player who removed the friend + * @param playerName their name + * @param targetId the removed friend + * @param targetName their name + */ + public FriendRemovedEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendRequestSendEvent.java b/api/src/main/java/com/friends/api/bungee/FriendRequestSendEvent.java new file mode 100644 index 0000000..198c89f --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendRequestSendEvent.java @@ -0,0 +1,47 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +import net.md_5.bungee.api.plugin.Cancellable; + +/** + * A player is about to send a friend request (command or API). Cancel to block it. + * + *

Seen by a last-order listener it is about to happen, unless a simultaneous change gets there first (then the + * action fails its re-check). Not fired when the target had already asked: that is a {@link FriendAddEvent}. + */ +public final class FriendRequestSendEvent extends FriendEvent implements Cancellable { + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player sending the request + * @param playerName their name + * @param targetId the player who would receive it + * @param targetName their name + */ + public FriendRequestSendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendStatusChangeEvent.java b/api/src/main/java/com/friends/api/bungee/FriendStatusChangeEvent.java new file mode 100644 index 0000000..ff1b999 --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendStatusChangeEvent.java @@ -0,0 +1,91 @@ +package com.friends.api.bungee; + +import java.util.UUID; + +import com.friends.api.Status; + +import net.md_5.bungee.api.plugin.Cancellable; +import net.md_5.bungee.api.plugin.Event; + +/** + * A player is about to change the status their friends see ({@code /status} or the API). Cancel to keep the old one. + * Called on Friends' threads, like every Friends event (see {@link FriendEvent}). + */ +public final class FriendStatusChangeEvent extends Event implements Cancellable { + private final UUID playerId; + private final String playerName; + private final Status oldStatus; + private final Status newStatus; + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the player + * @param playerName their name + * @param oldStatus their current status + * @param newStatus the status they are switching to + */ + public FriendStatusChangeEvent(UUID playerId, String playerName, Status oldStatus, Status newStatus) { + this.playerId = playerId; + this.playerName = playerName; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + } + + /** + * The player changing status. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The status before the change. + * + * @return never null + */ + public Status getOldStatus() { + return oldStatus; + } + + /** + * The status after the change. + * + * @return never null + */ + public Status getNewStatus() { + return newStatus; + } + + /** + * Whether a plugin cancelled the action. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the action. Friends tells nobody: the cancelling plugin should explain it to the player. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendAddEvent.java b/api/src/main/java/com/friends/api/velocity/FriendAddEvent.java new file mode 100644 index 0000000..c306c91 --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendAddEvent.java @@ -0,0 +1,50 @@ +package com.friends.api.velocity; + +import java.util.Objects; +import java.util.UUID; + +import com.velocitypowered.api.event.ResultedEvent; + +/** + * Two players are about to become friends: {@code player} accepted {@code target}'s request (or asked back while one + * was pending). Cancel to prevent it; the request then stays pending. + * + *

To react to the friendship itself (rewards, statistics), listen to {@link FriendAddedEvent}, which fires exactly + * once per friendship that actually starts. + */ +public final class FriendAddEvent extends FriendEvent implements ResultedEvent { + private GenericResult result = GenericResult.allowed(); + + /** + * Created by Friends. + * + * @param playerId the player accepting + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + */ + public FriendAddEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether the action may go ahead. + * + * @return {@code allowed()} unless a plugin denied it + */ + @Override + public GenericResult getResult() { + return result; + } + + /** + * {@code GenericResult.denied()} cancels the action. Friends tells nobody: the cancelling plugin should explain it + * to the player. + * + * @param result {@code allowed()} or {@code denied()} + */ + @Override + public void setResult(GenericResult result) { + this.result = Objects.requireNonNull(result, "result"); + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendAddedEvent.java b/api/src/main/java/com/friends/api/velocity/FriendAddedEvent.java new file mode 100644 index 0000000..9efb39a --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendAddedEvent.java @@ -0,0 +1,35 @@ +package com.friends.api.velocity; + +import java.time.Instant; +import java.util.UUID; + +/** + * Two players just became friends. Fired exactly once per new friendship, after the change is applied (the database + * write is queued). Not cancellable: see {@link FriendAddEvent}. + */ +public final class FriendAddedEvent extends FriendEvent { + private final Instant since; + + /** + * Created by Friends. + * + * @param playerId the player who accepted + * @param playerName their name + * @param targetId the player who sent the request + * @param targetName their name + * @param since when they became friends + */ + public FriendAddedEvent(UUID playerId, String playerName, UUID targetId, String targetName, Instant since) { + super(playerId, playerName, targetId, targetName); + this.since = since; + } + + /** + * When they became friends. + * + * @return never null + */ + public Instant getSince() { + return since; + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendEvent.java b/api/src/main/java/com/friends/api/velocity/FriendEvent.java new file mode 100644 index 0000000..3c7725c --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendEvent.java @@ -0,0 +1,68 @@ +package com.friends.api.velocity; + +import java.util.UUID; + +/** + * Base of Friends' Velocity events about two players: {@code player} acts on {@code target}. + * + *

Fired on Friends' threads (Friends waits for the listeners of cancellable events). On a multi-proxy network an + * event fires only on the proxy where the change happens, so once network-wide. Events hold UUIDs and names, never + * {@code Player} objects; look players up with {@code ProxyServer.getPlayer(uuid)} if needed. + */ +public abstract class FriendEvent { + private final UUID playerId; + private final String playerName; + private final UUID targetId; + private final String targetName; + + /** + * Created by Friends. + * + * @param playerId the acting player + * @param playerName their name + * @param targetId the other player + * @param targetName their name + */ + protected FriendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + this.playerId = playerId; + this.playerName = playerName; + this.targetId = targetId; + this.targetName = targetName; + } + + /** + * The acting player. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The acting player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The other player. + * + * @return their UUID, never null + */ + public UUID getTargetId() { + return targetId; + } + + /** + * The other player's name. + * + * @return never null + */ + public String getTargetName() { + return targetName; + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java new file mode 100644 index 0000000..047078a --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java @@ -0,0 +1,49 @@ +package com.friends.api.velocity; + +import java.util.Objects; +import java.util.UUID; + +import com.velocitypowered.api.event.ResultedEvent; + +/** + * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: + * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * + *

To react to the removal itself, listen to {@link FriendRemovedEvent}. + */ +public final class FriendRemoveEvent extends FriendEvent implements ResultedEvent { + private GenericResult result = GenericResult.allowed(); + + /** + * Created by Friends. + * + * @param playerId the player removing a friend + * @param playerName their name + * @param targetId the friend being removed + * @param targetName their name + */ + public FriendRemoveEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether the action may go ahead. + * + * @return {@code allowed()} unless a plugin denied it + */ + @Override + public GenericResult getResult() { + return result; + } + + /** + * {@code GenericResult.denied()} cancels the action. Friends tells nobody: the cancelling plugin should explain it + * to the player. + * + * @param result {@code allowed()} or {@code denied()} + */ + @Override + public void setResult(GenericResult result) { + this.result = Objects.requireNonNull(result, "result"); + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendRemovedEvent.java b/api/src/main/java/com/friends/api/velocity/FriendRemovedEvent.java new file mode 100644 index 0000000..a8be8a8 --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendRemovedEvent.java @@ -0,0 +1,21 @@ +package com.friends.api.velocity; + +import java.util.UUID; + +/** + * A friendship just ended (for both sides). Fired exactly once per friendship, after the change is applied (the + * database write is queued). Not cancellable: see {@link FriendRemoveEvent}. + */ +public final class FriendRemovedEvent extends FriendEvent { + /** + * Created by Friends. + * + * @param playerId the player who removed the friend + * @param playerName their name + * @param targetId the removed friend + * @param targetName their name + */ + public FriendRemovedEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendRequestSendEvent.java b/api/src/main/java/com/friends/api/velocity/FriendRequestSendEvent.java new file mode 100644 index 0000000..16026aa --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendRequestSendEvent.java @@ -0,0 +1,49 @@ +package com.friends.api.velocity; + +import java.util.Objects; +import java.util.UUID; + +import com.velocitypowered.api.event.ResultedEvent; + +/** + * A player is about to send a friend request (command or API). Cancel to block it. + * + *

Seen by a last-order listener it is about to happen, unless a simultaneous change gets there first (then the + * action fails its re-check). Not fired when the target had already asked: that is a {@link FriendAddEvent}. + */ +public final class FriendRequestSendEvent extends FriendEvent implements ResultedEvent { + private GenericResult result = GenericResult.allowed(); + + /** + * Created by Friends. + * + * @param playerId the player sending the request + * @param playerName their name + * @param targetId the player who would receive it + * @param targetName their name + */ + public FriendRequestSendEvent(UUID playerId, String playerName, UUID targetId, String targetName) { + super(playerId, playerName, targetId, targetName); + } + + /** + * Whether the action may go ahead. + * + * @return {@code allowed()} unless a plugin denied it + */ + @Override + public GenericResult getResult() { + return result; + } + + /** + * {@code GenericResult.denied()} cancels the action. Friends tells nobody: the cancelling plugin should explain it + * to the player. + * + * @param result {@code allowed()} or {@code denied()} + */ + @Override + public void setResult(GenericResult result) { + this.result = Objects.requireNonNull(result, "result"); + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendStatusChangeEvent.java b/api/src/main/java/com/friends/api/velocity/FriendStatusChangeEvent.java new file mode 100644 index 0000000..542f5eb --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendStatusChangeEvent.java @@ -0,0 +1,91 @@ +package com.friends.api.velocity; + +import java.util.Objects; +import java.util.UUID; + +import com.friends.api.Status; +import com.velocitypowered.api.event.ResultedEvent; + +/** + * A player is about to change the status their friends see ({@code /status} or the API). Cancel to keep the old one. + * Fired on Friends' threads, like every Friends event (see {@link FriendEvent}). + */ +public final class FriendStatusChangeEvent implements ResultedEvent { + private final UUID playerId; + private final String playerName; + private final Status oldStatus; + private final Status newStatus; + private GenericResult result = GenericResult.allowed(); + + /** + * Created by Friends. + * + * @param playerId the player + * @param playerName their name + * @param oldStatus their current status + * @param newStatus the status they are switching to + */ + public FriendStatusChangeEvent(UUID playerId, String playerName, Status oldStatus, Status newStatus) { + this.playerId = playerId; + this.playerName = playerName; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + } + + /** + * The player changing status. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The status before the change. + * + * @return never null + */ + public Status getOldStatus() { + return oldStatus; + } + + /** + * The status after the change. + * + * @return never null + */ + public Status getNewStatus() { + return newStatus; + } + + /** + * Whether the action may go ahead. + * + * @return {@code allowed()} unless a plugin denied it + */ + @Override + public GenericResult getResult() { + return result; + } + + /** + * {@code GenericResult.denied()} cancels the action. Friends tells nobody: the cancelling plugin should explain it + * to the player. + * + * @param result {@code allowed()} or {@code denied()} + */ + @Override + public void setResult(GenericResult result) { + this.result = Objects.requireNonNull(result, "result"); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d83a403..9a91df9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { allprojects { group = "com.friends" - version = "0.4.0" + version = "0.5.0" repositories { mavenCentral() diff --git a/bungee/build.gradle.kts b/bungee/build.gradle.kts index 25c9f02..2f2cf9d 100644 --- a/bungee/build.gradle.kts +++ b/bungee/build.gradle.kts @@ -8,6 +8,11 @@ dependencies { compileOnly("net.md-5:bungeecord-api:1.21-R0.4") compileOnly("net.luckperms:api:5.5") + testImplementation("net.md-5:bungeecord-api:1.21-R0.4") + testImplementation(platform("org.junit:junit-bom:6.1.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // BungeeCord has no Adventure or SLF4J: bundle them (relocated below); SLF4J logs to BungeeCord's JUL logger. implementation(platform("net.kyori:adventure-bom:5.2.0")) implementation("net.kyori:adventure-api") @@ -19,6 +24,7 @@ dependencies { } tasks.shadowJar { + exclude("com/friends/api/bukkit/**", "com/friends/api/velocity/**") // other platforms' events relocate("net.kyori", "com.friends.lib.kyori") relocate("org.slf4j", "com.friends.lib.slf4j") relocate("com.google.gson", "com.friends.lib.gson") diff --git a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java new file mode 100644 index 0000000..589611c --- /dev/null +++ b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java @@ -0,0 +1,62 @@ +package com.friends.bungee; + +import java.time.Instant; +import java.util.function.Consumer; + +import com.friends.api.Status; +import com.friends.api.bungee.FriendAddEvent; +import com.friends.api.bungee.FriendAddedEvent; +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.Hooks; +import com.friends.common.Storage.PlayerRow; + +import net.md_5.bungee.api.plugin.Cancellable; +import net.md_5.bungee.api.plugin.Event; + +/** The core's hooks as BungeeCord events, called on Friends' threads (never on BungeeCord's network threads). */ +final class BungeeHooks implements Hooks { + private final Consumer call; // PluginManager::callEvent + + BungeeHooks(Consumer call) { + this.call = call; + } + + @Override + public boolean requesting(PlayerRow from, PlayerRow to) { + return allowed(new FriendRequestSendEvent(from.id(), from.name(), to.id(), to.name())); + } + + @Override + public boolean befriending(PlayerRow player, PlayerRow sender) { + return allowed(new FriendAddEvent(player.id(), player.name(), sender.id(), sender.name())); + } + + @Override + public void befriended(PlayerRow player, PlayerRow sender, Instant since) { + call.accept(new FriendAddedEvent(player.id(), player.name(), sender.id(), sender.name(), since)); + } + + @Override + public boolean unfriending(PlayerRow player, Friend friend) { + return allowed(new FriendRemoveEvent(player.id(), player.name(), friend.id(), friend.name())); + } + + @Override + public void unfriended(PlayerRow player, Friend friend) { + call.accept(new FriendRemovedEvent(player.id(), player.name(), friend.id(), friend.name())); + } + + @Override + public boolean statusChanging(PlayerRow player, Status from, Status to) { + return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); + } + + private boolean allowed(E event) { + call.accept(event); // BungeeCord's event bus catches (and logs) listeners' exceptions + return !event.isCancelled(); + } +} diff --git a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java index 22d62bf..57889e2 100644 --- a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java +++ b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java @@ -62,7 +62,7 @@ public void onEnable() { @Override public String prefix(UUID id) { return prefixes.apply(id); } @Override public void send(ProxiedPlayer p, BaseComponent[] message) { p.sendMessage(message); } }); - runtime = new FriendsRuntime(settings, storage, network, sessions, log); + runtime = new FriendsRuntime(settings, storage, network, sessions, new BungeeHooks(getProxy().getPluginManager()::callEvent), log); log.info("Friends enabled (proxy id: {})", network.proxyId()); } catch (IOException | SQLException | IllegalArgumentException e) { log.error("Friends is disabled: {}", e.getMessage(), e); diff --git a/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java b/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java new file mode 100644 index 0000000..3c8be2d --- /dev/null +++ b/bungee/src/test/java/com/friends/bungee/BungeeHooksTest.java @@ -0,0 +1,117 @@ +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/build.gradle.kts b/common/build.gradle.kts index 470e499..e97fdb6 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -3,6 +3,8 @@ plugins { } dependencies { + api(project(":api")) + // Provided at runtime by Velocity (and Paper), so compileOnly here. compileOnly(platform("net.kyori:adventure-bom:5.2.0")) compileOnly("net.kyori:adventure-api") diff --git a/common/src/main/java/com/friends/common/FriendCommand.java b/common/src/main/java/com/friends/common/FriendCommand.java index ad43a14..d526d84 100644 --- a/common/src/main/java/com/friends/common/FriendCommand.java +++ b/common/src/main/java/com/friends/common/FriendCommand.java @@ -8,6 +8,9 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; +import com.friends.api.Result; +import com.friends.api.Status; +import com.friends.common.Friends.Target; import com.friends.common.Platform.Online; /** Parses {@code /friend ...} arguments. {@code /fl} and {@code /status} arrive as "list"/"status". */ @@ -36,33 +39,33 @@ public static String[] completing(String[] args) { return args.length == 0 ? new String[] {""} : args; } - public CompletableFuture execute(Online s, String[] args) { + public CompletableFuture execute(Online s, String[] args) { String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT); - String arg = args.length > 1 ? args[1] : null; - CompletableFuture result = switch (sub) { + Target arg = args.length > 1 ? Target.named(args[1]) : null; + CompletableFuture result = switch (sub) { case "help" -> friends.help(s); case "add" -> arg == null ? usage(s, "/f add ") : friends.add(s, arg); case "accept" -> arg == null ? usage(s, "/f accept ") : friends.accept(s, arg); case "deny", "decline" -> arg == null ? usage(s, "/f deny ") : friends.deny(s, arg); case "remove", "delete" -> arg == null ? usage(s, "/f remove ") : friends.remove(s, arg); - case "best" -> arg == null ? usage(s, "/f best ") : friends.best(s, arg); + case "best" -> arg == null ? usage(s, "/f best ") : friends.best(s, arg, null); case "nickname", "nick" -> arg == null ? usage(s, "/f nickname [nickname]") : friends.nickname(s, arg, args.length > 2 ? String.join(" ", Arrays.copyOfRange(args, 2, args.length)) : null); case "list" -> list(s, args); case "requests" -> friends.requests(s); - case "removeall" -> friends.removeAll(s, "confirm".equalsIgnoreCase(arg)); + case "removeall" -> friends.removeAll(s, args.length > 1 && "confirm".equalsIgnoreCase(args[1])); case "notifications", "notification", "notif" -> friends.toggleNotifications(s); - case "status" -> status(s, arg); - default -> args.length == 1 ? friends.add(s, args[0]) : friends.help(s); // "/f Steve" = "/f add Steve" + case "status" -> status(s, args.length > 1 ? args[1] : null); + default -> args.length == 1 ? friends.add(s, Target.named(args[0])) : friends.help(s); // "/f Steve" = "/f add Steve" }; return result.exceptionally(_ -> { s.audience().sendMessage(Messages.error()); - return null; + return Result.ERROR; }); } - private CompletableFuture list(Online s, String[] args) { + private CompletableFuture list(Online s, String[] args) { boolean best = args.length > 1 && args[1].equalsIgnoreCase("best"); int pageArg = best ? 2 : 1; int page = 1; @@ -76,7 +79,7 @@ private CompletableFuture list(Online s, String[] args) { return friends.list(s, page, best); } - private CompletableFuture status(Online s, String arg) { + private CompletableFuture status(Online s, String arg) { if (arg == null) return friends.status(s, null); return switch (arg.toLowerCase(Locale.ROOT)) { case "online" -> friends.status(s, Status.ONLINE); @@ -87,9 +90,9 @@ private CompletableFuture status(Online s, String arg) { }; } - private static CompletableFuture usage(Online s, String usage) { + private static CompletableFuture usage(Online s, String usage) { s.audience().sendMessage(Messages.usage(usage)); - return completedFuture(null); + return completedFuture(Result.INVALID_ARGUMENT); } public List suggest(Online s, String[] args) { diff --git a/common/src/main/java/com/friends/common/Friends.java b/common/src/main/java/com/friends/common/Friends.java index e17eca0..acac560 100644 --- a/common/src/main/java/com/friends/common/Friends.java +++ b/common/src/main/java/com/friends/common/Friends.java @@ -19,10 +19,13 @@ import java.util.concurrent.Executor; import java.util.function.UnaryOperator; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.slf4j.Logger; +import com.friends.api.Result; +import com.friends.api.Status; import com.friends.common.Platform.Online; import com.friends.common.Storage.PlayerRow; @@ -32,7 +35,8 @@ /** * Friends core: rules, caches and messaging. Reads come from memory; SQL writes are queued on {@code db}, which must * run tasks one at a time in order. Anything other proxies must see is an {@link Event}: applied here via - * {@link #apply}, then published on the {@link Network}, whose other proxies apply the same event. + * {@link #apply}, then published on the {@link Network}, whose other proxies apply the same event. Commands also back + * the API: each returns a {@link Result} besides replying to the player, and asks {@link Hooks} (plugins) first. */ public final class Friends implements Network.Listener { public static final int PAGE_SIZE = 10; @@ -68,6 +72,8 @@ private interface SqlRun { private final Clock clock; private final Duration requestExpiry; private final int maxFriends; + private final Hooks hooks; + private final Executor async; // asks plugins (hooks) off the database thread private final Logger log; // Every state change (decide, update caches, queue the SQL write, publish) happens under this lock, so SQL writes // are queued in the same order as the cache changes. Reads stay lock-free (concurrent maps). @@ -80,9 +86,18 @@ private interface SqlRun { private final Set stale = ConcurrentHashMap.newKeySet(); // Pending requests, network-wide. ponytail: memory/Redis only; they expire within minutes anyway. private final Map requests = new ConcurrentHashMap<>(); - - public Friends(Storage storage, Platform platform, Network network, Executor db, Clock clock, - Duration requestExpiry, int maxFriends, Logger log) { + // Friends of players not loaded here, read for the API. An entry is dropped as soon as a friendship or the presence + // of its player changes, so only the friends' names and last-seen times can be up to OFFLINE_TTL old. + // ponytail: evicts arbitrary entries once full; Caffeine if hit rates ever matter. + private static final int OFFLINE_MAX = 1024; + private static final Duration OFFLINE_TTL = Duration.ofSeconds(30); + private final Map offline = new ConcurrentHashMap<>(); + + private record Cached(CompletableFuture> friends, Instant expires) {} + + /** {@code db} runs SQL in order, one task at a time; {@code async} runs whatever asks plugins (any threads). */ + public Friends(Storage storage, Platform platform, Network network, Executor db, Executor async, Clock clock, + Duration requestExpiry, int maxFriends, Hooks hooks, Logger log) { this.storage = storage; this.platform = platform; this.network = network; @@ -91,6 +106,8 @@ public Friends(Storage storage, Platform platform, Network network, Executor db, this.clock = clock; this.requestExpiry = requestExpiry; this.maxFriends = maxFriends; + this.hooks = hooks; + this.async = async; this.log = log; } @@ -150,6 +167,7 @@ void apply(Event event) { private void applyLocked(Event event) { switch (event) { case Event.Joined(UUID id, Presence p) -> { + offline.remove(id); presence.put(id, p); Instant now = clock.instant(); forFriendsOf(id, f -> f.seen(p.name(), p.prefix(), now), p.status() != Status.OFFLINE, true); @@ -157,6 +175,7 @@ private void applyLocked(Event event) { case Event.Updated(UUID id, Presence p) -> presence.compute(id, (_, old) -> old == null || old.proxy().equals(p.proxy()) ? p : old); case Event.Left(UUID id, String from, Instant lastSeen) -> { + offline.remove(id); Presence p = presence.get(id); if (p == null || !p.proxy().equals(from) || !presence.remove(id, p)) return; // moved proxies meanwhile forFriendsOf(id, f -> f.seen(f.name(), f.prefix(), lastSeen), p.status() != Status.OFFLINE, false); @@ -169,13 +188,17 @@ private void applyLocked(Event event) { case Event.Befriended(PlayerRow a, PlayerRow b, Instant since) -> { requests.remove(new Request.Key(a.id(), b.id())); requests.remove(new Request.Key(b.id(), a.id())); + offline.remove(a.id()); + offline.remove(b.id()); link(a, b, since); link(b, a, since); } case Event.Unfriended(UUID player, List others) -> { Profile me = profiles.get(player); if (me == null) markIfLoading(player); + offline.remove(player); for (UUID other : others) { + offline.remove(other); if (me != null) me.friends.remove(other); Profile theirs = profiles.get(other); if (theirs != null) theirs.friends.remove(player); @@ -285,147 +308,200 @@ public void expireRequests() { } } - // --- commands --- + // --- commands (and API actions): each replies to the player and returns what happened --- + + /** Who a command is about: the name a player typed, or a UUID (API). */ + record Target(UUID id, String name) { + static Target named(String name) { + return new Target(null, name); + } + + static Target of(UUID id) { + return new Target(id, null); + } + + boolean is(UUID otherId, String otherName) { + return id != null ? id.equals(otherId) : name.equalsIgnoreCase(otherName); + } + + /** How messages name it. */ + String label() { + return name != null ? name : id.toString(); + } + } - public CompletableFuture help(Online s) { - return reply(s, Messages.help()); + public CompletableFuture help(Online s) { + return reply(s, Messages.help(), Result.SUCCESS); } - public CompletableFuture add(Online s, String name) { + public CompletableFuture add(Online s, Target target) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); - if (s.name().equalsIgnoreCase(name)) return reply(s, Messages.addSelf()); - return resolve(name).thenCompose(found -> { - if (found.isEmpty()) return reply(s, Messages.notFound(name)); - PlayerRow target = found.get(); - return friendCount(target.id()).thenCompose(count -> { - Request theirs; - synchronized (lock) { // all checks and the send in one step - Component targetName = Messages.name(target); - if (me.friends.containsKey(target.id())) return reply(s, Messages.alreadyFriends(targetName)); - theirs = valid(requests.get(new Request.Key(target.id(), s.id()))); - if (theirs == null) { - if (valid(requests.get(new Request.Key(s.id(), target.id()))) != null) return reply(s, Messages.alreadySent(targetName)); - if (me.friends.size() >= maxFriends) return reply(s, Messages.limitSelf(maxFriends)); - if (count >= maxFriends) return reply(s, Messages.limitOther(targetName)); - emit(new Event.RequestSent(new Request(row(s), target, clock.instant().plus(requestExpiry)))); - return reply(s, Messages.sent(targetName, requestExpiry.toMinutes())); - } + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + if (target.is(s.id(), s.name())) return reply(s, Messages.addSelf(), Result.SELF); + return resolve(target).thenCompose(found -> found.isEmpty() + ? reply(s, Messages.notFound(target.label()), Result.PLAYER_NOT_FOUND) + : friendCount(found.get().id()).thenComposeAsync(count -> add(s, me, found.get(), count, false), async)); + } + + /** All checks and the send in one locked step, asking plugins in between ({@code vetted}: they agreed). */ + private CompletableFuture add(Online s, Profile me, PlayerRow target, int count, boolean vetted) { + Request theirs; + synchronized (lock) { + Component targetName = Messages.name(target); + if (me.friends.containsKey(target.id())) return reply(s, Messages.alreadyFriends(targetName), Result.ALREADY_FRIENDS); + theirs = valid(requests.get(new Request.Key(target.id(), s.id()))); + if (theirs == null) { + if (valid(requests.get(new Request.Key(s.id(), target.id()))) != null) { + return reply(s, Messages.alreadySent(targetName), Result.ALREADY_REQUESTED); } - return accept(s, me, theirs); // both asked: just make them friends (outside the lock: it may claim) - }); - }); + if (me.friends.size() >= maxFriends) return reply(s, Messages.limitSelf(maxFriends), Result.LIMIT_REACHED); + if (count >= maxFriends) return reply(s, Messages.limitOther(targetName), Result.TARGET_LIMIT_REACHED); + if (vetted) { + emit(new Event.RequestSent(new Request(row(s), target, clock.instant().plus(requestExpiry)))); + return reply(s, Messages.sent(targetName, requestExpiry.toMinutes()), Result.SUCCESS); + } + } + } + if (theirs != null) { // both asked: just make them friends (outside the lock: it may claim) + return accept(s, me, theirs).thenApply(r -> r == Result.SUCCESS ? Result.BECAME_FRIENDS : r); + } + // Plugins are asked outside the lock (their listeners may be slow), so everything is checked again after. + return hooks.requesting(row(s), target) ? add(s, me, target, count, true) : completedFuture(Result.CANCELLED); } - public CompletableFuture accept(Online s, String name) { + public CompletableFuture accept(Online s, Target target) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); - Request r = incoming(s.id()).filter(x -> x.from().name().equalsIgnoreCase(name)).findFirst().orElse(null); - return r == null ? reply(s, Messages.noRequest(name)) : accept(s, me, r); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + Request r = incoming(s.id()).filter(x -> target.is(x.from().id(), x.from().name())).findFirst().orElse(null); + return r == null ? reply(s, Messages.noRequest(target.label()), Result.NO_REQUEST) : accept(s, me, r); } - private CompletableFuture accept(Online s, Profile me, Request r) { + private CompletableFuture accept(Online s, Profile me, Request r) { PlayerRow other = r.from(); - if (me.friends.size() >= maxFriends) return reply(s, Messages.limitSelf(maxFriends)); - return friendCount(other.id()).thenCompose(count -> { - if (count >= maxFriends) return reply(s, Messages.limitOther(Messages.name(other))); - if (!network.claim(s.id(), other.id())) return reply(s, Messages.noRequest(other.name())); // lost a crossing accept + if (me.friends.size() >= maxFriends) return reply(s, Messages.limitSelf(maxFriends), Result.LIMIT_REACHED); + return friendCount(other.id()).thenComposeAsync(count -> { + if (count >= maxFriends) return reply(s, Messages.limitOther(Messages.name(other)), Result.TARGET_LIMIT_REACHED); + PlayerRow mine = row(s); + if (!hooks.befriending(mine, other)) return completedFuture(Result.CANCELLED); + if (!network.claim(s.id(), other.id())) return reply(s, Messages.noRequest(other.name()), Result.NO_REQUEST); // lost a crossing accept synchronized (lock) { - if (!requests.remove(r.key(), r)) return reply(s, Messages.noRequest(other.name())); // expired/raced - if (me.friends.containsKey(other.id())) return completedFuture(null); // crossing accepts: already done + if (!requests.remove(r.key(), r)) return reply(s, Messages.noRequest(other.name()), Result.NO_REQUEST); // expired/raced + if (me.friends.containsKey(other.id())) return completedFuture(Result.ALREADY_FRIENDS); // crossing accepts: already done Instant now = clock.instant(); write(() -> storage.addFriendship(s.id(), other.id(), now)); - emit(new Event.Befriended(other, row(s), now)); // tells both sides, wherever they are - return completedFuture(null); + emit(new Event.Befriended(other, mine, now)); // tells both sides, wherever they are + hooks.befriended(mine, other, now); + return completedFuture(Result.SUCCESS); } - }); + }, async); } - public CompletableFuture deny(Online s, String name) { + public CompletableFuture deny(Online s, Target target) { synchronized (lock) { - Request r = incoming(s.id()).filter(x -> x.from().name().equalsIgnoreCase(name)).findFirst().orElse(null); - if (r == null || !requests.remove(r.key(), r)) return reply(s, Messages.noRequest(name)); + Request r = incoming(s.id()).filter(x -> target.is(x.from().id(), x.from().name())).findFirst().orElse(null); + if (r == null || !requests.remove(r.key(), r)) return reply(s, Messages.noRequest(target.label()), Result.NO_REQUEST); emit(new Event.RequestDenied(r.from().id(), s.id())); - return reply(s, Messages.declined(Messages.name(r.from()))); + return reply(s, Messages.declined(Messages.name(r.from())), Result.SUCCESS); } } - public CompletableFuture remove(Online s, String name) { - return withFriend(s, name, (_, f) -> { + public CompletableFuture remove(Online s, Target target) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + Friend f = find(me, target); + if (f == null) return reply(s, Messages.notFriend(target.label()), Result.NOT_FRIENDS); + PlayerRow mine = row(s); + if (!hooks.unfriending(mine, f)) return completedFuture(Result.CANCELLED); + synchronized (lock) { + if (!me.friends.containsKey(f.id())) return reply(s, Messages.notFriend(target.label()), Result.NOT_FRIENDS); // meanwhile write(() -> storage.removeFriendships(s.id(), List.of(f.id()))); emit(new Event.Unfriended(s.id(), List.of(f.id()))); - return reply(s, Messages.removed(Messages.name(f.prefix(), f.name()))); - }); + hooks.unfriended(mine, f); + return reply(s, Messages.removed(Messages.name(f.prefix(), f.name())), Result.SUCCESS); + } } - public CompletableFuture removeAll(Online s, boolean confirmed) { + public CompletableFuture removeAll(Online s, boolean confirmed) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + List victims = me.friends.values().stream().filter(f -> !f.best()).toList(); + if (victims.isEmpty()) return reply(s, Messages.nothingToRemove(), Result.NOT_FRIENDS); + if (!confirmed) return reply(s, Messages.removeAllConfirm(victims.size()), Result.SUCCESS); + PlayerRow mine = row(s); + Set allowed = victims.stream().filter(f -> hooks.unfriending(mine, f)).map(Friend::id).collect(Collectors.toSet()); + if (allowed.isEmpty()) return completedFuture(Result.CANCELLED); synchronized (lock) { - List victims = me.friends.values().stream().filter(f -> !f.best()).map(Friend::id).toList(); - if (victims.isEmpty()) return reply(s, Messages.nothingToRemove()); - if (!confirmed) return reply(s, Messages.removeAllConfirm(victims.size())); - write(() -> storage.removeFriendships(s.id(), victims)); - emit(new Event.Unfriended(s.id(), victims)); - return reply(s, Messages.removedAll(victims.size())); + List removed = me.friends.values().stream().filter(f -> !f.best() && allowed.contains(f.id())).toList(); + if (removed.isEmpty()) return reply(s, Messages.nothingToRemove(), Result.NOT_FRIENDS); + List ids = removed.stream().map(Friend::id).toList(); + write(() -> storage.removeFriendships(s.id(), ids)); + emit(new Event.Unfriended(s.id(), ids)); + removed.forEach(f -> hooks.unfriended(mine, f)); + return reply(s, Messages.removedAll(ids.size()), Result.SUCCESS); } } - public CompletableFuture best(Online s, String name) { - return withFriend(s, name, (me, f) -> { - boolean best = !f.best(); - me.friends.put(f.id(), f.withBest(best)); - write(() -> storage.setBest(s.id(), f.id(), best)); - return reply(s, Messages.best(Messages.name(f.prefix(), f.name()), best)); + /** {@code best} null toggles. */ + public CompletableFuture best(Online s, Target target, Boolean best) { + return withFriend(s, target, (me, f) -> { + boolean on = best != null ? best : !f.best(); + me.friends.put(f.id(), f.withBest(on)); + write(() -> storage.setBest(s.id(), f.id(), on)); + return reply(s, Messages.best(Messages.name(f.prefix(), f.name()), on), Result.SUCCESS); }); } /** {@code nickname} null clears it. */ - public CompletableFuture nickname(Online s, String name, String nickname) { - if (nickname != null && !NICKNAME.matcher(nickname).matches()) return reply(s, Messages.invalidNickname()); - return withFriend(s, name, (me, f) -> { + public CompletableFuture nickname(Online s, Target target, String nickname) { + if (nickname != null && !NICKNAME.matcher(nickname).matches()) return reply(s, Messages.invalidNickname(), Result.INVALID_ARGUMENT); + return withFriend(s, target, (me, f) -> { me.friends.put(f.id(), f.withNickname(nickname)); write(() -> storage.setNickname(s.id(), f.id(), nickname)); - return reply(s, Messages.nickname(Messages.name(f.prefix(), f.name()), nickname)); + return reply(s, Messages.nickname(Messages.name(f.prefix(), f.name()), nickname), Result.SUCCESS); }); } - public CompletableFuture toggleNotifications(Online s) { + public CompletableFuture toggleNotifications(Online s) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); synchronized (lock) { boolean enabled = !me.notifications; me.notifications = enabled; write(() -> storage.setNotifications(s.id(), enabled)); - return reply(s, Messages.notifications(enabled)); + return reply(s, Messages.notifications(enabled), Result.SUCCESS); } } /** {@code status} null shows the current status with clickable options. */ - public CompletableFuture status(Online s, Status status) { + public CompletableFuture status(Online s, Status status) { + Presence current = presence.get(s.id()); + if (!profiles.containsKey(s.id()) || current == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + if (status == null) return reply(s, Messages.statusMenu(current.status()), Result.SUCCESS); + if (status != current.status() && !hooks.statusChanging(row(s), current.status(), status)) { + return completedFuture(Result.CANCELLED); + } synchronized (lock) { - Presence current = presence.get(s.id()); - if (!profiles.containsKey(s.id()) || current == null) return reply(s, Messages.notLoaded()); - if (status == null) return reply(s, Messages.statusMenu(current.status())); + Presence now = presence.get(s.id()); // re-read: plugins were asked outside the lock + if (!profiles.containsKey(s.id()) || now == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); write(() -> storage.setStatus(s.id(), status)); - emit(new Event.Updated(s.id(), current.withStatus(status))); - return reply(s, Messages.statusSet(status)); + emit(new Event.Updated(s.id(), now.withStatus(status))); + return reply(s, Messages.statusSet(status), Result.SUCCESS); } } - public CompletableFuture list(Online s, int page, boolean bestOnly) { + public CompletableFuture list(Online s, int page, boolean bestOnly) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); List entries = me.friends.values().stream() .filter(f -> !bestOnly || f.best()) .map(this::entry) .sorted(LIST_ORDER) .toList(); - if (entries.isEmpty()) return reply(s, Messages.noFriends(bestOnly)); + if (entries.isEmpty()) return reply(s, Messages.noFriends(bestOnly), Result.SUCCESS); int pages = (entries.size() + PAGE_SIZE - 1) / PAGE_SIZE; int p = Math.clamp(page, 1, pages); List shown = entries.subList((p - 1) * PAGE_SIZE, Math.min(entries.size(), p * PAGE_SIZE)); - return reply(s, Messages.list(shown, p, pages, bestOnly, clock.instant())); + return reply(s, Messages.list(shown, p, pages, bestOnly, clock.instant()), Result.SUCCESS); } /** Best friends first, then online (by name), then offline (most recently seen first). */ @@ -443,11 +519,11 @@ private Messages.Entry entry(Friend f) { : new Messages.Entry(f, p.status(), p.server()); } - public CompletableFuture requests(Online s) { + public CompletableFuture requests(Online s) { List in = incoming(s.id()).toList(); - List out = requests.values().stream().filter(r -> r.from().id().equals(s.id()) && valid(r) != null).toList(); - if (in.isEmpty() && out.isEmpty()) return reply(s, Messages.noRequests()); - return reply(s, Messages.requests(in, out, clock.instant())); + List out = outgoing(s.id()).toList(); + if (in.isEmpty() && out.isEmpty()) return reply(s, Messages.noRequests(), Result.SUCCESS); + return reply(s, Messages.requests(in, out, clock.instant()), Result.SUCCESS); } // --- tab completion --- @@ -461,7 +537,7 @@ public List requesterNames(UUID id) { return incoming(id).map(r -> r.from().name()).toList(); } - /** A local player's cached friends (tests compare this with the database). */ + /** A local player's cached friends (the API's reads; tests compare this with the database). */ List cachedFriends(UUID id) { Profile p = profiles.get(id); return p == null ? List.of() : List.copyOf(p.friends.values()); @@ -472,27 +548,86 @@ public List onlineNames() { return presence.values().stream().filter(p -> p.status() != Status.OFFLINE).map(Presence::name).toList(); } + // --- API reads (memory, except friendsOf) --- + + boolean loaded(UUID id) { + return profiles.containsKey(id); + } + + /** From memory: false unless {@code a} or {@code b} is loaded here. */ + boolean friendsWith(UUID a, UUID b) { + Profile p = profiles.get(a); + if (p != null) return p.friends.containsKey(b); + Profile q = profiles.get(b); + return q != null && q.friends.containsKey(a); + } + + /** Where the player is on the network, or null if offline. */ + Presence presenceOf(UUID id) { + return presence.get(id); + } + + /** Any player's friends: the profile if loaded here, else the database (briefly cached, see {@link #offline}). */ + CompletableFuture> friendsOf(UUID id) { + Profile p = profiles.get(id); + if (p != null) return completedFuture(List.copyOf(p.friends.values())); + Instant now = clock.instant(); + Cached cached = offline.get(id); + if (cached != null && now.isBefore(cached.expires())) return cached.friends(); + if (offline.size() >= OFFLINE_MAX) evictOffline(now); + // Cache first, then queue the read: a change that drops the entry meanwhile has queued its write before + // dropping it, so any entry still cached was read after that write. + CompletableFuture> read = new CompletableFuture<>(); + Cached entry = new Cached(read, now.plus(OFFLINE_TTL)); + offline.put(id, entry); + db(() -> List.copyOf(storage.load(id).friends())).whenComplete((friends, e) -> { + if (e == null) { + read.complete(friends); + } else { + offline.remove(id, entry); // don't cache failures + read.completeExceptionally(e); + } + }); + return read; + } + + private void evictOffline(Instant now) { + offline.values().removeIf(c -> !now.isBefore(c.expires())); + for (var it = offline.keySet().iterator(); offline.size() >= OFFLINE_MAX && it.hasNext(); ) { + it.next(); + it.remove(); + } + } + // --- helpers --- private interface FriendAction { - CompletableFuture apply(Profile me, Friend friend); + CompletableFuture apply(Profile me, Friend friend); } - private CompletableFuture withFriend(Online s, String name, FriendAction action) { + private CompletableFuture withFriend(Online s, Target target, FriendAction action) { Profile me = profiles.get(s.id()); - if (me == null) return reply(s, Messages.notLoaded()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); synchronized (lock) { // look up and change in one step - return me.friends.values().stream().filter(f -> f.name().equalsIgnoreCase(name)).findFirst() - .map(f -> action.apply(me, f)) - .orElseGet(() -> reply(s, Messages.notFriend(name))); + Friend f = find(me, target); + return f != null ? action.apply(me, f) : reply(s, Messages.notFriend(target.label()), Result.NOT_FRIENDS); } } - // ponytail: linear scan over all pending requests; index by receiver if request volume ever matters. - private Stream incoming(UUID id) { + private static Friend find(Profile me, Target target) { + if (target.id() != null) return me.friends.get(target.id()); + return me.friends.values().stream().filter(f -> f.name().equalsIgnoreCase(target.name())).findFirst().orElse(null); + } + + // ponytail: linear scans over all pending requests; index by player if request volume ever matters. + Stream incoming(UUID id) { return requests.values().stream().filter(r -> r.to().id().equals(id) && valid(r) != null); } + Stream outgoing(UUID id) { + return requests.values().stream().filter(r -> r.from().id().equals(id) && valid(r) != null); + } + private Request valid(Request r) { return r != null && clock.instant().isBefore(r.expires()) ? r : null; } @@ -502,7 +637,14 @@ private PlayerRow row(Online p) { } /** Online anywhere on the network first (no query), then the database. */ - private CompletableFuture> resolve(String name) { + private CompletableFuture> resolve(Target target) { + if (target.id() != null) { + Presence p = presence.get(target.id()); + return p != null + ? completedFuture(Optional.of(new PlayerRow(target.id(), p.name(), p.prefix(), clock.instant()))) + : db(() -> storage.player(target.id())); + } + String name = target.name(); if (!NAME.matcher(name).matches()) return completedFuture(Optional.empty()); for (var e : presence.entrySet()) { // ponytail: O(online players), fine for a typed command Presence p = e.getValue(); @@ -523,9 +665,9 @@ private void message(UUID id, Component message) { if (p != null) p.audience.sendMessage(message); } - private static CompletableFuture reply(Online s, Component message) { + private static CompletableFuture reply(Online s, Component message, Result result) { s.audience().sendMessage(message); - return completedFuture(null); + return completedFuture(result); } private CompletableFuture db(SqlCall call) { diff --git a/common/src/main/java/com/friends/common/FriendsApi.java b/common/src/main/java/com/friends/common/FriendsApi.java new file mode 100644 index 0000000..0884fd3 --- /dev/null +++ b/common/src/main/java/com/friends/common/FriendsApi.java @@ -0,0 +1,140 @@ +package com.friends.common; + +import static java.util.concurrent.CompletableFuture.completedFuture; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Function; + +import com.friends.api.Friend; +import com.friends.api.FriendRequest; +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 net.kyori.adventure.audience.Audience; + +/** {@link FriendsAPI} over the core: reads come straight from its caches, actions are silent commands run on {@code async}. */ +public final class FriendsApi implements FriendsAPI { + private final Friends friends; + private final Platform platform; + private final Executor async; + + public FriendsApi(Friends friends, Platform platform, Executor async) { + this.friends = friends; + this.platform = platform; + this.async = async; + } + + @Override + public boolean isLoaded(UUID player) { + return player != null && friends.loaded(player); + } + + @Override + public List getFriends(UUID player) { + return player == null ? List.of() : views(friends.cachedFriends(player)); + } + + @Override + public boolean areFriends(UUID a, UUID b) { + return a != null && b != null && friends.friendsWith(a, b); + } + + @Override + public Optional getStatus(UUID player) { + return presence(player).map(Presence::status); + } + + @Override + public Optional getServer(UUID player) { + return presence(player).filter(p -> p.status() != Status.OFFLINE).map(Presence::server); + } + + @Override + public List getIncomingRequests(UUID player) { + return player == null ? List.of() : friends.incoming(player).map(FriendsApi::view).toList(); + } + + @Override + public List getOutgoingRequests(UUID player) { + return player == null ? List.of() : friends.outgoing(player).map(FriendsApi::view).toList(); + } + + @Override + public CompletableFuture> loadFriends(UUID player) { + return player == null ? completedFuture(List.of()) : friends.friendsOf(player).thenApply(FriendsApi::views); + } + + @Override + public CompletableFuture sendRequest(UUID sender, UUID target) { + return act(sender, target, s -> friends.add(s, Target.of(target))); + } + + @Override + public CompletableFuture acceptRequest(UUID player, UUID sender) { + return act(player, sender, s -> friends.accept(s, Target.of(sender))); + } + + @Override + public CompletableFuture denyRequest(UUID player, UUID sender) { + return act(player, sender, s -> friends.deny(s, Target.of(sender))); + } + + @Override + public CompletableFuture removeFriend(UUID player, UUID friend) { + return act(player, friend, s -> friends.remove(s, Target.of(friend))); + } + + @Override + public CompletableFuture setBestFriend(UUID player, UUID friend, boolean best) { + return act(player, friend, s -> friends.best(s, Target.of(friend), best)); + } + + @Override + public CompletableFuture setNickname(UUID player, UUID friend, String nickname) { + return act(player, friend, s -> friends.nickname(s, Target.of(friend), nickname)); + } + + @Override + public CompletableFuture setStatus(UUID player, Status status) { + return act(player, status, s -> friends.status(s, status)); + } + + /** + * Runs {@code action} as {@code player}'s command, without the chat reply. The whole action runs on {@code async} + * (never on the caller's thread, which may be the server thread events must not be fired from). + */ + private CompletableFuture act(UUID player, Object arg, Function> action) { + if (player == null || arg == null) return completedFuture(Result.INVALID_ARGUMENT); + try { + return CompletableFuture.supplyAsync(() -> platform.player(player) + .map(o -> action.apply(new Online(o.id(), o.name(), o.prefix(), o.server(), Audience.empty()))) + .orElseGet(() -> completedFuture(Result.NOT_LOADED)), async) + .thenCompose(Function.identity()) + .exceptionally(_ -> Result.ERROR); // the database error is already logged + } catch (RejectedExecutionException e) { // Friends is shutting down + return completedFuture(Result.ERROR); + } + } + + private Optional presence(UUID player) { + return player == null ? Optional.empty() : Optional.ofNullable(friends.presenceOf(player)); + } + + 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(); + } + + private static FriendRequest view(Request r) { + return new FriendRequest(r.from().id(), r.from().name(), r.to().id(), r.to().name(), r.expires()); + } +} diff --git a/common/src/main/java/com/friends/common/FriendsRuntime.java b/common/src/main/java/com/friends/common/FriendsRuntime.java index 8b5f162..575e075 100644 --- a/common/src/main/java/com/friends/common/FriendsRuntime.java +++ b/common/src/main/java/com/friends/common/FriendsRuntime.java @@ -1,34 +1,58 @@ package com.friends.common; import java.time.Clock; +import java.time.Instant; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; -/** The core wired up (one ordered DB thread, network, commands); every platform plugin is glue around this. */ +import com.friends.api.FriendsProvider; +import com.friends.api.Status; +import com.friends.common.Storage.PlayerRow; + +/** The core wired up (one ordered DB thread, network, commands, hooks); every platform plugin is glue around this. */ public final class FriendsRuntime implements AutoCloseable { public final Friends friends; public final FriendCommand command; + public final FriendsApi api; 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()); private final Storage storage; private final Network network; private final Logger log; - /** Takes ownership of {@code storage} and {@code network} (closed by {@link #close}). */ - public FriendsRuntime(Settings settings, Storage storage, Network network, Platform platform, 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.storage = storage; this.network = network; this.log = log; - this.friends = new Friends(storage, platform, network, db, Clock.systemUTC(), settings.requestExpiry(), settings.maxFriends(), log); + this.friends = new Friends(storage, platform, network, db, async, Clock.systemUTC(), settings.requestExpiry(), + settings.maxFriends(), inOrder(hooks), log); this.command = new FriendCommand(friends); + this.api = new FriendsApi(friends, platform, async); friends.start(); + FriendsProvider.register(api); } /** Call once players are disconnected: finishes queued writes (and the publishes behind them), then leaves. */ @Override public void close() { + FriendsProvider.unregister(api); // other plugins must not keep using (or pinning) a stopped instance + 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"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + done.shutdown(); // deliver the "done" events still queued + try { + if (!done.awaitTermination(5, TimeUnit.SECONDS)) log.warn("Friends: gave up delivering events"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } db.shutdown(); try { if (!db.awaitTermination(10, TimeUnit.SECONDS)) log.warn("Friends: gave up waiting for pending database writes"); @@ -38,4 +62,16 @@ public void close() { network.close(); storage.close(); } + + /** The core makes "done" calls under its lock: hand them to one thread, which delivers them in order. */ + private Hooks inOrder(Hooks hooks) { + return new Hooks() { + @Override public boolean requesting(PlayerRow from, PlayerRow to) { return hooks.requesting(from, to); } + @Override public boolean befriending(PlayerRow player, PlayerRow sender) { return hooks.befriending(player, sender); } + @Override public void befriended(PlayerRow player, PlayerRow sender, Instant since) { done.execute(() -> hooks.befriended(player, sender, since)); } + @Override public boolean unfriending(PlayerRow player, Friend friend) { return hooks.unfriending(player, friend); } + @Override public void unfriended(PlayerRow player, Friend friend) { done.execute(() -> hooks.unfriended(player, friend)); } + @Override public boolean statusChanging(PlayerRow player, Status from, Status to) { return hooks.statusChanging(player, from, to); } + }; + } } diff --git a/common/src/main/java/com/friends/common/Hooks.java b/common/src/main/java/com/friends/common/Hooks.java new file mode 100644 index 0000000..c386b78 --- /dev/null +++ b/common/src/main/java/com/friends/common/Hooks.java @@ -0,0 +1,42 @@ +package com.friends.common; + +import java.time.Instant; + +import com.friends.api.Status; +import com.friends.common.Storage.PlayerRow; + +/** + * Lets other plugins veto and observe changes made on this proxy/server: each platform turns these into its own + * events. The vetoes ({@code ...ing}) run on the acting thread, never under the core's lock nor on the database + * thread, and may block on listeners; the action is re-checked afterwards. The {@code ...ed} calls come exactly once + * per change, in the order changes were applied: {@link Friends} makes them under its lock, {@link FriendsRuntime} + * delivers them from one thread of its own (so there, they may block too). + */ +public interface Hooks { + + /** {@code from} is about to send {@code to} a request. False cancels it. */ + default boolean requesting(PlayerRow from, PlayerRow to) { + return true; + } + + /** {@code player} is about to accept {@code sender}'s request. False cancels it (the request stays pending). */ + default boolean befriending(PlayerRow player, PlayerRow sender) { + return true; + } + + default void befriended(PlayerRow player, PlayerRow sender, Instant since) {} + + /** {@code player} is about to remove {@code friend}. False keeps the friendship. */ + default boolean unfriending(PlayerRow player, Friend friend) { + return true; + } + + default void unfriended(PlayerRow player, Friend friend) {} + + /** False keeps the current status. */ + default boolean statusChanging(PlayerRow player, Status from, Status to) { + return true; + } + + Hooks NONE = new Hooks() {}; +} diff --git a/common/src/main/java/com/friends/common/Messages.java b/common/src/main/java/com/friends/common/Messages.java index cc1b112..3c1041c 100644 --- a/common/src/main/java/com/friends/common/Messages.java +++ b/common/src/main/java/com/friends/common/Messages.java @@ -15,6 +15,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; +import com.friends.api.Status; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; diff --git a/common/src/main/java/com/friends/common/Presence.java b/common/src/main/java/com/friends/common/Presence.java index 7004a7d..bf00b31 100644 --- a/common/src/main/java/com/friends/common/Presence.java +++ b/common/src/main/java/com/friends/common/Presence.java @@ -1,5 +1,7 @@ package com.friends.common; +import com.friends.api.Status; + /** Where an online player is on the network. {@code server} is null until they reach a backend. */ public record Presence(String name, String prefix, String proxy, String server, Status status) { diff --git a/common/src/main/java/com/friends/common/Status.java b/common/src/main/java/com/friends/common/Status.java deleted file mode 100644 index 9f19513..0000000 --- a/common/src/main/java/com/friends/common/Status.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.friends.common; - -/** Online status shown to friends, like Hypixel's /status. OFFLINE means "appear offline". */ -public enum Status { - ONLINE, AWAY, BUSY, OFFLINE -} diff --git a/common/src/main/java/com/friends/common/Storage.java b/common/src/main/java/com/friends/common/Storage.java index f5d6d7e..0fbbb15 100644 --- a/common/src/main/java/com/friends/common/Storage.java +++ b/common/src/main/java/com/friends/common/Storage.java @@ -14,6 +14,7 @@ import java.util.Optional; import java.util.UUID; +import com.friends.api.Status; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -159,12 +160,24 @@ public Optional player(String name) throws SQLException { try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement( "SELECT uuid, name, prefix, last_seen FROM friends_players WHERE name_lower = ? ORDER BY last_seen DESC LIMIT 1")) { p.setString(1, name.toLowerCase(Locale.ROOT)); - try (ResultSet rs = p.executeQuery()) { - return rs.next() - ? Optional.of(new PlayerRow(UUID.fromString(rs.getString(1)), rs.getString(2), rs.getString(3), - Instant.ofEpochMilli(rs.getLong(4)))) - : Optional.empty(); - } + return playerRow(p); + } + } + + public Optional player(UUID id) throws SQLException { + try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement( + "SELECT uuid, name, prefix, last_seen FROM friends_players WHERE uuid = ?")) { + p.setString(1, id.toString()); + return playerRow(p); + } + } + + private static Optional playerRow(PreparedStatement p) throws SQLException { + try (ResultSet rs = p.executeQuery()) { + return rs.next() + ? Optional.of(new PlayerRow(UUID.fromString(rs.getString(1)), rs.getString(2), rs.getString(3), + Instant.ofEpochMilli(rs.getLong(4)))) + : Optional.empty(); } } diff --git a/common/src/test/java/com/friends/common/BungeeChatTest.java b/common/src/test/java/com/friends/common/BungeeChatTest.java index f1e4158..cd835d3 100644 --- a/common/src/test/java/com/friends/common/BungeeChatTest.java +++ b/common/src/test/java/com/friends/common/BungeeChatTest.java @@ -10,6 +10,8 @@ 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; diff --git a/common/src/test/java/com/friends/common/ConsistencyTest.java b/common/src/test/java/com/friends/common/ConsistencyTest.java index 3e68d1f..7f8b3fc 100644 --- a/common/src/test/java/com/friends/common/ConsistencyTest.java +++ b/common/src/test/java/com/friends/common/ConsistencyTest.java @@ -148,7 +148,7 @@ void concurrentCommandsOnTheRealDatabaseThreadStayConsistent() throws Exception for (String name : crowd) online.put(name, h.join(name)); ExecutorService pool = Executors.newFixedThreadPool(8); - List> commands = new ArrayList<>(); + List> commands = new ArrayList<>(); Set errors = new HashSet<>(); for (int t = 0; t < 8; t++) { Random random = new Random(t); diff --git a/common/src/test/java/com/friends/common/FriendsApiTest.java b/common/src/test/java/com/friends/common/FriendsApiTest.java new file mode 100644 index 0000000..e5cfe47 --- /dev/null +++ b/common/src/test/java/com/friends/common/FriendsApiTest.java @@ -0,0 +1,524 @@ +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/RedisNetworkTest.java b/common/src/test/java/com/friends/common/RedisNetworkTest.java index ab522b3..9467e86 100644 --- a/common/src/test/java/com/friends/common/RedisNetworkTest.java +++ b/common/src/test/java/com/friends/common/RedisNetworkTest.java @@ -16,6 +16,7 @@ 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; diff --git a/common/src/test/java/com/friends/common/StorageTest.java b/common/src/test/java/com/friends/common/StorageTest.java index 9b20b95..75d97ac 100644 --- a/common/src/test/java/com/friends/common/StorageTest.java +++ b/common/src/test/java/com/friends/common/StorageTest.java @@ -15,6 +15,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.friends.api.Status; + class StorageTest { @TempDir Path dir; diff --git a/common/src/test/java/com/friends/common/TestSupport.java b/common/src/test/java/com/friends/common/TestSupport.java index 36db9d7..ff7e727 100644 --- a/common/src/test/java/com/friends/common/TestSupport.java +++ b/common/src/test/java/com/friends/common/TestSupport.java @@ -226,16 +226,23 @@ static final class Harness implements AutoCloseable { 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, java.util.concurrent.Executor db) { + 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, clock, Duration.ofMinutes(5), maxFriends, - LoggerFactory.getLogger(Harness.class)); + 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(); } diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index 530ce2a..a31f4d8 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -5,6 +5,12 @@ plugins { // Two layers (see FriendsBootstrap): a Java 8 bootstrap Bukkit loads, and the real plugin as an embedded jar. val bootstrap: SourceSet = sourceSets.create("bootstrap") +// The API (Java 8) sits in the bootstrap layer: other plugins only see what Bukkit's class loader loaded. +val apiJar: Configuration = configurations.create("apiJar") { isTransitive = false } +// Test dependencies, declared once: compile the tests, and run them (see tasks.test). +val mockServer: Configuration = configurations.create("mockServer") +configurations.testImplementation { extendsFrom(mockServer) } +val testRuntime: Configuration = configurations.create("testRuntime") { extendsFrom(mockServer) } dependencies { implementation(project(":common")) @@ -20,6 +26,24 @@ dependencies { implementation("net.kyori:adventure-text-serializer-legacy") implementation("org.slf4j:slf4j-api:2.0.17") runtimeOnly("org.slf4j:slf4j-jdk14:2.0.17") + + "apiJar"(project(":api")) + + // MockBukkit 4.116.3 is built against paper-api 1.21.11: tests run the plugin's real wiring on it. + "mockServer"("org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.116.3") + "mockServer"("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + "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.junit.platform:junit-platform-launcher") +} + +tasks.test { + // Like on a server: the shipped implementation jar (its Adventure 5 relocated) beside paper-api's own Adventure 4. + // Unrelocated, Adventure 5 breaks paper-api (BookMeta implements Adventure 4's Book, sealed in 5). + classpath = sourceSets.test.get().output + files(tasks.processResources) + files(tasks.shadowJar) + testRuntime // + plugin.yml + jvmArgs("--enable-native-access=ALL-UNNAMED") // sqlite-jdbc loads a native library } tasks.named("compileBootstrapJava") { @@ -44,12 +68,16 @@ tasks.shadowJar { relocate("net.kyori", "com.friends.lib.kyori") relocate("org.slf4j", "com.friends.lib.slf4j") exclude("plugin.yml") + exclude("com/friends/api/**") // in the bootstrap layer instead } // The plugin jar: bootstrap + plugin.yml + config.yml + the implementation jar. val pluginJar = tasks.register("pluginJar") { archiveBaseName = "friends-paper" from(bootstrap.output) + from(zipTree(apiJar.elements.map { it.single().asFile })) { + 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(tasks.shadowJar) { rename { "friends-paper-impl.jar" } } diff --git a/paper/src/main/java/com/friends/paper/BukkitHooks.java b/paper/src/main/java/com/friends/paper/BukkitHooks.java new file mode 100644 index 0000000..9a56a3d --- /dev/null +++ b/paper/src/main/java/com/friends/paper/BukkitHooks.java @@ -0,0 +1,62 @@ +package com.friends.paper; + +import java.time.Instant; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; + +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; +import com.friends.common.Friend; +import com.friends.common.Hooks; +import com.friends.common.Storage.PlayerRow; + +/** The core's hooks as Bukkit events, all asynchronous: hooks never run on the server thread. */ +final class BukkitHooks implements Hooks { + private final PluginManager events; + + BukkitHooks(PluginManager events) { + this.events = events; + } + + @Override + public boolean requesting(PlayerRow from, PlayerRow to) { + return allowed(new FriendRequestSendEvent(from.id(), from.name(), to.id(), to.name())); + } + + @Override + public boolean befriending(PlayerRow player, PlayerRow sender) { + return allowed(new FriendAddEvent(player.id(), player.name(), sender.id(), sender.name())); + } + + @Override + public void befriended(PlayerRow player, PlayerRow sender, Instant since) { + events.callEvent(new FriendAddedEvent(player.id(), player.name(), sender.id(), sender.name(), since)); + } + + @Override + public boolean unfriending(PlayerRow player, Friend friend) { + return allowed(new FriendRemoveEvent(player.id(), player.name(), friend.id(), friend.name())); + } + + @Override + public void unfriended(PlayerRow player, Friend friend) { + events.callEvent(new FriendRemovedEvent(player.id(), player.name(), friend.id(), friend.name())); + } + + @Override + public boolean statusChanging(PlayerRow player, Status from, Status to) { + return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); + } + + private boolean allowed(E event) { + events.callEvent(event); // Bukkit catches (and logs) listeners' exceptions + return !event.isCancelled(); + } +} diff --git a/paper/src/main/java/com/friends/paper/PaperFriends.java b/paper/src/main/java/com/friends/paper/PaperFriends.java index 49207d6..5f1f88c 100644 --- a/paper/src/main/java/com/friends/paper/PaperFriends.java +++ b/paper/src/main/java/com/friends/paper/PaperFriends.java @@ -17,10 +17,12 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.ServicePriority; import org.bukkit.plugin.java.JavaPlugin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.friends.api.FriendsAPI; import com.friends.common.ChatSessions; import com.friends.common.FriendCommand; import com.friends.common.FriendsRuntime; @@ -64,7 +66,7 @@ public void enable() { @Override public String prefix(UUID id) { return prefixes.apply(id); } @Override public void send(Player p, BaseComponent[] message) { p.spigot().sendMessage(message); } }); - runtime = new FriendsRuntime(settings, storage, Network.LOCAL, sessions, log); + runtime = new FriendsRuntime(settings, storage, Network.LOCAL, sessions, new BukkitHooks(plugin.getServer().getPluginManager()), log); } catch (SQLException | IllegalArgumentException e) { log.error("Friends is disabled: {}", e.getMessage(), e); if (storage != null) storage.close(); @@ -83,6 +85,8 @@ public void enable() { 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/test/java/com/friends/paper/PaperApiTest.java b/paper/src/test/java/com/friends/paper/PaperApiTest.java new file mode 100644 index 0000000..b19fe42 --- /dev/null +++ b/paper/src/test/java/com/friends/paper/PaperApiTest.java @@ -0,0 +1,208 @@ +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 207c048..116edee 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,4 @@ rootProject.name = "friends-plugin" -include("common", "velocity", "bungee", "paper") +include("api", "common", "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 e1fd514..135c91e 100644 --- a/tools/e2e/README.md +++ b/tools/e2e/README.md @@ -21,4 +21,6 @@ FRIENDS_MYSQL=127.0.0.1:3306/friends:root: FRIENDS_REDIS=127.0.0.1:6379 python3 Scenarios: `paper-`, `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). +(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 +listeners and, on Paper, from the server thread; needs `./gradlew :probe:jar`). diff --git a/tools/e2e/probe/build.gradle.kts b/tools/e2e/probe/build.gradle.kts new file mode 100644 index 0000000..88ab365 --- /dev/null +++ b/tools/e2e/probe/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + java +} + +// A test-only plugin using the API like any other plugin would (see tools/e2e/run.py, "api-*" scenarios). One jar for +// Paper, BungeeCord and Velocity: each reads its own descriptor. Java 8, like the API: Paper 1.13-1.20's class +// rewriter must read it. +java { + disableAutoTargetJvm() // velocity-api is Java 25 bytecode: only referenced +} + +tasks.compileJava { + options.release = 8 + options.compilerArgs.add("-Xlint:-options") +} + +dependencies { + compileOnly(project(":api")) + compileOnly("org.github.paperspigot:paperspigot-api:1.8.8-R0.1-SNAPSHOT") + compileOnly("com.velocitypowered:velocity-api:4.2.0") + compileOnly("net.md-5:bungeecord-api:1.21-R0.4") +} + +tasks.jar { + archiveFileName = "friends-probe.jar" + manifest { attributes("paperweight-mappings-namespace" to "mojang") } // no NMS: Paper needn't remap it +} diff --git a/tools/e2e/probe/src/main/java/com/friends/probe/BungeeProbe.java b/tools/e2e/probe/src/main/java/com/friends/probe/BungeeProbe.java new file mode 100644 index 0000000..a7a9505 --- /dev/null +++ b/tools/e2e/probe/src/main/java/com/friends/probe/BungeeProbe.java @@ -0,0 +1,60 @@ +package com.friends.probe; + +import com.friends.api.FriendsAPI; +import com.friends.api.bungee.FriendAddEvent; +import com.friends.api.bungee.FriendAddedEvent; +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 net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; + +public final class BungeeProbe extends Plugin implements Listener { + private FriendsAPI api; + + @Override + public void onEnable() { + api = FriendsAPI.get(); + log("probe: api ok"); + getProxy().getPluginManager().registerListener(this, this); + } + + private void log(String line) { + getLogger().info(line); + } + + @EventHandler + public void on(FriendRequestSendEvent e) { + log(Probe.pair("FriendRequestSendEvent", e.getPlayerName(), e.getTargetName())); + } + + @EventHandler + public void on(FriendAddEvent e) { + log(Probe.pair("FriendAddEvent", e.getPlayerName(), e.getTargetName())); + } + + @EventHandler + public void on(FriendAddedEvent e) { + log(Probe.pair("FriendAddedEvent", e.getPlayerName(), e.getTargetName())); + Probe.befriended(api, e.getPlayerId(), e.getPlayerName(), e.getTargetId(), this::log, Runnable::run); + } + + @EventHandler + public void on(FriendRemoveEvent e) { + log(Probe.pair("FriendRemoveEvent", e.getPlayerName(), e.getTargetName())); + } + + @EventHandler + public void on(FriendRemovedEvent e) { + log(Probe.pair("FriendRemovedEvent", e.getPlayerName(), e.getTargetName())); + } + + @EventHandler + public void on(FriendStatusChangeEvent e) { + e.setCancelled(Probe.cancels(e.getNewStatus())); + log(Probe.status(e.getPlayerName(), e.getOldStatus(), e.getNewStatus())); + } +} diff --git a/tools/e2e/probe/src/main/java/com/friends/probe/PaperProbe.java b/tools/e2e/probe/src/main/java/com/friends/probe/PaperProbe.java new file mode 100644 index 0000000..8e870c1 --- /dev/null +++ b/tools/e2e/probe/src/main/java/com/friends/probe/PaperProbe.java @@ -0,0 +1,68 @@ +package com.friends.probe; + +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; + +import com.friends.api.FriendsAPI; +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; + +public final class PaperProbe extends JavaPlugin implements Listener { + private FriendsAPI api; + + @Override + public void onEnable() { + api = FriendsAPI.get(); + log("probe: api ok service=" + (getServer().getServicesManager().load(FriendsAPI.class) == api)); + getServer().getPluginManager().registerEvents(this, this); + } + + private void log(String line) { + getLogger().info(line); + } + + private static String threads(Event e) { + return " async=" + e.isAsynchronous() + " main=" + Bukkit.isPrimaryThread(); + } + + @EventHandler + public void on(FriendRequestSendEvent e) { + log(Probe.pair("FriendRequestSendEvent", e.getPlayerName(), e.getTargetName()) + threads(e)); + } + + @EventHandler + public void on(FriendAddEvent e) { + log(Probe.pair("FriendAddEvent", e.getPlayerName(), e.getTargetName()) + threads(e)); + } + + @EventHandler + public void on(FriendAddedEvent e) { + log(Probe.pair("FriendAddedEvent", e.getPlayerName(), e.getTargetName()) + threads(e)); + // The action runs from the server thread: Friends must still fire its (async) event elsewhere. + Probe.befriended(api, e.getPlayerId(), e.getPlayerName(), e.getTargetId(), this::log, + task -> getServer().getScheduler().runTask(this, task)); + } + + @EventHandler + public void on(FriendRemoveEvent e) { + log(Probe.pair("FriendRemoveEvent", e.getPlayerName(), e.getTargetName()) + threads(e)); + } + + @EventHandler + public void on(FriendRemovedEvent e) { + log(Probe.pair("FriendRemovedEvent", e.getPlayerName(), e.getTargetName()) + threads(e)); + } + + @EventHandler + public void on(FriendStatusChangeEvent e) { + e.setCancelled(Probe.cancels(e.getNewStatus())); + log(Probe.status(e.getPlayerName(), e.getOldStatus(), e.getNewStatus()) + threads(e)); + } +} diff --git a/tools/e2e/probe/src/main/java/com/friends/probe/Probe.java b/tools/e2e/probe/src/main/java/com/friends/probe/Probe.java new file mode 100644 index 0000000..b0001dd --- /dev/null +++ b/tools/e2e/probe/src/main/java/com/friends/probe/Probe.java @@ -0,0 +1,42 @@ +package com.friends.probe; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +import com.friends.api.Friend; +import com.friends.api.FriendsAPI; +import com.friends.api.Status; + +/** What every platform's probe does; it logs "probe: ..." lines that run.py waits for. */ +final class Probe { + private Probe() {} + + static String pair(String event, String player, String target) { + return "probe: " + event + " " + player + "->" + target; + } + + static String status(String player, Status from, Status to) { + return "probe: FriendStatusChangeEvent " + player + " " + from + "->" + to + (to == Status.AWAY ? " cancelled" : ""); + } + + /** "/status away" is refused, to check that cancelling works. */ + static boolean cancels(Status to) { + return to == Status.AWAY; + } + + /** A friendship started: read it back from inside the listener, then act on it from {@code sync}. */ + static void befriended(FriendsAPI api, UUID player, String name, UUID target, Consumer log, Executor sync) { + boolean friends = api.areFriends(player, target); + api.loadFriends(player).thenAccept(list -> log.accept("probe: areFriends=" + friends + " friends of " + name + "=" + names(list))); + sync.execute(() -> api.setStatus(player, Status.BUSY).thenAccept(r -> log.accept("probe: setStatus " + name + " BUSY -> " + r))); + } + + private static List names(List friends) { + List names = new ArrayList<>(); + for (Friend f : friends) names.add(f.getName()); + return names; + } +} diff --git a/tools/e2e/probe/src/main/java/com/friends/probe/VelocityProbe.java b/tools/e2e/probe/src/main/java/com/friends/probe/VelocityProbe.java new file mode 100644 index 0000000..de5acba --- /dev/null +++ b/tools/e2e/probe/src/main/java/com/friends/probe/VelocityProbe.java @@ -0,0 +1,68 @@ +package com.friends.probe; + +import org.slf4j.Logger; + +import com.friends.api.FriendsAPI; +import com.friends.api.velocity.FriendAddEvent; +import com.friends.api.velocity.FriendAddedEvent; +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.google.inject.Inject; +import com.velocitypowered.api.event.ResultedEvent.GenericResult; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; + +/** Velocity registers the main instance as a listener by itself. */ +public final class VelocityProbe { + private final Logger log; + private FriendsAPI api; + + @Inject + public VelocityProbe(Logger log) { + this.log = log; + } + + @Subscribe + public void onInit(ProxyInitializeEvent event) { + api = FriendsAPI.get(); // Friends (a dependency) initialised first + log("probe: api ok"); + } + + private void log(String line) { + log.info(line); + } + + @Subscribe + public void on(FriendRequestSendEvent e) { + log(Probe.pair("FriendRequestSendEvent", e.getPlayerName(), e.getTargetName())); + } + + @Subscribe + public void on(FriendAddEvent e) { + log(Probe.pair("FriendAddEvent", e.getPlayerName(), e.getTargetName())); + } + + @Subscribe + public void on(FriendAddedEvent e) { + log(Probe.pair("FriendAddedEvent", e.getPlayerName(), e.getTargetName())); + Probe.befriended(api, e.getPlayerId(), e.getPlayerName(), e.getTargetId(), this::log, Runnable::run); + } + + @Subscribe + public void on(FriendRemoveEvent e) { + log(Probe.pair("FriendRemoveEvent", e.getPlayerName(), e.getTargetName())); + } + + @Subscribe + public void on(FriendRemovedEvent e) { + log(Probe.pair("FriendRemovedEvent", e.getPlayerName(), e.getTargetName())); + } + + @Subscribe + public void on(FriendStatusChangeEvent e) { + if (Probe.cancels(e.getNewStatus())) e.setResult(GenericResult.denied()); + log(Probe.status(e.getPlayerName(), e.getOldStatus(), e.getNewStatus())); + } +} diff --git a/tools/e2e/probe/src/main/resources/bungee.yml b/tools/e2e/probe/src/main/resources/bungee.yml new file mode 100644 index 0000000..23fbf20 --- /dev/null +++ b/tools/e2e/probe/src/main/resources/bungee.yml @@ -0,0 +1,4 @@ +name: FriendsProbe +main: com.friends.probe.BungeeProbe +version: "1" +depends: [Friends] diff --git a/tools/e2e/probe/src/main/resources/plugin.yml b/tools/e2e/probe/src/main/resources/plugin.yml new file mode 100644 index 0000000..2419c1e --- /dev/null +++ b/tools/e2e/probe/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: FriendsProbe +main: com.friends.probe.PaperProbe +version: "1" +api-version: "1.13" +depend: [Friends] diff --git a/tools/e2e/probe/src/main/resources/velocity-plugin.json b/tools/e2e/probe/src/main/resources/velocity-plugin.json new file mode 100644 index 0000000..c66f81c --- /dev/null +++ b/tools/e2e/probe/src/main/resources/velocity-plugin.json @@ -0,0 +1,2 @@ +{"id": "friends-probe", "name": "FriendsProbe", "version": "1", "main": "com.friends.probe.VelocityProbe", + "dependencies": [{"id": "friends", "optional": false}]} diff --git a/tools/e2e/run.py b/tools/e2e/run.py index 60a7361..28c5057 100644 --- a/tools/e2e/run.py +++ b/tools/e2e/run.py @@ -6,10 +6,12 @@ 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), 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). Servers run in /e2e-*. +FRIENDS_MYSQL=host:port/db:user:password and FRIENDS_REDIS=host:port), api- / api-bungee / api-velocity (another +plugin, tools/e2e/probe, using the API and its events). Servers run in /e2e-*. """ import os import platform +import shutil import sys import time import traceback @@ -19,8 +21,9 @@ import servers # noqa: E402 ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -VERSION = "0.4.0" +VERSION = "0.5.0" PLUGIN = {m: os.path.join(ROOT, m, "build", "libs", f"friends-{m}-{VERSION}.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 @@ -139,6 +142,58 @@ def scenario_proxy(jars, kind, version="1.8.8"): back.stop() +def api_flow(srv, alice, bob): + """What the probe plugin sees and does through the API: events (one cancelled), reads and actions from listeners.""" + srv.wait_log(r"probe: api ok") + mark = len(srv.lines) + cmd(alice, "/f add Bob", "You sent a friend request to Bob!") + srv.wait_log(r"probe: FriendRequestSendEvent Alice->Bob", mark) + cmd(bob, "/f accept Alice", "You are now friends with Alice") + for line in (r"probe: FriendAddEvent Bob->Alice", r"probe: FriendAddedEvent Bob->Alice", + r"probe: areFriends=true friends of Bob=\[Alice\]", r"probe: FriendStatusChangeEvent Bob ONLINE->BUSY", + r"probe: setStatus Bob BUSY -> SUCCESS"): + srv.wait_log(line, mark) + cmd(alice, "/fl", "Bob is busy") # the probe's API call, as players see it + bob.say("/status away") # the probe cancels this one: no reply, nothing changes + srv.wait_log(r"probe: FriendStatusChangeEvent Bob BUSY->AWAY cancelled", mark) + time.sleep(PAUSE) + cmd(alice, "/fl", "Bob is busy") + cmd(alice, "/f remove Bob", "You removed Bob from your friends list!") + srv.wait_log(r"probe: FriendRemoveEvent Alice->Bob", mark) + srv.wait_log(r"probe: FriendRemovedEvent Alice->Bob", mark) + + +def scenario_api(jars, where): + """The probe on Paper (a version), or on BungeeCord / Velocity (with a 1.8.8 backend).""" + if where in ("bungee", "velocity"): + back_port, proxy_port = 25800 + (0 if where == "bungee" else 2), 25801 + (0 if where == "bungee" else 2) + back = backend(jars, back_port) + make = servers.bungee if where == "bungee" else servers.velocity + jar = os.path.join(jars, "BungeeCord.jar" if where == "bungee" else "velocity.jar") + srv = make(os.path.join(jars, f"e2e-api-{where}"), jar, proxy_port, back_port, PLUGIN[where]) + port, protocol, stop = proxy_port, 47, "end" + else: + back, port, protocol, stop = None, 25810 + list(PROTOCOL).index(where), PROTOCOL[where], "stop" + srv = servers.paper(os.path.join(jars, f"e2e-api-{where}"), os.path.join(jars, f"paper-{where}.jar"), port, PLUGIN["paper"]) + shutil.copy(PROBE, os.path.join(srv.folder, "plugins")) + try: + if back: + back.start() + srv.start() + alice, bob = players(port, protocol) + api_flow(srv, alice, bob) + for b in (alice, bob): + b.close() + if not back: # Bukkit: every event asynchronous, never on the server thread + probe = [line for line in srv.lines if "probe: Friend" in line] + assert probe and all("async=true main=false" in line for line in probe), "".join(probe) + check_clean(srv) + finally: + srv.stop(stop) + if back: + back.stop() + + def scenario_mixed(jars): rhost, rport = os.environ["FRIENDS_REDIS"].split(":") shared = {**mysql_settings(), "redis.enabled": True, "redis.host": rhost, "redis.port": int(rport), @@ -168,13 +223,16 @@ def scenario_mixed(jars): def main(): jars = os.path.abspath(sys.argv[1]) - wanted = sys.argv[2:] or [f"paper-{v}" for v in PROTOCOL] + ["bungee", "velocity", "bungee-26.3", "velocity-26.3"] + wanted = sys.argv[2:] or [f"paper-{v}" for v in PROTOCOL] + ["bungee", "velocity", "bungee-26.3", "velocity-26.3", + "api-1.8.8", "api-1.20.6", "api-26.3", "api-bungee", "api-velocity"] failed = [] for name in wanted: start = time.time() try: if name.startswith("paper-"): scenario_paper(jars, name[6:]) + elif name.startswith("api-"): + scenario_api(jars, name[4:]) elif name.startswith("load-"): scenario_load(jars, name[5:]) elif name.split("-")[0] in ("bungee", "velocity"): diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 37540fd..67d1e75 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -10,9 +10,18 @@ dependencies { 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") + + testImplementation("com.velocitypowered:velocity-api:4.2.0") + testImplementation(platform("org.junit:junit-bom:6.1.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } configurations.runtimeClasspath { exclude(group = "org.slf4j") // provided by Velocity exclude(group = "com.google.code.gson") // provided by Velocity } + +tasks.shadowJar { + exclude("com/friends/api/bukkit/**", "com/friends/api/bungee/**") // other platforms' events +} diff --git a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java index 040d86b..ff97770 100644 --- a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java +++ b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java @@ -10,6 +10,9 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Function; import org.slf4j.Logger; @@ -44,13 +47,15 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -@Plugin(id = "friends", name = "Friends", version = "0.4.0", +@Plugin(id = "friends", name = "Friends", version = "0.5.0", description = "Hypixel-style friends system", dependencies = @Dependency(id = "luckperms", optional = true)) public final class FriendsVelocity { private final ProxyServer proxy; private final Logger log; private final Path dataDir; + // Commands may wait on SQL and on plugins' event listeners: run them on virtual threads, not Velocity's. + private final ExecutorService commandThreads = Executors.newVirtualThreadPerTaskExecutor(); private FriendsRuntime runtime; private VelocityPlatform platform; @@ -70,7 +75,7 @@ public void onInit(ProxyInitializeEvent event) { Network network = RedisNetwork.open(settings, log); Function prefixes = proxy.getPluginManager().isLoaded("luckperms") ? LuckPermsPrefix::of : _ -> null; platform = new VelocityPlatform(proxy, prefixes); - runtime = new FriendsRuntime(settings, storage, network, platform, log); + runtime = new FriendsRuntime(settings, storage, network, platform, new VelocityHooks(proxy.getEventManager()::fire), log); log.info("Friends enabled (proxy id: {})", network.proxyId()); } catch (IOException | SQLException | IllegalArgumentException e) { log.error("Friends is disabled: {}", e.getMessage(), e); @@ -78,9 +83,9 @@ public void onInit(ProxyInitializeEvent event) { return; } CommandManager commands = proxy.getCommandManager(); - commands.register(commands.metaBuilder("friend").aliases("f", "friends").plugin(this).build(), new Cmd(runtime.command, platform, null)); - commands.register(commands.metaBuilder("fl").plugin(this).build(), new Cmd(runtime.command, platform, "list")); - commands.register(commands.metaBuilder("status").plugin(this).build(), new Cmd(runtime.command, platform, "status")); + 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(); } @@ -105,6 +110,7 @@ public void onDisconnect(DisconnectEvent event) { @Subscribe public void onShutdown(ProxyShutdownEvent event) { + commandThreads.shutdown(); if (runtime != null) runtime.close(); // players are already disconnected } @@ -144,11 +150,12 @@ public Optional player(UUID id) { } /** Routes /friend, /fl ("list") and /status ("status") into the shared command parser. */ - record Cmd(FriendCommand command, VelocityPlatform platform, String sub) implements SimpleCommand { + record Cmd(FriendCommand command, VelocityPlatform platform, String sub, Executor commands) implements SimpleCommand { @Override public void execute(Invocation invocation) { if (invocation.source() instanceof Player p) { - command.execute(platform.online(p), FriendCommand.withSub(sub, invocation.arguments())); + Platform.Online online = platform.online(p); + commands.execute(() -> command.execute(online, FriendCommand.withSub(sub, invocation.arguments()))); } else { invocation.source().sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED)); } diff --git a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java new file mode 100644 index 0000000..d71c363 --- /dev/null +++ b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java @@ -0,0 +1,62 @@ +package com.friends.velocity; + +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.friends.api.Status; +import com.friends.api.velocity.FriendAddEvent; +import com.friends.api.velocity.FriendAddedEvent; +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.Hooks; +import com.friends.common.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. */ +final class VelocityHooks implements Hooks { + private final Function> fire; // EventManager::fire + + VelocityHooks(Function> fire) { + this.fire = fire; + } + + @Override + public boolean requesting(PlayerRow from, PlayerRow to) { + return allowed(new FriendRequestSendEvent(from.id(), from.name(), to.id(), to.name())); + } + + @Override + public boolean befriending(PlayerRow player, PlayerRow sender) { + return allowed(new FriendAddEvent(player.id(), player.name(), sender.id(), sender.name())); + } + + @Override + public void befriended(PlayerRow player, PlayerRow sender, Instant since) { + fire.apply(new FriendAddedEvent(player.id(), player.name(), sender.id(), sender.name(), since)).join(); + } + + @Override + public boolean unfriending(PlayerRow player, Friend friend) { + return allowed(new FriendRemoveEvent(player.id(), player.name(), friend.id(), friend.name())); + } + + @Override + public void unfriended(PlayerRow player, Friend friend) { + fire.apply(new FriendRemovedEvent(player.id(), player.name(), friend.id(), friend.name())).join(); + } + + @Override + public boolean statusChanging(PlayerRow player, Status from, Status to) { + return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); + } + + /** Waits for every listener, then reads their verdict. */ + private boolean allowed(ResultedEvent event) { + fire.apply(event).join(); + return event.getResult().isAllowed(); + } +} diff --git a/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java b/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java new file mode 100644 index 0000000..60a42e2 --- /dev/null +++ b/velocity/src/test/java/com/friends/velocity/VelocityHooksTest.java @@ -0,0 +1,86 @@ +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()); + } +}