From ace3644111053c62a1229a0e0446526760f1224b Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:32:29 +0200 Subject: [PATCH] feat: Add conditional & relational MiniPlaceholders support (#197) * Added relational mini placeholders support Fixed some problems Removed 300ms delay after joining a server Code refactor * Updated MiniPlacehodlers dependency Removed max team length for 1.18+ clients Fixed problem of backend sending team packets for online players and added a warning message * Added docs Added more time/date placeholders * Added mini condition system * Fixed problem due to adventure string quoting * Fixed problem in a rare use case * Removed debug message * Fixed conversations Fixed packet unregistration problem * Added docs Fixed a problem * Added yaml multi-line docs * Changed docs * Added papi support for conditions * Cone clenaup * Fixed placeholders in conditions * Fixed conversations * Fixed problems * Fixed problems while using minedown or legacy Added check for team packets tracker * Fixed problems Added support for hex colors in legacy formatter * Fixed problems * Fixed problem with header & footer * Resolved conversations --- build.gradle | 33 +- docs/Conditional-Placeholders.md | 42 ++ docs/Config-File.md | 34 ++ docs/Home.md | 2 + docs/Placeholders.md | 61 +-- docs/Relational-Placeholders.md | 26 ++ docs/_Sidebar.md | 2 + .../net/william278/velocitab/Velocitab.java | 4 +- .../velocitab/api/VelocitabAPI.java | 28 ++ .../velocitab/config/ConfigProvider.java | 2 +- .../velocitab/config/Formatter.java | 66 ++- .../william278/velocitab/config/Group.java | 5 + .../velocitab/config/Placeholder.java | 163 ++++++-- .../velocitab/config/ServerUrl.java | 2 +- .../velocitab/config/TabGroups.java | 22 +- .../velocitab/hook/MiniPlaceholdersHook.java | 26 +- .../hook/VelocitabMiniExpansion.java | 125 ++++++ .../miniconditions/MiniConditionManager.java | 196 +++++++++ .../velocitab/packet/PacketEventManager.java | 45 +- .../velocitab/packet/PacketRegistration.java | 7 +- .../packet/PlayerChannelHandler.java | 91 ++-- .../velocitab/packet/Protocol404Adapter.java | 36 +- .../velocitab/packet/Protocol48Adapter.java | 36 +- .../velocitab/packet/Protocol735Adapter.java | 7 +- .../velocitab/packet/Protocol765Adapter.java | 7 +- .../velocitab/packet/ScoreboardManager.java | 147 +++++-- .../velocitab/packet/TeamsPacketAdapter.java | 7 +- .../velocitab/packet/UpdateTeamsPacket.java | 45 +- .../velocitab/player/TabPlayer.java | 118 +++++- .../velocitab/providers/LoggerProvider.java | 1 + .../providers/ScoreboardProvider.java | 2 +- .../velocitab/sorting/SortingManager.java | 14 +- .../net/william278/velocitab/tab/Nametag.java | 8 +- .../velocitab/tab/PlayerTabList.java | 389 +++++++++--------- .../velocitab/tab/TabListListener.java | 23 +- .../william278/velocitab/tab/TaskManager.java | 132 ++++++ .../velocitab/tab/VanishTabList.java | 24 +- .../velocitab/util/QuadFunction.java | 35 ++ .../velocitab/util/SerializerUtil.java | 32 ++ 39 files changed, 1591 insertions(+), 454 deletions(-) create mode 100644 docs/Conditional-Placeholders.md create mode 100644 docs/Relational-Placeholders.md create mode 100644 src/main/java/net/william278/velocitab/hook/VelocitabMiniExpansion.java create mode 100644 src/main/java/net/william278/velocitab/hook/miniconditions/MiniConditionManager.java create mode 100644 src/main/java/net/william278/velocitab/tab/TaskManager.java create mode 100644 src/main/java/net/william278/velocitab/util/QuadFunction.java create mode 100644 src/main/java/net/william278/velocitab/util/SerializerUtil.java diff --git a/build.gradle b/build.gradle index 7b0bb0d..c9b4bca 100644 --- a/build.gradle +++ b/build.gradle @@ -24,8 +24,8 @@ ext { repositories { mavenCentral() - maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://repo.william278.net/velocity/' } + maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://repo.william278.net/releases/' } maven { url = 'https://jitpack.io/' } maven { url = 'https://repo.minebench.de/' } @@ -35,19 +35,21 @@ dependencies { compileOnly "com.velocitypowered:velocity-api:${velocity_api_version}-SNAPSHOT" compileOnly "com.velocitypowered:velocity-proxy:${velocity_api_version}-SNAPSHOT" + compileOnly 'io.netty:netty-codec-http:4.1.110.Final' + compileOnly 'org.projectlombok:lombok:1.18.32' + compileOnly 'net.luckperms:api:5.4' + compileOnly 'io.github.miniplaceholders:miniplaceholders-api:2.2.3' + compileOnly 'net.william278:PAPIProxyBridge:1.5' + compileOnly 'it.unimi.dsi:fastutil:8.5.13' + compileOnly 'net.kyori:adventure-nbt:4.17.0' + implementation 'org.apache.commons:commons-text:1.12.0' implementation 'net.william278:DesertWell:2.0.4' implementation 'net.william278:minedown:1.8.2' implementation 'org.bstats:bstats-velocity:3.0.2' implementation 'de.exlll:configlib-yaml:4.5.0' - - compileOnly 'io.netty:netty-codec-http:4.1.111.Final' - compileOnly 'net.luckperms:api:5.4' - compileOnly 'io.github.miniplaceholders:miniplaceholders-api:2.0.0' - compileOnly 'net.william278:PAPIProxyBridge:1.5' - compileOnly 'it.unimi.dsi:fastutil:8.5.13' - compileOnly 'net.kyori:adventure-nbt:4.17.0' - compileOnly 'org.projectlombok:lombok:1.18.32' + implementation 'org.apache.commons:commons-jexl3:3.4.0' + implementation 'net.jodah:expiringmap:0.5.11' annotationProcessor 'org.projectlombok:lombok:1.18.32' } @@ -92,6 +94,10 @@ shadowJar { relocate 'net.william278.desertwell', 'net.william278.velocitab.libraries.desertwell' relocate 'org.bstats', 'net.william278.velocitab.libraries.bstats' relocate 'de.exlll.configlib', 'net.william278.velocitab.libraries.configlib' + relocate 'org.snakeyaml', 'net.william278.velocitab.libraries.snakeyaml' + relocate 'org.apache.commons.jexl3', 'net.william278.velocitab.libraries.commons.jexl3' + relocate 'org.apache.commons.logging', 'net.william278.velocitab.libraries.commons.logging' + relocate 'net.jodah.expiringmap', 'net.william278.velocitab.libraries.expiringmap' dependencies { exclude dependency(':slf4j-api') @@ -101,7 +107,9 @@ shadowJar { destinationDirectory.set(file("$rootDir/target")) archiveClassifier.set('') - minimize() + minimize() { + exclude dependency('commons-logging:commons-logging') + } } jar.dependsOn shadowJar clean.delete "$rootDir/target" @@ -151,6 +159,11 @@ publishing { tasks { runVelocity { velocityVersion("${velocity_api_version}-SNAPSHOT") + + downloadPlugins { + modrinth ("papiproxybridge", "1.6.1") + modrinth ("miniplaceholders", "2.2.4") + } } } diff --git a/docs/Conditional-Placeholders.md b/docs/Conditional-Placeholders.md new file mode 100644 index 0000000..1430d38 --- /dev/null +++ b/docs/Conditional-Placeholders.md @@ -0,0 +1,42 @@ +# Velocitab Conditional Placeholders Documentation + +In order to use these placeholders, install MiniPlaceholders on your Velocity proxy, set the `formatter_type` +to `MINIMESSAGE`, and ensure `enable_miniplaceholders_hook` is set to `true`. + +Conditional placeholders allow you to display different values based on certain conditions. The format +is `||>`. + +Currently, this system is only available for the `format` and `nametag` fields in the tab groups configuration. + +## Table of Conditional Placeholders + +| Placeholder Example | Description | Example Output | +|--------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| +| `= 10:rich:poor>` | Checks if the player's vault balance is greater than or equal to 10. If true, displays "rich", else "poor". | `rich` or `poor` | +| `` | Checks if the player's health is below 10. If true, displays "Low Health", else "Healthy". | `Low Health` or `Healthy` | +| `` | Checks if the player's ping is 50 or below. If true, displays "Good Ping", else "Bad Ping". | `Good Ping` or `Bad Ping` | +| `= 30:High Level:Low Level>` | Checks if the player's level is 30 or above. If true, displays "High Level", else "Low Level". | `High Level` or `Low Level` | +| `= 1000:XP Master:XP Novice>` | Checks if the player has 1000 or more experience points. If true, displays "XP Master", else "XP Novice". | `XP Master` or `XP Novice` | +| `` | Checks if the player's name is either "AlexDev_" or "William278". If true, displays "Developer", else "NotDev". | `Developer` or `NotDev` | +| `` | Checks if the player's name starts with "AlexDe". If true, displays "IsAlex", else "NotAlex". | `IsAlex` or `NotAlex` | +| `` | Checks if the player's name ends with "278". If true, displays "EndsWith278", else "DoesNotEndWith278". | `EndsWith278` or `DoesNotEndWith278` | +| `` | Checks if the player is in creative mode. If true, displays "Creative Mode", else "Not Creative Mode". | `Creative Mode` or `Not Creative Mode` | +| `` | Checks if the player is in the Nether. If true, displays "In Nether", else "Not in Nether". | `In Nether` or `Not in Nether` | +| `` | Checks if the player is in a desert biome. If true, displays "In Desert", else "Not in Desert". | `In Desert` or `Not in Desert` | +| ` ` | Checks if the player is in survival or spectator mode. If true, displays "Survival or Spectator", else "Not Survival or Spectator". | `Survival or Spectator` or `Not Survival or Spectator` | +| ` ` | Checks if the player's health is the same as the target player's health. If true, displays "Same health", else "Not same health". | `Same health` or `Not same health` | + +**Note:** For string comparisons, use double quotes `" "` or single quotes `' '`. For numerical comparisons, quotes are +not needed. +Also if you use `'` for quotes, you need to escape them with `''`. The same applies for `"` and `""`. Example: `''%player_name%''` or `"'%player_name%'"` +In order to use papi placeholders for target you need to use `''%target_player_name%''` in order to get `''%player_name%''` replaced with the target player's name. + +If you want to use `:` as a character in the condition or in the true/false value, you need to replace it with `?dp?`. Example: ``. + +# Example +If you want to compare audience player's health with target player's health, you can use the following configuration: +```yaml +format: "" +``` + +This is system is based on [JEXL](https://commons.apache.org/proper/commons-jexl/reference/examples.html) expressions. \ No newline at end of file diff --git a/docs/Config-File.md b/docs/Config-File.md index 6a5e8d7..090bdd5 100644 --- a/docs/Config-File.md +++ b/docs/Config-File.md @@ -122,5 +122,39 @@ Velocitab supports basic header and footer animations by adding multiple frames ### Placeholders You can use various placeholders that will be replaced with values (for example, `%username%`) in your config. Support for PlaceholderAPI is also available through [a bridge library plugin](https://modrinth.com/plugin/papiproxybridge), as is the component-based MiniPlaceholders for users of that plugin with the MiniMessage formatter. See [[Placeholders]] for more information. +### YAML MultiLine Syntax + +In order to have a multi-line string in YAML, you can use the `|-` or `|` syntax. The `|-` syntax will remove last newline character, while the `|` syntax will keep it. +You can also use `\n` to add a newline character in a string. + +# Example 1 +```yaml +foo: |- + bar 1 + bar 2 + bar 3 +``` + +is equivalent to + +```yaml +foo: "bar 1\nbar 2\nbar 3" +``` + +# Example 2 + +```yaml +foo: | + bar 1 + bar 2 + bar 3 +``` + +is equivalent to + +```yaml +foo: "bar 1\nbar 2\nbar 3\n" +``` + ### Server Links For Minecraft 1.21+ clients, Velocitab supports specifying a list of URLs that will be sent to display in the player pause menu. See [[Server Links]] for more information. \ No newline at end of file diff --git a/docs/Home.md b/docs/Home.md index 3db3898..d3aa96b 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -15,6 +15,8 @@ Please click through to the topic you wish to read about. * πŸ“› [[Nametags]] * πŸ“Š [[Sorting]] * ✍️ [[Placeholders]] +* πŸ”— [[Relational Placeholders]] +* πŸ”€ [[Conditional Placeholders]] * ✨ [[Animations]] * πŸ–ΌοΈ [[Custom Logos]] * πŸ”— [[Server Links]] diff --git a/docs/Placeholders.md b/docs/Placeholders.md index 534cc78..e8e3cc1 100644 --- a/docs/Placeholders.md +++ b/docs/Placeholders.md @@ -3,28 +3,42 @@ Velocitab supports a number of Placeholders that will be replaced with their res ## Default placeholders Placeholders can be included in the header, footer and player name format of the TAB list. The following placeholders are supported out of the box: -| Placeholder | Description | Example | -|---------------------------------|------------------------------------------------------|--------------------| -| `%players_online%` | Players online on the proxy | `6` | -| `%max_players_online%` | Player capacity of the proxy | `500` | -| `%local_players_online%` | Players online on the server the player is on | `3` | -| `%group_players_online_(name)%` | Players online on the group provided | `11` | -| `%group_players_online%` | Players online on player's group | `15` | -| `%current_date%` | Current real-world date of the server | `24 Feb 2023` | -| `%current_time%` | Current real-world time of the server | `21:45:32` | -| `%username%` | The player's username | `William278` | -| `%username_lower%` | The player's username, in lowercase | `william278` | -| `%server%` | Name of the server the player is on | `alpha` | -| `%ping%` | Ping of the player (in ms) | `6` | -| `%prefix%` | The player's prefix (from LuckPerms) | `&4[Admin]` | -| `%suffix%` | The player's suffix (from LuckPerms) | `&c ` | -| `%role%` | The player's primary LuckPerms group name | `admin` | -| `%role_display_name%` | The player's primary LuckPerms group display name | `Admin` | -| `%role_weight%` | Comparable-formatted primary LuckPerms group weight. | `100` | -| `%luckperms_meta_(key)%` | Formats a meta key from the user's LuckPerms group | (varies) | -| `%server_group%` | The name of the server group the player is on | `default` | -| `%server_group_index%` | Indexed order of the server group in the list | `0` | -| `%debug_team_name%` | (Debug) Player's team name, used for [[Sorting]] | `1alphaWilliam278` | +| Placeholder | Description | Example | +|---------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|--------------------| +| `%players_online%` | Players online on the proxy | `6` | +| `%max_players_online%` | Player capacity of the proxy | `500` | +| `%local_players_online%` | Players online on the server the player is on | `3` | +| `%group_players_online_(name)%` | Players online on the group provided | `11` | +| `%group_players_online%` | Players online on player's group | `15` | +| `%current_date_day%` | Current day of the month | `14` | +| `%current_date_weekday%` | Current day of the week | `Wednesday` | +| `%current_date_weekday_(tag)%` | Current day of the week ([localized](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags)) `it-IT` as example | `MercoledΓ¬` | +| `%current_date_month%` | Current month of the year | `06` | +| `%current_date_year%` | Current year | `2024` | +| `%current_date%` | Current real-world date of the server | `14/06/2023` | +| `%current_date_(tag)%` | Current real-world date ([localized](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags)) `en-US` as example | `06/14/2023` | +| `%current_time_hour%` | Current hour of the day | `21` | +| `%current_time_minute%` | Current minute of the hour | `45` | +| `%current_time_second%` | Current second of the minute | `32` | +| `%current_time%` | Current real-world time of the server | `21:45:32` | +| `%current_time_(tag)%` | Current real-world time ([localized](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags)) `en-US` as example | `9:45 PM` | +| `%username%` | The player's username | `William278` | +| `%username_lower%` | The player's username, in lowercase | `william278` | +| `%server%` | Name of the server the player is on | `alpha` | +| `%ping%` | Ping of the player (in ms) | `6` | +| `%prefix%` | The player's prefix (from LuckPerms) | `&4[Admin]` | +| `%suffix%` | The player's suffix (from LuckPerms) | `&c ` | +| `%role%` | The player's primary LuckPerms group name | `admin` | +| `%role_display_name%` | The player's primary LuckPerms group display name | `Admin` | +| `%role_weight%` | Comparable-formatted primary LuckPerms group weight | `100` | +| `%luckperms_meta_(key)%` | Formats a meta key from the user's LuckPerms group | (varies) | +| `%server_group%` | The name of the server group the player is on | `default` | +| `%server_group_index%` | Indexed order of the server group in the list | `0` | +| `%debug_team_name%` | (Debug) Player's team name, used for [[Sorting]] | `1alphaWilliam278` | + +**Note:** `(tag)` stands for IETF language tag, used for localization of date and time placeholders. For example, `en-US` for American English, `fr-FR` for French, `it-IT` for Italian, etc. +You can find a list of common primary language subtags [here](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags). + ### Customising server display names You can make use of the `server_display_names` feature in `config.yml` to customise how server display name appear when using the `%server%` placeholder. In the below example, if a user is connected to a server with the name "`very-long-server-`name" and the player name format for the group that server belongs to includes a `%server%` placeholder, the placeholder would be replaced with "`VSLN`" instead of the full server name. @@ -46,4 +60,5 @@ To use PlaceholderAPI placeholders in Velocitab, install the [PAPIProxyBridge](h PlaceholderAPI placeholders are cached to reduce plugin message traffic. By default, placeholders are cached for 30 seconds (30000 milliseconds); if you wish to use PAPI placeholders that update more frequently, you can reduce the cache time in the Velocitab config.yml file by adjusting the `papi_cache_time` value. ## MiniPlaceholders support -If you are using MiniMessage [[Formatting]], you can use [MiniPlaceholders](https://github.com/MiniPlaceholders/MiniPlaceholders) with Velocitab for MiniMessage-styled component placeholders provided by other proxy plugins. Install MiniPlaceholders on your Velocity proxy, set the `formatter_type` to `MINIMESSAGE` and ensure `enable_miniplaceholders_hook` is set to `true` \ No newline at end of file +If you are using MiniMessage [[Formatting]], you can use [MiniPlaceholders](https://github.com/MiniPlaceholders/MiniPlaceholders) with Velocitab for MiniMessage-styled component placeholders provided by other proxy plugins. Install MiniPlaceholders on your Velocity proxy, set the `formatter_type` to `MINIMESSAGE` and ensure `enable_miniplaceholders_hook` is set to `true` +You can also use [Relational Placeholders](Relational-Placeholders). \ No newline at end of file diff --git a/docs/Relational-Placeholders.md b/docs/Relational-Placeholders.md new file mode 100644 index 0000000..8c9b00e --- /dev/null +++ b/docs/Relational-Placeholders.md @@ -0,0 +1,26 @@ +In order to use these placeholders, install MiniPlaceholders on your Velocity proxy, set the `formatter_type` to `MINIMESSAGE` and ensure `enable_miniplaceholders_hook` is set to `true` + +In all examples target is the one that sees the message, and the audience is the one that is being seen. + +Example: +My username is `William278` and I can see in tablist an audience player named `Player1`. + +## Table of Placeholders + +| Placeholder | Description | Example Usage | +|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------|---------------| +| `` | Displays the username of the target player, used for debug | `William278` | +| `` | Checks if the target player has a specific permission and, if true parse value with the audience player's name. | See below | +| `` | Checks if the audience player can see the target player, considering the vanish status. | `true` | + +## Examples of `` Placeholder + +**Note:** In the value, you can [Velocitab](Placeholders.md) placeholders or [MiniPlaceholders](https://github.com/MiniPlaceholders/MiniPlaceholders/wiki/Placeholders#proxy-expansion) + +| Placeholder Example Usage | Description | Output Example | +|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|--------------------------| +| `` | Checks if the target player has the permission 'check.server' and displays the server of the audience player. | `Target is on survival!` | +| `>` | Checks if the target player has the permission 'clientcheck' and displays the client of the audience player. | `LunarClient` | +| `>` | Checks if the target player has the permission 'pingcheck' and displays the ping of the audience player. | `23` | + + diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 05361f4..5fa6af1 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -9,6 +9,8 @@ * πŸ“› [[Nametags]] * πŸ“Š [[Sorting]] * ✍️ [[Placeholders]] +* πŸ”— [[Relational Placeholders]] +* πŸ”€ [[Conditional Placeholders]] * ✨ [[Animations]] * πŸ–ΌοΈ [[Custom Logos]] * πŸ”— [[Server Links]] diff --git a/src/main/java/net/william278/velocitab/Velocitab.java b/src/main/java/net/william278/velocitab/Velocitab.java index 8eab81b..b9b12ea 100644 --- a/src/main/java/net/william278/velocitab/Velocitab.java +++ b/src/main/java/net/william278/velocitab/Velocitab.java @@ -43,6 +43,7 @@ import net.william278.velocitab.config.Settings; import net.william278.velocitab.config.TabGroups; import net.william278.velocitab.hook.Hook; import net.william278.velocitab.hook.LuckPermsHook; +import net.william278.velocitab.hook.MiniPlaceholdersHook; import net.william278.velocitab.packet.PacketEventManager; import net.william278.velocitab.packet.ScoreboardManager; import net.william278.velocitab.providers.HookProvider; @@ -113,9 +114,10 @@ public class Velocitab implements ConfigProvider, ScoreboardProvider, LoggerProv @Subscribe public void onProxyShutdown(@NotNull ProxyShutdownEvent event) { - server.getScheduler().tasksByPlugin(this).forEach(ScheduledTask::cancel); +// server.getScheduler().tasksByPlugin(this).forEach(ScheduledTask::cancel); disableScoreboardManager(); getLuckPermsHook().ifPresent(LuckPermsHook::closeEvent); + getMiniPlaceholdersHook().ifPresent(MiniPlaceholdersHook::unregisterExpansion); unregisterAPI(); logger.info("Successfully disabled Velocitab"); } diff --git a/src/main/java/net/william278/velocitab/api/VelocitabAPI.java b/src/main/java/net/william278/velocitab/api/VelocitabAPI.java index fd64caa..76147e0 100644 --- a/src/main/java/net/william278/velocitab/api/VelocitabAPI.java +++ b/src/main/java/net/william278/velocitab/api/VelocitabAPI.java @@ -29,6 +29,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.List; import java.util.Optional; /** @@ -190,6 +191,33 @@ public class VelocitabAPI { return getUser(player).map(TabPlayer::getGroup).orElse(null); } + /** + * Retrieves a list of server groups. + * + * @return A list of Group objects representing server groups. + * @since 1.6.6 + */ + @NotNull + public List getServerGroups() { + return plugin.getTabGroups().getGroups(); + } + + /** + * Retrieves an optional Group object with the given name. + * + * @param name The name of the group to retrieve. + * @return An optional Group object containing the group with the given name, or an empty optional if no group exists with that name. + * @since 1.6.6 + */ + @NotNull + public Optional getGroup(@NotNull String name) { + return plugin.getTabGroups().getGroup(name); + } + + public Optional getGroupFromServer(@NotNull String server) { + return plugin.getTabGroups().getGroupFromServer(server, plugin); + } + /** * An exception indicating the Velocitab API was accessed before it was registered. * diff --git a/src/main/java/net/william278/velocitab/config/ConfigProvider.java b/src/main/java/net/william278/velocitab/config/ConfigProvider.java index 41bf739..16744bf 100644 --- a/src/main/java/net/william278/velocitab/config/ConfigProvider.java +++ b/src/main/java/net/william278/velocitab/config/ConfigProvider.java @@ -156,7 +156,7 @@ public interface ConfigProvider { /** * Saves the tab groups to the "tab_groups.yml" config file. - * Uses the YamlConfigurations.save method to write the tab groups object to the specified config file path. + * Uses the YamlConfigurations#save method to write the tab groups object to the specified config file path. * This method assumes that the getConfigDirectory method returns a valid directory path. * * @throws IllegalStateException if the getConfigDirectory method returns null diff --git a/src/main/java/net/william278/velocitab/config/Formatter.java b/src/main/java/net/william278/velocitab/config/Formatter.java index dbf5536..6d93121 100644 --- a/src/main/java/net/william278/velocitab/config/Formatter.java +++ b/src/main/java/net/william278/velocitab/config/Formatter.java @@ -25,8 +25,10 @@ import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.william278.velocitab.Velocitab; import net.william278.velocitab.player.TabPlayer; -import org.apache.commons.lang3.function.TriFunction; +import net.william278.velocitab.util.QuadFunction; +import net.william278.velocitab.util.SerializerUtil; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.function.Function; @@ -36,26 +38,33 @@ import java.util.function.Function; @SuppressWarnings("unused") public enum Formatter { MINEDOWN( - (text, player, plugin) -> new MineDown(text).toComponent(), + (text, player, viewer, plugin) -> new MineDown(text).toComponent(), MineDown::escape, "MineDown", - (text) -> new MineDown(text).toComponent() + (text) -> new MineDown(text).toComponent(), + (text) -> { + throw new UnsupportedOperationException("MineDown does not support serialization"); + } ), MINIMESSAGE( - (text, player, plugin) -> plugin.getMiniPlaceholdersHook() - .map(hook -> hook.format(text, player.getPlayer())) + (text, player, viewer, plugin) -> plugin.getMiniPlaceholdersHook() + .filter(hook -> viewer != null) + .map(hook -> hook.format(text, player.getPlayer(), viewer.getPlayer())) .orElse(MiniMessage.miniMessage().deserialize(text)), (text) -> MiniMessage.miniMessage().escapeTags(text), "MiniMessage", - (text) -> MiniMessage.miniMessage().deserialize(text) + (text) -> MiniMessage.miniMessage().deserialize(text), + MiniMessage.miniMessage()::serialize ), LEGACY( - (text, player, plugin) -> LegacyComponentSerializer.legacyAmpersand().deserialize(text), + (text, player, viewer, plugin) -> SerializerUtil.LEGACY_SERIALIZER.deserialize(text), Function.identity(), "Legacy Text", - (text) -> LegacyComponentSerializer.legacyAmpersand().deserialize(text) + SerializerUtil.LEGACY_SERIALIZER::deserialize, + SerializerUtil.LEGACY_SERIALIZER::serialize ); + /** * Name of the formatter */ @@ -64,24 +73,50 @@ public enum Formatter { /** * Function to apply formatting to a string */ - private final TriFunction formatter; + private final QuadFunction formatter; /** * Function to escape formatting characters in a string */ private final Function escaper; private final Function emptyFormatter; + private final Function serializer; - Formatter(@NotNull TriFunction formatter, @NotNull Function escaper, - @NotNull String name, @NotNull Function emptyFormatter) { + Formatter(@NotNull QuadFunction formatter, @NotNull Function escaper, + @NotNull String name, @NotNull Function emptyFormatter, @NotNull Function serializer) { this.formatter = formatter; this.escaper = escaper; this.name = name; this.emptyFormatter = emptyFormatter; + this.serializer = serializer; } + /** + * Formats the given text using a specific formatter. + * + * @param text The text to format + * @param player The TabPlayer object representing the player + * @param tabPlayer The TabPlayer object representing the viewer (can be null) + * @param plugin The Velocitab plugin instance + * @return The formatted Component object + * @throws NullPointerException if any of the parameters (text, player, plugin) is null + */ + @NotNull + public Component format(@NotNull String text, @NotNull TabPlayer player, @Nullable TabPlayer tabPlayer, @NotNull Velocitab plugin) { + return formatter.apply(text, player, tabPlayer, plugin); + } + + /** + * Formats the given text using a specific formatter. + * + * @param text The text to format + * @param player The TabPlayer object representing the player + * @param plugin The Velocitab plugin instance + * @return The formatted Component object + * @throws NullPointerException if any of the parameters (text, player, plugin) is null + */ @NotNull public Component format(@NotNull String text, @NotNull TabPlayer player, @NotNull Velocitab plugin) { - return formatter.apply(text, player, plugin); + return formatter.apply(text, player, null, plugin); } @NotNull @@ -91,7 +126,7 @@ public enum Formatter { } @NotNull - public Component emptyFormat(@NotNull String text) { + public Component deserialize(@NotNull String text) { return emptyFormatter.apply(text); } @@ -105,4 +140,9 @@ public enum Formatter { return name; } + @NotNull + public String serialize(@NotNull Component component) { + return serializer.apply(component); + } + } diff --git a/src/main/java/net/william278/velocitab/config/Group.java b/src/main/java/net/william278/velocitab/config/Group.java index 4c8fadb..b2672fe 100644 --- a/src/main/java/net/william278/velocitab/config/Group.java +++ b/src/main/java/net/william278/velocitab/config/Group.java @@ -64,6 +64,11 @@ public record Group( .get(Math.max(0, Math.min(index, footers.size() - 1)))); } + public boolean containsServer(@NotNull Velocitab plugin, @NotNull String serverName) { + return registeredServers(plugin).stream() + .anyMatch(registeredServer -> registeredServer.getServerInfo().getName().equalsIgnoreCase(serverName)); + } + @NotNull public Set registeredServers(@NotNull Velocitab plugin) { return registeredServers(plugin, true); diff --git a/src/main/java/net/william278/velocitab/config/Placeholder.java b/src/main/java/net/william278/velocitab/config/Placeholder.java index edff0f7..b01571b 100644 --- a/src/main/java/net/william278/velocitab/config/Placeholder.java +++ b/src/main/java/net/william278/velocitab/config/Placeholder.java @@ -21,16 +21,23 @@ package net.william278.velocitab.config; import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; +import it.unimi.dsi.fastutil.Pair; +import net.kyori.adventure.text.minimessage.MiniMessage; import net.william278.velocitab.Velocitab; import net.william278.velocitab.player.TabPlayer; import net.william278.velocitab.tab.Nametag; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.function.TriFunction; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.event.Level; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.BiFunction; import java.util.regex.Matcher; @@ -45,12 +52,47 @@ public enum Placeholder { .map(RegisteredServer::getPlayersConnected) .map(players -> Integer.toString(players.size())) .orElse("")), - GROUP_PLAYERS_ONLINE_((param, plugin, player) -> plugin.getTabGroups().getGroup(param) - .map(group -> Integer.toString(group.getPlayers(plugin).size())) - .orElse("Group " + param + " not found")), - GROUP_PLAYERS_ONLINE((plugin, player) -> Integer.toString(player.getGroup().getPlayers(plugin).size())), - CURRENT_DATE((plugin, player) -> DateTimeFormatter.ofPattern("dd MMM yyyy").format(LocalDateTime.now())), - CURRENT_TIME((plugin, player) -> DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now())), + GROUP_PLAYERS_ONLINE((param, plugin, player) -> { + if (param.isEmpty()) { + return Integer.toString(player.getGroup().getPlayers(plugin).size()); + } + return plugin.getTabGroups().getGroup(param) + .map(group -> Integer.toString(group.getPlayers(plugin).size())) + .orElse("Group " + param + " not found"); + }), + CURRENT_DATE_DAY((plugin, player) -> DateTimeFormatter.ofPattern("dd").format(LocalDateTime.now())), + CURRENT_DATE_WEEKDAY((param, plugin, player) -> { + if (param.isEmpty()) { + return DateTimeFormatter.ofPattern("EEEE").format(LocalDateTime.now()); + } + + final String countryCode = param.toUpperCase(); + final Locale locale = Locale.forLanguageTag(countryCode); + return DateTimeFormatter.ofPattern("EEEE").withLocale(locale).format(LocalDateTime.now()); + }), + CURRENT_DATE_MONTH((plugin, player) -> DateTimeFormatter.ofPattern("MM").format(LocalDateTime.now())), + CURRENT_DATE_YEAR((plugin, player) -> DateTimeFormatter.ofPattern("yyyy").format(LocalDateTime.now())), + CURRENT_DATE((param, plugin, player) -> { + if (param.isEmpty()) { + return DateTimeFormatter.ofPattern("dd/MM/yyyy").format(LocalDateTime.now()); + } + + final String countryCode = param.toUpperCase(); + final Locale locale = Locale.forLanguageTag(countryCode); + return DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale).format(LocalDateTime.now()); + }), + CURRENT_TIME_HOUR((plugin, player) -> DateTimeFormatter.ofPattern("HH").format(LocalDateTime.now())), + CURRENT_TIME_MINUTE((plugin, player) -> DateTimeFormatter.ofPattern("mm").format(LocalDateTime.now())), + CURRENT_TIME_SECOND((plugin, player) -> DateTimeFormatter.ofPattern("ss").format(LocalDateTime.now())), + CURRENT_TIME((param, plugin, player) -> { + if (param.isEmpty()) { + return DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalTime.now()); + } + + final String countryCode = param.toUpperCase(); + final Locale locale = Locale.forLanguageTag(countryCode); + return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(LocalTime.now()); + }), USERNAME((plugin, player) -> player.getCustomName().orElse(player.getPlayer().getUsername())), USERNAME_LOWER((plugin, player) -> player.getCustomName().orElse(player.getPlayer().getUsername()).toLowerCase()), SERVER((plugin, player) -> player.getServerDisplayName(plugin)), @@ -67,18 +109,26 @@ public enum Placeholder { SERVER_GROUP((plugin, player) -> player.getGroup().name()), SERVER_GROUP_INDEX((plugin, player) -> Integer.toString(player.getServerGroupPosition(plugin))), DEBUG_TEAM_NAME((plugin, player) -> plugin.getFormatter().escape(player.getLastTeamName().orElse(""))), - LUCKPERMS_META_((param, plugin, player) -> plugin.getLuckPermsHook() + LUCKPERMS_META((param, plugin, player) -> plugin.getLuckPermsHook() .map(hook -> hook.getMeta(player.getPlayer(), param)) .orElse(getPlaceholderFallback(plugin, "%luckperms_meta_" + param + "%"))); + private final static Pattern VELOCITAB_PATTERN = Pattern.compile(""); + private final static Pattern PLACEHOLDER_PATTERN = Pattern.compile("%.*?%"); + private final static Pattern CONDITIONAL_PATTERN = Pattern.compile(""); + private final static String DELIMITER = ":::"; + private final static String REL_SUBSTITUTE = "-REL-"; + public final static Map SYMBOL_SUBSTITUTES = Map.of( + "<", "-COND-1", + ">", "-COND-2" + ); + /** * Function to replace placeholders with a real value */ private final TriFunction replacer; private final boolean parameterised; private final Pattern pattern; - private final static Pattern CHECK_PLACEHOLDERS = Pattern.compile("%.*?%"); - private final static String DELIMITER = ":::"; Placeholder(@NotNull BiFunction replacer) { this.parameterised = false; @@ -89,7 +139,7 @@ public enum Placeholder { Placeholder(@NotNull TriFunction parameterisedReplacer) { this.parameterised = true; this.replacer = parameterisedReplacer; - this.pattern = Pattern.compile("%" + this.name().toLowerCase() + "[^%]+%", Pattern.CASE_INSENSITIVE); + this.pattern = Pattern.compile("%" + this.name().toLowerCase() + "[^%]*%", Pattern.CASE_INSENSITIVE); } public static CompletableFuture replace(@NotNull Nametag nametag, @NotNull Velocitab plugin, @@ -107,31 +157,88 @@ public enum Placeholder { return ""; } + @NotNull + public static String replaceInternal(@NotNull String format, @NotNull Velocitab plugin, @Nullable TabPlayer player) { + final Pair result = processRelationalPlaceholders(format, plugin); + format = result.right(); + format = replacePlaceholders(format, plugin, player); + + if (result.left()) { + format = format.replace(REL_SUBSTITUTE, "%"); + } + + return format; + } + + private static Pair processRelationalPlaceholders(@NotNull String format, @NotNull Velocitab plugin) { + boolean foundRelational = false; + if (format.contains(" + Matcher.quoteReplacement( + placeholder.replacer.apply(StringUtils.chop(matchResult.group().replace("%" + placeholder.name().toLowerCase(), "") + .replaceFirst("_", "")) + , plugin, player) + )); + } else { + format = matcher.replaceAll(matchResult -> Matcher.quoteReplacement(placeholder.replacer.apply(null, plugin, player))); + } + } + return format; + } + + @NotNull + private static String replaceSymbols(@NotNull String input) { + String fixedString = input.replace("%", REL_SUBSTITUTE); + fixedString = MiniMessage.miniMessage().serialize(Formatter.LEGACY.deserialize(fixedString)); + for (Map.Entry entry : SYMBOL_SUBSTITUTES.entrySet()) { + fixedString = fixedString.replace(entry.getKey(), entry.getValue()); + } + return fixedString; + } + public static CompletableFuture replace(@NotNull String format, @NotNull Velocitab plugin, @NotNull TabPlayer player) { + if (format.equals(DELIMITER)) { return CompletableFuture.completedFuture(""); } - for (Placeholder placeholder : values()) { - Matcher matcher = placeholder.pattern.matcher(format); - if (placeholder.parameterised) { - // Replace the placeholder with the result of the replacer function with the parameter - format = matcher.replaceAll(matchResult -> - Matcher.quoteReplacement(placeholder.replacer.apply( - StringUtils.chop(matchResult.group().replace( - "%" + placeholder.name().toLowerCase(), "" - )), plugin, player - ))); - } else { - // Replace the placeholder with the result of the replacer function - format = matcher.replaceAll(matchResult -> Matcher.quoteReplacement(placeholder.replacer.apply(null, plugin, player))); - } + final String replaced = replaceInternal(format, plugin, player); - } - - final String replaced = format; - if (!CHECK_PLACEHOLDERS.matcher(replaced).find()) { + if (!PLACEHOLDER_PATTERN.matcher(replaced).find()) { return CompletableFuture.completedFuture(replaced); } diff --git a/src/main/java/net/william278/velocitab/config/ServerUrl.java b/src/main/java/net/william278/velocitab/config/ServerUrl.java index 51ff6d8..c4274a3 100644 --- a/src/main/java/net/william278/velocitab/config/ServerUrl.java +++ b/src/main/java/net/william278/velocitab/config/ServerUrl.java @@ -46,7 +46,7 @@ public record ServerUrl( (type) -> CompletableFuture.completedFuture(ServerLink.serverLink(type, url())) ).orElseGet( () -> Placeholder.replace(label(), plugin, player) - .thenApply(replaced -> plugin.getFormatter().format(replaced, player, plugin)) + .thenApply(replaced -> plugin.getFormatter().format(replaced, player, plugin)) .thenApply(formatted -> ServerLink.serverLink(formatted, url())) ); } diff --git a/src/main/java/net/william278/velocitab/config/TabGroups.java b/src/main/java/net/william278/velocitab/config/TabGroups.java index 254e63c..0ea7a39 100644 --- a/src/main/java/net/william278/velocitab/config/TabGroups.java +++ b/src/main/java/net/william278/velocitab/config/TabGroups.java @@ -70,32 +70,34 @@ public class TabGroups implements ConfigValidator { .orElseThrow(() -> new IllegalStateException("No group with name " + name + " found")); } - @NotNull public Optional getGroup(@NotNull String name) { return groups.stream() .filter(group -> group.name().equals(name)) .findFirst(); } - @NotNull - public Group getGroupFromServer(@NotNull String server, @NotNull Velocitab plugin) { + public Optional getGroupFromServer(@NotNull String server, @NotNull Velocitab plugin) { final List groups = new ArrayList<>(this.groups); final Optional defaultGroup = getGroup("default"); - // Ensure the default group is always checked last - if (defaultGroup.isPresent()) { - groups.remove(defaultGroup.get()); - groups.add(defaultGroup.get()); - } else { + if (defaultGroup.isEmpty()) { throw new IllegalStateException("No default group found"); } + // Ensure the default group is always checked last + groups.remove(defaultGroup.get()); + groups.add(defaultGroup.get()); for (Group group : groups) { if (group.registeredServers(plugin, false) .stream() .anyMatch(s -> s.getServerInfo().getName().equalsIgnoreCase(server))) { - return group; + return Optional.of(group); } } - return getGroupFromName("default"); + + if (!plugin.getSettings().isFallbackEnabled()) { + return Optional.empty(); + } + + return defaultGroup; } public int getPosition(@NotNull Group group) { diff --git a/src/main/java/net/william278/velocitab/hook/MiniPlaceholdersHook.java b/src/main/java/net/william278/velocitab/hook/MiniPlaceholdersHook.java index 66106ce..323af75 100644 --- a/src/main/java/net/william278/velocitab/hook/MiniPlaceholdersHook.java +++ b/src/main/java/net/william278/velocitab/hook/MiniPlaceholdersHook.java @@ -25,16 +25,38 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; import net.william278.velocitab.Velocitab; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; public class MiniPlaceholdersHook extends Hook { + public final static Map REPLACE = Map.of( + "\"", "-q-", + "'", "-a-" + ); + + private final VelocitabMiniExpansion expansion; + public MiniPlaceholdersHook(@NotNull Velocitab plugin) { super(plugin); + this.expansion = new VelocitabMiniExpansion(plugin); + expansion.registerExpansion(); } @NotNull - public Component format(@NotNull String text, @NotNull Audience player) { - return MiniMessage.miniMessage().deserialize(text, MiniPlaceholders.getAudienceGlobalPlaceholders(player)); + public Component format(@NotNull String text, @NotNull Audience player, @Nullable Audience viewer) { + for (Map.Entry entry : REPLACE.entrySet()) { + text = text.replace(entry.getKey(), entry.getValue()); + } + if (viewer == null) { + return MiniMessage.miniMessage().deserialize(text, MiniPlaceholders.getAudienceGlobalPlaceholders(player)); + } + return MiniMessage.miniMessage().deserialize(text, MiniPlaceholders.getRelationalGlobalPlaceholders(player, viewer)); + } + + public void unregisterExpansion() { + expansion.unregisterExpansion(); } } diff --git a/src/main/java/net/william278/velocitab/hook/VelocitabMiniExpansion.java b/src/main/java/net/william278/velocitab/hook/VelocitabMiniExpansion.java new file mode 100644 index 0000000..7e25748 --- /dev/null +++ b/src/main/java/net/william278/velocitab/hook/VelocitabMiniExpansion.java @@ -0,0 +1,125 @@ +/* + * This file is part of Velocitab, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.velocitab.hook; + +import com.velocitypowered.api.proxy.Player; +import io.github.miniplaceholders.api.Expansion; +import io.github.miniplaceholders.api.MiniPlaceholders; +import io.github.miniplaceholders.api.utils.TagsUtils; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.william278.velocitab.Velocitab; +import net.william278.velocitab.config.Placeholder; +import net.william278.velocitab.hook.miniconditions.MiniConditionManager; +import net.william278.velocitab.player.TabPlayer; + +import java.util.Map; +import java.util.Optional; + +public class VelocitabMiniExpansion { + + private final Velocitab plugin; + private final MiniConditionManager miniConditionManager; + private Expansion expansion; + + public VelocitabMiniExpansion(Velocitab plugin) { + this.plugin = plugin; + this.miniConditionManager = new MiniConditionManager(plugin); + } + + public void registerExpansion() { + final Expansion.Builder builder = Expansion.builder("velocitab"); + builder.relationalPlaceholder("condition", ((a1, a2, queue, ctx) -> { + if (!(a2 instanceof Player target)) { + return TagsUtils.EMPTY_TAG; + } + if (!(a1 instanceof Player audience)) { + return TagsUtils.EMPTY_TAG; + } + + return Tag.selfClosingInserting(miniConditionManager.checkConditions(target, audience, queue)); + })); + builder.relationalPlaceholder("who-is-seeing", ((a1, a2, queue, ctx) -> { + if (!(a2 instanceof Player target)) { + return TagsUtils.EMPTY_TAG; + } + if (!(a1 instanceof Player)) { + return TagsUtils.EMPTY_TAG; + } + + return Tag.selfClosingInserting(Component.text(target.getUsername())); + })); + builder.relationalPlaceholder("perm", ((a1, a2, queue, ctx) -> { + if (!(a2 instanceof Player target)) { + return TagsUtils.EMPTY_TAG; + } + if (!(a1 instanceof Player audience)) { + return TagsUtils.EMPTY_TAG; + } + + final Optional targetOptional = plugin.getTabList().getTabPlayer(audience); + if (targetOptional.isEmpty()) { + return TagsUtils.EMPTY_TAG; + } + + final TabPlayer targetPlayer = targetOptional.get(); + + if (!queue.hasNext()) { + return TagsUtils.EMPTY_TAG; + } + + final String permission = queue.pop().value(); + + if (!queue.hasNext()) { + return TagsUtils.EMPTY_TAG; + } + + if (!target.hasPermission(permission)) { + return TagsUtils.EMPTY_TAG; + } + + final String value = queue.pop().value(); + String replaced = Placeholder.replaceInternal(value, plugin, targetPlayer); + for (final Map.Entry entry : Placeholder.SYMBOL_SUBSTITUTES.entrySet()) { + replaced = replaced.replace(entry.getValue(), entry.getKey()); + } + return Tag.selfClosingInserting(MiniMessage.miniMessage().deserialize(replaced, MiniPlaceholders.getAudienceGlobalPlaceholders(audience))); + })); + builder.relationalPlaceholder("vanish", ((a1, otherAudience, queue, ctx) -> { + if (!(otherAudience instanceof Player target)) { + return TagsUtils.EMPTY_TAG; + } + if (!(a1 instanceof Player audience)) { + return TagsUtils.EMPTY_TAG; + } + + return Tag.selfClosingInserting(Component.text(plugin.getVanishManager().getIntegration().canSee(audience.getUsername(), target.getUsername()))); + })); + plugin.getLogger().info("Registered Velocitab MiniExpansion"); + expansion = builder.build(); + expansion.register(); + } + + public void unregisterExpansion() { + expansion.unregister(); + } + +} diff --git a/src/main/java/net/william278/velocitab/hook/miniconditions/MiniConditionManager.java b/src/main/java/net/william278/velocitab/hook/miniconditions/MiniConditionManager.java new file mode 100644 index 0000000..b55c011 --- /dev/null +++ b/src/main/java/net/william278/velocitab/hook/miniconditions/MiniConditionManager.java @@ -0,0 +1,196 @@ +/* + * This file is part of Velocitab, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.velocitab.hook.miniconditions; + +import com.google.common.collect.Lists; +import com.velocitypowered.api.proxy.Player; +import net.jodah.expiringmap.ExpiringMap; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.william278.velocitab.Velocitab; +import net.william278.velocitab.config.Placeholder; +import net.william278.velocitab.hook.MiniPlaceholdersHook; +import net.william278.velocitab.player.TabPlayer; +import org.apache.commons.jexl3.JexlBuilder; +import org.apache.commons.jexl3.JexlContext; +import org.apache.commons.jexl3.JexlEngine; +import org.apache.commons.jexl3.MapContext; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MiniConditionManager { + + private final Velocitab plugin; + private final JexlEngine jexlEngine; + private final JexlContext jexlContext; + private final Pattern targetPlaceholderPattern; + private final Pattern miniEscapeEndTags; + private final Map cachedExpressions; + + public MiniConditionManager(@NotNull Velocitab plugin) { + this.plugin = plugin; + this.jexlEngine = createJexlEngine(); + this.jexlContext = createJexlContext(); + this.targetPlaceholderPattern = Pattern.compile("%target_(\\w+)?%"); + this.miniEscapeEndTags = Pattern.compile(""); + this.cachedExpressions = ExpiringMap.builder() + .expiration(5, TimeUnit.MINUTES) + .build(); + } + + @NotNull + private JexlEngine createJexlEngine() { + return new JexlBuilder().create(); + } + + @NotNull + private JexlContext createJexlContext() { + final JexlContext jexlContext = new MapContext(); + jexlContext.set("startsWith", new StartsWith()); + jexlContext.set("endsWith", new EndsWith()); + return jexlContext; + } + + @NotNull + public Component checkConditions(@NotNull Player target, @NotNull Player audience, @NotNull ArgumentQueue queue) { + final List parameters = collectParameters(queue); + if (parameters.isEmpty()) { + plugin.getLogger().warn("Empty condition"); + return Component.empty(); + } + + String condition = decodeCondition(parameters.get(0)); + if (parameters.size() < 3) { + plugin.getLogger().warn("Invalid condition: Missing true/false values for condition: {}", condition); + return Component.empty(); + } + + final Optional tabPlayer = plugin.getTabList().getTabPlayer(target); + if (tabPlayer.isEmpty()) { + return Component.empty(); + } + + condition = Placeholder.replaceInternal(condition, plugin, tabPlayer.get()); + String falseValue = processFalseValue(parameters.get(2)); + final String expression = buildExpression(condition); + return evaluateAndFormatCondition(expression, target, audience, parameters.get(1), falseValue); + } + + @NotNull + private List collectParameters(@NotNull ArgumentQueue queue) { + final List parameters = Lists.newArrayList(); + while (queue.hasNext()) { + parameters.add(queue.pop().value()); + } + return parameters; + } + + @NotNull + private String decodeCondition(@NotNull String condition) { + condition = condition.replace("?lt;", "<").replace("?gt;", ">"); + for (Map.Entry entry : MiniPlaceholdersHook.REPLACE.entrySet()) { + condition = condition.replace(entry.getValue(), entry.getKey()); + condition = condition.replace(entry.getKey() + entry.getKey(), entry.getKey()); + } + for (Map.Entry entry : Placeholder.SYMBOL_SUBSTITUTES.entrySet()) { + condition = condition.replace(entry.getValue(), entry.getKey()); + } + return condition; + } + + @NotNull + private String processFalseValue(@NotNull String falseValue) { + final Matcher matcher = miniEscapeEndTags.matcher(falseValue); + if (matcher.find()) { + final String tag = matcher.group(1); + if (falseValue.startsWith("")) { + falseValue = falseValue.substring(tag.length() + 3); + } + } + return falseValue; + } + + @NotNull + private String buildExpression(@NotNull String condition) { + return condition.replace("and", "&&").replace("or", "||") + .replace("AND", "&&").replace("OR", "||"); + } + + @NotNull + private Component evaluateAndFormatCondition(@NotNull String expression, @NotNull Player target, @NotNull Player audience, @NotNull String trueValue, @NotNull String falseValue) { + final String targetString = parseTargetPlaceholders(expression, target); + try { + final Object result = evaluateExpression(targetString); + if (result instanceof Boolean) { + final boolean boolResult = (Boolean) result; + final String value = boolResult ? trueValue : falseValue; + return plugin.getMiniPlaceholdersHook().orElseThrow().format(value, target, audience); + } + } catch (Exception e) { + plugin.getLogger().warn("Failed to evaluate condition: {} error: {}", expression, e.getMessage()); + } + return Component.empty(); + } + + @NotNull + private Object evaluateExpression(@NotNull String expression) { + return cachedExpressions.computeIfAbsent(expression, key -> jexlEngine.createExpression(key).evaluate(jexlContext)); + } + + @NotNull + private String parseTargetPlaceholders(@NotNull String input, @NotNull Player target) { + final Optional tabPlayer = plugin.getTabList().getTabPlayer(target); + if (tabPlayer.isEmpty()) { + return input; + } + + return targetPlaceholderPattern.matcher(input).replaceAll(match -> { + final String placeholder = match.group(1); + if (placeholder == null) { + return ""; + } + + final String text = "%" + placeholder + "%"; + final Optional placeholderValue = tabPlayer.get().getCachedPlaceholderValue(text); + return placeholderValue.orElse(text); + }); + } + + @SuppressWarnings("unused") + private static class StartsWith { + public boolean startsWith(String str, String prefix) { + return str != null && str.startsWith(prefix); + } + } + + @SuppressWarnings("unused") + private static class EndsWith { + public boolean endsWith(String str, String suffix) { + return str != null && str.endsWith(suffix); + } + } + +} diff --git a/src/main/java/net/william278/velocitab/packet/PacketEventManager.java b/src/main/java/net/william278/velocitab/packet/PacketEventManager.java index cb57836..a9fa6b6 100644 --- a/src/main/java/net/william278/velocitab/packet/PacketEventManager.java +++ b/src/main/java/net/william278/velocitab/packet/PacketEventManager.java @@ -19,7 +19,6 @@ package net.william278.velocitab.packet; -import com.google.common.collect.Sets; import com.velocitypowered.api.event.AwaitingEventExecutor; import com.velocitypowered.api.event.EventTask; import com.velocitypowered.api.event.connection.DisconnectEvent; @@ -27,31 +26,20 @@ import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import com.velocitypowered.proxy.network.Connections; -import com.velocitypowered.proxy.protocol.packet.UpsertPlayerInfoPacket; -import com.velocitypowered.proxy.protocol.packet.chat.ComponentHolder; import io.netty.channel.Channel; -import lombok.Getter; +import io.netty.channel.ChannelHandler; +import io.netty.channel.DefaultChannelPipeline; import net.william278.velocitab.Velocitab; -import net.william278.velocitab.player.TabPlayer; import org.jetbrains.annotations.NotNull; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - public class PacketEventManager { private static final String KEY = "velocitab"; - private static final String CITIZENS_PREFIX = "CIT"; private final Velocitab plugin; - @Getter - private final Set velocitabEntries; public PacketEventManager(@NotNull Velocitab plugin) { this.plugin = plugin; - this.velocitabEntries = Sets.newConcurrentHashSet(); this.loadPlayers(); this.loadListeners(); } @@ -87,32 +75,17 @@ public class PacketEventManager { public void removePlayer(@NotNull Player player) { final ConnectedPlayer connectedPlayer = (ConnectedPlayer) player; final Channel channel = connectedPlayer.getConnection().getChannel(); - if (channel.pipeline().get(KEY) != null) { - channel.pipeline().remove(KEY); + final ChannelHandler handler = channel.pipeline().get(KEY); + if (handler == null) { + return; } - } - - protected void handleEntry(@NotNull UpsertPlayerInfoPacket packet, @NotNull Player player) { - final List toUpdate = packet.getEntries().stream() - .filter(entry -> entry.getProfile() != null) - .filter(entry -> !entry.getProfile().getName().startsWith(CITIZENS_PREFIX)) - .filter(entry -> velocitabEntries.stream().noneMatch(uuid -> uuid.equals(entry.getProfile().getId()))) - .map(entry -> entry.getProfile().getId()) - .map(id -> plugin.getTabList().getTabPlayer(id)) - .filter(Optional::isPresent) - .map(Optional::get) - .filter(TabPlayer::isLoaded) - .toList(); - - if (toUpdate.isEmpty()) { + if (channel.pipeline() instanceof DefaultChannelPipeline defaultChannelPipeline) { + defaultChannelPipeline.removeIfExists(KEY); return; } - toUpdate.forEach(tabPlayer -> packet.getEntries().stream() - .filter(entry -> entry.getProfileId().equals(tabPlayer.getPlayer().getUniqueId())) - .findFirst() - .ifPresent(entry -> entry.setDisplayName( - new ComponentHolder(player.getProtocolVersion(), tabPlayer.getLastDisplayName())))); + plugin.getLogger().warn("Failed to remove player {} from Velocitab packet handler {}", + player.getUsername(), channel.pipeline().getClass().getName()); } } diff --git a/src/main/java/net/william278/velocitab/packet/PacketRegistration.java b/src/main/java/net/william278/velocitab/packet/PacketRegistration.java index d0e7e35..631f950 100644 --- a/src/main/java/net/william278/velocitab/packet/PacketRegistration.java +++ b/src/main/java/net/william278/velocitab/packet/PacketRegistration.java @@ -30,10 +30,7 @@ import org.jetbrains.annotations.NotNull; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.function.Supplier; // Based on VPacketEvents PacketRegistration API @@ -105,7 +102,7 @@ public final class PacketRegistration

{ try { IntObjectMap> packetIdToSupplier = (IntObjectMap>) PACKET_REGISTRY$packetIdToSupplier.invoke(protocolRegistry); Object2IntMap> packetClassToId = (Object2IntMap>) PACKET_REGISTRY$packetClassToId.invoke(protocolRegistry); - packetIdToSupplier.keySet().stream() + Set.copyOf(packetIdToSupplier.keySet()).stream() .filter(supplier -> packetIdToSupplier.get(supplier).get().getClass().equals(packetClass)) .forEach(packetIdToSupplier::remove); packetClassToId.values().intStream() diff --git a/src/main/java/net/william278/velocitab/packet/PlayerChannelHandler.java b/src/main/java/net/william278/velocitab/packet/PlayerChannelHandler.java index 699d8f3..ba3326c 100644 --- a/src/main/java/net/william278/velocitab/packet/PlayerChannelHandler.java +++ b/src/main/java/net/william278/velocitab/packet/PlayerChannelHandler.java @@ -41,40 +41,79 @@ public class PlayerChannelHandler extends ChannelDuplexHandler { @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof final UpdateTeamsPacket updateTeamsPacket && plugin.getSettings().isSendScoreboardPackets()) { + final Optional scoreboardManager = plugin.getScoreboardManager(); + if (scoreboardManager.isEmpty()) { + super.write(ctx, msg, promise); + return; + } + + if (updateTeamsPacket.isRemoveTeam()) { + super.write(ctx, msg, promise); + return; + } + + if (scoreboardManager.get().isInternalTeam(updateTeamsPacket.teamName())) { + super.write(ctx, msg, promise); + return; + } + + if (!updateTeamsPacket.hasEntities()) { + super.write(ctx, msg, promise); + return; + } + + if (updateTeamsPacket.entities().stream().noneMatch(entity -> plugin.getServer().getPlayer(entity).isPresent())) { + super.write(ctx, msg, promise); + return; + } + + // Cancel packet if the backend is trying to send a team packet with an online player. + // This is to prevent conflicts with Velocitab teams. + plugin.getLogger().warn("Cancelled team \"{}\" packet from backend for player {}. " + + "We suggest disabling \"send_scoreboard_packets\" in Velocitab's config.yml file, " + + "but note this will disable TAB sorting", + updateTeamsPacket.teamName(), player.getUsername()); + return; + } if (!(msg instanceof final UpsertPlayerInfoPacket minecraftPacket)) { super.write(ctx, msg, promise); return; } - final Optional tabPlayer = plugin.getTabList().getTabPlayer(player); - if (tabPlayer.isEmpty()) { + try { + final Optional tabPlayer = plugin.getTabList().getTabPlayer(player); + if (tabPlayer.isEmpty()) { + super.write(ctx, msg, promise); + return; + } + + if (plugin.getSettings().isRemoveSpectatorEffect() && minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.UPDATE_GAME_MODE)) { + forceGameMode(minecraftPacket.getEntries()); + } + + //fix for duplicate entries + if (minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.ADD_PLAYER)) { + minecraftPacket.getEntries().stream() + .filter(entry -> entry.getProfile() != null && !entry.getProfile().getId().equals(entry.getProfileId())) + .forEach(entry -> entry.setListed(false)); + } + + if (!minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.ADD_PLAYER) && !minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.UPDATE_LISTED)) { + super.write(ctx, msg, promise); + return; + } + + if (minecraftPacket.getEntries().stream().allMatch(entry -> entry.getProfile() != null && entry.getProfile().getName().startsWith("CIT"))) { + super.write(ctx, msg, promise); + return; + } + super.write(ctx, msg, promise); - return; - } - - if (plugin.getSettings().isRemoveSpectatorEffect() && minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.UPDATE_GAME_MODE)) { - forceGameMode(minecraftPacket.getEntries()); - } - - //fix for duplicate entries - if (minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.ADD_PLAYER)) { - minecraftPacket.getEntries().stream() - .filter(entry -> entry.getProfile() != null && !entry.getProfile().getId().equals(entry.getProfileId())) - .forEach(entry -> entry.setListed(false)); - } - - if (!minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.ADD_PLAYER) && !minecraftPacket.containsAction(UpsertPlayerInfoPacket.Action.UPDATE_LISTED)) { + } catch (Exception e) { + plugin.getLogger().error("An error occurred while handling a packet", e); super.write(ctx, msg, promise); - return; } - - if (minecraftPacket.getEntries().stream().allMatch(entry -> entry.getProfile() != null && entry.getProfile().getName().startsWith("CIT"))) { - super.write(ctx, msg, promise); - return; - } - - plugin.getPacketEventManager().handleEntry(minecraftPacket, player); - super.write(ctx, msg, promise); } private void forceGameMode(@NotNull List entries) { diff --git a/src/main/java/net/william278/velocitab/packet/Protocol404Adapter.java b/src/main/java/net/william278/velocitab/packet/Protocol404Adapter.java index 8bb3bb0..aa9afc2 100644 --- a/src/main/java/net/william278/velocitab/packet/Protocol404Adapter.java +++ b/src/main/java/net/william278/velocitab/packet/Protocol404Adapter.java @@ -55,7 +55,34 @@ public class Protocol404Adapter extends TeamsPacketAdapter { public Protocol404Adapter(@NotNull Velocitab plugin, Set protocolVersions) { super(plugin, protocolVersions); - serializer = null; + serializer = GsonComponentSerializer.colorDownsamplingGson(); + } + + @Override + public void decode(@NotNull ByteBuf byteBuf, @NotNull UpdateTeamsPacket packet, @NotNull ProtocolVersion protocolVersion) { + packet.teamName(ProtocolUtils.readString(byteBuf)); + UpdateTeamsPacket.UpdateMode mode = UpdateTeamsPacket.UpdateMode.byId(byteBuf.readByte()); + packet.mode(mode); + if (mode == UpdateTeamsPacket.UpdateMode.REMOVE_TEAM) { + return; + } + if (mode == UpdateTeamsPacket.UpdateMode.CREATE_TEAM || mode == UpdateTeamsPacket.UpdateMode.UPDATE_INFO) { + packet.displayName(readComponent(byteBuf)); + packet.friendlyFlags(UpdateTeamsPacket.FriendlyFlag.fromBitMask(byteBuf.readByte())); + packet.nametagVisibility(UpdateTeamsPacket.NametagVisibility.byId(ProtocolUtils.readString(byteBuf))); + packet.collisionRule(UpdateTeamsPacket.CollisionRule.byId(ProtocolUtils.readString(byteBuf))); + packet.color(byteBuf.readByte()); + packet.prefix(readComponent(byteBuf)); + packet.suffix(readComponent(byteBuf)); + } + if (mode == UpdateTeamsPacket.UpdateMode.CREATE_TEAM || mode == UpdateTeamsPacket.UpdateMode.ADD_PLAYERS || mode == UpdateTeamsPacket.UpdateMode.REMOVE_PLAYERS) { + int count = ProtocolUtils.readVarInt(byteBuf); + List entities = new ArrayList<>(); + for (int i = 0; i < count; i++) { + entities.add(ProtocolUtils.readString(byteBuf)); + } + packet.entities(entities); + } } @Override @@ -84,8 +111,13 @@ public class Protocol404Adapter extends TeamsPacketAdapter { } } - protected void writeComponent(ByteBuf buf, Component component) { + protected void writeComponent(@NotNull ByteBuf buf, @NotNull Component component) { ProtocolUtils.writeString(buf, serializer.serialize(component)); } + @NotNull + protected Component readComponent(@NotNull ByteBuf buf) { + return serializer.deserialize(ProtocolUtils.readString(buf)); + } + } diff --git a/src/main/java/net/william278/velocitab/packet/Protocol48Adapter.java b/src/main/java/net/william278/velocitab/packet/Protocol48Adapter.java index 961a616..f922456 100644 --- a/src/main/java/net/william278/velocitab/packet/Protocol48Adapter.java +++ b/src/main/java/net/william278/velocitab/packet/Protocol48Adapter.java @@ -44,6 +44,35 @@ public class Protocol48Adapter extends TeamsPacketAdapter { serializer = LegacyComponentSerializer.legacySection(); } + @Override + public void decode(@NotNull ByteBuf byteBuf, @NotNull UpdateTeamsPacket packet, @NotNull ProtocolVersion protocolVersion) { + packet.teamName(ProtocolUtils.readString(byteBuf)); + UpdateTeamsPacket.UpdateMode mode = UpdateTeamsPacket.UpdateMode.byId(byteBuf.readByte()); + packet.mode(mode); + if (mode == UpdateTeamsPacket.UpdateMode.REMOVE_TEAM) { + return; + } + if (mode == UpdateTeamsPacket.UpdateMode.CREATE_TEAM || mode == UpdateTeamsPacket.UpdateMode.UPDATE_INFO) { + packet.displayName(readComponent(byteBuf)); + packet.prefix(readComponent(byteBuf)); + packet.suffix(readComponent(byteBuf)); + packet.friendlyFlags(UpdateTeamsPacket.FriendlyFlag.fromBitMask(byteBuf.readByte())); + packet.nametagVisibility(UpdateTeamsPacket.NametagVisibility.byId(ProtocolUtils.readString(byteBuf))); + if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_12_2) >= 0) { + packet.collisionRule(UpdateTeamsPacket.CollisionRule.byId(ProtocolUtils.readString(byteBuf))); + } + packet.color(byteBuf.readByte()); + } + if (mode == UpdateTeamsPacket.UpdateMode.CREATE_TEAM || mode == UpdateTeamsPacket.UpdateMode.ADD_PLAYERS || mode == UpdateTeamsPacket.UpdateMode.REMOVE_PLAYERS) { + int count = ProtocolUtils.readVarInt(byteBuf); + List entities = new ArrayList<>(); + for (int i = 0; i < count; i++) { + entities.add(ProtocolUtils.readString(byteBuf)); + } + packet.entities(entities); + } + } + @Override public void encode(@NotNull ByteBuf byteBuf, @NotNull UpdateTeamsPacket packet, @NotNull ProtocolVersion protocolVersion) { ProtocolUtils.writeString(byteBuf, shrinkString(packet.teamName())); @@ -83,7 +112,12 @@ public class Protocol48Adapter extends TeamsPacketAdapter { return string.substring(0, Math.min(string.length(), 16)); } - protected void writeComponent(ByteBuf buf, Component component) { + protected void writeComponent(@NotNull ByteBuf buf, @NotNull Component component) { ProtocolUtils.writeString(buf, shrinkString(serializer.serialize(component))); } + + @NotNull + protected Component readComponent(@NotNull ByteBuf buf) { + return serializer.deserialize(ProtocolUtils.readString(buf)); + } } diff --git a/src/main/java/net/william278/velocitab/packet/Protocol735Adapter.java b/src/main/java/net/william278/velocitab/packet/Protocol735Adapter.java index e8ba7a1..ed343b6 100644 --- a/src/main/java/net/william278/velocitab/packet/Protocol735Adapter.java +++ b/src/main/java/net/william278/velocitab/packet/Protocol735Adapter.java @@ -58,8 +58,13 @@ public class Protocol735Adapter extends Protocol404Adapter { } @Override - protected void writeComponent(ByteBuf buf, Component component) { + protected void writeComponent(@NotNull ByteBuf buf, @NotNull Component component) { ProtocolUtils.writeString(buf, serializer.serialize(component)); } + @NotNull + protected Component readComponent(@NotNull ByteBuf buf) { + return serializer.deserialize(ProtocolUtils.readString(buf)); + } + } \ No newline at end of file diff --git a/src/main/java/net/william278/velocitab/packet/Protocol765Adapter.java b/src/main/java/net/william278/velocitab/packet/Protocol765Adapter.java index 3969e83..d80bc18 100644 --- a/src/main/java/net/william278/velocitab/packet/Protocol765Adapter.java +++ b/src/main/java/net/william278/velocitab/packet/Protocol765Adapter.java @@ -44,9 +44,14 @@ public class Protocol765Adapter extends Protocol404Adapter { )); } - protected void writeComponent(ByteBuf buf, Component component) { + protected void writeComponent(@NotNull ByteBuf buf, @NotNull Component component) { final BinaryTag tag = ComponentHolder.serialize(GsonComponentSerializer.gson().serializeToTree(component)); ProtocolUtils.writeBinaryTag(buf, ProtocolVersion.MINECRAFT_1_20_3, tag); } + @NotNull + protected Component readComponent(@NotNull ByteBuf buf) { + return GsonComponentSerializer.gson().deserializeFromTree(ComponentHolder.deserialize(ProtocolUtils.readBinaryTag(buf, ProtocolVersion.MINECRAFT_1_20_3, null))); + } + } diff --git a/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java b/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java index bdebdc8..6fa9690 100644 --- a/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java +++ b/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java @@ -20,6 +20,8 @@ package net.william278.velocitab.packet; import com.google.common.collect.Maps; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; import com.google.common.collect.Sets; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.Player; @@ -28,6 +30,7 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.StateRegistry; +import net.kyori.adventure.text.Component; import net.william278.velocitab.Velocitab; import net.william278.velocitab.config.Group; import net.william278.velocitab.player.TabPlayer; @@ -49,12 +52,14 @@ public class ScoreboardManager { private final Set versions; private final Map createdTeams; private final Map nametags; + private final Multimap trackedTeams; public ScoreboardManager(@NotNull Velocitab velocitab) { this.plugin = velocitab; this.createdTeams = Maps.newConcurrentMap(); this.nametags = Maps.newConcurrentMap(); this.versions = Sets.newHashSet(); + this.trackedTeams = Multimaps.synchronizedMultimap(Multimaps.newSetMultimap(Maps.newConcurrentMap(), Sets::newConcurrentHashSet)); this.registerVersions(); } @@ -70,6 +75,10 @@ public class ScoreboardManager { } } + public boolean isInternalTeam(@NotNull String teamName) { + return nametags.containsKey(teamName); + } + @NotNull public TeamsPacketAdapter getPacketAdapter(@NotNull ProtocolVersion version) { return versions.stream() @@ -88,6 +97,7 @@ public class ScoreboardManager { plugin.getTabList().getTabPlayer(player).ifPresent(tabPlayer -> dispatchGroupPacket(UpdateTeamsPacket.removeTeam(plugin, team), tabPlayer) ); + trackedTeams.removeAll(player.getUniqueId()); } } @@ -119,13 +129,15 @@ public class ScoreboardManager { final Set siblings = tabPlayer.getGroup().registeredServers(plugin); final Optional cachedTag = Optional.ofNullable(nametags.getOrDefault(teamName, null)); - cachedTag.ifPresent(nametag -> { - final UpdateTeamsPacket packet = vanish ? UpdateTeamsPacket.removeTeam(plugin, teamName) : - UpdateTeamsPacket.create(plugin, tabPlayer, teamName, nametag, player.getUsername()); - siblings.forEach(server -> server.getPlayersConnected().stream().filter(p -> p != player) - .filter(p -> vanish && !plugin.getVanishManager().canSee(p.getUsername(), player.getUsername())) - .forEach(connected -> dispatchPacket(packet, connected))); - }); + cachedTag.ifPresent(nametag -> siblings.forEach(server -> server.getPlayersConnected().stream().filter(p -> p != player) + .forEach(connected -> { + if (vanish && !plugin.getVanishManager().canSee(connected.getUsername(), player.getUsername())) { + dispatchPacket(UpdateTeamsPacket.removeTeam(plugin, teamName), connected); + trackedTeams.remove(connected.getUniqueId(), teamName); + } else { + dispatchGroupCreatePacket(plugin, tabPlayer, teamName, nametag, player.getUsername()); + } + }))); } /** @@ -154,16 +166,12 @@ public class ScoreboardManager { createdTeams.put(player.getUniqueId(), role); this.nametags.put(role, newTag); - dispatchGroupPacket( - UpdateTeamsPacket.create(plugin, tabPlayer, role, newTag, name), - tabPlayer - ); + dispatchGroupCreatePacket(plugin, tabPlayer, role, newTag, name); } else if (force || (this.nametags.containsKey(role) && !this.nametags.get(role).equals(newTag))) { this.nametags.put(role, newTag); - dispatchGroupPacket( - UpdateTeamsPacket.changeNametag(plugin, tabPlayer, role, newTag), - tabPlayer - ); + dispatchGroupChangePacket(plugin, tabPlayer, role, newTag); + } else { + updatePlaceholders(tabPlayer); } }).exceptionally(e -> { plugin.log(Level.ERROR, "Failed to update role for " + player.getUsername(), e); @@ -171,6 +179,17 @@ public class ScoreboardManager { }); } + public void updatePlaceholders(@NotNull TabPlayer tabPlayer) { + final Player player = tabPlayer.getPlayer(); + + final String role = createdTeams.get(player.getUniqueId()); + if (role == null) { + return; + } + + final Optional optionalNametag = Optional.ofNullable(nametags.get(role)); + optionalNametag.ifPresent(nametag -> dispatchGroupChangePacket(plugin, tabPlayer, role, nametag)); + } public void resendAllTeams(@NotNull TabPlayer tabPlayer) { if (!plugin.getSettings().isSendScoreboardPackets()) { @@ -196,7 +215,6 @@ public class ScoreboardManager { } final Optional optionalTabPlayer = plugin.getTabList().getTabPlayer(p); - if (optionalTabPlayer.isEmpty()) { return; } @@ -212,14 +230,69 @@ public class ScoreboardManager { // Send packet final Nametag tag = nametags.get(role); if (tag != null) { - final UpdateTeamsPacket packet = UpdateTeamsPacket.create( - plugin, targetTabPlayer, role, tag, p.getUsername() - ); - dispatchPacket(packet, player); + dispatchCreatePacket(plugin, targetTabPlayer, role, tag, tabPlayer, p.getUsername()); } }); } + private void dispatchGroupCreatePacket(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, + @NotNull String teamName, @NotNull Nametag nametag, + @NotNull String... teamMembers) { + tabPlayer.getGroup().getTabPlayers(plugin, tabPlayer).forEach(viewer -> { + if (!viewer.getPlayer().isActive()) { + return; + } + + + dispatchCreatePacket(plugin, tabPlayer, teamName, nametag, viewer, teamMembers); + }); + } + + private void dispatchCreatePacket(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, + @NotNull String teamName, @NotNull Nametag nametag, + @NotNull TabPlayer viewer, + @NotNull String... teamMembers) { + final boolean canSee = plugin.getVanishManager().canSee(viewer.getPlayer().getUsername(), tabPlayer.getPlayer().getUsername()); + if (!canSee) { + return; + } + + final UpdateTeamsPacket packet = UpdateTeamsPacket.create(plugin, tabPlayer, teamName, nametag, viewer, teamMembers); + trackedTeams.put(viewer.getPlayer().getUniqueId(), teamName); + dispatchPacket(packet, viewer.getPlayer()); + } + + private void dispatchGroupChangePacket(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, + @NotNull String teamName, + @NotNull Nametag nametag) { + tabPlayer.getGroup().getTabPlayers(plugin, tabPlayer).forEach(viewer -> { + if (viewer == tabPlayer || !viewer.getPlayer().isActive()) { + return; + } + + final boolean canSee = plugin.getVanishManager().canSee(viewer.getPlayer().getUsername(), tabPlayer.getPlayer().getUsername()); + if (!canSee) { + return; + } + + // Prevent sending change nametag packets to players who are not tracking the team + if (!trackedTeams.containsEntry(viewer.getPlayer().getUniqueId(), teamName)) { + return; + } + + final UpdateTeamsPacket packet = UpdateTeamsPacket.changeNametag(plugin, tabPlayer, teamName, viewer, nametag); + final Component prefix = packet.prefix(); + final Component suffix = packet.suffix(); + final Optional cached = tabPlayer.getRelationalNametag(viewer.getPlayer().getUniqueId()); + // Skip if the nametag is the same as the cached one + if (cached.isPresent() && cached.get()[0].equals(prefix) && cached.get()[1].equals(suffix)) { + return; + } + tabPlayer.setRelationalNametag(viewer.getPlayer().getUniqueId(), prefix, suffix); + dispatchPacket(packet, viewer.getPlayer()); + }); + } + private void dispatchPacket(@NotNull UpdateTeamsPacket packet, @NotNull Player player) { if (!player.isActive()) { plugin.getTabList().removeOfflinePlayer(player); @@ -235,10 +308,14 @@ public class ScoreboardManager { } private void dispatchGroupPacket(@NotNull UpdateTeamsPacket packet, @NotNull Group group) { + final boolean isRemove = packet.isRemoveTeam(); group.registeredServers(plugin).forEach(server -> server.getPlayersConnected().forEach(connected -> { try { final ConnectedPlayer connectedPlayer = (ConnectedPlayer) connected; connectedPlayer.getConnection().write(packet); + if (isRemove) { + trackedTeams.remove(connected.getUniqueId(), packet.teamName()); + } } catch (Throwable e) { plugin.log(Level.ERROR, "Failed to dispatch packet (unsupported client or server version)", e); } @@ -274,18 +351,18 @@ public class ScoreboardManager { .direction(ProtocolUtils.Direction.CLIENTBOUND) .packetSupplier(() -> new UpdateTeamsPacket(plugin)) .stateRegistry(StateRegistry.PLAY) - .mapping(0x3E, MINECRAFT_1_8, true) - .mapping(0x44, MINECRAFT_1_12_2, true) - .mapping(0x47, MINECRAFT_1_13, true) - .mapping(0x4B, MINECRAFT_1_14, true) - .mapping(0x4C, MINECRAFT_1_15, true) - .mapping(0x55, MINECRAFT_1_17, true) - .mapping(0x58, MINECRAFT_1_19_1, true) - .mapping(0x56, MINECRAFT_1_19_3, true) - .mapping(0x5A, MINECRAFT_1_19_4, true) - .mapping(0x5C, MINECRAFT_1_20_2, true) - .mapping(0x5E, MINECRAFT_1_20_3, true) - .mapping(0x60, MINECRAFT_1_20_5, true); + .mapping(0x3E, MINECRAFT_1_8, false) + .mapping(0x44, MINECRAFT_1_12_2, false) + .mapping(0x47, MINECRAFT_1_13, false) + .mapping(0x4B, MINECRAFT_1_14, false) + .mapping(0x4C, MINECRAFT_1_15, false) + .mapping(0x55, MINECRAFT_1_17, false) + .mapping(0x58, MINECRAFT_1_19_1, false) + .mapping(0x56, MINECRAFT_1_19_3, false) + .mapping(0x5A, MINECRAFT_1_19_4, false) + .mapping(0x5C, MINECRAFT_1_20_2, false) + .mapping(0x5E, MINECRAFT_1_20_3, false) + .mapping(0x60, MINECRAFT_1_20_5, false); packetRegistration.register(); } catch (Throwable e) { plugin.log(Level.ERROR, "Failed to register UpdateTeamsPacket", e); @@ -320,14 +397,12 @@ public class ScoreboardManager { final UpdateTeamsPacket removeTeam = UpdateTeamsPacket.removeTeam(plugin, team); dispatchPacket(removeTeam, player); + trackedTeams.remove(player.getUniqueId(), team); if (canSee) { final Nametag tag = nametags.get(team); if (tag != null) { - final UpdateTeamsPacket addTeam = UpdateTeamsPacket.create( - plugin, tabPlayer, team, tag, target.getPlayer().getUsername() - ); - dispatchPacket(addTeam, player); + dispatchCreatePacket(plugin, tabPlayer, team, tag, target, target.getPlayer().getUsername()); } } } diff --git a/src/main/java/net/william278/velocitab/packet/TeamsPacketAdapter.java b/src/main/java/net/william278/velocitab/packet/TeamsPacketAdapter.java index 9808224..6d705dc 100644 --- a/src/main/java/net/william278/velocitab/packet/TeamsPacketAdapter.java +++ b/src/main/java/net/william278/velocitab/packet/TeamsPacketAdapter.java @@ -38,6 +38,11 @@ public abstract class TeamsPacketAdapter { public abstract void encode(@NotNull ByteBuf byteBuf, @NotNull UpdateTeamsPacket packet, @NotNull ProtocolVersion protocolVersion); - protected abstract void writeComponent(ByteBuf buf, Component component); + public abstract void decode(@NotNull ByteBuf byteBuf, @NotNull UpdateTeamsPacket packet, @NotNull ProtocolVersion protocolVersion); + + protected abstract void writeComponent(@NotNull ByteBuf buf, @NotNull Component component); + + @NotNull + protected abstract Component readComponent(@NotNull ByteBuf buf); } diff --git a/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java b/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java index 93185eb..6e43de8 100644 --- a/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java +++ b/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java @@ -64,21 +64,29 @@ public class UpdateTeamsPacket implements MinecraftPacket { this.plugin = plugin; } + public boolean isRemoveTeam() { + return mode == UpdateMode.REMOVE_TEAM; + } + + public boolean hasEntities() { + return entities != null && !entities.isEmpty(); + } + @NotNull protected static UpdateTeamsPacket create(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, - @NotNull String teamName, - @NotNull Nametag nametag, + @NotNull String teamName, @NotNull Nametag nametag, + @NotNull TabPlayer viewer, @NotNull String... teamMembers) { return new UpdateTeamsPacket(plugin) - .teamName(teamName.length() > 16 ? teamName.substring(0, 16) : teamName) + .teamName(teamName) .mode(UpdateMode.CREATE_TEAM) .displayName(Component.empty()) .friendlyFlags(List.of(FriendlyFlag.CAN_HURT_FRIENDLY)) .nametagVisibility(isNametagPresent(nametag, plugin) ? NametagVisibility.ALWAYS : NametagVisibility.NEVER) .collisionRule(tabPlayer.getGroup().collisions() ? CollisionRule.ALWAYS : CollisionRule.NEVER) .color(getLastColor(tabPlayer, nametag.prefix(), plugin)) - .prefix(nametag.getPrefixComponent(plugin, tabPlayer)) - .suffix(nametag.getSuffixComponent(plugin, tabPlayer)) + .prefix(nametag.getPrefixComponent(plugin, tabPlayer, viewer)) + .suffix(nametag.getSuffixComponent(plugin, tabPlayer, viewer)) .entities(Arrays.asList(teamMembers)); } @@ -92,25 +100,25 @@ public class UpdateTeamsPacket implements MinecraftPacket { @NotNull protected static UpdateTeamsPacket changeNametag(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, - @NotNull String teamName, + @NotNull String teamName, @NotNull TabPlayer viewer, @NotNull Nametag nametag) { return new UpdateTeamsPacket(plugin) - .teamName(teamName.length() > 16 ? teamName.substring(0, 16) : teamName) + .teamName(teamName) .mode(UpdateMode.UPDATE_INFO) .displayName(Component.empty()) .friendlyFlags(List.of(FriendlyFlag.CAN_HURT_FRIENDLY)) .nametagVisibility(isNametagPresent(nametag, plugin) ? NametagVisibility.ALWAYS : NametagVisibility.NEVER) .collisionRule(tabPlayer.getGroup().collisions() ? CollisionRule.ALWAYS : CollisionRule.NEVER) .color(getLastColor(tabPlayer, nametag.prefix(), plugin)) - .prefix(nametag.getPrefixComponent(plugin, tabPlayer)) - .suffix(nametag.getSuffixComponent(plugin, tabPlayer)); + .prefix(nametag.getPrefixComponent(plugin, tabPlayer, viewer)) + .suffix(nametag.getSuffixComponent(plugin, tabPlayer, viewer)); } @NotNull protected static UpdateTeamsPacket addToTeam(@NotNull Velocitab plugin, @NotNull String teamName, @NotNull String... teamMembers) { return new UpdateTeamsPacket(plugin) - .teamName(teamName.length() > 16 ? teamName.substring(0, 16) : teamName) + .teamName(teamName) .mode(UpdateMode.ADD_PLAYERS) .entities(Arrays.asList(teamMembers)); } @@ -119,7 +127,7 @@ public class UpdateTeamsPacket implements MinecraftPacket { protected static UpdateTeamsPacket removeFromTeam(@NotNull Velocitab plugin, @NotNull String teamName, @NotNull String... teamMembers) { return new UpdateTeamsPacket(plugin) - .teamName(teamName.length() > 16 ? teamName.substring(0, 16) : teamName) + .teamName(teamName) .mode(UpdateMode.REMOVE_PLAYERS) .entities(Arrays.asList(teamMembers)); } @@ -127,7 +135,7 @@ public class UpdateTeamsPacket implements MinecraftPacket { @NotNull protected static UpdateTeamsPacket removeTeam(@NotNull Velocitab plugin, @NotNull String teamName) { return new UpdateTeamsPacket(plugin) - .teamName(teamName.length() > 16 ? teamName.substring(0, 16) : teamName) + .teamName(teamName) .mode(UpdateMode.REMOVE_TEAM); } @@ -144,10 +152,10 @@ public class UpdateTeamsPacket implements MinecraftPacket { text = text + "z"; //serialize & deserialize to downsample rgb to legacy - Component component = plugin.getFormatter().emptyFormat(text); + final Component component = plugin.getFormatter().deserialize(text); text = LegacyComponentSerializer.legacyAmpersand().serialize(component); - int lastFormatIndex = text.lastIndexOf("&"); + final int lastFormatIndex = text.lastIndexOf("&"); if (lastFormatIndex == -1 || lastFormatIndex == text.length() - 1) { return 15; } @@ -200,16 +208,21 @@ public class UpdateTeamsPacket implements MinecraftPacket { @Override public void decode(ByteBuf byteBuf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) { - throw new UnsupportedOperationException("Operation not supported"); + final Optional optionalManager = plugin.getScoreboardManager(); + if (optionalManager.isEmpty()) { + return; + } + + optionalManager.get().getPacketAdapter(protocolVersion).decode(byteBuf, this, protocolVersion); } @Override public void encode(ByteBuf byteBuf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) { final Optional optionalManager = plugin.getScoreboardManager(); - if (optionalManager.isEmpty()) { return; } + optionalManager.get().getPacketAdapter(protocolVersion).encode(byteBuf, this, protocolVersion); } diff --git a/src/main/java/net/william278/velocitab/player/TabPlayer.java b/src/main/java/net/william278/velocitab/player/TabPlayer.java index 596128b..c7e8e69 100644 --- a/src/main/java/net/william278/velocitab/player/TabPlayer.java +++ b/src/main/java/net/william278/velocitab/player/TabPlayer.java @@ -19,12 +19,16 @@ package net.william278.velocitab.player; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; import com.velocitypowered.api.proxy.Player; import lombok.Getter; import lombok.Setter; import lombok.ToString; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; import net.william278.velocitab.Velocitab; +import net.william278.velocitab.config.Formatter; import net.william278.velocitab.config.Group; import net.william278.velocitab.config.Placeholder; import net.william278.velocitab.packet.UpdateTeamsPacket; @@ -34,20 +38,29 @@ import org.apache.commons.lang3.ObjectUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Optional; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Getter @ToString public final class TabPlayer implements Comparable { + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("%(\\w+)%"); + private static final String PLACEHOLDER_DELIMITER = "<-DELIMITER->"; + private final Velocitab plugin; private final Player player; @Setter private Role role; private int headerIndex = 0; private int footerIndex = 0; - private Component lastDisplayName; + // Each TabPlayer contains the components for each TabPlayer it's currently viewing this player + private final Map relationalDisplayNames; + private final Map relationalNametags; + private final Map cachedPlaceholders; + private String lastDisplayName; private Component lastHeader; private Component lastFooter; private String teamName; @@ -72,6 +85,9 @@ public final class TabPlayer implements Comparable { this.player = player; this.role = role; this.group = group; + this.relationalDisplayNames = Maps.newConcurrentMap(); + this.relationalNametags = Maps.newConcurrentMap(); + this.cachedPlaceholders = Maps.newConcurrentMap(); } @NotNull @@ -115,10 +131,47 @@ public final class TabPlayer implements Comparable { } @NotNull - public CompletableFuture getDisplayName(@NotNull Velocitab plugin) { - return Placeholder.replace(group.format(), plugin, this) - .thenApply(formatted -> plugin.getFormatter().format(formatted, this, plugin)) - .thenApply(c -> this.lastDisplayName = c); + public CompletableFuture getDisplayName(@NotNull Velocitab plugin) { + final String format = formatGroup(); + return Placeholder.replace(format, plugin, this) + .thenApply(d -> cacheDisplayName(d, format)); + } + + @NotNull + private String formatGroup() { + final Set placeholders = Sets.newHashSet(); + final Matcher matcher = PLACEHOLDER_PATTERN.matcher(group.format()); + while (matcher.find()) { + placeholders.add("%" + matcher.group(1) + "%"); + } + + return String.join(PLACEHOLDER_DELIMITER, placeholders); + } + + @NotNull + private String cacheDisplayName(@NotNull String placeholders, @NotNull String keys) { + String displayName = group.format(); + final String[] placeholderArray = placeholders.split(PLACEHOLDER_DELIMITER); + final String[] keyArray = keys.split(PLACEHOLDER_DELIMITER); + + for (int i = 0; i < placeholderArray.length; i++) { + final String placeholder = keyArray[i]; + final String value = placeholderArray[i]; + cachedPlaceholders.put(placeholder, value); + displayName = displayName.replace(placeholder, value); + } + + displayName = displayName.replace("\n", ""); + final boolean isMiniMessage = plugin.getFormatter().equals(Formatter.MINIMESSAGE); + if (isMiniMessage) { + displayName = Formatter.LEGACY.serialize(MiniMessage.miniMessage().deserialize(displayName)); + } + displayName = Placeholder.replaceInternal(displayName, plugin, this); + if (isMiniMessage) { + displayName = MiniMessage.miniMessage().serialize(Formatter.LEGACY.deserialize(displayName)) + .replace("\\<", "<"); + } + return lastDisplayName = displayName; } @NotNull @@ -140,18 +193,18 @@ public final class TabPlayer implements Comparable { return tabList.getHeader(this).thenCompose(header -> tabList.getFooter(this).thenAccept(footer -> { final boolean disabled = plugin.getSettings().isDisableHeaderFooterIfEmpty(); if (disabled) { - if (!Component.empty().equals(header)) { + if ((!Component.empty().equals(header) && !header.equals(lastHeader)) || + (!Component.empty().equals(footer) && !footer.equals(lastFooter))) { lastHeader = header; - player.sendPlayerListHeader(header); - } - if (!Component.empty().equals(footer)) { lastFooter = footer; - player.sendPlayerListFooter(footer); + player.sendPlayerListHeaderAndFooter(header, footer); } } else { - lastHeader = header; - lastFooter = footer; - player.sendPlayerListHeaderAndFooter(header, footer); + if (!header.equals(lastHeader) || !footer.equals(lastFooter)) { + lastHeader = header; + lastFooter = footer; + player.sendPlayerListHeaderAndFooter(header, footer); + } } })); } @@ -175,6 +228,39 @@ public final class TabPlayer implements Comparable { } } + public void setRelationalDisplayName(@NotNull UUID target, @NotNull Component displayName) { + relationalDisplayNames.put(target, displayName); + } + + public void unsetRelationalDisplayName(@NotNull UUID target) { + relationalDisplayNames.remove(target); + } + + public Optional getRelationalDisplayName(@NotNull UUID target) { + return Optional.ofNullable(relationalDisplayNames.get(target)); + } + + public void setRelationalNametag(@NotNull UUID target, @NotNull Component prefix, @NotNull Component suffix) { + relationalNametags.put(target, new Component[]{prefix, suffix}); + } + + public void unsetRelationalNametag(@NotNull UUID target) { + relationalNametags.remove(target); + } + + public Optional getRelationalNametag(@NotNull UUID target) { + return Optional.ofNullable(relationalNametags.get(target)); + } + + public void clearCachedData() { + loaded = false; + relationalDisplayNames.clear(); + relationalNametags.clear(); + lastHeader = null; + lastFooter = null; + role = Role.DEFAULT_ROLE; + } + /** * Returns the custom name of the TabPlayer, if it has been set. * @@ -197,4 +283,8 @@ public final class TabPlayer implements Comparable { public boolean equals(Object obj) { return obj instanceof TabPlayer other && player.getUniqueId().equals(other.player.getUniqueId()); } + + public Optional getCachedPlaceholderValue(@NotNull String placeholder) { + return Optional.ofNullable(cachedPlaceholders.get(placeholder)); + } } diff --git a/src/main/java/net/william278/velocitab/providers/LoggerProvider.java b/src/main/java/net/william278/velocitab/providers/LoggerProvider.java index c3dbab7..92a7662 100644 --- a/src/main/java/net/william278/velocitab/providers/LoggerProvider.java +++ b/src/main/java/net/william278/velocitab/providers/LoggerProvider.java @@ -55,6 +55,7 @@ public interface LoggerProvider { getLogger().warn(message); } } + case DEBUG -> getLogger().debug(message); case INFO -> getLogger().info(message); } } diff --git a/src/main/java/net/william278/velocitab/providers/ScoreboardProvider.java b/src/main/java/net/william278/velocitab/providers/ScoreboardProvider.java index 4903971..81733fd 100644 --- a/src/main/java/net/william278/velocitab/providers/ScoreboardProvider.java +++ b/src/main/java/net/william278/velocitab/providers/ScoreboardProvider.java @@ -86,7 +86,7 @@ public interface ScoreboardProvider { */ default void prepareScoreboard() { if (getPlugin().getSettings().isSendScoreboardPackets()) { - ScoreboardManager scoreboardManager = new ScoreboardManager(getPlugin()); + final ScoreboardManager scoreboardManager = new ScoreboardManager(getPlugin()); setScoreboardManager(scoreboardManager); scoreboardManager.registerPacket(); } diff --git a/src/main/java/net/william278/velocitab/sorting/SortingManager.java b/src/main/java/net/william278/velocitab/sorting/SortingManager.java index d3c3576..21b4b49 100644 --- a/src/main/java/net/william278/velocitab/sorting/SortingManager.java +++ b/src/main/java/net/william278/velocitab/sorting/SortingManager.java @@ -20,6 +20,7 @@ package net.william278.velocitab.sorting; import com.google.common.collect.Lists; +import com.velocitypowered.api.network.ProtocolVersion; import net.william278.velocitab.Velocitab; import net.william278.velocitab.config.Placeholder; import net.william278.velocitab.player.TabPlayer; @@ -49,7 +50,7 @@ public class SortingManager { return Placeholder.replace(String.join(DELIMITER, player.getGroup().sortingPlaceholders()), plugin, player) .thenApply(s -> Arrays.asList(s.split(DELIMITER))) - .thenApply(v -> v.stream().map(this::adaptValue).collect(Collectors.toList())) + .thenApply(v -> v.stream().map(s -> adaptValue(s, player)).collect(Collectors.toList())) .thenApply(v -> handleList(player, v)); } @@ -57,7 +58,7 @@ public class SortingManager { private String handleList(@NotNull TabPlayer player, @NotNull List values) { String result = String.join("", values); - if (result.length() > 12) { + if (result.length() > 12 && isLongTeamNotAllowed(player)) { result = result.substring(0, 12); } @@ -66,8 +67,13 @@ public class SortingManager { return result; } + private boolean isLongTeamNotAllowed(@NotNull TabPlayer player) { + return !player.getGroup().getPlayers(plugin, player).stream() + .allMatch(t -> t.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_18)); + } + @NotNull - private String adaptValue(@NotNull String value) { + private String adaptValue(@NotNull String value, @NotNull TabPlayer player) { if (value.isEmpty()) { return ""; } @@ -78,7 +84,7 @@ public class SortingManager { return compressNumber(Integer.MAX_VALUE / 4d - parsed); } - if (value.length() > 6) { + if (value.length() > 6 && isLongTeamNotAllowed(player)) { return value.substring(0, 4); } diff --git a/src/main/java/net/william278/velocitab/tab/Nametag.java b/src/main/java/net/william278/velocitab/tab/Nametag.java index 387754f..2d80a38 100644 --- a/src/main/java/net/william278/velocitab/tab/Nametag.java +++ b/src/main/java/net/william278/velocitab/tab/Nametag.java @@ -30,13 +30,13 @@ import org.jetbrains.annotations.NotNull; public record Nametag(@NotNull String prefix, @NotNull String suffix) { @NotNull - public Component getPrefixComponent(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer) { - return plugin.getFormatter().format(prefix, tabPlayer, plugin); + public Component getPrefixComponent(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, @NotNull TabPlayer target) { + return plugin.getFormatter().format(prefix, tabPlayer, target, plugin); } @NotNull - public Component getSuffixComponent(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer) { - return plugin.getFormatter().format(suffix, tabPlayer, plugin); + public Component getSuffixComponent(@NotNull Velocitab plugin, @NotNull TabPlayer tabPlayer, @NotNull TabPlayer target) { + return plugin.getFormatter().format(suffix, tabPlayer, target, plugin); } @Override diff --git a/src/main/java/net/william278/velocitab/tab/PlayerTabList.java b/src/main/java/net/william278/velocitab/tab/PlayerTabList.java index 525c716..d96fbaa 100644 --- a/src/main/java/net/william278/velocitab/tab/PlayerTabList.java +++ b/src/main/java/net/william278/velocitab/tab/PlayerTabList.java @@ -26,7 +26,6 @@ import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.player.TabList; import com.velocitypowered.api.proxy.player.TabListEntry; import com.velocitypowered.api.proxy.server.RegisteredServer; -import com.velocitypowered.api.scheduler.ScheduledTask; import lombok.AccessLevel; import lombok.Getter; import net.kyori.adventure.text.Component; @@ -45,6 +44,7 @@ import java.lang.reflect.Field; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * The main class for tracking the server TAB list @@ -55,15 +55,16 @@ public class PlayerTabList { private final VanishTabList vanishTabList; @Getter(value = AccessLevel.PUBLIC) private final Map players; - private final Map groupTasks; + private final TaskManager taskManager; public PlayerTabList(@NotNull Velocitab plugin) { this.plugin = plugin; this.vanishTabList = new VanishTabList(plugin, this); this.players = Maps.newConcurrentMap(); - this.groupTasks = Maps.newConcurrentMap(); + this.taskManager = new TaskManager(plugin); this.reloadUpdate(); this.registerListener(); + this.ensureDisplayNameTask(); } private void registerListener() { @@ -102,16 +103,12 @@ public class PlayerTabList { } final String serverName = server.get().getServerInfo().getName(); - final Group group = getGroup(serverName); - final boolean isDefault = group.registeredServers(plugin) - .stream() - .noneMatch(s -> s.getServerInfo().getName().equals(serverName)); - - if (isDefault && !plugin.getSettings().isFallbackEnabled()) { + final @NotNull Optional group = getGroup(serverName); + if (group.isEmpty()) { return; } - joinPlayer(p, group); + joinPlayer(p, group.get()); }); } @@ -120,7 +117,7 @@ public class PlayerTabList { * Removes the player's entry from the tab list of all other players on the same group servers. */ public void close() { - groupTasks.values().forEach(GroupTasks::cancel); + taskManager.cancelAllTasks(); plugin.getServer().getAllPlayers().forEach(p -> { final Optional server = p.getCurrentServer(); if (server.isEmpty()) return; @@ -140,88 +137,38 @@ public class PlayerTabList { }); } + protected void clearCachedData(@NotNull Player player) { + players.values().forEach(p -> { + p.unsetRelationalDisplayName(player.getUniqueId()); + p.unsetRelationalNametag(player.getUniqueId()); + }); + } + protected void joinPlayer(@NotNull Player joined, @NotNull Group group) { // Add the player to the tracking list if they are not already listed - final TabPlayer tabPlayer = getTabPlayer(joined).orElseGet(() -> createTabPlayer(joined, group)); - final boolean isVanished = plugin.getVanishManager().isVanished(joined.getUsername()); - tabPlayer.setGroup(group); - players.putIfAbsent(joined.getUniqueId(), tabPlayer); - - // Store the player's last server, so it's possible to have the last server on disconnect + final Optional tabPlayerOptional = getTabPlayer(joined); + if (tabPlayerOptional.isPresent()) { + tabPlayerOptional.get().clearCachedData(); + tabPlayerOptional.get().setGroup(group); + tabPlayerOptional.get().setRole(plugin.getLuckPermsHook().map(hook -> hook.getPlayerRole(joined)).orElse(Role.DEFAULT_ROLE)); + } + final TabPlayer tabPlayer = tabPlayerOptional.orElseGet(() -> createTabPlayer(joined, group)); final String serverName = getServerName(joined); + // Store last server, so it's possible to have the last server on disconnect tabPlayer.setLastServer(serverName); // Send server URLs (1.21 clients) sendPlayerServerLinks(tabPlayer); - // Determine display name, update TAB for player + // Set the player as not loaded until the display name is set tabPlayer.getDisplayName(plugin).thenAccept(d -> { - joined.getTabList().getEntry(joined.getUniqueId()) - .ifPresentOrElse(e -> e.setDisplayName(d), - () -> joined.getTabList().addEntry(createEntry(tabPlayer, joined.getTabList(), d))); + if (d == null) { + plugin.log(Level.ERROR, "Failed to get display name for " + joined.getUsername()); + return; + } - tabPlayer.sendHeaderAndFooter(this) - .thenAccept(v -> tabPlayer.setLoaded(true)) - .exceptionally(throwable -> { - plugin.log(Level.ERROR, String.format("Failed to send header and footer for %s (UUID: %s)", - joined.getUsername(), joined.getUniqueId()), throwable); - return null; - }); - - final Set serversInGroup = group.registeredServers(plugin).stream() - .map(server -> server.getServerInfo().getName()) - .collect(HashSet::new, HashSet::add, HashSet::addAll); - serversInGroup.remove(serverName); - - // Update lists - plugin.getServer().getScheduler() - .buildTask(plugin, () -> { - final TabList tabList = joined.getTabList(); - final Set tabPlayers = group.getTabPlayers(plugin); - for (final TabPlayer player : tabPlayers) { - // Skip players on other servers if the setting is enabled - if (group.onlyListPlayersInSameServer() && !serverName.equals(getServerName(player.getPlayer()))) { - continue; - } - // check if current player can see the joined player - if (!isVanished || plugin.getVanishManager().canSee(player.getPlayer().getUsername(), joined.getUsername())) { - addPlayerToTabList(player, tabPlayer, d); - } else { - player.getPlayer().getTabList().removeEntry(joined.getUniqueId()); - } - // check if joined player can see current player - if ((plugin.getVanishManager().isVanished(player.getPlayer().getUsername()) && - !plugin.getVanishManager().canSee(joined.getUsername(), player.getPlayer().getUsername())) && - player.getPlayer() != joined) { - tabList.removeEntry(player.getPlayer().getUniqueId()); - } else { - tabList.getEntry(player.getPlayer().getUniqueId()).ifPresentOrElse( - entry -> player.getDisplayName(plugin).thenAccept(entry::setDisplayName) - .exceptionally(throwable -> { - plugin.log(Level.ERROR, String.format("Failed to set display name for %s (UUID: %s)", - player.getPlayer().getUsername(), player.getPlayer().getUniqueId()), throwable); - return null; - }), - () -> createEntry(player, tabList).thenAccept(tabList::addEntry) - ); - } - - player.sendHeaderAndFooter(this); - } - - plugin.getScoreboardManager().ifPresent(s -> { - s.resendAllTeams(tabPlayer); - tabPlayer.getTeamName(plugin).thenAccept(t -> s.updateRole(tabPlayer, t, false)); - }); - - fixDuplicateEntries(joined); - - // Fire event without listening for result - plugin.getServer().getEventManager().fireAndForget(new PlayerAddedToTabEvent(tabPlayer, group)); - }) - .delay(300, TimeUnit.MILLISECONDS) - .schedule(); + handleDisplayLoad(tabPlayer); }).exceptionally(throwable -> { plugin.log(Level.ERROR, String.format("Failed to set display name for %s (UUID: %s)", joined.getUsername(), joined.getUniqueId()), throwable); @@ -229,6 +176,74 @@ public class PlayerTabList { }); } + private void handleDisplayLoad(@NotNull TabPlayer tabPlayer) { + final Player joined = tabPlayer.getPlayer(); + final Group group = tabPlayer.getGroup(); + final boolean isVanished = plugin.getVanishManager().isVanished(joined.getUsername()); + players.putIfAbsent(joined.getUniqueId(), tabPlayer); + tabPlayer.sendHeaderAndFooter(this) + .thenAccept(v -> tabPlayer.setLoaded(true)) + .exceptionally(throwable -> { + plugin.log(Level.ERROR, String.format("Failed to send header and footer for %s (UUID: %s)", + joined.getUsername(), joined.getUniqueId()), throwable); + return null; + }); + + final Set tabPlayers = group.getTabPlayers(plugin, tabPlayer); + updateTabListOnJoin(tabPlayer, group, tabPlayers, isVanished); + } + + private void updateTabListOnJoin(@NotNull TabPlayer tabPlayer, @NotNull Group group, + @NotNull Set tabPlayers, boolean isJoinedVanished) { + final Player joined = tabPlayer.getPlayer(); + final String serverName = getServerName(joined); + final Set uuids = tabPlayers.stream().map(p -> p.getPlayer().getUniqueId()).collect(Collectors.toSet()); + List.copyOf(tabPlayer.getPlayer().getTabList().getEntries()).forEach(entry -> { + if (!uuids.contains(entry.getProfile().getId())) { + tabPlayer.getPlayer().getTabList().removeEntry(entry.getProfile().getId()); + } + }); + for (final TabPlayer iteratedPlayer : tabPlayers) { + final Player player = iteratedPlayer.getPlayer(); + final String username = player.getUsername(); + final boolean isPlayerVanished = plugin.getVanishManager().isVanished(username); + + if (group.onlyListPlayersInSameServer() && !serverName.equals(getServerName(player))) { + continue; + } + + // Update lists regarding the joined player + checkVisibilityAndUpdateName(iteratedPlayer, tabPlayer, isJoinedVanished); + // Update lists regarding the iterated player + if (iteratedPlayer != tabPlayer) { + checkVisibilityAndUpdateName(tabPlayer, iteratedPlayer, isPlayerVanished); + } + iteratedPlayer.sendHeaderAndFooter(this); + } + plugin.getScoreboardManager().ifPresent(s -> { + s.resendAllTeams(tabPlayer); + tabPlayer.getTeamName(plugin).thenAccept(t -> s.updateRole(tabPlayer, t, false)); + }); + fixDuplicateEntries(joined); + // Fire event without listening for result + plugin.getServer().getEventManager().fireAndForget(new PlayerAddedToTabEvent(tabPlayer, group)); + } + + private void checkVisibilityAndUpdateName(@NotNull TabPlayer observedPlayer, @NotNull TabPlayer observableTabPlayer, + boolean isObservablePlayerVanished) { + final UUID observableUUID = observableTabPlayer.getPlayer().getUniqueId(); + final String observedUsername = observedPlayer.getPlayer().getUsername(); + final String observableUsername = observableTabPlayer.getPlayer().getUsername(); + final TabList observableTabPlayerTabList = observableTabPlayer.getPlayer().getTabList(); + + if (isObservablePlayerVanished && !plugin.getVanishManager().canSee(observableUsername, observedUsername) && + !observableUUID.equals(observedPlayer.getPlayer().getUniqueId())) { + observableTabPlayerTabList.removeEntry(observedPlayer.getPlayer().getUniqueId()); + } else { + updateDisplayName(observedPlayer, observableTabPlayer); + } + } + @NotNull private String getServerName(@NotNull Player player) { return player.getCurrentServer() @@ -236,6 +251,21 @@ public class PlayerTabList { .orElse(""); } + @NotNull + public Component getRelationalPlaceholder(@NotNull TabPlayer player, @NotNull TabPlayer viewer, + @NotNull Component single, @NotNull String toParse) { + if (plugin.getMiniPlaceholdersHook().isEmpty()) { + return single; + } + return plugin.getFormatter().format(toParse, player, viewer, plugin); + } + + @NotNull + public Component getRelationalPlaceholder(@NotNull TabPlayer player, @NotNull TabPlayer viewer, @NotNull String toParse) { + final Component single = plugin.getFormatter().format(toParse, player, viewer, plugin); + return getRelationalPlaceholder(player, viewer, single, toParse); + } + @SuppressWarnings("unchecked") private void fixDuplicateEntries(@NotNull Player target) { try { @@ -248,7 +278,7 @@ public class PlayerTabList { .filter(entry -> !entry.getKey().equals(target.getUniqueId())) .forEach(entry -> target.getTabList().removeEntry(entry.getKey())); } catch (Throwable error) { - plugin.log(Level.ERROR, "Failed to fix duplicate entries", error); + plugin.log(Level.ERROR, "Failed to fix duplicate entries for class " + target.getTabList().getClass().getName() , error); } } @@ -273,8 +303,9 @@ public class PlayerTabList { .forEach(player -> { player.getPlayer().getTabList().removeEntry(uuid); player.sendHeaderAndFooter(this); + updatePlayerDisplayName(player); })) - .delay(500, TimeUnit.MILLISECONDS) + .delay(250, TimeUnit.MILLISECONDS) .schedule(); // Delete player team plugin.getScoreboardManager().ifPresent(manager -> manager.resetCache(target)); @@ -283,10 +314,6 @@ public class PlayerTabList { } @NotNull - protected CompletableFuture createEntry(@NotNull TabPlayer player, @NotNull TabList tabList) { - return player.getDisplayName(plugin).thenApply(name -> createEntry(player, tabList, name)); - } - protected TabListEntry createEntry(@NotNull TabPlayer player, @NotNull TabList tabList, @NotNull Component displayName) { return TabListEntry.builder() .profile(player.getPlayer().getGameProfile()) @@ -296,25 +323,43 @@ public class PlayerTabList { .build(); } - private void addPlayerToTabList(@NotNull TabPlayer player, @NotNull TabPlayer newPlayer, @NotNull Component displayName) { - if (newPlayer.getPlayer().getUniqueId().equals(player.getPlayer().getUniqueId())) { + @NotNull + protected TabListEntry createEntry(@NotNull TabPlayer player, @NotNull TabList tabList, @NotNull TabPlayer viewer) { + if (!viewer.getPlayer().getTabList().equals(tabList)) { + throw new IllegalArgumentException("TabList of viewer is not the same as the TabList of the entry"); + } + final Component single = plugin.getFormatter().format(player.getLastDisplayName(), player, viewer, plugin); + final Component displayName = getRelationalPlaceholder(player, viewer, single, player.getGroup().format()); + player.setRelationalDisplayName(viewer.getPlayer().getUniqueId(), displayName); + return TabListEntry.builder() + .profile(player.getPlayer().getGameProfile()) + .displayName(displayName) + .latency(Math.max((int) player.getPlayer().getPing(), 0)) + .tabList(tabList) + .build(); + } + + protected void updateDisplayName(@NotNull TabPlayer player, @NotNull TabPlayer viewer) { + final Component displayName = getRelationalPlaceholder(player, viewer, player.getLastDisplayName()); + updateDisplayName(player, viewer, displayName); + } + + protected void updateDisplayName(@NotNull TabPlayer player, @NotNull TabPlayer viewer, @NotNull Component displayName) { + final Optional cached = player.getRelationalDisplayName(viewer.getPlayer().getUniqueId()); + if (cached.isPresent() && cached.get().equals(displayName) && + viewer.getPlayer().getTabList().getEntry(player.getPlayer().getUniqueId()) + .flatMap(TabListEntry::getDisplayNameComponent).map(displayName::equals) + .orElse(false) + ) { return; } - plugin.getPacketEventManager().getVelocitabEntries().add(newPlayer.getPlayer().getUniqueId()); - - plugin.getServer().getScheduler() - .buildTask(plugin, () -> plugin.getPacketEventManager().getVelocitabEntries().remove(newPlayer.getPlayer().getUniqueId())) - .delay(500, TimeUnit.MILLISECONDS) - .schedule(); - - player.getPlayer() - .getTabList().getEntries().stream() - .filter(e -> e.getProfile().getId().equals(newPlayer.getPlayer().getUniqueId())).findFirst() + player.setRelationalDisplayName(viewer.getPlayer().getUniqueId(), displayName); + viewer.getPlayer().getTabList().getEntry(player.getPlayer().getUniqueId()) .ifPresentOrElse( entry -> entry.setDisplayName(displayName), - () -> player.getPlayer().getTabList() - .addEntry(createEntry(newPlayer, player.getPlayer().getTabList(), displayName)) + () -> viewer.getPlayer().getTabList() + .addEntry(createEntry(player, viewer.getPlayer().getTabList(), displayName)) ); } @@ -353,11 +398,12 @@ public class PlayerTabList { } public void updatePlayerDisplayName(@NotNull TabPlayer tabPlayer) { - final Component lastDisplayName = tabPlayer.getLastDisplayName(); tabPlayer.getDisplayName(plugin).thenAccept(displayName -> { - if (displayName == null || displayName.equals(lastDisplayName)) { + if (displayName == null) { + plugin.log(Level.ERROR, "Failed to get display name for " + tabPlayer.getPlayer().getUsername()); return; } + final Component single = plugin.getFormatter().format(displayName, tabPlayer, plugin); final boolean isVanished = plugin.getVanishManager().isVanished(tabPlayer.getPlayer().getUsername()); final Set players = tabPlayer.getGroup().getTabPlayers(plugin, tabPlayer); @@ -367,18 +413,51 @@ public class PlayerTabList { return; } - player.getPlayer().getTabList().getEntries().stream() - .filter(e -> e.getProfile().getId().equals(tabPlayer.getPlayer().getUniqueId())).findFirst() - .ifPresent(entry -> entry.setDisplayName(displayName)); + final Component relationalPlaceholder = getRelationalPlaceholder(tabPlayer, player, single, displayName); + updateDisplayName(tabPlayer, player, relationalPlaceholder); }); }); } + public void checkCorrectDisplayName(@NotNull TabPlayer tabPlayer) { + if (!tabPlayer.isLoaded()) { + return; + } + players.values() + .stream() + .filter(TabPlayer::isLoaded) + .forEach(player -> player.getPlayer().getTabList().getEntry(tabPlayer.getPlayer().getUniqueId()) + .ifPresent(entry -> { + final Optional displayNameOptional = tabPlayer.getRelationalDisplayName(player.getPlayer().getUniqueId()); + if (displayNameOptional.isEmpty()) { + return; + } + + final Component lastDisplayName = displayNameOptional.get(); + if (entry.getDisplayNameComponent().isEmpty() || !lastDisplayName.equals(entry.getDisplayNameComponent().get())) { + entry.setDisplayName(lastDisplayName); + } + })); + } + + // Update the display names of all listed players public void updateDisplayNames() { players.values().forEach(this::updatePlayerDisplayName); } + public void checkCorrectDisplayNames() { + players.values().forEach(this::checkCorrectDisplayName); + } + + public void ensureDisplayNameTask() { + plugin.getServer().getScheduler() + .buildTask(plugin, this::checkCorrectDisplayNames) + .delay(1, TimeUnit.SECONDS) + .repeat(2, TimeUnit.SECONDS) + .schedule(); + } + // Get the component for the TAB list header public CompletableFuture getHeader(@NotNull TabPlayer player) { final String header = player.getGroup().getHeader(player.getHeaderIndex()); @@ -395,96 +474,11 @@ public class PlayerTabList { .thenApply(replaced -> plugin.getFormatter().format(replaced, player, plugin)); } - // Update the tab list periodically - private void updatePeriodically(@NotNull Group group) { - cancelTasks(group); - - ScheduledTask headerFooterTask = null; - ScheduledTask updateTask = null; - ScheduledTask latencyTask; - - if (group.headerFooterUpdateRate() > 0) { - headerFooterTask = plugin.getServer().getScheduler() - .buildTask(plugin, () -> updateGroupPlayers(group, false, true)) - .delay(1, TimeUnit.SECONDS) - .repeat(Math.max(200, group.headerFooterUpdateRate()), TimeUnit.MILLISECONDS) - .schedule(); - } - - if (group.placeholderUpdateRate() > 0) { - updateTask = plugin.getServer().getScheduler() - .buildTask(plugin, () -> updateGroupPlayers(group, true, false)) - .delay(1, TimeUnit.SECONDS) - .repeat(Math.max(200, group.placeholderUpdateRate()), TimeUnit.MILLISECONDS) - .schedule(); - } - - latencyTask = plugin.getServer().getScheduler() - .buildTask(plugin, () -> updateLatency(group)) - .delay(1, TimeUnit.SECONDS) - .repeat(3, TimeUnit.SECONDS) - .schedule(); - - groupTasks.put(group, new GroupTasks(headerFooterTask, updateTask, latencyTask)); - } - - private void updateLatency(@NotNull Group group) { - final Set groupPlayers = group.getTabPlayers(plugin); - if (groupPlayers.isEmpty()) { - return; - } - groupPlayers.stream() - .filter(player -> player.getPlayer().isActive()) - .forEach(player -> { - final int latency = (int) player.getPlayer().getPing(); - final Set players = group.getTabPlayers(plugin, player); - players.forEach(p -> p.getPlayer().getTabList().getEntries().stream() - .filter(e -> e.getProfile().getId().equals(player.getPlayer().getUniqueId())).findFirst() - .ifPresent(entry -> entry.setLatency(Math.max(latency, 0)))); - }); - } - - /** - * Updates the players in the given group. - * - * @param group The group whose players should be updated. - * @param all Whether to update all player properties, or just the header and footer. - * @param incrementIndexes Whether to increment the header and footer indexes. - */ - private void updateGroupPlayers(@NotNull Group group, boolean all, boolean incrementIndexes) { - final Set groupPlayers = group.getTabPlayers(plugin); - if (groupPlayers.isEmpty()) { - return; - } - groupPlayers.stream() - .filter(player -> player.getPlayer().isActive()) - .forEach(player -> { - if (incrementIndexes) { - player.incrementIndexes(); - } - if (all) { - this.updatePlayer(player, false); - } - player.sendHeaderAndFooter(this); - }); - if (all) { - updateDisplayNames(); - } - } - - private void cancelTasks(@NotNull Group group) { - final GroupTasks tasks = groupTasks.get(group); - if (tasks != null) { - tasks.cancel(); - groupTasks.remove(group); - } - } - /** * Update the TAB list for all players when a plugin or proxy reload is performed */ public void reloadUpdate() { - plugin.getTabGroups().getGroups().forEach(this::updatePeriodically); + plugin.getTabGroups().getGroups().forEach(taskManager::updatePeriodically); if (players.isEmpty()) { return; } @@ -496,8 +490,11 @@ public class PlayerTabList { return; } final String serverName = server.get().getServerInfo().getName(); - final Group group = getGroup(serverName); - player.setGroup(group); + final Optional group = getGroup(serverName); + if (group.isEmpty()) { + return; + } + player.setGroup(group.get()); this.sendPlayerServerLinks(player); this.updatePlayer(player, true); player.sendHeaderAndFooter(this); @@ -506,7 +503,7 @@ public class PlayerTabList { } @NotNull - public Group getGroup(@NotNull String serverName) { + public Optional getGroup(@NotNull String serverName) { return plugin.getTabGroups().getGroupFromServer(serverName, plugin); } diff --git a/src/main/java/net/william278/velocitab/tab/TabListListener.java b/src/main/java/net/william278/velocitab/tab/TabListListener.java index 6db6e41..70a2345 100644 --- a/src/main/java/net/william278/velocitab/tab/TabListListener.java +++ b/src/main/java/net/william278/velocitab/tab/TabListListener.java @@ -68,8 +68,12 @@ public class TabListListener { tabList.removePlayer(event.getPlayer()); } else if (event.getResult() instanceof KickedFromServerEvent.RedirectPlayer redirectPlayer) { tabList.removePlayer(event.getPlayer(), redirectPlayer.getServer()); + } else if (event.getResult() instanceof KickedFromServerEvent.Notify notify) { + return; } + event.getPlayer().getTabList().removeEntry(event.getPlayer().getUniqueId()); + event.getPlayer().getTabList().clearHeaderAndFooter(); justQuit.add(event.getPlayer().getUniqueId()); plugin.getServer().getScheduler().buildTask(plugin, @@ -88,14 +92,15 @@ public class TabListListener { .orElse(""); // Get the group the player should now be in - final Group group = tabList.getGroup(serverName); - plugin.getScoreboardManager().ifPresent(manager -> manager.resetCache(joined, group)); - final boolean isDefault = group.registeredServers(plugin).stream() - .noneMatch(server -> server.getServerInfo().getName().equalsIgnoreCase(serverName)); + final @NotNull Optional groupOptional = tabList.getGroup(serverName); + final boolean isDefault = groupOptional.map(g -> g.isDefault(plugin)).orElse(false); + + // Removes cached relational data of the joined player from all other players + plugin.getTabList().clearCachedData(joined); // If the server is not in a group, use fallback. // If fallback is disabled, permit the player to switch excluded servers without a header or footer override - if (isDefault && !plugin.getSettings().isFallbackEnabled()) { + if (isDefault && !plugin.getSettings().isFallbackEnabled() && !groupOptional.map(g -> g.containsServer(plugin, serverName)).orElse(false)) { final Optional tabPlayer = tabList.getTabPlayer(joined); if (tabPlayer.isEmpty()) { return; @@ -107,7 +112,6 @@ public class TabListListener { final Component header = tabPlayer.get().getLastHeader(); final Component footer = tabPlayer.get().getLastFooter(); - final Component displayName = tabPlayer.get().getLastDisplayName(); plugin.getServer().getScheduler().buildTask(plugin, () -> { final Component currentHeader = joined.getPlayerListHeader(); @@ -126,6 +130,12 @@ public class TabListListener { return; } + if (groupOptional.isEmpty()) { + return; + } + + final Group group = groupOptional.get(); + plugin.getScoreboardManager().ifPresent(manager -> manager.resetCache(joined, group)); if (justQuit.contains(joined.getUniqueId())) { plugin.getServer().getScheduler().buildTask(plugin, () -> tabList.joinPlayer(joined, group)) @@ -159,7 +169,6 @@ public class TabListListener { return; } tabList.removePlayer(player); - plugin.log("Player " + player.getUsername() + " was not removed from the tab list, removing now."); }).delay(500, TimeUnit.MILLISECONDS).schedule(); } diff --git a/src/main/java/net/william278/velocitab/tab/TaskManager.java b/src/main/java/net/william278/velocitab/tab/TaskManager.java new file mode 100644 index 0000000..76b6d87 --- /dev/null +++ b/src/main/java/net/william278/velocitab/tab/TaskManager.java @@ -0,0 +1,132 @@ +/* + * This file is part of Velocitab, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.velocitab.tab; + +import com.google.common.collect.Maps; +import com.velocitypowered.api.scheduler.ScheduledTask; +import net.william278.velocitab.Velocitab; +import net.william278.velocitab.config.Group; +import net.william278.velocitab.player.TabPlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class TaskManager { + + private final Velocitab plugin; + private final Map groupTasks; + + public TaskManager(@NotNull Velocitab plugin) { + this.plugin = plugin; + this.groupTasks = Maps.newConcurrentMap(); + } + + protected void cancelAllTasks() { + groupTasks.values().forEach(GroupTasks::cancel); + groupTasks.clear(); + } + + protected void updatePeriodically(@NotNull Group group) { + cancelTasks(group); + + ScheduledTask headerFooterTask = null; + ScheduledTask updateTask = null; + ScheduledTask latencyTask; + + if (group.headerFooterUpdateRate() > 0) { + headerFooterTask = plugin.getServer().getScheduler() + .buildTask(plugin, () -> updateGroupPlayers(group, false, true)) + .delay(1, TimeUnit.SECONDS) + .repeat(Math.max(200, group.headerFooterUpdateRate()), TimeUnit.MILLISECONDS) + .schedule(); + } + + if (group.placeholderUpdateRate() > 0) { + updateTask = plugin.getServer().getScheduler() + .buildTask(plugin, () -> updateGroupPlayers(group, true, false)) + .delay(1, TimeUnit.SECONDS) + .repeat(Math.max(200, group.placeholderUpdateRate()), TimeUnit.MILLISECONDS) + .schedule(); + } + + latencyTask = plugin.getServer().getScheduler() + .buildTask(plugin, () -> updateLatency(group)) + .delay(1, TimeUnit.SECONDS) + .repeat(3, TimeUnit.SECONDS) + .schedule(); + + groupTasks.put(group, new GroupTasks(headerFooterTask, updateTask, latencyTask)); + } + + /** + * Updates the players in the given group. + * + * @param group The group whose players should be updated. + * @param all Whether to update all player properties, or just the header and footer. + * @param incrementIndexes Whether to increment the header and footer indexes. + */ + private void updateGroupPlayers(@NotNull Group group, boolean all, boolean incrementIndexes) { + final Set groupPlayers = group.getTabPlayers(plugin); + if (groupPlayers.isEmpty()) { + return; + } + groupPlayers.stream() + .filter(player -> player.getPlayer().isActive()) + .forEach(player -> { + if (incrementIndexes) { + player.incrementIndexes(); + } + if (all) { + plugin.getTabList().updatePlayer(player, false); + } + player.sendHeaderAndFooter(plugin.getTabList()); + }); + if (all) { + plugin.getTabList().updateDisplayNames(); + } + } + + private void updateLatency(@NotNull Group group) { + final Set groupPlayers = group.getTabPlayers(plugin); + if (groupPlayers.isEmpty()) { + return; + } + groupPlayers.stream() + .filter(player -> player.getPlayer().isActive()) + .forEach(player -> { + final int latency = (int) player.getPlayer().getPing(); + final Set players = group.getTabPlayers(plugin, player); + players.forEach(p -> p.getPlayer().getTabList().getEntry(player.getPlayer().getUniqueId()) + .ifPresent(entry -> entry.setLatency(Math.max(latency, 0)))); + }); + } + + private void cancelTasks(@NotNull Group group) { + groupTasks.entrySet() + .stream() + .filter(entry -> entry.getKey().name().equals(group.name())) + .map(Map.Entry::getValue) + .findFirst().ifPresent(GroupTasks::cancel); + } + + +} diff --git a/src/main/java/net/william278/velocitab/tab/VanishTabList.java b/src/main/java/net/william278/velocitab/tab/VanishTabList.java index 89907bd..4f692cb 100644 --- a/src/main/java/net/william278/velocitab/tab/VanishTabList.java +++ b/src/main/java/net/william278/velocitab/tab/VanishTabList.java @@ -20,6 +20,8 @@ package net.william278.velocitab.tab; import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.player.TabListEntry; +import lombok.RequiredArgsConstructor; import net.william278.velocitab.Velocitab; import net.william278.velocitab.player.TabPlayer; import org.jetbrains.annotations.NotNull; @@ -30,17 +32,12 @@ import java.util.UUID; /** * The VanishTabList handles the tab list for vanished players */ +@RequiredArgsConstructor public class VanishTabList { private final Velocitab plugin; private final PlayerTabList tabList; - public VanishTabList(Velocitab plugin, PlayerTabList tabList) { - this.plugin = plugin; - this.tabList = tabList; - } - - public void vanishPlayer(@NotNull TabPlayer tabPlayer) { tabList.getPlayers().values().forEach(p -> { if (p.getPlayer().equals(tabPlayer.getPlayer())) { @@ -56,17 +53,17 @@ public class VanishTabList { public void unVanishPlayer(@NotNull TabPlayer tabPlayer) { final UUID uuid = tabPlayer.getPlayer().getUniqueId(); - tabPlayer.getDisplayName(plugin).thenAccept(c -> tabList.getPlayers().values().forEach(p -> { + tabList.getPlayers().values().forEach(p -> { if (p.getPlayer().equals(tabPlayer.getPlayer())) { return; } if (!p.getPlayer().getTabList().containsEntry(uuid)) { - p.getPlayer().getTabList().addEntry(tabList.createEntry(tabPlayer, p.getPlayer().getTabList(), c)); + tabList.createEntry(tabPlayer, p.getPlayer().getTabList(), p); } else { - p.getPlayer().getTabList().getEntry(uuid).ifPresent(entry -> entry.setDisplayName(c)); + tabList.updateDisplayName(tabPlayer, p); } - })); + }); } @@ -104,10 +101,9 @@ public class VanishTabList { plugin.getScoreboardManager().ifPresent(s -> s.recalculateVanishForPlayer(tabPlayer, target, false)); } else { if (!player.getTabList().containsEntry(p.getUniqueId())) { - tabList.createEntry(target, player.getTabList()).thenAccept(e -> { - player.getTabList().addEntry(e); - plugin.getScoreboardManager().ifPresent(s -> s.recalculateVanishForPlayer(tabPlayer, target, true)); - }); + final TabListEntry tabListEntry = tabList.createEntry(target, player.getTabList(), tabPlayer); + player.getTabList().addEntry(tabListEntry); + plugin.getScoreboardManager().ifPresent(s -> s.recalculateVanishForPlayer(tabPlayer, target, true)); } } }); diff --git a/src/main/java/net/william278/velocitab/util/QuadFunction.java b/src/main/java/net/william278/velocitab/util/QuadFunction.java new file mode 100644 index 0000000..b7c07b3 --- /dev/null +++ b/src/main/java/net/william278/velocitab/util/QuadFunction.java @@ -0,0 +1,35 @@ +/* + * This file is part of Velocitab, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.velocitab.util; + +@FunctionalInterface +public interface QuadFunction { + + /** + * Applies this function to the given arguments. + * + * @param t the first function argument + * @param u the second function argument + * @param v the third function argument + * @param s the fourth function argument + * @return the function result + */ + R apply(T t, U u, V v, S s); +} \ No newline at end of file diff --git a/src/main/java/net/william278/velocitab/util/SerializerUtil.java b/src/main/java/net/william278/velocitab/util/SerializerUtil.java new file mode 100644 index 0000000..6f863b7 --- /dev/null +++ b/src/main/java/net/william278/velocitab/util/SerializerUtil.java @@ -0,0 +1,32 @@ +/* + * This file is part of Velocitab, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.velocitab.util; + +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +public final class SerializerUtil { + + public final static LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.builder() + .hexCharacter('#') + .character('&') + .hexColors() + .build(); + +}