diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java b/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java index f2790c4e..9d2c700e 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java @@ -2,6 +2,7 @@ import net.kyori.adventure.permission.PermissionChecker; import net.kyori.adventure.pointer.Pointers; +import net.kyori.adventure.text.Component; import net.kyori.adventure.util.TriState; import net.luckperms.api.LuckPermsProvider; import net.luckperms.api.model.user.User; @@ -11,6 +12,7 @@ import net.minestom.server.network.player.PlayerConnection; import net.onelitefeather.cygnus.common.permission.LuckPermsSupport; import net.onelitefeather.cygnus.common.permission.TriStates; +import net.onelitefeather.cygnus.common.rank.RankTag; import org.jetbrains.annotations.NotNull; /** @@ -75,4 +77,28 @@ public Pointers pointers() { QueryOptions queryOptions = LuckPermsProvider.get().getContextManager().getQueryOptions(this); return TriStates.fromLuckPerms(user.getCachedData().getPermissionData(queryOptions).checkPermission(permission)); } + + /** + * Resolves this player's name tag icon from their LuckPerms primary group. + * + * @return the matching {@link RankTag}, or {@link RankTag#PLAYER} when LuckPerms is absent, this + * player has no LuckPerms user data, or their primary group names no known rank + */ + public RankTag rankTag() { + if (!LuckPermsSupport.isPresent()) { + return RankTag.PLAYER; + } + User user = LuckPermsProvider.get().getUserManager().getUser(getUuid()); + if (user == null) { + return RankTag.PLAYER; + } + return RankTag.fromGroup(user.getPrimaryGroup()).orElse(RankTag.PLAYER); + } + + /** + * Sets this player's display name to their username prefixed with {@link #rankTag()}. + */ + public void applyRankTagDisplayName() { + setDisplayName(rankTag().prefix(Component.text(getUsername()))); + } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/rank/RankTag.java b/common/src/main/java/net/onelitefeather/cygnus/common/rank/RankTag.java new file mode 100644 index 00000000..a018b639 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/rank/RankTag.java @@ -0,0 +1,100 @@ +package net.onelitefeather.cygnus.common.rank; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.ShadowColor; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Optional; + +/** + * Maps each LuckPerms rank to its name tag icon in the {@code olf:rank_tags} resource pack font. + *
+ * The glyph is rendered {@link NamedTextColor#WHITE} because the pack's icons are full-color bitmaps + * rather than the grayscale masks vanilla glyphs use - any other color would tint the artwork instead + * of leaving it as designed. The client's default drop shadow is disabled for the same reason: it is + * meant for flat glyph masks and just muddies a full-color bitmap. + *
+ * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public enum RankTag { + + ADMINISTRATOR(0xF0190), + ASSISTENT(0xF219B), + MOD(0xF119B), + CONTENT(0xF2190), + MEDIA(0xF2195), + LITE(0xF2191), + PLAYER(0xF1196); + + private final Component glyph; + + RankTag(int codepoint) { + this.glyph = Component.text(new String(Character.toChars(codepoint)), NamedTextColor.WHITE) + .font(rankFont()) + .shadowColor(ShadowColor.none()); + } + + /** + * Returns the {@code olf:rank_tags} resource pack font every {@link #glyph()} is drawn from. + *+ * A method rather than a static field: enum constants are initialized before the class's other + * static fields, so a static field here would not yet be set while the constants above are built. + *
+ * + * @return the font key + */ + private static Key rankFont() { + return Key.key("olf", "rank_tags"); + } + + /** + * Returns the styled glyph component for this rank. + * + * @return the icon component + */ + public Component glyph() { + return glyph; + } + + /** + * Prepends this rank's icon and a space in front of the given name. + *+ * The icon has to be appended as a child of a plain, font-less root rather than used as the root + * itself: Adventure components inherit style from their parent, so a root carrying + * {@code font(olf:rank_tags)} would leak that font onto the space and name appended after it, + * which has no letter glyphs and renders them as missing-character boxes in the client. + *
+ * + * @param name the name component to prefix + * @return the icon followed by a space and the given name + */ + public Component prefix(Component name) { + return Component.text() + .append(glyph) + .appendSpace() + .append(name) + .build(); + } + + /** + * Looks up the rank tag whose name matches a LuckPerms group id (e.g. {@code "administrator"}), + * case-insensitively. + * + * @param group the LuckPerms group id, or {@code null} + * @return the matching tag, or empty when the group is {@code null} or names no known rank + */ + public static Optional+ * The round overwrote it with a {@link net.onelitefeather.cygnus.team.RoleIcon} (Slender/Survivor) + * or a struck-through spectator name; the restart lobby shows rank instead of round role again. + *
+ */ + @Override + public void onStart() { + super.onStart(); + resetDisplayNames(); + } + + /** + * Sets every online player's display name back to their rank tag. + *+ * Package-private so it can be exercised directly in tests without going through {@link #onStart()}, + * which also schedules the phase's repeating update task. + *
+ */ + void resetDisplayNames() { + for (Player player : MinecraftServer.getConnectionManager().getOnlinePlayers()) { + if (player instanceof PermissionAwarePlayer permissionAwarePlayer) { + permissionAwarePlayer.applyRankTagDisplayName(); + } + } + } /** * Ends the process once the countdown has run out. diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java index e7b6b842..5c187f08 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java @@ -8,6 +8,7 @@ import net.onelitefeather.cygnus.CygnusPlayerTestBase; import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.rank.RankTag; import net.onelitefeather.cygnus.jumpscare.JumpScareManager; import net.onelitefeather.cygnus.phase.GamePhase; import net.onelitefeather.cygnus.phase.LobbyPhase; @@ -41,7 +42,8 @@ void testFirstSpawnInLobbyPhaseSetsDisplayNameAndTeleports(@NotNull Env env) { listener.accept(firstSpawn); assertTrue(spawned.get(), "Spawn supplier must be called on first spawn in lobby"); - assertEquals(Component.text(player.getUsername()), player.getDisplayName()); + assertEquals(RankTag.PLAYER.prefix(Component.text(player.getUsername())), player.getDisplayName(), + "the lobby must show the player's rank tag (LuckPerms is absent in tests, so it falls back to RankTag.PLAYER)"); env.destroyInstance(instance, true); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/phase/RestartPhaseTest.java b/game/src/test/java/net/onelitefeather/cygnus/phase/RestartPhaseTest.java new file mode 100644 index 00000000..b4496e55 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/phase/RestartPhaseTest.java @@ -0,0 +1,31 @@ +package net.onelitefeather.cygnus.phase; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.rank.RankTag; +import net.onelitefeather.cygnus.team.RoleIcon; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RestartPhaseTest extends CygnusPlayerTestBase { + + @Test + void testResetDisplayNamesReplacesTheRoundRoleIconWithTheRankTag(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + player.setDisplayName(RoleIcon.SLENDER.prefix(Component.text(player.getUsername(), NamedTextColor.GRAY))); + + new RestartPhase().resetDisplayNames(); + + assertEquals(RankTag.PLAYER.prefix(Component.text(player.getUsername())), player.getDisplayName(), + "the restart lobby must show the rank tag again, not the round's role icon (LuckPerms is absent in tests, so it falls back to RankTag.PLAYER)"); + + env.destroyInstance(instance, true); + } +}