Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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())));
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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<RankTag> fromGroup(@Nullable String group) {
if (group == null) return Optional.empty();
try {
return Optional.of(RankTag.valueOf(group.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NotNullByDefault
package net.onelitefeather.cygnus.common.rank;

import org.jetbrains.annotations.NotNullByDefault;
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package net.onelitefeather.cygnus.common.player;

import net.kyori.adventure.text.Component;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Instance;
import net.minestom.server.network.player.GameProfile;
import net.minestom.server.network.player.PlayerConnection;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
import net.onelitefeather.cygnus.common.rank.RankTag;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

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

/**
* Verifies that a player falls back to {@link RankTag#PLAYER} without LuckPerms present, which is
* the state every test run is in.
*
* @author theEvilReaper
* @version 1.0.0
* @since 1.0.0
*/
@ExtendWith(MicrotusExtension.class)
class PermissionAwarePlayerRankTagTest {

@BeforeAll
static void setUp(Env env) {
env.process().connection().setPlayerProvider(TestPlayer::new);
}

@Test
void testRankTagFallsBackToPlayerWithoutLuckPerms(Env env) {
Instance instance = env.createFlatInstance();
Player player = env.createPlayer(instance);

PermissionAwarePlayer permissionAware = assertInstanceOf(PermissionAwarePlayer.class, player);
assertEquals(RankTag.PLAYER, permissionAware.rankTag());

env.destroyInstance(instance, true);
}

@Test
void testApplyRankTagDisplayNameSetsThePrefixedName(Env env) {
Instance instance = env.createFlatInstance();
Player player = env.createPlayer(instance);

PermissionAwarePlayer permissionAware = assertInstanceOf(PermissionAwarePlayer.class, player);
permissionAware.applyRankTagDisplayName();

assertEquals(RankTag.PLAYER.prefix(Component.text(player.getUsername())), player.getDisplayName());

env.destroyInstance(instance, true);
}

/**
* A player which adds nothing to {@link PermissionAwarePlayer}, so the test observes the
* rank tag handling of the base class and nothing else.
*/
private static final class TestPlayer extends PermissionAwarePlayer {

private TestPlayer(PlayerConnection playerConnection, GameProfile gameProfile) {
super(playerConnection, gameProfile);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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.serializer.plain.PlainTextComponentSerializer;
import org.junit.jupiter.api.Test;

import java.util.Optional;

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 RankTagTest {

private static final Key RANK_FONT = Key.key("olf", "rank_tags");

@Test
void testAdministratorGlyph() {
assertGlyph(RankTag.ADMINISTRATOR, 0xF0190);
}

@Test
void testAssistentGlyph() {
assertGlyph(RankTag.ASSISTENT, 0xF219B);
}

@Test
void testModGlyph() {
assertGlyph(RankTag.MOD, 0xF119B);
}

@Test
void testContentGlyph() {
assertGlyph(RankTag.CONTENT, 0xF2190);
}

@Test
void testMediaGlyph() {
assertGlyph(RankTag.MEDIA, 0xF2195);
}

@Test
void testLiteGlyph() {
assertGlyph(RankTag.LITE, 0xF2191);
}

@Test
void testPlayerGlyph() {
assertGlyph(RankTag.PLAYER, 0xF1196);
}

@Test
void testPrefixDoesNotLeakTheRankFontOntoTheName() {
Component name = Component.text("theEvilReaper", NamedTextColor.GREEN);
Component prefixed = RankTag.ADMINISTRATOR.prefix(name);

assertNull(prefixed.style().font(), "the rank font must not leak onto the space and name, or the client shows missing-glyph boxes");
assertEquals(3, prefixed.children().size());
assertEquals(RankTag.ADMINISTRATOR.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"));
}

@Test
void testFromGroupMatchesCaseInsensitively() {
assertEquals(Optional.of(RankTag.ADMINISTRATOR), RankTag.fromGroup("administrator"));
assertEquals(Optional.of(RankTag.ADMINISTRATOR), RankTag.fromGroup("Administrator"));
assertEquals(Optional.of(RankTag.PLAYER), RankTag.fromGroup("PLAYER"));
}

@Test
void testFromGroupIsEmptyForUnknownOrNullGroup() {
assertEquals(Optional.empty(), RankTag.fromGroup("default"));
assertEquals(Optional.empty(), RankTag.fromGroup(null));
}

private void assertGlyph(RankTag tag, int expectedCodepoint) {
assertEquals(RANK_FONT, tag.glyph().style().font());
assertEquals(NamedTextColor.WHITE, tag.glyph().style().color());

String expectedGlyph = new String(Character.toChars(expectedCodepoint));
assertEquals(expectedGlyph, PlainTextComponentSerializer.plainText().serialize(tag.glyph()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import net.minestom.server.event.player.PlayerSpawnEvent;
import net.onelitefeather.cygnus.common.Messages;
import net.onelitefeather.cygnus.common.Tags;
import net.onelitefeather.cygnus.common.player.PermissionAwarePlayer;
import net.onelitefeather.cygnus.phase.LobbyPhase;

import java.util.function.Consumer;
Expand Down Expand Up @@ -36,6 +37,9 @@ public void accept(PlayerSpawnEvent event) {
player.setDisplayName(Component.text(player.getUsername()));

if (phaseSupplier.get() instanceof LobbyPhase lobbyPhase) {
if (player instanceof PermissionAwarePlayer permissionAwarePlayer) {
permissionAwarePlayer.applyRankTagDisplayName();
}
Broadcaster.broadcast(Messages.getJoinMessage(player));
this.spawnSupplier.accept(player);
lobbyPhase.setLevel(player);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import net.minestom.server.entity.Player;
import net.onelitefeather.cygnus.common.Messages;
import net.onelitefeather.cygnus.common.bootstrap.ServiceShutdown;
import net.onelitefeather.cygnus.common.player.PermissionAwarePlayer;

import java.time.temporal.ChronoUnit;

Expand All @@ -32,6 +33,33 @@ public RestartPhase() {
this.setEndTicks(-1);
}

/**
* Re-applies every online player's rank tag to their tab list name.
* <p>
* 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.
* </p>
*/
@Override
public void onStart() {
super.onStart();
resetDisplayNames();
}

/**
* Sets every online player's display name back to their rank tag.
* <p>
* Package-private so it can be exercised directly in tests without going through {@link #onStart()},
* which also schedules the phase's repeating update task.
* </p>
*/
void resetDisplayNames() {
for (Player player : MinecraftServer.getConnectionManager().getOnlinePlayers()) {
if (player instanceof PermissionAwarePlayer permissionAwarePlayer) {
permissionAwarePlayer.applyRankTagDisplayName();
}
}
}

/**
* Ends the process once the countdown has run out.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading