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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Butterfly is a simple minecraft paper plugin and minestom extension/api that all
- Set scoreboard teams
- Set chat formats
- Chat formatting (MiniMessage) restricted by permission, see [Chat tag permissions](#chat-tag-permissions)
- Sender's player head in front of every chat line (`[head] [Admin] Steve: hello`); switch off with `butterfly.chat.head.enabled`, see [Configuration](#configuration). Native rendering needs a 1.21.9+ client. Display name, tab list and team prefix never contain the head.


## Chat tag permissions
Expand Down Expand Up @@ -49,7 +50,7 @@ Without any `butterfly.chat.tag.*` permission, players lose all chat formatting
- Put exactly one Butterfly jar into the server's `extensions/` directory (at least the release containing the extension entry point). Do not also shade it into the host or depend on it there, two copies conflict.
- LuckPerms must run inside the host. Butterfly declares no extension dependency and only accesses LuckPerms in `initialize()`. If LuckPerms is not available it logs an error and stays inactive.
- Settings are read from `extensions/Butterfly/config.yaml`, see [Configuration](#configuration).
- Library users keep calling `Butterfly.create().load()` and `terminate()`. `Butterfly.create()` uses the defaults overridden only by system properties and never touches the file system; `Butterfly.create(ButterflySettings)` takes the settings from the host, for example `Butterfly.create(new ButterflySettings("%02d", true)).load()`.
- Library users keep calling `Butterfly.create().load()` and `terminate()`. `Butterfly.create()` uses the defaults overridden only by system properties and never touches the file system; `Butterfly.create(ButterflySettings)` takes the settings from the host, for example `Butterfly.create(new ButterflySettings("%02d", true, true)).load()`.

## Configuration
Butterfly reads `config.yaml` from its own data folder and writes it with every default on first start; an existing file is never overwritten.
Expand All @@ -63,12 +64,16 @@ Butterfly reads `config.yaml` from its own data folder and writes it with every
|-----|----------|---------|-------------|
| `butterfly.teams.sort-format` | Paper, Minestom | `%04d` | `String.format` pattern for the numeric team-name prefix that sorts the tab list |
| `butterfly.teams.collision` | Minestom | `false` | `true` makes players in the same team push each other |
| `butterfly.chat.head.enabled` | Paper, Minestom | `true` | `false` removes the sender's player head from the front of chat lines |

```yaml
butterfly:
teams:
sort-format: "%04d"
collision: false
chat:
head:
enabled: true
```

- An unusable value falls back to its default and logs a warning naming the key and the value.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import org.jetbrains.annotations.Nullable;

/**
* Composes a chat line from its parts, identically on every platform.
*/
public final class ChatLine {

private ChatLine() {
throw new IllegalStateException("Utility class");
}

/**
* {@code head + space + name + ": " + message}; without a head there is no leading space.
*
* @param head the sender's head, or {@code null} to leave it out
* @param name the sender's prefix and name
* @param message the message part
*/
public static Component compose(@Nullable Component head, Component name, Component message) {
TextComponent.Builder line = Component.text();
if (head != null) {
line.append(head).append(Component.space());
}
return line.append(name).append(Component.text(": ")).append(message).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.object.ObjectContents;
import net.kyori.adventure.text.object.PlayerHeadObjectContents;
import org.jetbrains.annotations.Nullable;

import java.util.UUID;

/**
* Builds the inline player head that precedes a chat line.
*/
public final class PlayerHeads {

private static final String TEXTURES_PROPERTY = "textures";

private PlayerHeads() {
throw new IllegalStateException("Utility class");
}

/**
* The head of the given player. It always carries the UUID and the name; the skin texture is added when
* known so that clients do not have to look the skin up themselves.
*
* @param id UUID of the player
* @param name user name of the player; left out when it is not a valid head name
* @param texture value of the {@code textures} profile property, or {@code null} when unknown
* @param signature signature of the texture, or {@code null}
*/
public static Component of(UUID id, String name, @Nullable String texture, @Nullable String signature) {
PlayerHeadObjectContents.Builder builder = ObjectContents.playerHead().id(id);
// names such as Bedrock ones with spaces are rejected by Adventure; the UUID still identifies the player
if (PlayerHeadObjectContents.isValidName(name)) {
builder.name(name);
}
if (texture != null) {
builder.profileProperty(PlayerHeadObjectContents.property(TEXTURES_PROPERTY, texture, signature));
}
return Component.object(builder.build());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@
*
* @param sortFormat {@link String#format} pattern for the numeric team-name prefix that sorts the tab list
* @param teamCollision whether players in the same team push each other (Minestom only)
* @param chatHeadEnabled whether chat lines start with the sender's player head
*/
public record ButterflySettings(String sortFormat, boolean teamCollision) {
public record ButterflySettings(String sortFormat, boolean teamCollision, boolean chatHeadEnabled) {

public static final String SORT_FORMAT_KEY = "butterfly.teams.sort-format";
public static final String TEAM_COLLISION_KEY = "butterfly.teams.collision";
/** Key of the system property that configured the sort format before the settings file existed. */
static final String LEGACY_SORT_FORMAT_KEY = "butterfly.format";

public static final String CHAT_HEAD_KEY = "butterfly.chat.head.enabled";

private static final String DEFAULT_SORT_FORMAT = "%04d";
private static final boolean DEFAULT_TEAM_COLLISION = false;
private static final boolean DEFAULT_CHAT_HEAD = true;

public static ButterflySettings defaults() {
return new ButterflySettings(DEFAULT_SORT_FORMAT, DEFAULT_TEAM_COLLISION);
return new ButterflySettings(DEFAULT_SORT_FORMAT, DEFAULT_TEAM_COLLISION, DEFAULT_CHAT_HEAD);
}

/**
Expand All @@ -33,7 +37,8 @@ public static ButterflySettings defaults() {
static ButterflySettings from(Configuration configuration, Logger logger) {
return new ButterflySettings(
readSortFormat(configuration, logger),
readTeamCollision(configuration, logger)
readTeamCollision(configuration, logger),
readChatHead(configuration, logger)
);
}

Expand Down Expand Up @@ -61,6 +66,15 @@ private static boolean readTeamCollision(Configuration configuration, Logger log
return DEFAULT_TEAM_COLLISION;
}

private static boolean readChatHead(Configuration configuration, Logger logger) {
Optional<String> value = configuration.getOptional(CHAT_HEAD_KEY);
if (value.isEmpty()) return DEFAULT_CHAT_HEAD;
if (value.get().equalsIgnoreCase("true")) return true;
if (value.get().equalsIgnoreCase("false")) return false;
logger.warn("Invalid value '{}' for {}, using the default '{}'", value.get(), CHAT_HEAD_KEY, DEFAULT_CHAT_HEAD);
return DEFAULT_CHAT_HEAD;
}

private static boolean canFormatSortId(String format) {
try {
String.format(format, 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;

class ChatLineTest {

private static final Component HEAD = PlayerHeads.of(UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"), "Steve", null, null);

private static String plain(Component component) {
return PlainTextComponentSerializer.plainText().serialize(component);
}

@Test
@DisplayName("the line starts with the head")
void lineStartsWithHead() {
Component line = ChatLine.compose(HEAD, Component.text("[Admin] Steve"), Component.text("hello"));

assertSame(HEAD, line.children().get(0), "the head is the first part of the line");
}

@Test
@DisplayName("a single space separates the head from the name")
void spaceSeparatesHeadFromName() {
Component line = ChatLine.compose(HEAD, Component.text("[Admin] Steve"), Component.text("hello"));

assertEquals(Component.space(), line.children().get(1), "space right after the head");
}

@Test
@DisplayName("the line is name, colon, space and message after the head")
void lineHasNameColonMessage() {
Component line = ChatLine.compose(HEAD, Component.text("[Admin] Steve"), Component.text("hello"));

assertEquals(List.of(HEAD, Component.space(), Component.text("[Admin] Steve"), Component.text(": "), Component.text("hello")),
line.children(), "head, space, name, separator, message");
}

@Test
@DisplayName("without a head the line has no leading space")
void noHeadNoLeadingSpace() {
Component line = ChatLine.compose(null, Component.text("[Admin] Steve"), Component.text("hello"));

assertEquals("[Admin] Steve: hello", plain(line), "plain text without head and leading space");
assertEquals(List.of(Component.text("[Admin] Steve"), Component.text(": "), Component.text("hello")),
line.children(), "name, separator, message only");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ObjectComponent;
import net.kyori.adventure.text.object.PlayerHeadObjectContents;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

class PlayerHeadsTest {

private static final UUID ID = UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5");

private static PlayerHeadObjectContents contentsOf(Component head) {
ObjectComponent object = assertInstanceOf(ObjectComponent.class, head, "the head is an object component");
return assertInstanceOf(PlayerHeadObjectContents.class, object.contents(), "the object is a player head");
}

@Test
@DisplayName("the head identifies the player by UUID and name")
void headCarriesIdAndName() {
PlayerHeadObjectContents contents = contentsOf(PlayerHeads.of(ID, "Steve", null, null));

assertEquals(ID, contents.id(), "UUID of the sender");
assertEquals("Steve", contents.name(), "name of the sender");
}

@Test
@DisplayName("the head carries the textures property with value and signature")
void headCarriesTextures() {
PlayerHeadObjectContents contents = contentsOf(PlayerHeads.of(ID, "Steve", "texture-value", "texture-signature"));

assertEquals(1, contents.profileProperties().size(), "exactly one property");
PlayerHeadObjectContents.ProfileProperty property = contents.profileProperties().get(0);
assertEquals("textures", property.name(), "property name");
assertEquals("texture-value", property.value(), "texture value");
assertEquals("texture-signature", property.signature(), "texture signature");
}

@Test
@DisplayName("the head carries the textures property without a signature when none is known")
void headCarriesTexturesWithoutSignature() {
PlayerHeadObjectContents contents = contentsOf(PlayerHeads.of(ID, "Steve", "texture-value", null));

assertEquals(1, contents.profileProperties().size(), "exactly one property");
assertEquals("texture-value", contents.profileProperties().get(0).value(), "texture value");
assertNull(contents.profileProperties().get(0).signature(), "no signature");
}

@Test
@DisplayName("the head has no property when no texture is known")
void headWithoutTextureHasNoProperty() {
PlayerHeadObjectContents contents = contentsOf(PlayerHeads.of(ID, "Steve", null, null));

assertTrue(contents.profileProperties().isEmpty(), "no profile property without texture");
}

@Test
@DisplayName("a name that is not a valid head name is left out instead of failing")
void invalidNameIsLeftOut() {
PlayerHeadObjectContents contents = contentsOf(PlayerHeads.of(ID, "Foo Bar", null, null));

assertEquals(ID, contents.id(), "UUID still identifies the sender");
assertNull(contents.name(), "an invalid name is not sent");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ void defaultsAreDocumented() {

assertEquals("%04d", defaults.sortFormat());
assertFalse(defaults.teamCollision(), "collision defaults to off");
assertTrue(defaults.chatHeadEnabled(), "the chat head defaults to on");
}

@Test
Expand Down Expand Up @@ -96,4 +97,32 @@ void unusableLegacyFormatFallsBack() {
assertEquals("%04d", settings.sortFormat());
assertTrue(log.warnings().get(0).contains("butterfly.format"), "warning names the legacy key");
}

@Test
@DisplayName("the chat head is on when the key is absent")
void chatHeadIsOnWhenAbsent() {
ButterflySettings settings = settingsFrom(Configuration.builder());

assertTrue(settings.chatHeadEnabled(), "chat head defaults to on");
}

@Test
@DisplayName("the chat head can be switched off")
void chatHeadCanBeSwitchedOff() {
ButterflySettings settings = settingsFrom(Configuration.builder().put("butterfly.chat.head.enabled", "false"));

assertFalse(settings.chatHeadEnabled(), "chat head was switched off");
assertEquals(List.of(), log.warnings(), "a valid value does not warn");
}

@Test
@DisplayName("a non-boolean chat head value falls back to on and warns with key and value")
void nonBooleanChatHeadFallsBack() {
ButterflySettings settings = settingsFrom(Configuration.builder().put("butterfly.chat.head.enabled", "maybe"));

assertTrue(settings.chatHeadEnabled(), "chat head falls back to on");
assertEquals(1, log.warnings().size(), "exactly one warning");
assertTrue(log.warnings().get(0).contains("butterfly.chat.head.enabled"), "warning names the key");
assertTrue(log.warnings().get(0).contains("maybe"), "warning names the rejected value");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ void existingFileIsKept(@TempDir Path dataFolder) throws IOException {

ButterflySettings settings = load(dataFolder);

assertEquals(new ButterflySettings("%03d", true), settings);
assertEquals(new ButterflySettings("%03d", true, true), settings);
assertEquals(custom, Files.readString(file), "the file content must not change");
}

Expand Down Expand Up @@ -181,6 +181,6 @@ void fromSystemPropertiesApplied() {
ButterflySettings settings = SettingsFile.fromSystemProperties(
Map.of("butterfly.format", "%02d", "butterfly.teams.collision", "true"), log.logger());

assertEquals(new ButterflySettings("%02d", true), settings);
assertEquals(new ButterflySettings("%02d", true, true), settings);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public void onEnable() {
ButterflySettings settings = SettingsFile.load(getDataPath(), "net/onelitefeather/butterfly/bukkit/default-config.yaml", Path.of("flags.properties"), getSLF4JLogger());
LuckPermsAPI.setLuckPermsService(new BukkitLuckPermsService(settings));
LuckPermsAPI.luckPermsAPI().subscribeEvents();
getServer().getPluginManager().registerEvents(new PlayerListener(), this);
getServer().getPluginManager().registerEvents(new PlayerListener(settings), this);
getServer().getCommandMap().register("clearteams", new ClearTeamsCommand());
getServer().getCommandMap().register("updateteams", new UpdateTeamsCommand());
}
Expand Down
Loading
Loading