From ccdaa80ecd0e487045a1f7af8f34b97698c28c5f Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 13 Sep 2026 15:27:10 +0200 Subject: [PATCH 1/4] feat(role): add icon holder and test --- .../onelitefeather/cygnus/team/RoleIcon.java | 74 +++++++++++++++++++ .../cygnus/team/RoleIconTest.java | 54 ++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java b/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java new file mode 100644 index 00000000..0f9b0eb6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java @@ -0,0 +1,74 @@ +package net.onelitefeather.cygnus.team; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.onelitefeather.cygnus.common.config.GameConfig; + +/** + * Maps each game role to the tab list icon the {@code cygnus:icons} resource pack font provides. + *

+ * 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. + *

+ * + * @author theEvilReaper + * @version 1.0.0 + * @since 1.0.0 + */ +public enum RoleIcon { + + SLENDER(GameConfig.SLENDER_KEY, 0xF0005), + SURVIVOR(GameConfig.SURVIVOR_KEY, 0xF000A), + SPECTATOR(GameConfig.SPECTATOR_KEY, 0xF0000); + + private final Key roleKey; + private final Component glyph; + + RoleIcon(Key roleKey, int codepoint) { + this.roleKey = roleKey; + this.glyph = Component.text(new String(Character.toChars(codepoint)), NamedTextColor.WHITE).font(iconFont()); + } + + /** + * Returns the {@code cygnus:icons} 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 iconFont() { + return Key.key("cygnus", "icons"); + } + + /** + * Returns the {@link GameConfig} role key this icon represents. + * + * @return the role key + */ + public Key roleKey() { + return roleKey; + } + + /** + * Returns the styled glyph component for this role. + * + * @return the icon component + */ + public Component glyph() { + return glyph; + } + + /** + * Prepends this role's icon and a space in front of the given name. + * + * @param name the name component to prefix + * @return the icon followed by a space and the given name + */ + public Component prefix(Component name) { + return glyph.appendSpace().append(name); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java b/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java new file mode 100644 index 00000000..7fdcce6d --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.team; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.cygnus.common.config.GameConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RoleIconTest { + + private static final Key ICON_FONT = Key.key("cygnus", "icons"); + + @Test + void testSlenderGlyph() { + assertGlyph(RoleIcon.SLENDER, GameConfig.SLENDER_KEY, 0xF0005); + } + + @Test + void testSurvivorGlyph() { + assertGlyph(RoleIcon.SURVIVOR, GameConfig.SURVIVOR_KEY, 0xF000A); + } + + @Test + void testSpectatorGlyph() { + assertGlyph(RoleIcon.SPECTATOR, GameConfig.SPECTATOR_KEY, 0xF0000); + } + + @Test + void testPrefixKeepsIconAndAppendsGivenName() { + Component name = Component.text("theEvilReaper", NamedTextColor.GREEN); + Component prefixed = RoleIcon.SURVIVOR.prefix(name); + + assertEquals(ICON_FONT, prefixed.style().font()); + assertEquals(2, prefixed.children().size()); + assertEquals(Component.space(), prefixed.children().get(0)); + assertEquals(name, prefixed.children().get(1)); + + String plainText = PlainTextComponentSerializer.plainText().serialize(prefixed); + assertTrue(plainText.endsWith(" theEvilReaper")); + } + + private void assertGlyph(RoleIcon icon, Key expectedRoleKey, int expectedCodepoint) { + assertEquals(expectedRoleKey, icon.roleKey()); + assertEquals(ICON_FONT, icon.glyph().style().font()); + assertEquals(NamedTextColor.WHITE, icon.glyph().style().color()); + + String expectedGlyph = new String(Character.toChars(expectedCodepoint)); + assertEquals(expectedGlyph, PlainTextComponentSerializer.plainText().serialize(icon.glyph())); + } +} From ccbc47ada11b3644048d625bb4fa21b71ec9a6f9 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 13 Sep 2026 15:27:24 +0200 Subject: [PATCH 2/4] chore(team): use new role icon --- .../net/onelitefeather/cygnus/team/TeamHelper.java | 5 ++--- .../onelitefeather/cygnus/utils/TeamHelperTest.java | 11 ++++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java index ade5f9fe..e9661494 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java @@ -162,13 +162,12 @@ public static void updateTabList(TeamService teamService) { } slenderTeam.getPlayers().forEach(player -> { - Component slenderDisplayName = Component.text("⛧ ", NamedTextColor.RED) - .append(Component.text(player.getUsername(), NamedTextColor.GRAY)); + Component slenderDisplayName = RoleIcon.SLENDER.prefix(Component.text(player.getUsername(), NamedTextColor.GRAY)); player.setDisplayName(slenderDisplayName); }); survivorTeam.getPlayers().forEach(player -> { - Component survivorDisplayName = Component.text(player.getUsername(), NamedTextColor.GREEN); + Component survivorDisplayName = RoleIcon.SURVIVOR.prefix(Component.text(player.getUsername(), NamedTextColor.GREEN)); player.setDisplayName(survivorDisplayName); }); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java index 59cfef6a..ed316773 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.utils; +import net.onelitefeather.cygnus.team.RoleIcon; import net.onelitefeather.cygnus.team.TeamHelper; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; @@ -171,13 +172,17 @@ void testUpdateTabList(@NotNull Env env) { Component displayName = player.getDisplayName(); assertNotNull(displayName); - assertTrue(PlainTextComponentSerializer.plainText().serialize(displayName).contains("⛧")); + assertEquals(RoleIcon.SLENDER.glyph().style().font(), displayName.style().font()); + assertTrue(PlainTextComponentSerializer.plainText().serialize(displayName).endsWith(player.getUsername())); survivorTeam.getPlayers().forEach(survivor -> { Component survivorDisplayName = survivor.getDisplayName(); assertNotNull(survivorDisplayName); - assertTrue(survivorDisplayName.hasStyling()); - assertEquals(NamedTextColor.GREEN, survivorDisplayName.style().color()); + assertEquals(RoleIcon.SURVIVOR.glyph().style().font(), survivorDisplayName.style().font()); + + Component nameComponent = survivorDisplayName.children().get(1); + assertTrue(nameComponent.hasStyling()); + assertEquals(NamedTextColor.GREEN, nameComponent.style().color()); }); survivorTeam.removePlayers(survivors, Entity::remove); From 12a1ed91b04e2f42d3822871dd6af9d4e98ff4fa Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 13 Sep 2026 15:27:30 +0200 Subject: [PATCH 3/4] chore(spectator): use new role icon --- .../onelitefeather/cygnus/spectator/SpectatorService.java | 4 +++- .../cygnus/spectator/SpectatorServiceTest.java | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java b/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java index 397659e0..fa41e4c7 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java @@ -14,6 +14,7 @@ import net.onelitefeather.cygnus.player.event.SpectatorAddEvent; import net.onelitefeather.cygnus.player.listener.SpectatorAddListener; import net.onelitefeather.cygnus.player.listener.SpectatorItemListener; +import net.onelitefeather.cygnus.team.RoleIcon; import net.onelitefeather.cygnus.team.TeamHelper; import net.onelitefeather.cygnus.utils.Items; import net.onelitefeather.cygnus.visibility.VisibilityRules; @@ -90,7 +91,8 @@ public void join(Player player) { * @param player the player who just became a spectator */ private static void markAsSpectator(Player player) { - player.setDisplayName(Component.text(player.getUsername(), NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH)); + Component name = Component.text(player.getUsername(), NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH); + player.setDisplayName(RoleIcon.SPECTATOR.prefix(name)); } /** diff --git a/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java index 3695e7d1..e86fa6f8 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java @@ -13,6 +13,7 @@ import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.team.RoleIcon; import net.theevilreaper.xerus.api.team.Team; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -188,9 +189,9 @@ void testJoinStrikesTheTabListNameThrough(@NotNull Env env) { Component displayName = player.getDisplayName(); assertNotNull(displayName, "a spectator needs a display name to show up in the tab list"); assertEquals( - Component.text(player.getUsername(), NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH), + RoleIcon.SPECTATOR.prefix(Component.text(player.getUsername(), NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH)), displayName, - "a spectator must be struck through in gray instead of keeping the green survivor name" + "a spectator must show the spectator icon and be struck through in gray instead of keeping the green survivor name" ); env.destroyInstance(instance, true); From 05f4e89d0a20b4d1c530be7199c3a4b1808abdfe Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 13 Sep 2026 15:46:09 +0200 Subject: [PATCH 4/4] chore(role): add component reset --- .../net/onelitefeather/cygnus/team/RoleIcon.java | 12 +++++++++++- .../net/onelitefeather/cygnus/team/RoleIconTest.java | 10 ++++++---- .../onelitefeather/cygnus/utils/TeamHelperTest.java | 9 ++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java b/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java index 0f9b0eb6..80d98fba 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/RoleIcon.java @@ -64,11 +64,21 @@ public Component glyph() { /** * Prepends this role'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(cygnus:icons)} 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 glyph.appendSpace().append(name); + return Component.text() + .append(glyph) + .appendSpace() + .append(name) + .build(); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java b/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java index 7fdcce6d..59620c31 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/team/RoleIconTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class RoleIconTest { @@ -34,10 +35,11 @@ void testPrefixKeepsIconAndAppendsGivenName() { Component name = Component.text("theEvilReaper", NamedTextColor.GREEN); Component prefixed = RoleIcon.SURVIVOR.prefix(name); - assertEquals(ICON_FONT, prefixed.style().font()); - assertEquals(2, prefixed.children().size()); - assertEquals(Component.space(), prefixed.children().get(0)); - assertEquals(name, prefixed.children().get(1)); + assertNull(prefixed.style().font(), "the icon font must not leak onto the space and name, or the client shows missing-glyph boxes for them"); + assertEquals(3, prefixed.children().size()); + assertEquals(RoleIcon.SURVIVOR.glyph(), prefixed.children().get(0)); + assertEquals(Component.space(), prefixed.children().get(1)); + assertEquals(name, prefixed.children().get(2)); String plainText = PlainTextComponentSerializer.plainText().serialize(prefixed); assertTrue(plainText.endsWith(" theEvilReaper")); diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java index ed316773..a809560d 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java @@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -172,15 +173,17 @@ void testUpdateTabList(@NotNull Env env) { Component displayName = player.getDisplayName(); assertNotNull(displayName); - assertEquals(RoleIcon.SLENDER.glyph().style().font(), displayName.style().font()); + assertNull(displayName.style().font(), "the icon font must not leak onto the whole name, or the client shows missing-glyph boxes"); + assertEquals(RoleIcon.SLENDER.glyph(), displayName.children().get(0)); assertTrue(PlainTextComponentSerializer.plainText().serialize(displayName).endsWith(player.getUsername())); survivorTeam.getPlayers().forEach(survivor -> { Component survivorDisplayName = survivor.getDisplayName(); assertNotNull(survivorDisplayName); - assertEquals(RoleIcon.SURVIVOR.glyph().style().font(), survivorDisplayName.style().font()); + assertNull(survivorDisplayName.style().font(), "the icon font must not leak onto the whole name, or the client shows missing-glyph boxes"); + assertEquals(RoleIcon.SURVIVOR.glyph(), survivorDisplayName.children().get(0)); - Component nameComponent = survivorDisplayName.children().get(1); + Component nameComponent = survivorDisplayName.children().get(2); assertTrue(nameComponent.hasStyling()); assertEquals(NamedTextColor.GREEN, nameComponent.style().color()); });