Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/known_errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :<module>: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.
73 changes: 68 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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).
44 changes: 44 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<MavenPublication>("maven") {
artifactId = "friends-api"
from(components["java"])
}
}
}
111 changes: 111 additions & 0 deletions api/src/main/java/com/friends/api/Friend.java
Original file line number Diff line number Diff line change
@@ -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 + "}";
}
}
95 changes: 95 additions & 0 deletions api/src/main/java/com/friends/api/FriendRequest.java
Original file line number Diff line number Diff line change
@@ -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 + "}";
}
}
Loading
Loading