From a2596c481debfaa298b7dff3c406cc7ee9b81001 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 21:50:02 +0200 Subject: [PATCH 1/6] docs(openspec): archive replace-togglz-with-avaje-config Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/minestom-extension/spec.md | 0 .../specs/plugin-configuration/spec.md | 0 .../tasks.md | 2 +- openspec/specs/minestom-extension/spec.md | 13 +- openspec/specs/plugin-configuration/spec.md | 127 ++++++++++++++++++ 8 files changed, 133 insertions(+), 9 deletions(-) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/.openspec.yaml (100%) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/design.md (100%) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/proposal.md (100%) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/specs/minestom-extension/spec.md (100%) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/specs/plugin-configuration/spec.md (100%) rename openspec/changes/{replace-togglz-with-avaje-config => archive/2026-10-05-replace-togglz-with-avaje-config}/tasks.md (98%) create mode 100644 openspec/specs/plugin-configuration/spec.md diff --git a/openspec/changes/replace-togglz-with-avaje-config/.openspec.yaml b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/.openspec.yaml similarity index 100% rename from openspec/changes/replace-togglz-with-avaje-config/.openspec.yaml rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/.openspec.yaml diff --git a/openspec/changes/replace-togglz-with-avaje-config/design.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/design.md similarity index 100% rename from openspec/changes/replace-togglz-with-avaje-config/design.md rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/design.md diff --git a/openspec/changes/replace-togglz-with-avaje-config/proposal.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/proposal.md similarity index 100% rename from openspec/changes/replace-togglz-with-avaje-config/proposal.md rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/proposal.md diff --git a/openspec/changes/replace-togglz-with-avaje-config/specs/minestom-extension/spec.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/specs/minestom-extension/spec.md similarity index 100% rename from openspec/changes/replace-togglz-with-avaje-config/specs/minestom-extension/spec.md rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/specs/minestom-extension/spec.md diff --git a/openspec/changes/replace-togglz-with-avaje-config/specs/plugin-configuration/spec.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/specs/plugin-configuration/spec.md similarity index 100% rename from openspec/changes/replace-togglz-with-avaje-config/specs/plugin-configuration/spec.md rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/specs/plugin-configuration/spec.md diff --git a/openspec/changes/replace-togglz-with-avaje-config/tasks.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md similarity index 98% rename from openspec/changes/replace-togglz-with-avaje-config/tasks.md rename to openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md index 8c830ba..3ee6560 100644 --- a/openspec/changes/replace-togglz-with-avaje-config/tasks.md +++ b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md @@ -37,4 +37,4 @@ commits: `refactor(config)`; the breaking commit carries `!` and a ## 6. Pull request -- [ ] 6.1 Open the pull request titled `refactor(config)!: replace togglz with avaje-config` with a `BREAKING CHANGE:` section (`flags.properties` no longer read, new data-folder `config.yaml`, Minestom collision via `butterfly.teams.collision`); verify `gh pr view` shows the title and CI is green +- [x] 6.1 Open the pull request titled `refactor(config)!: replace togglz with avaje-config` with a `BREAKING CHANGE:` section (`flags.properties` no longer read, new data-folder `config.yaml`, Minestom collision via `butterfly.teams.collision`); verify `gh pr view` shows the title and CI is green diff --git a/openspec/specs/minestom-extension/spec.md b/openspec/specs/minestom-extension/spec.md index 9ad8514..e8c2e42 100644 --- a/openspec/specs/minestom-extension/spec.md +++ b/openspec/specs/minestom-extension/spec.md @@ -35,12 +35,16 @@ If LuckPerms is unavailable during `initialize()`, the extension SHALL log an er - **THEN** an error is logged, no listeners are registered and the server keeps running ### Requirement: Team and prefix on spawn -On player spawn the extension SHALL place the player in a Minestom team derived from their LuckPerms primary group (name `%04d` + group for tab list ordering) and apply the team colour and prefix as nametag prefix, identical to the library behaviour. +On player spawn the extension SHALL place the player in a Minestom team derived from their LuckPerms primary group (name formatted with the configured sort format `butterfly.teams.sort-format`, default `%04d`, + group for tab list ordering) and apply the team colour and prefix as nametag prefix, identical to the library behaviour. #### Scenario: Player spawns - **WHEN** a player with a LuckPerms group spawns - **THEN** the player is a member of the group's team with the configured colour and prefix +#### Scenario: Custom sort format +- **WHEN** `butterfly.teams.sort-format` is `%02d` and a player whose primary group has sort id 5 spawns +- **THEN** the player's team name starts with `05` + ### Requirement: Chat format The extension SHALL format chat messages as the MiniMessage group prefix followed by the message, identical to the library behaviour. @@ -55,13 +59,6 @@ On `terminate()` the extension SHALL remove the event node it registered and unr - **WHEN** `terminate()` runs - **THEN** later spawn and chat events are not handled by Butterfly and the created teams no longer exist -### Requirement: Feature flags under the extension classloader -The extension SHALL configure Togglz explicitly so it works inside the extension classloader and SHALL read `flags.properties` from the extension data directory, using defaults when the file is absent. - -#### Scenario: No flags file -- **WHEN** `extensions/Butterfly/flags.properties` does not exist -- **THEN** default flag values apply and initialization succeeds - ### Requirement: Library compatibility The `butterfly-minestom` artifact SHALL remain usable as a library through `Butterfly.create().load()` and `terminate()` without the extension system. diff --git a/openspec/specs/plugin-configuration/spec.md b/openspec/specs/plugin-configuration/spec.md new file mode 100644 index 0000000..8d8baee --- /dev/null +++ b/openspec/specs/plugin-configuration/spec.md @@ -0,0 +1,127 @@ +# plugin-configuration Specification + +## Purpose +Defines where Butterfly reads its settings from on Paper and Minestom, which +settings exist with which defaults, and how overrides take precedence, so +operators have one predictable place to configure Butterfly. + +## Requirements + +### Requirement: Settings are read from the data folder +Butterfly SHALL read its settings from `config.yaml` inside its own data folder: +`plugins/Butterfly/config.yaml` on Paper and `extensions/Butterfly/config.yaml` +when running as a Minestom extension. It MUST NOT read settings files from the +server's working directory. + +#### Scenario: Paper settings file is applied +- **WHEN** `plugins/Butterfly/config.yaml` sets `butterfly.teams.sort-format` to `%03d` +- **AND** the plugin is enabled +- **THEN** team names are built with a three-digit sort prefix + +#### Scenario: Minestom extension settings file is applied +- **WHEN** `extensions/Butterfly/config.yaml` sets `butterfly.teams.collision` to `true` +- **AND** the extension is initialized and a player spawns +- **THEN** the player's team has collision rule `ALWAYS` + +#### Scenario: Settings file in the server root is ignored +- **WHEN** a file named `application.yaml` or `config.yaml` in the server's working directory sets `butterfly.teams.sort-format` +- **AND** the data-folder `config.yaml` does not set it +- **THEN** the default sort format `%04d` is used + +### Requirement: Default settings file is created on first start +When the data-folder `config.yaml` does not exist at start, Butterfly SHALL create +the data folder if needed and write `config.yaml` with every key supported on that +platform set to its default value. An existing file MUST NOT be overwritten or +modified. + +#### Scenario: First start writes defaults +- **WHEN** Butterfly starts and its data-folder `config.yaml` is absent +- **THEN** the file exists afterwards and contains `butterfly.teams.sort-format` with value `%04d` + +#### Scenario: Existing file is kept +- **WHEN** Butterfly starts and its data-folder `config.yaml` already exists with custom values +- **THEN** the file content is unchanged afterwards + +#### Scenario: Data folder cannot be written +- **WHEN** the data folder cannot be created or written +- **THEN** a warning naming the folder is logged +- **AND** Butterfly starts with default settings + +### Requirement: Missing keys fall back to defaults +Every setting SHALL have a documented default that applies when the key is absent +from all configuration sources. + +| Key | Platform | Default | +|-----|----------|---------| +| `butterfly.teams.sort-format` | Paper, Minestom | `%04d` | +| `butterfly.teams.collision` | Minestom | `false` | + +#### Scenario: Empty settings file +- **WHEN** the data-folder `config.yaml` exists but is empty +- **THEN** every setting takes its default value + +#### Scenario: Collision stays off by default +- **WHEN** no source sets `butterfly.teams.collision` +- **AND** a player spawns on Minestom +- **THEN** the player's team has collision rule `NEVER` + +### Requirement: Invalid values fall back to defaults with a warning +When a setting has a value that cannot be used (for example a sort format that +cannot format a single integer, or a collision value that is not a boolean), +Butterfly SHALL use that setting's default and log a warning naming the key and +the rejected value. Startup MUST continue. + +#### Scenario: Unusable sort format +- **WHEN** `butterfly.teams.sort-format` is set to `%s%s` +- **THEN** the default `%04d` is used +- **AND** a warning naming `butterfly.teams.sort-format` and `%s%s` is logged + +#### Scenario: Non-boolean collision value +- **WHEN** `butterfly.teams.collision` is set to `sometimes` +- **THEN** the default `false` is used +- **AND** a warning naming `butterfly.teams.collision` and `sometimes` is logged + +### Requirement: System properties override file values +A JVM system property with the same name as a setting key SHALL take precedence +over the value from the settings file. The legacy system property +`butterfly.format` SHALL continue to override `butterfly.teams.sort-format`; when +both are set, `butterfly.teams.sort-format` wins. + +#### Scenario: Legacy format property still works +- **WHEN** the server is started with `-Dbutterfly.format=%02d` +- **AND** no other source sets `butterfly.teams.sort-format` +- **THEN** team names are built with a two-digit sort prefix + +#### Scenario: New property beats legacy property +- **WHEN** the server is started with `-Dbutterfly.format=%02d` and `-Dbutterfly.teams.sort-format=%05d` +- **THEN** team names are built with a five-digit sort prefix + +### Requirement: Legacy flags file is reported, not read +Butterfly MUST NOT read `flags.properties`. When a `flags.properties` file exists +at the location the previous version used (the working directory on Paper, the +extension data folder on Minestom), Butterfly SHALL log a single warning at start +that the file is no longer used and name `butterfly.teams.collision` as its +replacement. The file MUST NOT be modified or deleted. + +#### Scenario: Old flags file present +- **WHEN** `extensions/Butterfly/flags.properties` with `TEAM_COLLISION=true` exists +- **AND** the Minestom extension is initialized and a player spawns +- **THEN** one warning mentions `flags.properties` and `butterfly.teams.collision` +- **AND** the player's team has collision rule `NEVER` + +### Requirement: Minestom library takes settings from the host +When Butterfly is used as a Minestom library (not loaded as an extension), it +SHALL accept its settings from the host at creation time. When the host passes no +settings, Butterfly SHALL use the defaults overridden only by JVM system +properties, and MUST NOT read or write any file. + +#### Scenario: Host enables collision +- **WHEN** the host creates Butterfly with settings where `butterfly.teams.collision` is `true` +- **AND** a player spawns +- **THEN** the player's team has collision rule `ALWAYS` + +#### Scenario: No settings passed +- **WHEN** the host creates Butterfly without settings +- **AND** a player spawns +- **THEN** the player's team has collision rule `NEVER` +- **AND** no file is created in the working directory From 516eef5c79600c7bd1672960b437cd92d7e1cbb2 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 21:50:04 +0200 Subject: [PATCH 2/6] docs(openspec): archive restrict-chat-tags-by-permission Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/chat-tag-permissions/spec.md | 0 .../tasks.md | 2 +- openspec/specs/chat-tag-permissions/spec.md | 83 +++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) rename openspec/changes/{restrict-chat-tags-by-permission => archive/2026-10-05-restrict-chat-tags-by-permission}/.openspec.yaml (100%) rename openspec/changes/{restrict-chat-tags-by-permission => archive/2026-10-05-restrict-chat-tags-by-permission}/design.md (100%) rename openspec/changes/{restrict-chat-tags-by-permission => archive/2026-10-05-restrict-chat-tags-by-permission}/proposal.md (100%) rename openspec/changes/{restrict-chat-tags-by-permission => archive/2026-10-05-restrict-chat-tags-by-permission}/specs/chat-tag-permissions/spec.md (100%) rename openspec/changes/{restrict-chat-tags-by-permission => archive/2026-10-05-restrict-chat-tags-by-permission}/tasks.md (98%) create mode 100644 openspec/specs/chat-tag-permissions/spec.md diff --git a/openspec/changes/restrict-chat-tags-by-permission/.openspec.yaml b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/.openspec.yaml similarity index 100% rename from openspec/changes/restrict-chat-tags-by-permission/.openspec.yaml rename to openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/.openspec.yaml diff --git a/openspec/changes/restrict-chat-tags-by-permission/design.md b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/design.md similarity index 100% rename from openspec/changes/restrict-chat-tags-by-permission/design.md rename to openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/design.md diff --git a/openspec/changes/restrict-chat-tags-by-permission/proposal.md b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/proposal.md similarity index 100% rename from openspec/changes/restrict-chat-tags-by-permission/proposal.md rename to openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/proposal.md diff --git a/openspec/changes/restrict-chat-tags-by-permission/specs/chat-tag-permissions/spec.md b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/specs/chat-tag-permissions/spec.md similarity index 100% rename from openspec/changes/restrict-chat-tags-by-permission/specs/chat-tag-permissions/spec.md rename to openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/specs/chat-tag-permissions/spec.md diff --git a/openspec/changes/restrict-chat-tags-by-permission/tasks.md b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md similarity index 98% rename from openspec/changes/restrict-chat-tags-by-permission/tasks.md rename to openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md index a467279..4cb92ca 100644 --- a/openspec/changes/restrict-chat-tags-by-permission/tasks.md +++ b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md @@ -29,4 +29,4 @@ that switches both platforms to the restricted parser is `feat(chat)!` with a ## 5. Pull request -- [ ] 5.1 Open the pull request titled `feat(chat)!: restrict minimessage tags in chat by permission` with a `BREAKING CHANGE:` section (formatting now needs `butterfly.chat.tag.`; grant `butterfly.chat.tag.*` to `default` to restore the old behaviour); verify `gh pr view` shows the title and CI is green +- [x] 5.1 Open the pull request titled `feat(chat)!: restrict minimessage tags in chat by permission` with a `BREAKING CHANGE:` section (formatting now needs `butterfly.chat.tag.`; grant `butterfly.chat.tag.*` to `default` to restore the old behaviour); verify `gh pr view` shows the title and CI is green diff --git a/openspec/specs/chat-tag-permissions/spec.md b/openspec/specs/chat-tag-permissions/spec.md new file mode 100644 index 0000000..0e0d92e --- /dev/null +++ b/openspec/specs/chat-tag-permissions/spec.md @@ -0,0 +1,83 @@ +# chat-tag-permissions Specification + +## Purpose +Controls which MiniMessage formatting tags a player may use in chat messages, per +LuckPerms rank, so servers decide who may color text, add click or hover events, +or embed objects such as player heads. + +## Requirements + +### Requirement: Tags require a permission per tag type +When Butterfly formats a chat message, it SHALL interpret a MiniMessage tag in the +message text only if the sender has the permission `butterfly.chat.tag.` +for that tag's type, as resolved by LuckPerms for the sender. This applies on +Paper and on Minestom. + +| Type | Tags covered | +|------|--------------| +| `color` | named colours, hex colours, `` | +| `decoration` | ``, ``, ``, ``, `` and their short forms | +| `gradient` | `` | +| `rainbow` | `` | +| `transition` | `` | +| `pride` | `` | +| `shadow` | `` | +| `font` | `` | +| `reset` | `` | +| `newline` | `` / `
` | +| `click` | `` | +| `hover` | `` | +| `insertion` | `` | +| `keybind` | `` | +| `translatable` | ``, ``, `` | +| `selector` | `` | +| `score` | `` | +| `nbt` | `` | +| `sprite` | `` | +| `head` | `` | + +#### Scenario: Permitted colour +- **WHEN** a player with `butterfly.chat.tag.color` sends `hello` +- **THEN** the message part of the chat line reads `hello` in red + +#### Scenario: Permission for one type does not grant another +- **WHEN** a player with `butterfly.chat.tag.color` but not `butterfly.chat.tag.click` sends `x` +- **THEN** the chat line carries no click event + +#### Scenario: Wildcard grants every type +- **WHEN** a player whose group has `butterfly.chat.tag.*` sends `hi` +- **THEN** the message is rendered with the rainbow effect + +#### Scenario: Permission granted through a group +- **WHEN** the group `vip` has `butterfly.chat.tag.gradient` and a player in `vip` sends `hi
` +- **THEN** the message is rendered with the gradient + +### Requirement: Disallowed tags stay as literal text +A tag the sender has no permission for SHALL appear in the chat line exactly as +typed, including its angle brackets and arguments. Text around it MUST NOT be +removed or altered. + +#### Scenario: No permissions at all +- **WHEN** a player without any `butterfly.chat.tag.*` permission sends `hello a < b` +- **THEN** the message part of the chat line is the plain text `hello a < b` + +#### Scenario: Mixed allowed and disallowed tags +- **WHEN** a player with only `butterfly.chat.tag.decoration` sends `hi there` +- **THEN** `hi` is bold +- **AND** `there` appears literally + +### Requirement: Tag permissions do not affect the prefix +The sender's LuckPerms prefix and name in the chat line SHALL keep their +MiniMessage formatting regardless of the sender's tag permissions. + +#### Scenario: Coloured prefix for a player without tag permissions +- **WHEN** a player in a group with prefix `[Admin] ` and no tag permissions sends `hi` +- **THEN** the chat line shows `[Admin] ` in red followed by the name and `hi` + +### Requirement: Every viewer sees the same message +The message part of a chat line SHALL be the same for every viewer; it depends +only on the sender's permissions, not on the viewer's. + +#### Scenario: Two viewers +- **WHEN** a player with `butterfly.chat.tag.color` sends `hi` and two other players receive it +- **THEN** both receive `hi` in red From 6890bab9cb414966df16b3d0c86a3d69c1b2e953 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 21:50:07 +0200 Subject: [PATCH 3/6] docs(openspec): add add-player-head-to-chat change Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- .../add-player-head-to-chat/.openspec.yaml | 2 + .../changes/add-player-head-to-chat/design.md | 73 +++++++++++++++++++ .../add-player-head-to-chat/proposal.md | 44 +++++++++++ .../specs/chat-player-head/spec.md | 52 +++++++++++++ .../specs/plugin-configuration/spec.md | 26 +++++++ .../changes/add-player-head-to-chat/tasks.md | 30 ++++++++ 6 files changed, 227 insertions(+) create mode 100644 openspec/changes/add-player-head-to-chat/.openspec.yaml create mode 100644 openspec/changes/add-player-head-to-chat/design.md create mode 100644 openspec/changes/add-player-head-to-chat/proposal.md create mode 100644 openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md create mode 100644 openspec/changes/add-player-head-to-chat/specs/plugin-configuration/spec.md create mode 100644 openspec/changes/add-player-head-to-chat/tasks.md diff --git a/openspec/changes/add-player-head-to-chat/.openspec.yaml b/openspec/changes/add-player-head-to-chat/.openspec.yaml new file mode 100644 index 0000000..e3966d7 --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/add-player-head-to-chat/design.md b/openspec/changes/add-player-head-to-chat/design.md new file mode 100644 index 0000000..f03898f --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/design.md @@ -0,0 +1,73 @@ +# Design + +## Context + +- After `restrict-chat-tags-by-permission`, both platforms build the message part + with `ChatMessageParser` (api) once per chat message. Paper composes + `displayName + ": " + message` in the `AsyncChatEvent` renderer; Minestom builds + `prefix + name + ": " + message` in `Butterfly#playerChat` and returns early + (vanilla format) when the sender has no prefix. +- Adventure 5.2 provides `Component.object(...)` with `PlayerHeadObjectContents` + (UUID, name, profile properties). Minestom 26.2 encodes `ObjectComponent` + (`ComponentCodecs`), Paper 26.x natively. +- Skin sources: Paper `Player#getPlayerProfile().getProperties()` (`textures` + with value + signature); Minestom `Player#getSkin()` (`textures`, `signature`, + may be `null`). +- Settings live in `ButterflySettings` and the namespaced `default-config.yaml` + resources (from `replace-togglz-with-avaje-config`). + +## Goals / Non-Goals + +**Goals:** +- One platform-neutral builder for the head and the chat line, unit-tested in `api`. +- No client-side skin lookups for online-mode players. + +**Non-Goals:** +- Heads in tab list, nametags, or other messages (join/quit, death). +- Permission-gating the head per rank (setting is global). +- Changing the Minestom early return for players without prefix. + +## Decisions + +### 1. `ChatLine` in `api` +``` +ChatLine.compose(@Nullable Component head, Component name, Component message) -> Component +PlayerHeads.of(UUID id, String name, @Nullable String texture, @Nullable String signature) -> Component +``` +`compose` emits `head + " "` only when `head` is non-null, then +`name + ": " + message`. Platforms pass `null` when +`settings.chatHeadEnabled()` is false. Pure functions -> fast unit tests without a +server. + +*Alternative:* put the head into the display name. Rejected in exploration: the tab +list already shows heads (duplicate) and other plugins reading the display name +would inherit it. + +### 2. Head built once per message +Paper: build the head in `handleChat` next to the parsed message and capture it in +the renderer. Minestom: build in `playerChat`. + +### 3. Setting `butterfly.chat.head.enabled` +Added to `ButterflySettings` (default `true`, invalid -> default + warning, same +rules as `butterfly.teams.collision`) and to both bundled `default-config.yaml` +files. Existing data-folder files are never rewritten, so servers that already +have a `config.yaml` get the default from code. + +### 4. Space after the head +A plain `Component.space()` keeps the glyph from touching the prefix's first +character; no configurable separator. + +## Risks / Trade-offs + +- [Clients < 1.21.9 via ViaBackwards: unknown what the downgrade produces (nothing, + placeholder, or fallback text)] -> manual check with an old client (task 4.2); + if the result is ugly, a follow-up can make the head per-client-version, the + setting allows turning it off meanwhile. +- [Offline-mode servers have no textures; clients may resolve by name or show a + default skin] -> accepted, spec requires UUID + name only in that case. +- [The leading space shifts existing chat layout] -> visible change, documented. + +## Migration Plan + +Additive `feat(chat)`. Default on; operators set `butterfly.chat.head.enabled: +false` to keep the old look. Rollback: previous jar. diff --git a/openspec/changes/add-player-head-to-chat/proposal.md b/openspec/changes/add-player-head-to-chat/proposal.md new file mode 100644 index 0000000..7615e85 --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/proposal.md @@ -0,0 +1,44 @@ +# Proposal + +Ships as: `feat(chat): show the player head before the prefix in chat` + +Depends on: `replace-togglz-with-avaje-config` (PR #133) and +`restrict-chat-tags-by-permission` (same chat code path; implement after it). + +## Why + +Since Minecraft 1.21.9 text components can embed a player's head as an inline +glyph. Showing the sender's head in front of the rank prefix makes chat easier to +scan. Paper (Adventure 5.2) and Minestom both support these object components. + +## What Changes + +- Chat lines formatted by Butterfly start with the sender's head, followed by a + space, the prefix and name, `: ` and the message: + `[head] [Admin] Steve: hello`. +- The head carries the sender's skin textures from their profile, so clients do + not have to look the skin up themselves. +- New setting `butterfly.chat.head.enabled` (default `true`, Paper and Minestom) + turns the head off; it is added to both bundled default `config.yaml` files. +- Display name, tab list name and team prefix stay unchanged; the head only + appears in chat. + +## Capabilities + +### New Capabilities +- `chat-player-head`: the sender's head in front of the chat line, its skin + source, and the setting that disables it. + +### Modified Capabilities +- `plugin-configuration`: new key `butterfly.chat.head.enabled` in the defaults + table (spec created by `replace-togglz-with-avaje-config`; archive that change + first). + +## Impact + +- Code: `ButterflySettings` and `SettingsFile` defaults in `api`, both + `default-config.yaml` resources, `bukkit/.../listener/PlayerListener.java`, + `minestom/.../Butterfly.java` (`playerChat`). +- Clients older than 1.21.9 (via ViaVersion/ViaBackwards) depend on how + ViaBackwards downgrades object components; verified manually. +- No new dependencies. diff --git a/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md b/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md new file mode 100644 index 0000000..1f91317 --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md @@ -0,0 +1,52 @@ +# Spec Delta + +## Purpose + +Shows the sender's player head as an inline glyph in front of each chat line that +Butterfly formats, so players can recognise who is talking at a glance. + +## ADDED Requirements + +### Requirement: Head precedes the prefix in chat +When Butterfly formats a chat line and `butterfly.chat.head.enabled` is `true`, +the line SHALL start with a player-head object component of the sender, followed +by a single space, then the sender's prefix and name, `: ` and the message. This +applies on Paper and on Minestom. + +#### Scenario: Player with prefix chats +- **WHEN** a player `Steve` with prefix `[Admin] ` sends `hello` +- **THEN** the first part of the chat line is a player-head component for `Steve`'s UUID +- **AND** the plain text of the rest of the line is ` [Admin] Steve: hello` + +#### Scenario: Every viewer sees the head +- **WHEN** a player sends a message that two other players receive +- **THEN** both receive the line with the sender's head in front + +### Requirement: Head carries the sender's skin +The head component SHALL identify the sender by UUID and name and SHALL carry the +sender's skin texture property (value and signature) when the server knows it. +When no texture is known, the component SHALL still be sent with UUID and name. + +#### Scenario: Online-mode player +- **WHEN** a player whose profile has a `textures` property sends a message +- **THEN** the head component contains that `textures` value and signature + +#### Scenario: Player without textures +- **WHEN** a player whose profile has no `textures` property sends a message +- **THEN** the head component contains the player's UUID and name and no texture property + +### Requirement: Head only in chat +Adding the head MUST NOT change the player's display name, tab list name or team +prefix. + +#### Scenario: Tab list unchanged +- **WHEN** a player joins with the head feature enabled +- **THEN** the player's tab list name contains no player-head component + +### Requirement: Head can be disabled +With `butterfly.chat.head.enabled` set to `false`, chat lines SHALL be formatted +without the head and without the leading space. + +#### Scenario: Disabled +- **WHEN** `butterfly.chat.head.enabled` is `false` and a player with prefix `[Admin] ` sends `hello` +- **THEN** the plain text of the chat line is `[Admin] Steve: hello` and it contains no player-head component diff --git a/openspec/changes/add-player-head-to-chat/specs/plugin-configuration/spec.md b/openspec/changes/add-player-head-to-chat/specs/plugin-configuration/spec.md new file mode 100644 index 0000000..c2a4e2a --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/specs/plugin-configuration/spec.md @@ -0,0 +1,26 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Missing keys fall back to defaults +Every setting SHALL have a documented default that applies when the key is absent +from all configuration sources. + +| Key | Platform | Default | +|-----|----------|---------| +| `butterfly.teams.sort-format` | Paper, Minestom | `%04d` | +| `butterfly.teams.collision` | Minestom | `false` | +| `butterfly.chat.head.enabled` | Paper, Minestom | `true` | + +#### Scenario: Empty settings file +- **WHEN** the data-folder `config.yaml` exists but is empty +- **THEN** every setting takes its default value + +#### Scenario: Collision stays off by default +- **WHEN** no source sets `butterfly.teams.collision` +- **AND** a player spawns on Minestom +- **THEN** the player's team has collision rule `NEVER` + +#### Scenario: Existing settings file without the head key +- **WHEN** a `config.yaml` written by an earlier version has no `butterfly.chat.head.enabled` +- **THEN** the head is shown in chat diff --git a/openspec/changes/add-player-head-to-chat/tasks.md b/openspec/changes/add-player-head-to-chat/tasks.md new file mode 100644 index 0000000..0d9e045 --- /dev/null +++ b/openspec/changes/add-player-head-to-chat/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +Start after `restrict-chat-tags-by-permission` is merged (and therefore #133). +Branch from the up-to-date `origin/main`. All implementation commits use +`feat(chat)`. + +## 1. Setting + +- [ ] 1.1 Write failing `ButterflySettingsTest` cases for `butterfly.chat.head.enabled` (absent -> `true`, `false` -> `false`, `maybe` -> `true` + warning naming key and value); implement the field in `ButterflySettings`; verify `./gradlew :api:test` passes +- [ ] 1.2 Add `butterfly.chat.head.enabled: true` with a comment to both `default-config.yaml` resources; verify the existing tests that compare the written default file still pass with `./gradlew build` + +## 2. Head and chat line in `api` + +- [ ] 2.1 Write failing unit tests for `PlayerHeads.of(...)` (UUID and name set; `textures` property with value and signature when given; no property when texture is `null`) and `ChatLine.compose(...)` (head + space + name + `: ` + message; no head and no leading space when head is `null`); verify they fail with `./gradlew :api:test` +- [ ] 2.2 Implement `PlayerHeads` and `ChatLine`; verify `./gradlew :api:test` passes + +## 3. Platforms + +- [ ] 3.1 Minestom: write failing `ExtensionBehaviourTest` cases (fresh `Env`): the formatted chat line's first child is a player-head component with the sender's UUID; with settings `chatHeadEnabled=false` there is none and the plain text equals today's; the player's display name has no head component. Then build the head from `Player#getSkin()` and compose with `ChatLine` in `Butterfly#playerChat`; verify `./gradlew :minestom:test` passes +- [ ] 3.2 Paper: in `PlayerListener#handleChat` build the head once from `getPlayerProfile()` (`textures` property) when `settings.chatHeadEnabled()`, and compose with `ChatLine` in the renderer; pass `ButterflySettings` into `PlayerListener` by constructor; verify `./gradlew :bukkit:build` succeeds +- [ ] 3.3 Run `./gradlew build :minestom:smokeTest`; verify both succeed + +## 4. Docs and manual checks + +- [ ] 4.1 README: document `butterfly.chat.head.enabled` in the configuration section and the client requirement (1.21.9+ for native rendering); verify the key appears in the README +- [ ] 4.2 Manual Paper test via `./gradlew :bukkit:runServer` with LuckPerms: a current client shows the sender's own skin head before the prefix; with `butterfly.chat.head.enabled: false` it disappears after restart; tab list and nametag show no head; with ViaVersion + ViaBackwards and a client older than 1.21.9, record what the line looks like (report both in the PR description) + +## 5. Pull request + +- [ ] 5.1 Open the pull request titled `feat(chat): show the player head before the prefix in chat` including the manual-test results; verify `gh pr view` shows the title and CI is green From bd45d97e8f4b57440ba0c48e84f6178ced9f6db7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 21:54:02 +0200 Subject: [PATCH 4/6] feat(chat): show the player head before the prefix in chat Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- README.md | 7 +- .../butterfly/api/chat/ChatLine.java | 30 +++++ .../butterfly/api/chat/PlayerHeads.java | 41 +++++++ .../api/config/ButterflySettings.java | 20 +++- .../butterfly/api/chat/ChatLineTest.java | 56 +++++++++ .../butterfly/api/chat/PlayerHeadsTest.java | 72 ++++++++++++ .../api/config/ButterflySettingsTest.java | 29 +++++ .../api/config/SettingsFileTest.java | 4 +- .../butterfly/bukkit/Butterfly.java | 2 +- .../bukkit/listener/PlayerListener.java | 27 ++++- .../butterfly/bukkit/default-config.yaml | 4 + .../butterfly/minestom/Butterfly.java | 14 ++- .../butterfly/minestom/default-config.yaml | 4 + .../minestom/ButterflyLibraryTest.java | 29 ++++- .../minestom/ExtensionBehaviourTest.java | 107 +++++++++++++++++- .../minestom/ExtensionConfigFileTest.java | 27 +++++ .../changes/add-player-head-to-chat/tasks.md | 16 +-- 17 files changed, 457 insertions(+), 32 deletions(-) create mode 100644 api/src/main/java/net/onelitefeather/butterfly/api/chat/ChatLine.java create mode 100644 api/src/main/java/net/onelitefeather/butterfly/api/chat/PlayerHeads.java create mode 100644 api/src/test/java/net/onelitefeather/butterfly/api/chat/ChatLineTest.java create mode 100644 api/src/test/java/net/onelitefeather/butterfly/api/chat/PlayerHeadsTest.java diff --git a/README.md b/README.md index 7369655..e12fb2b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/api/src/main/java/net/onelitefeather/butterfly/api/chat/ChatLine.java b/api/src/main/java/net/onelitefeather/butterfly/api/chat/ChatLine.java new file mode 100644 index 0000000..24e0975 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/butterfly/api/chat/ChatLine.java @@ -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(); + } +} diff --git a/api/src/main/java/net/onelitefeather/butterfly/api/chat/PlayerHeads.java b/api/src/main/java/net/onelitefeather/butterfly/api/chat/PlayerHeads.java new file mode 100644 index 0000000..5b3ad14 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/butterfly/api/chat/PlayerHeads.java @@ -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()); + } +} diff --git a/api/src/main/java/net/onelitefeather/butterfly/api/config/ButterflySettings.java b/api/src/main/java/net/onelitefeather/butterfly/api/config/ButterflySettings.java index eae4545..a326911 100644 --- a/api/src/main/java/net/onelitefeather/butterfly/api/config/ButterflySettings.java +++ b/api/src/main/java/net/onelitefeather/butterfly/api/config/ButterflySettings.java @@ -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); } /** @@ -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) ); } @@ -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 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); diff --git a/api/src/test/java/net/onelitefeather/butterfly/api/chat/ChatLineTest.java b/api/src/test/java/net/onelitefeather/butterfly/api/chat/ChatLineTest.java new file mode 100644 index 0000000..4d2ce4a --- /dev/null +++ b/api/src/test/java/net/onelitefeather/butterfly/api/chat/ChatLineTest.java @@ -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"); + } +} diff --git a/api/src/test/java/net/onelitefeather/butterfly/api/chat/PlayerHeadsTest.java b/api/src/test/java/net/onelitefeather/butterfly/api/chat/PlayerHeadsTest.java new file mode 100644 index 0000000..c215f25 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/butterfly/api/chat/PlayerHeadsTest.java @@ -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"); + } +} diff --git a/api/src/test/java/net/onelitefeather/butterfly/api/config/ButterflySettingsTest.java b/api/src/test/java/net/onelitefeather/butterfly/api/config/ButterflySettingsTest.java index d7948ab..5ba793d 100644 --- a/api/src/test/java/net/onelitefeather/butterfly/api/config/ButterflySettingsTest.java +++ b/api/src/test/java/net/onelitefeather/butterfly/api/config/ButterflySettingsTest.java @@ -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 @@ -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"); + } } diff --git a/api/src/test/java/net/onelitefeather/butterfly/api/config/SettingsFileTest.java b/api/src/test/java/net/onelitefeather/butterfly/api/config/SettingsFileTest.java index 9e16509..cd8c88a 100644 --- a/api/src/test/java/net/onelitefeather/butterfly/api/config/SettingsFileTest.java +++ b/api/src/test/java/net/onelitefeather/butterfly/api/config/SettingsFileTest.java @@ -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"); } @@ -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); } } diff --git a/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/Butterfly.java b/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/Butterfly.java index 197a46c..a92f0cb 100644 --- a/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/Butterfly.java +++ b/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/Butterfly.java @@ -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()); } diff --git a/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/listener/PlayerListener.java b/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/listener/PlayerListener.java index 4a7e9da..1b6d91a 100644 --- a/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/listener/PlayerListener.java +++ b/bukkit/src/main/java/net/onelitefeather/butterfly/bukkit/listener/PlayerListener.java @@ -1,10 +1,14 @@ package net.onelitefeather.butterfly.bukkit.listener; +import com.destroystokyo.paper.profile.ProfileProperty; import io.papermc.paper.event.player.AsyncChatEvent; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.onelitefeather.butterfly.api.LuckPermsAPI; +import net.onelitefeather.butterfly.api.chat.ChatLine; import net.onelitefeather.butterfly.api.chat.ChatMessageParser; +import net.onelitefeather.butterfly.api.chat.PlayerHeads; +import net.onelitefeather.butterfly.api.config.ButterflySettings; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -16,6 +20,12 @@ public final class PlayerListener implements Listener { + private final ButterflySettings settings; + + public PlayerListener(ButterflySettings settings) { + this.settings = settings; + } + @EventHandler(priority = EventPriority.LOWEST) public void handlePlayerLogin(PlayerLoginEvent event) { LuckPermsAPI.luckPermsAPI().setDisplayName(LuckPermsAPI.luckPermsAPI().getUser(event.getPlayer().getUniqueId())); @@ -34,10 +44,17 @@ public void handleChat(AsyncChatEvent event) { Component parsed = ChatMessageParser.parse( PlainTextComponentSerializer.plainText().serialize(event.message()), node -> LuckPermsAPI.luckPermsAPI().hasPermission(senderId, node)); - event.renderer((source, sourceDisplayName, message, viewer) -> Component.text() - .append(sourceDisplayName) - .append(Component.text(": ")) - .append(parsed) - .build()); + // Built once per message as well; null leaves the head out + Component head = settings.chatHeadEnabled() ? headOf(event.getPlayer()) : null; + event.renderer((source, sourceDisplayName, message, viewer) -> ChatLine.compose(head, sourceDisplayName, parsed)); + } + + private static Component headOf(Player player) { + ProfileProperty textures = player.getPlayerProfile().getProperties().stream() + .filter(property -> property.getName().equals("textures")) + .findFirst() + .orElse(null); + return PlayerHeads.of(player.getUniqueId(), player.getName(), + textures == null ? null : textures.getValue(), textures == null ? null : textures.getSignature()); } } diff --git a/bukkit/src/main/resources/net/onelitefeather/butterfly/bukkit/default-config.yaml b/bukkit/src/main/resources/net/onelitefeather/butterfly/bukkit/default-config.yaml index b8b620d..93349d1 100644 --- a/bukkit/src/main/resources/net/onelitefeather/butterfly/bukkit/default-config.yaml +++ b/bukkit/src/main/resources/net/onelitefeather/butterfly/bukkit/default-config.yaml @@ -2,3 +2,7 @@ butterfly: teams: # String.format pattern for the numeric team-name prefix (tab sorting) sort-format: "%04d" + chat: + head: + # true = chat lines start with the sender's player head (clients 1.21.9+) + enabled: true diff --git a/minestom/src/main/java/net/onelitefeather/butterfly/minestom/Butterfly.java b/minestom/src/main/java/net/onelitefeather/butterfly/minestom/Butterfly.java index 6601a12..d556696 100644 --- a/minestom/src/main/java/net/onelitefeather/butterfly/minestom/Butterfly.java +++ b/minestom/src/main/java/net/onelitefeather/butterfly/minestom/Butterfly.java @@ -4,12 +4,15 @@ import net.kyori.adventure.text.minimessage.MiniMessage; import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; +import net.minestom.server.entity.PlayerSkin; import net.minestom.server.event.Event; import net.minestom.server.event.EventNode; import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.event.player.PlayerSpawnEvent; import net.onelitefeather.butterfly.api.LuckPermsAPI; +import net.onelitefeather.butterfly.api.chat.ChatLine; import net.onelitefeather.butterfly.api.chat.ChatMessageParser; +import net.onelitefeather.butterfly.api.chat.PlayerHeads; import net.onelitefeather.butterfly.api.config.ButterflySettings; import net.onelitefeather.butterfly.api.config.SettingsFile; import org.jetbrains.annotations.NotNull; @@ -77,11 +80,12 @@ private void playerChat(PlayerChatEvent playerChatEvent) { var prefix = prefixOptional.get(); String displayName = prefix + player.getUsername(); - playerChatEvent.setFormattedMessage(Component.text() - .append(MiniMessage.miniMessage().deserialize(displayName)) - .append(Component.text(": ")) - .append(ChatMessageParser.parse(playerChatEvent.getRawMessage(), node -> LuckPermsAPI.luckPermsAPI().hasPermission(player.getUuid(), node))) - .build()); + PlayerSkin skin = player.getSkin(); + Component head = settings.chatHeadEnabled() + ? PlayerHeads.of(player.getUuid(), player.getUsername(), skin == null ? null : skin.textures(), skin == null ? null : skin.signature()) + : null; + Component message = ChatMessageParser.parse(playerChatEvent.getRawMessage(), node -> LuckPermsAPI.luckPermsAPI().hasPermission(player.getUuid(), node)); + playerChatEvent.setFormattedMessage(ChatLine.compose(head, MiniMessage.miniMessage().deserialize(displayName), message)); } public void terminate() { diff --git a/minestom/src/main/resources/net/onelitefeather/butterfly/minestom/default-config.yaml b/minestom/src/main/resources/net/onelitefeather/butterfly/minestom/default-config.yaml index 8e17d4c..0636b7e 100644 --- a/minestom/src/main/resources/net/onelitefeather/butterfly/minestom/default-config.yaml +++ b/minestom/src/main/resources/net/onelitefeather/butterfly/minestom/default-config.yaml @@ -4,3 +4,7 @@ butterfly: sort-format: "%04d" # true = players in the same team push each other collision: false + chat: + head: + # true = chat lines start with the sender's player head (clients 1.21.9+) + enabled: true diff --git a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ButterflyLibraryTest.java b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ButterflyLibraryTest.java index e161583..0005edf 100644 --- a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ButterflyLibraryTest.java +++ b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ButterflyLibraryTest.java @@ -1,6 +1,8 @@ package net.onelitefeather.butterfly.minestom; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.network.packet.server.play.TeamsPacket; import net.minestom.testing.Env; import net.minestom.testing.EnvTest; @@ -17,6 +19,8 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; /** * Butterfly used as a library: the host passes the settings, nothing is read from disk. @@ -44,7 +48,7 @@ private Player spawnWith(Env env, Path dataDirectory, ButterflySettings settings @Test @DisplayName("settings with collision on give the team collision rule ALWAYS") void hostEnablesCollision(Env env, @TempDir Path dir) { - Player player = spawnWith(env, dir, new ButterflySettings("%04d", true)); + Player player = spawnWith(env, dir, new ButterflySettings("%04d", true, true)); assertEquals(TeamsPacket.CollisionRule.ALWAYS, player.getTeam().getCollisionRule()); } @@ -60,11 +64,32 @@ void defaultsDisableCollision(Env env, @TempDir Path dir) { @Test @DisplayName("a custom sort format names the team") void customSortFormatNamesTeam(Env env, @TempDir Path dir) { - Player player = spawnWith(env, dir, new ButterflySettings("%02d", false)); + Player player = spawnWith(env, dir, new ButterflySettings("%02d", false, true)); assertEquals("01admin", player.getTeam().getTeamName()); } + @Test + @DisplayName("settings with the chat head off give a chat line without head and leading space") + void hostDisablesChatHead(Env env, @TempDir Path dir) { + Player player = spawnWith(env, dir, new ButterflySettings("%04d", false, false)); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + assertEquals("[Admin] Alice: hello", PlainTextComponentSerializer.plainText().serialize(event.getFormattedMessage())); + assertFalse(ExtensionBehaviourTest.containsObject(event.getFormattedMessage()), "no player head in the line"); + } + + @Test + @DisplayName("default settings give a chat line that starts with the head") + void defaultsShowChatHead(Env env, @TempDir Path dir) { + Player player = spawnWith(env, dir, ButterflySettings.defaults()); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + assertNotNull(ExtensionBehaviourTest.headOf(event.getFormattedMessage()), "the line starts with the head"); + } + @Test @DisplayName("create() without settings writes no file into the working directory") void createWithoutSettingsWritesNoFile(Env env, @TempDir Path dir) throws IOException { diff --git a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionBehaviourTest.java b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionBehaviourTest.java index 76ac7d1..b81cfd8 100644 --- a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionBehaviourTest.java +++ b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionBehaviourTest.java @@ -1,11 +1,14 @@ package net.onelitefeather.butterfly.minestom; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ObjectComponent; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.object.PlayerHeadObjectContents; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.minestom.server.color.TeamColor; import net.minestom.server.entity.Player; +import net.minestom.server.entity.PlayerSkin; import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.network.packet.server.play.TeamsPacket; import net.minestom.server.scoreboard.Team; @@ -18,6 +21,7 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -77,7 +81,7 @@ void chatIsFormatted() { PlayerChatEvent event = fixture.chat(player, "hello"); - assertEquals("[Admin] Alice: hello", PlainTextComponentSerializer.plainText().serialize(event.getFormattedMessage())); + assertEquals(" [Admin] Alice: hello", textWithoutHead(event.getFormattedMessage())); } @Test @@ -108,7 +112,7 @@ void chatUsesEffectivePrefix() { PlayerChatEvent event = fixture.chat(player, "hi"); - assertEquals("[Content] Alice: hi", PlainTextComponentSerializer.plainText().serialize(event.getFormattedMessage())); + assertEquals(" [Content] Alice: hi", textWithoutHead(event.getFormattedMessage())); } @Test @@ -119,7 +123,7 @@ void userOwnPrefixWins() { PlayerChatEvent event = fixture.chat(player, "hi"); - assertEquals("[VIP] Alice: hi", PlainTextComponentSerializer.plainText().serialize(event.getFormattedMessage())); + assertEquals(" [VIP] Alice: hi", textWithoutHead(event.getFormattedMessage())); assertEquals("[VIP] Alice", PlainTextComponentSerializer.plainText().serialize(player.getDisplayName())); } @@ -144,7 +148,7 @@ void chatAppliesPermittedTag() { PlayerChatEvent event = fixture.chat(player, "hello"); Component message = event.getFormattedMessage(); - assertEquals("[Admin] Alice: hello", PlainTextComponentSerializer.plainText().serialize(message), "the tag must be consumed"); + assertEquals(" [Admin] Alice: hello", textWithoutHead(message), "the tag must be consumed"); assertEquals(NamedTextColor.BLUE, textColor(message, "hello"), "the message part must be blue"); } @@ -155,7 +159,7 @@ void chatKeepsDisallowedTagLiteral() { PlayerChatEvent event = fixture.chat(player, "hello"); - assertEquals("[Admin] Alice: hello", PlainTextComponentSerializer.plainText().serialize(event.getFormattedMessage()), + assertEquals(" [Admin] Alice: hello", textWithoutHead(event.getFormattedMessage()), "the tag must stay in the text"); } @@ -180,6 +184,99 @@ void prefixKeepsColourWithoutTagPermissions() { assertEquals(NamedTextColor.RED, textColor(event.getFormattedMessage(), "[Admin] Alice"), "the prefix and name must stay red"); } + @Test + @DisplayName("chat starts with the sender's player head") + void chatStartsWithPlayerHead() { + Player player = fixture.spawnPlayer(); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + PlayerHeadObjectContents head = headOf(event.getFormattedMessage()); + assertNotNull(head, "the first part of the line is a player head"); + assertEquals(ExtensionFixture.PLAYER_ID, head.id(), "the head belongs to the sender"); + assertEquals(ExtensionFixture.PLAYER_NAME, head.name(), "the head carries the sender's name"); + } + + @Test + @DisplayName("a single space separates the head from the prefix") + void spaceSeparatesHeadFromPrefix() { + Player player = fixture.spawnPlayer(); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + assertEquals(Component.space(), event.getFormattedMessage().children().get(1), "space right after the head"); + } + + @Test + @DisplayName("the head carries the sender's skin texture and signature") + void headCarriesSkin() { + Player player = fixture.spawnPlayer(); + player.setSkin(new PlayerSkin("texture-value", "texture-signature")); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + PlayerHeadObjectContents head = headOf(event.getFormattedMessage()); + assertNotNull(head, "the first part of the line is a player head"); + assertEquals(1, head.profileProperties().size(), "exactly one profile property"); + assertEquals("textures", head.profileProperties().get(0).name(), "property name"); + assertEquals("texture-value", head.profileProperties().get(0).value(), "texture value"); + assertEquals("texture-signature", head.profileProperties().get(0).signature(), "texture signature"); + } + + @Test + @DisplayName("a player without a skin still gets a head with UUID and name but no texture") + void headWithoutSkinHasNoTexture() { + Player player = fixture.spawnPlayer(); + player.setSkin(null); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + PlayerHeadObjectContents head = headOf(event.getFormattedMessage()); + assertNotNull(head, "the first part of the line is a player head"); + assertEquals(ExtensionFixture.PLAYER_ID, head.id(), "the head belongs to the sender"); + assertTrue(head.profileProperties().isEmpty(), "no texture property without a skin"); + } + + @Test + @DisplayName("the display name carries no player head") + void displayNameHasNoHead() { + Player player = fixture.spawnPlayer(); + + fixture.chat(player, "hello"); + + assertFalse(containsObject(player.getDisplayName()), "the display name must stay head-free"); + assertEquals("[Admin] Alice", PlainTextComponentSerializer.plainText().serialize(player.getDisplayName())); + } + + @Test + @DisplayName("the team prefix carries no player head") + void teamPrefixHasNoHead() { + Player player = fixture.spawnPlayer(); + + assertFalse(containsObject(player.getTeam().getPrefix()), "the team prefix must stay head-free"); + } + + /** The plain text of the line without the head glyph, so the separating space stays visible. */ + static String textWithoutHead(Component line) { + return line.children().stream() + .filter(part -> !(part instanceof ObjectComponent)) + .map(part -> PlainTextComponentSerializer.plainText().serialize(part)) + .collect(Collectors.joining()); + } + + /** The head contents when the first part of the line is a player head, otherwise {@code null}. */ + static PlayerHeadObjectContents headOf(Component line) { + if (line.children().isEmpty()) return null; + if (line.children().get(0) instanceof ObjectComponent object && object.contents() instanceof PlayerHeadObjectContents head) { + return head; + } + return null; + } + + static boolean containsObject(Component component) { + return component instanceof ObjectComponent || component.children().stream().anyMatch(ExtensionBehaviourTest::containsObject); + } + private static NamedTextColor textColor(Component component, String content) { if (component instanceof TextComponent text && text.content().equals(content)) { return (NamedTextColor) text.color(); diff --git a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionConfigFileTest.java b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionConfigFileTest.java index 182617f..1b56178 100644 --- a/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionConfigFileTest.java +++ b/minestom/src/test/java/net/onelitefeather/butterfly/minestom/ExtensionConfigFileTest.java @@ -1,6 +1,7 @@ package net.onelitefeather.butterfly.minestom; import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.network.packet.server.play.TeamsPacket; import net.minestom.testing.Env; import net.minestom.testing.EnvTest; @@ -15,6 +16,8 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @EnvTest @@ -47,6 +50,30 @@ void configFileEnablesCollision(Env env, @TempDir Path dataDirectory) throws IOE assertEquals(TeamsPacket.CollisionRule.ALWAYS, player.getTeam().getCollisionRule()); } + @Test + @DisplayName("butterfly.chat.head.enabled false in config.yaml removes the head from chat") + void configFileDisablesChatHead(Env env, @TempDir Path dataDirectory) throws IOException { + Files.writeString(dataDirectory.resolve("config.yaml"), "butterfly:\n chat:\n head:\n enabled: false\n"); + startExtension(env, dataDirectory); + Player player = fixture.spawnPlayer(); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + assertFalse(ExtensionBehaviourTest.containsObject(event.getFormattedMessage()), "no player head in the line"); + } + + @Test + @DisplayName("a config.yaml from an earlier version without the head key still shows the head") + void configFileWithoutHeadKeyShowsHead(Env env, @TempDir Path dataDirectory) throws IOException { + Files.writeString(dataDirectory.resolve("config.yaml"), "butterfly:\n teams:\n sort-format: \"%04d\"\n"); + startExtension(env, dataDirectory); + Player player = fixture.spawnPlayer(); + + PlayerChatEvent event = fixture.chat(player, "hello"); + + assertNotNull(ExtensionBehaviourTest.headOf(event.getFormattedMessage()), "the line starts with the head"); + } + @Test @DisplayName("butterfly.teams.sort-format in config.yaml names the team") void configFileSetsSortFormat(Env env, @TempDir Path dataDirectory) throws IOException { diff --git a/openspec/changes/add-player-head-to-chat/tasks.md b/openspec/changes/add-player-head-to-chat/tasks.md index 0d9e045..fca7cc6 100644 --- a/openspec/changes/add-player-head-to-chat/tasks.md +++ b/openspec/changes/add-player-head-to-chat/tasks.md @@ -6,23 +6,23 @@ Branch from the up-to-date `origin/main`. All implementation commits use ## 1. Setting -- [ ] 1.1 Write failing `ButterflySettingsTest` cases for `butterfly.chat.head.enabled` (absent -> `true`, `false` -> `false`, `maybe` -> `true` + warning naming key and value); implement the field in `ButterflySettings`; verify `./gradlew :api:test` passes -- [ ] 1.2 Add `butterfly.chat.head.enabled: true` with a comment to both `default-config.yaml` resources; verify the existing tests that compare the written default file still pass with `./gradlew build` +- [x] 1.1 Write failing `ButterflySettingsTest` cases for `butterfly.chat.head.enabled` (absent -> `true`, `false` -> `false`, `maybe` -> `true` + warning naming key and value); implement the field in `ButterflySettings`; verify `./gradlew :api:test` passes +- [x] 1.2 Add `butterfly.chat.head.enabled: true` with a comment to both `default-config.yaml` resources; verify the existing tests that compare the written default file still pass with `./gradlew build` ## 2. Head and chat line in `api` -- [ ] 2.1 Write failing unit tests for `PlayerHeads.of(...)` (UUID and name set; `textures` property with value and signature when given; no property when texture is `null`) and `ChatLine.compose(...)` (head + space + name + `: ` + message; no head and no leading space when head is `null`); verify they fail with `./gradlew :api:test` -- [ ] 2.2 Implement `PlayerHeads` and `ChatLine`; verify `./gradlew :api:test` passes +- [x] 2.1 Write failing unit tests for `PlayerHeads.of(...)` (UUID and name set; `textures` property with value and signature when given; no property when texture is `null`) and `ChatLine.compose(...)` (head + space + name + `: ` + message; no head and no leading space when head is `null`); verify they fail with `./gradlew :api:test` +- [x] 2.2 Implement `PlayerHeads` and `ChatLine`; verify `./gradlew :api:test` passes ## 3. Platforms -- [ ] 3.1 Minestom: write failing `ExtensionBehaviourTest` cases (fresh `Env`): the formatted chat line's first child is a player-head component with the sender's UUID; with settings `chatHeadEnabled=false` there is none and the plain text equals today's; the player's display name has no head component. Then build the head from `Player#getSkin()` and compose with `ChatLine` in `Butterfly#playerChat`; verify `./gradlew :minestom:test` passes -- [ ] 3.2 Paper: in `PlayerListener#handleChat` build the head once from `getPlayerProfile()` (`textures` property) when `settings.chatHeadEnabled()`, and compose with `ChatLine` in the renderer; pass `ButterflySettings` into `PlayerListener` by constructor; verify `./gradlew :bukkit:build` succeeds -- [ ] 3.3 Run `./gradlew build :minestom:smokeTest`; verify both succeed +- [x] 3.1 Minestom: write failing `ExtensionBehaviourTest` cases (fresh `Env`): the formatted chat line's first child is a player-head component with the sender's UUID; with settings `chatHeadEnabled=false` there is none and the plain text equals today's; the player's display name has no head component. Then build the head from `Player#getSkin()` and compose with `ChatLine` in `Butterfly#playerChat`; verify `./gradlew :minestom:test` passes +- [x] 3.2 Paper: in `PlayerListener#handleChat` build the head once from `getPlayerProfile()` (`textures` property) when `settings.chatHeadEnabled()`, and compose with `ChatLine` in the renderer; pass `ButterflySettings` into `PlayerListener` by constructor; verify `./gradlew :bukkit:build` succeeds +- [x] 3.3 Run `./gradlew build :minestom:smokeTest`; verify both succeed ## 4. Docs and manual checks -- [ ] 4.1 README: document `butterfly.chat.head.enabled` in the configuration section and the client requirement (1.21.9+ for native rendering); verify the key appears in the README +- [x] 4.1 README: document `butterfly.chat.head.enabled` in the configuration section and the client requirement (1.21.9+ for native rendering); verify the key appears in the README - [ ] 4.2 Manual Paper test via `./gradlew :bukkit:runServer` with LuckPerms: a current client shows the sender's own skin head before the prefix; with `butterfly.chat.head.enabled: false` it disappears after restart; tab list and nametag show no head; with ViaVersion + ViaBackwards and a client older than 1.21.9, record what the line looks like (report both in the PR description) ## 5. Pull request From 509a1fa0dcaffe24420d1bc63189f7fa669634d6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 21:54:41 +0200 Subject: [PATCH 5/6] docs(openspec): allow heads without invalid player names Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- openspec/changes/add-player-head-to-chat/design.md | 3 ++- .../specs/chat-player-head/spec.md | 14 +++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-player-head-to-chat/design.md b/openspec/changes/add-player-head-to-chat/design.md index f03898f..4d0b032 100644 --- a/openspec/changes/add-player-head-to-chat/design.md +++ b/openspec/changes/add-player-head-to-chat/design.md @@ -37,7 +37,8 @@ PlayerHeads.of(UUID id, String name, @Nullable String texture, @Nullable String `compose` emits `head + " "` only when `head` is non-null, then `name + ": " + message`. Platforms pass `null` when `settings.chatHeadEnabled()` is false. Pure functions -> fast unit tests without a -server. +server. `PlayerHeads.of` leaves out a name that Adventure rejects as a head name +(`isValidName`, e.g. Bedrock names with spaces) and keeps the UUID, so chat never fails. *Alternative:* put the head into the display name. Rejected in exploration: the tab list already shows heads (duplicate) and other plugins reading the display name diff --git a/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md b/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md index 1f91317..06c1143 100644 --- a/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md +++ b/openspec/changes/add-player-head-to-chat/specs/chat-player-head/spec.md @@ -23,9 +23,12 @@ applies on Paper and on Minestom. - **THEN** both receive the line with the sender's head in front ### Requirement: Head carries the sender's skin -The head component SHALL identify the sender by UUID and name and SHALL carry the -sender's skin texture property (value and signature) when the server knows it. -When no texture is known, the component SHALL still be sent with UUID and name. +The head component SHALL identify the sender by UUID, and by name when the name is +a valid player-head name (Adventure `PlayerHeadObjectContents.isValidName`), and +SHALL carry the sender's skin texture property (value and signature) when the +server knows it. A name that is not valid (for example a Bedrock name containing a +space) MUST be left out instead of failing the chat message. When no texture is +known, the component SHALL still be sent with UUID and name. #### Scenario: Online-mode player - **WHEN** a player whose profile has a `textures` property sends a message @@ -35,6 +38,11 @@ When no texture is known, the component SHALL still be sent with UUID and name. - **WHEN** a player whose profile has no `textures` property sends a message - **THEN** the head component contains the player's UUID and name and no texture property +#### Scenario: Player name not valid for a head +- **WHEN** a player whose name contains a space sends a message +- **THEN** the chat line is sent +- **AND** the head component contains the player's UUID and no name + ### Requirement: Head only in chat Adding the head MUST NOT change the player's display name, tab list name or team prefix. From e789b66831c61b3802e3ebd2b93f06319bbb6e07 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 5 Oct 2026 22:06:44 +0200 Subject: [PATCH 6/6] docs(openspec): record manual paper test results Claude-Session: https://claude.ai/code/session_01Wt7kcAoVtZTEALDSX4CxWF --- .../2026-10-05-replace-togglz-with-avaje-config/tasks.md | 2 +- .../2026-10-05-restrict-chat-tags-by-permission/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md index 3ee6560..cb78c97 100644 --- a/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md +++ b/openspec/changes/archive/2026-10-05-replace-togglz-with-avaje-config/tasks.md @@ -33,7 +33,7 @@ commits: `refactor(config)`; the breaking commit carries `!` and a - [x] 5.1 Relocate `io.avaje.config` to `net.onelitefeather.butterfly.libs.avaje.config` in both `shadowJar` blocks (keep `mergeServiceFiles()`); verify with `unzip -l` that no `io/avaje/` entries remain in either shaded jar and that `./gradlew :minestom:smokeTest` (shaded jar via the extension manager) passes - [x] 5.2 Remove the `togglz` entry from the version catalog; verify `grep -rni togglz settings.gradle.kts` returns nothing and `./gradlew build` succeeds - [x] 5.3 Replace the feature-flag lines in `README.md` with a configuration section: both `config.yaml` locations, every key with its default from the spec table, `-Dbutterfly.format` legacy override, `Butterfly.create(ButterflySettings)` for library users, and the note to delete `flags.properties`; verify every key from the spec table appears in the README -- [ ] 5.4 Manual Paper smoke test via `./gradlew :bukkit:runServer` with LuckPerms: `plugins/Butterfly/config.yaml` is created on first start and unchanged on the second; with `sort-format: "%02d"` and `/updateteams` team names have a two-digit prefix; with `-Dbutterfly.format=%03d` and no key in the file, a three-digit prefix (report results in the PR description) +- [x] 5.4 Manual Paper smoke test via `./gradlew :bukkit:runServer` with LuckPerms: `plugins/Butterfly/config.yaml` is created on first start and unchanged on the second; with `sort-format: "%02d"` and `/updateteams` team names have a two-digit prefix; with `-Dbutterfly.format=%03d` and no key in the file, a three-digit prefix (report results in the PR description) ## 6. Pull request diff --git a/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md index 4cb92ca..a0742a3 100644 --- a/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md +++ b/openspec/changes/archive/2026-10-05-restrict-chat-tags-by-permission/tasks.md @@ -25,7 +25,7 @@ that switches both platforms to the restricted parser is `feat(chat)!` with a ## 4. Docs and manual check - [x] 4.1 README: section listing every `butterfly.chat.tag.` node (generated from or checked against `ChatTagType`), the wildcard, the literal-text behaviour and the migration line; verify every type in the spec table appears -- [ ] 4.2 Manual Paper test via `./gradlew :bukkit:runServer` with LuckPerms: player without nodes sees `x` literally; after `lp group default permission set butterfly.chat.tag.color true` the same message is red; `x` has no click event without `butterfly.chat.tag.click` (report in the PR description) +- [x] 4.2 Manual Paper test via `./gradlew :bukkit:runServer` with LuckPerms: player without nodes sees `x` literally; after `lp group default permission set butterfly.chat.tag.color true` the same message is red; `x` has no click event without `butterfly.chat.tag.click` (report in the PR description) ## 5. Pull request