diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3b5cc3df9..b8ae8d757 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { // Test testImplementation(libs.junit) + testImplementation(libs.mockito) // Annotation Processors compileOnly(projects.ap) diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java index 1373cfe2f..7280e0bf3 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java @@ -41,6 +41,7 @@ import org.geysermc.geyser.entity.EntityDefinition; import org.geysermc.geyser.entity.GeyserDirtyMetadata; import org.geysermc.geyser.entity.properties.GeyserEntityPropertyManager; import org.geysermc.geyser.item.Items; +import org.geysermc.geyser.scoreboard.Team; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.EntityUtils; @@ -102,7 +103,7 @@ public class Entity implements GeyserEntity { @Setter(AccessLevel.NONE) private float boundingBoxWidth; @Setter(AccessLevel.NONE) - private String displayName = ""; + private String displayName; @Setter(AccessLevel.NONE) protected boolean silent = false; /* Metadata end */ @@ -131,6 +132,7 @@ public class Entity implements GeyserEntity { public Entity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition definition, Vector3f position, Vector3f motion, float yaw, float pitch, float headYaw) { this.session = session; + this.displayName = EntityUtils.entityTypeName(definition.entityType()); this.entityId = entityId; this.geyserId = geyserId; @@ -346,7 +348,7 @@ public class Entity implements GeyserEntity { * Sends the Bedrock metadata to the client */ public void updateBedrockMetadata() { - if (!valid) { + if (!isValid()) { return; } @@ -415,27 +417,42 @@ public class Entity implements GeyserEntity { return 300; } + public String teamIdentifier() { + return uuid.toString(); + } + public void setDisplayName(EntityMetadata, ?> entityMetadata) { - // the difference between displayName and nametag aren't important for non-living entities and for players, - // but the displayName is needed for living entities that are part of a scoreboard team. - // For them the nametag is prefix + displayName + suffix + // displayName is shown when always display name is enabled. Either with or without team. + // That's why there are both a displayName and a nametag variable. + // Displayname is ignored for players, and is always their username. Optional name = entityMetadata.getValue(); if (name.isPresent()) { var displayName = MessageTranslator.convertMessage(name.get(), session.locale()); this.displayName = displayName; setNametag(displayName, true); - } else { - this.displayName = ""; - setNametag(null, true); + return; } + + // if no displayName is set, use entity name (ENDER_DRAGON -> Ender Dragon) + // maybe we can/should use a translatable here instead? + this.displayName = EntityUtils.entityTypeName(definition.entityType()); + setNametag(null, true); } protected void setNametag(@Nullable String nametag, boolean fromDisplayName) { - var hide = nametag == null; - if (hide) { + // ensure that the team format is used when nametag changes + if (nametag != null && fromDisplayName) { + var team = session.getWorldCache().getScoreboard().getTeamFor(teamIdentifier()); + if (team != null) { + updateNametag(team); + return; + } + } + + if (nametag == null) { nametag = ""; } - var changed = Objects.equals(this.nametag, nametag); + var changed = !Objects.equals(this.nametag, nametag); this.nametag = nametag; // we only update metadata if the value has changed if (!changed) { @@ -444,7 +461,29 @@ public class Entity implements GeyserEntity { dirtyMetadata.put(EntityDataTypes.NAME, nametag); // if nametag (player with team) is hidden for player, so should the score (belowname) - scoreVisibility(!hide); + scoreVisibility(!nametag.isEmpty()); + } + + public void updateNametag(@Nullable Team team) { + // allow LivingEntity+ to have a different visibility check + updateNametag(team, true); + } + + protected void updateNametag(@Nullable Team team, boolean visible) { + if (team != null) { + String newNametag; + // (team) visibility is LivingEntity+, team displayName is Entity+ + if (visible) { + newNametag = team.displayName(getDisplayName()); + } else { + // The name is not visible to the session player; clear name + newNametag = ""; + } + setNametag(newNametag, false); + return; + } + // The name has reset, if it was previously something else + setNametag(null, false); } protected void scoreVisibility(boolean show) {} diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java index 3b63b4480..518c2bf78 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java @@ -25,6 +25,11 @@ package org.geysermc.geyser.entity.type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; @@ -47,7 +52,6 @@ import org.geysermc.geyser.registry.type.ItemMapping; import org.geysermc.geyser.scoreboard.Team; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.item.ItemTranslator; -import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.AttributeUtils; import org.geysermc.geyser.util.InteractionResult; import org.geysermc.geyser.util.MathUtils; @@ -66,9 +70,6 @@ import org.geysermc.mcprotocollib.protocol.data.game.level.particle.EntityEffect import org.geysermc.mcprotocollib.protocol.data.game.level.particle.Particle; import org.geysermc.mcprotocollib.protocol.data.game.level.particle.ParticleType; -import java.util.*; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; - @Getter @Setter public class LivingEntity extends Entity { @@ -146,45 +147,16 @@ public class LivingEntity extends Entity { dirtyMetadata.put(EntityDataTypes.STRUCTURAL_INTEGRITY, 1); } + @Override public void updateNametag(@Nullable Team team) { - if (team != null) { - String newNametag; - if (team.isVisibleFor(session.getPlayerEntity().getUsername())) { - TeamColor color = team.color(); - String chatColor = MessageTranslator.toChatColor(color); - // We have to emulate what modern Java text already does for us and add the color to each section - newNametag = chatColor + team.prefix() + chatColor + getDisplayName() + chatColor + team.suffix(); - } else { - // The name is not visible to the session player; clear name - newNametag = ""; - } - setNametag(newNametag, false); - return; - } - // The name has reset, if it was previously something else - setNametag(null, false); + // if name not visible, don't mark it as visible + updateNametag(team, team == null || team.isVisibleFor(session.getPlayerEntity().getUsername())); } public void hideNametag() { setNametag("", false); } - public String teamIdentifier() { - return uuid.toString(); - } - - @Override - protected void setNametag(@Nullable String nametag, boolean fromDisplayName) { - if (nametag != null && fromDisplayName) { - var team = session.getWorldCache().getScoreboard().getTeamFor(teamIdentifier()); - if (team != null) { - updateNametag(team); - return; - } - } - super.setNametag(nametag, fromDisplayName); - } - public void setLivingEntityFlags(ByteEntityMetadata entityMetadata) { byte xd = entityMetadata.getPrimitiveValue(); diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java index 9c799738c..475b37d48 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java @@ -25,6 +25,7 @@ package org.geysermc.geyser.entity.type.player; +import java.util.Objects; import lombok.Getter; import lombok.Setter; import net.kyori.adventure.text.Component; @@ -37,12 +38,14 @@ import org.cloudburstmc.protocol.bedrock.data.AbilityLayer; import org.cloudburstmc.protocol.bedrock.data.GameType; import org.cloudburstmc.protocol.bedrock.data.PlayerPermission; import org.cloudburstmc.protocol.bedrock.data.command.CommandPermission; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataMap; import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag; import org.cloudburstmc.protocol.bedrock.data.entity.EntityLinkData; import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData; import org.cloudburstmc.protocol.bedrock.packet.*; import org.geysermc.geyser.api.entity.type.player.GeyserPlayerEntity; +import org.geysermc.geyser.entity.EntityDefinition; import org.geysermc.geyser.entity.EntityDefinitions; import org.geysermc.geyser.entity.attribute.GeyserAttributeType; import org.geysermc.geyser.entity.type.Entity; @@ -61,6 +64,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; +import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType; @Getter @Setter public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { @@ -78,7 +82,7 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { private String username; - private String cachedScore; + private String cachedScore = ""; private boolean scoreVisible = true; /** @@ -108,6 +112,31 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { this.texturesProperty = texturesProperty; } + /** + * Do not use! For testing purposes only + */ + public PlayerEntity(GeyserSession session, long geyserId, UUID uuid, String username) { + super( + session, + -1, + geyserId, + uuid, + EntityDefinition.builder(null).type(EntityType.PLAYER).build(false), + Vector3f.ZERO, + Vector3f.ZERO, + 0, + 0, + 0 + ); + this.username = username; + this.nametag = username; + this.texturesProperty = null; + + // clear initial metadata + dirtyMetadata.apply(new EntityDataMap()); + setFlagsDirty(false); + } + @Override protected void initializeMetadata() { super.initializeMetadata(); @@ -391,19 +420,26 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { text = ""; } + var changed = !Objects.equals(cachedScore, text); cachedScore = text; - if (scoreVisible) { + if (isScoreVisible() && changed) { dirtyMetadata.put(EntityDataTypes.SCORE, text); } } @Override protected void scoreVisibility(boolean show) { - var changed = scoreVisible != show; + var visibilityChanged = scoreVisible != show; scoreVisible = show; - if (changed) { - dirtyMetadata.put(EntityDataTypes.SCORE, show ? cachedScore : ""); + if (!visibilityChanged) { + return; } + // if the player has no cachedScore, we never have to change the score. + // hide = set to "" (does nothing), show = change from "" (does nothing) + if (cachedScore.isEmpty()) { + return; + } + dirtyMetadata.put(EntityDataTypes.SCORE, show ? cachedScore : ""); } @Override diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java index 368f608d9..c15c1944d 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java @@ -83,7 +83,7 @@ public final class Objective { } public void updateProperties(Component displayNameComponent, ScoreType type, NumberFormat format) { - var displayName = MessageTranslator.convertMessage(displayNameComponent, scoreboard.session().locale()); + var displayName = MessageTranslator.convertMessageRaw(displayNameComponent, scoreboard.session().locale()); var changed = !Objects.equals(this.displayName, displayName) || this.type != type; this.displayName = displayName; diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java index ddb673f6a..20a129216 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java @@ -31,8 +31,8 @@ import java.util.Set; import net.kyori.adventure.text.Component; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.entity.type.Entity; -import org.geysermc.geyser.entity.type.LivingEntity; import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.text.ChatColor; import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility; import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; @@ -45,7 +45,7 @@ public final class Team { private final String id; private final Set entities; - private final Set managedEntities; + private final Set managedEntities; @NonNull private NameTagVisibility nameTagVisibility = NameTagVisibility.ALWAYS; private TeamColor color; @@ -68,23 +68,23 @@ public final class Team { this.id = id; this.entities = new ObjectOpenHashSet<>(); this.managedEntities = new ObjectOpenHashSet<>(); + this.lastUpdate = LAST_UPDATE_DEFAULT; - addEntitiesNoUpdate(players); - // this calls the update + // doesn't call entity update updateProperties(name, prefix, suffix, visibility, color); + // calls entitity update + addEntities(players); lastUpdate = LAST_UPDATE_DEFAULT; } public void addEntities(String... names) { - addAddedEntities(addEntitiesNoUpdate(names)); - } - - private Set addEntitiesNoUpdate(String... names) { Set added = new HashSet<>(); for (String name : names) { - if (entities.add(name)) { - added.add(name); + // go to next score if score is already present + if (!entities.add(name)) { + continue; } + added.add(name); scoreboard.getPlayerToTeam().compute(name, (player, oldTeam) -> { if (oldTeam != null) { // Remove old team from this map, and from the set of players of the old team. @@ -96,12 +96,12 @@ public final class Team { } if (added.isEmpty()) { - return added; + return; } // we don't have to change our updateType, - // because the scores itself need updating, not the team + // because the scores themselves need updating, not the team scoreboard.setTeamFor(this, added); - return added; + addAddedEntities(added); } public void removeEntities(String... names) { @@ -120,7 +120,14 @@ public final class Team { } public String displayName(String score) { - return prefix + score + suffix; + var chatColor = ChatColor.chatColorFor(color); + // most sidebar plugins will use the reset color, because they don't want color + // skip the unneeded double reset color in that case + if (ChatColor.RESET.equals(chatColor)) { + chatColor = ""; + } + // also add reset because setting the color does not reset the formatting, unlike Java + return chatColor + prefix + ChatColor.RESET + chatColor + score + ChatColor.RESET + chatColor + suffix; } public boolean isVisibleFor(String entity) { @@ -148,9 +155,9 @@ public final class Team { var oldVisible = isVisibleFor(playerName()); var oldColor = this.color; - this.name = MessageTranslator.convertMessage(name, session().locale()); - this.prefix = MessageTranslator.convertMessage(prefix, session().locale()); - this.suffix = MessageTranslator.convertMessage(suffix, session().locale()); + this.name = MessageTranslator.convertMessageRaw(name, session().locale()); + this.prefix = MessageTranslator.convertMessageRaw(prefix, session().locale()); + this.suffix = MessageTranslator.convertMessageRaw(suffix, session().locale()); // matches vanilla behaviour, the visibility is not reset (to ALWAYS) if it is null. // instead the visibility is not altered if (visibility != null) { @@ -159,24 +166,9 @@ public final class Team { this.color = color; if (lastUpdate == LAST_UPDATE_DEFAULT) { - if (entities.isEmpty()) { - return; - } - - var hidden = false; - if (nameTagVisibility != NameTagVisibility.ALWAYS && !isVisibleFor(playerName())) { - // while the team has technically changed, we don't mark it as changed because the visibility - // doesn't influence any of the display slots - hideEntities(); - hidden = true; - } - + // addEntities is called after the initial updateProperties, so no need to do any entity updates here if (this.color != TeamColor.RESET || !this.prefix.isEmpty() || !this.suffix.isEmpty()) { markChanged(); - // we've already hidden the entities, so we don't have to update them again - if (!hidden) { - updateEntities(); - } } return; } @@ -220,36 +212,32 @@ public final class Team { refreshAllEntities(); return; } - for (LivingEntity entity : managedEntities) { + for (Entity entity : managedEntities) { entity.updateNametag(null); entity.updateBedrockMetadata(); } } - private void hideEntities() { - for (LivingEntity entity : managedEntities) { - entity.hideNametag(); - } - } - private void updateEntities() { - for (LivingEntity entity : managedEntities) { + for (Entity entity : managedEntities) { entity.updateNametag(this); + entity.updateBedrockMetadata(); } } private void addAddedEntities(Set names) { + // can't contain self if none are added + if (names.isEmpty()) { + return; + } var containsSelf = names.contains(playerName()); for (Entity entity : session().getEntityCache().getEntities().values()) { - if (!(entity instanceof LivingEntity living)) { - continue; - } - if (names.contains(living.teamIdentifier())) { - managedEntities.add(living); + if (names.contains(entity.teamIdentifier())) { + managedEntities.add(entity); if (!containsSelf) { - living.updateNametag(this); - living.updateBedrockMetadata(); + entity.updateNametag(this); + entity.updateBedrockMetadata(); } } } @@ -281,11 +269,8 @@ public final class Team { private void refreshAllEntities() { for (Entity entity : session().getEntityCache().getEntities().values()) { - if (!(entity instanceof LivingEntity living)) { - continue; - } - living.updateNametag(scoreboard.getTeamFor(living.teamIdentifier())); - living.updateBedrockMetadata(); + entity.updateNametag(scoreboard.getTeamFor(entity.teamIdentifier())); + entity.updateBedrockMetadata(); } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java index 6c4c807dc..16b8f2c66 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java @@ -40,6 +40,7 @@ public final class SidebarDisplayScore extends DisplayScore { private ScoreInfo cachedInfo; private Team team; private String order; + private boolean onlyScoreValueChanged; public SidebarDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference) { super(slot, scoreId, reference); @@ -81,6 +82,9 @@ public final class SidebarDisplayScore extends DisplayScore { finalName = order + ChatColor.RESET + finalName; } + if (cachedInfo != null) { + onlyScoreValueChanged = finalName.equals(cachedInfo.getName()); + } cachedInfo = new ScoreInfo(id, slot.objectiveId(), reference.score(), finalName); } @@ -128,4 +132,8 @@ public final class SidebarDisplayScore extends DisplayScore { public boolean exists() { return cachedInfo != null; } + + public boolean onlyScoreValueChanged() { + return onlyScoreValueChanged; + } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java index 3421204df..5355f56ce 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java @@ -50,8 +50,6 @@ public class BelownameDisplaySlot extends DisplaySlot { public BelownameDisplaySlot(GeyserSession session, Objective objective) { super(session, objective, ScoreboardPosition.BELOW_NAME); - setAndAddBelownameForExisting(); - updateType = UpdateType.NOTHING; } @Override @@ -61,7 +59,13 @@ public class BelownameDisplaySlot extends DisplaySlot { // when the objective is added, updated or removed we thus have to update the belowname for every player // when an individual score is updated (score or number format) we have to update the individual player - // add is handled in the constructor and remove is handled in #remove() + // remove is handled in #remove() + if (updateType == UpdateType.ADD) { + for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) { + playerRegistered(player); + } + return; + } if (updateType == UpdateType.UPDATE) { for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) { setBelowNameText(player, scoreFor(player.getUsername())); @@ -118,12 +122,6 @@ public class BelownameDisplaySlot extends DisplaySlot { displayScores.remove(player.getGeyserId()); } - private void setAndAddBelownameForExisting() { - for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) { - playerRegistered(player); - } - } - private void addDisplayScore(ScoreReference reference) { var players = session.getEntityCache().getPlayersByName(reference.name()); for (PlayerEntity player : players) { diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java index f4ec1bdab..3aafbb464 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java @@ -147,7 +147,7 @@ public final class SidebarDisplaySlot extends DisplaySlot { // we need this as long as MCPE-143063 hasn't been fixed. // the checks after 'add' are there to prevent removing scores that // are going to be removed anyway / don't need to be removed - if (add && exists && !(objectiveUpdate || objectiveAdd)) { + if (add && exists && !(objectiveUpdate || objectiveAdd) && !score.onlyScoreValueChanged()) { removeScores.add(score.cachedInfo()); } } diff --git a/core/src/main/java/org/geysermc/geyser/text/ChatColor.java b/core/src/main/java/org/geysermc/geyser/text/ChatColor.java index b36a822e3..22e553678 100644 --- a/core/src/main/java/org/geysermc/geyser/text/ChatColor.java +++ b/core/src/main/java/org/geysermc/geyser/text/ChatColor.java @@ -25,6 +25,8 @@ package org.geysermc.geyser.text; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; + public class ChatColor { public static final String ANSI_RESET = (char) 0x1b + "[0m"; @@ -86,6 +88,8 @@ public class ChatColor { } public static String styleOrder(int index) { + // https://bugs.mojang.com/browse/MCPE-41729 + // strikethrough and underlined do not exist on Bedrock return switch (index) { case 0 -> BLACK; case 1 -> DARK_BLUE; @@ -105,9 +109,35 @@ public class ChatColor { case 15 -> WHITE; case 16 -> OBFUSCATED; case 17 -> BOLD; - case 18 -> STRIKETHROUGH; - case 19 -> UNDERLINE; default -> ITALIC; }; } + + public static String chatColorFor(TeamColor teamColor) { + // https://bugs.mojang.com/browse/MCPE-41729 + // strikethrough and underlined do not exist on Bedrock + return switch (teamColor) { + case BLACK -> BLACK; + case DARK_BLUE -> DARK_BLUE; + case DARK_GREEN -> DARK_GREEN; + case DARK_AQUA -> DARK_AQUA; + case DARK_RED -> DARK_RED; + case DARK_PURPLE -> DARK_PURPLE; + case GOLD -> GOLD; + case GRAY -> GRAY; + case DARK_GRAY -> DARK_GRAY; + case BLUE -> BLUE; + case GREEN -> GREEN; + case AQUA -> AQUA; + case RED -> RED; + case LIGHT_PURPLE -> LIGHT_PURPLE; + case YELLOW -> YELLOW; + case WHITE -> WHITE; + case OBFUSCATED -> OBFUSCATED; + case BOLD -> BOLD; + case STRIKETHROUGH, UNDERLINED -> ""; + case ITALIC -> ITALIC; + default -> RESET; + }; + } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java index 152bf4160..99c0e718f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java @@ -25,6 +25,8 @@ package org.geysermc.geyser.translator.text; +import java.util.ArrayList; +import java.util.List; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ScoreComponent; import net.kyori.adventure.text.TranslatableComponent; @@ -39,14 +41,16 @@ import org.cloudburstmc.nbt.NbtMap; import org.cloudburstmc.protocol.bedrock.packet.TextPacket; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.session.GeyserSession; -import org.geysermc.geyser.text.*; +import org.geysermc.geyser.text.ChatColor; +import org.geysermc.geyser.text.DummyLegacyHoverEventSerializer; +import org.geysermc.geyser.text.GeyserLocale; +import org.geysermc.geyser.text.GsonComponentSerializerWrapper; +import org.geysermc.geyser.text.MinecraftTranslationRegistry; +import org.geysermc.geyser.text.TextDecoration; import org.geysermc.mcprotocollib.protocol.data.DefaultComponentSerializer; import org.geysermc.mcprotocollib.protocol.data.game.Holder; import org.geysermc.mcprotocollib.protocol.data.game.chat.ChatType; import org.geysermc.mcprotocollib.protocol.data.game.chat.ChatTypeDecoration; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; - -import java.util.*; public class MessageTranslator { // These are used for handling the translations of the messages @@ -59,9 +63,6 @@ public class MessageTranslator { private static final LegacyComponentSerializer BEDROCK_SERIALIZER; private static final String BEDROCK_COLORS; - // Store team colors for player names - private static final Map TEAM_COLORS = new EnumMap<>(TeamColor.class); - // Legacy formatting character private static final String BASE = "\u00a7"; @@ -69,31 +70,6 @@ public class MessageTranslator { private static final String RESET = BASE + "r"; static { - TEAM_COLORS.put(TeamColor.RESET, RESET); - - TEAM_COLORS.put(TeamColor.BLACK, BASE + "0"); - TEAM_COLORS.put(TeamColor.DARK_BLUE, BASE + "1"); - TEAM_COLORS.put(TeamColor.DARK_GREEN, BASE + "2"); - TEAM_COLORS.put(TeamColor.DARK_AQUA, BASE + "3"); - TEAM_COLORS.put(TeamColor.DARK_RED, BASE + "4"); - TEAM_COLORS.put(TeamColor.DARK_PURPLE, BASE + "5"); - TEAM_COLORS.put(TeamColor.GOLD, BASE + "6"); - TEAM_COLORS.put(TeamColor.GRAY, BASE + "7"); - TEAM_COLORS.put(TeamColor.DARK_GRAY, BASE + "8"); - TEAM_COLORS.put(TeamColor.BLUE, BASE + "9"); - TEAM_COLORS.put(TeamColor.GREEN, BASE + "a"); - TEAM_COLORS.put(TeamColor.AQUA, BASE + "b"); - TEAM_COLORS.put(TeamColor.RED, BASE + "c"); - TEAM_COLORS.put(TeamColor.LIGHT_PURPLE, BASE + "d"); - TEAM_COLORS.put(TeamColor.YELLOW, BASE + "e"); - TEAM_COLORS.put(TeamColor.WHITE, BASE + "f"); - - // Formats, not colors - TEAM_COLORS.put(TeamColor.OBFUSCATED, BASE + "k"); - TEAM_COLORS.put(TeamColor.BOLD, BASE + "l"); - TEAM_COLORS.put(TeamColor.STRIKETHROUGH, BASE + "m"); - TEAM_COLORS.put(TeamColor.ITALIC, BASE + "o"); - // Temporary fix for https://github.com/KyoriPowered/adventure/issues/447 - TODO resolve properly GsonComponentSerializer source = DefaultComponentSerializer.get() .toBuilder() @@ -145,13 +121,31 @@ public class MessageTranslator { } /** - * Convert a Java message to the legacy format ready for bedrock + * Convert a Java message to the legacy format ready for bedrock. Unlike + * {@link #convertMessageRaw(Component, String)} this adds a leading color reset. In Bedrock + * some places have build-in colors. * * @param message Java message * @param locale Locale to use for translation strings * @return Parsed and formatted message for bedrock */ public static String convertMessage(Component message, String locale) { + return convertMessage(message, locale, true); + } + + /** + * Convert a Java message to the legacy format ready for bedrock. Unlike {@link #convertMessage(Component, String)} + * this version does not add a leading color reset. In Bedrock some places have build-in colors. + * + * @param message Java message + * @param locale Locale to use for translation strings + * @return Parsed and formatted message for bedrock + */ + public static String convertMessageRaw(Component message, String locale) { + return convertMessage(message, locale, false); + } + + private static String convertMessage(Component message, String locale, boolean addLeadingResetFormat) { try { // Translate any components that require it message = RENDERER.render(message, locale); @@ -160,7 +154,7 @@ public class MessageTranslator { StringBuilder finalLegacy = new StringBuilder(); char[] legacyChars = legacy.toCharArray(); - boolean lastFormatReset = false; + boolean lastFormatReset = !addLeadingResetFormat; for (int i = 0; i < legacyChars.length; i++) { char legacyChar = legacyChars[i]; if (legacyChar != ChatColor.ESCAPE || i >= legacyChars.length - 1) { @@ -173,7 +167,7 @@ public class MessageTranslator { char next = legacyChars[++i]; if (BEDROCK_COLORS.indexOf(next) != -1) { - // Append this color code, as well as a necessary reset code + // Unlike Java Edition, the ChatFormatting is not reset when a ChatColor is added if (!lastFormatReset) { finalLegacy.append(RESET); } @@ -366,16 +360,6 @@ public class MessageTranslator { session.sendUpstreamPacket(textPacket); } - /** - * Convert a team color to a chat color - * - * @param teamColor Color or format to convert - * @return The chat color character - */ - public static String toChatColor(TeamColor teamColor) { - return TEAM_COLORS.getOrDefault(teamColor, ""); - } - /** * Checks if the given message is over 256 characters (Java edition server chat limit) and sends a message to the user if it is * diff --git a/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java b/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java index 53aefde1e..1cf4eaad9 100644 --- a/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java @@ -290,6 +290,28 @@ public final class EntityUtils { }; } + public static String entityTypeName(EntityType type) { + var typeName = type.name(); + var builder = new StringBuilder(); + + boolean upNext = true; + for (int i = 0; i < typeName.length(); i++) { + char c = typeName.charAt(i); + if ('_' == c) { + upNext = true; + continue; + } + + // enums are upper case by default + if (!upNext) { + c = Character.toLowerCase(c); + } + builder.append(c); + upNext = false; + } + return builder.toString(); + } + private EntityUtils() { } } diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java new file mode 100644 index 000000000..523e4dca2 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockAndAddPlayerEntity; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import net.kyori.adventure.text.Component; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; +import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetPlayerTeamTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.CollisionRule; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket; +import org.junit.jupiter.api.Test; + +public class NameVisibilityScoreboardTest { + @Test + void playerVisibilityNever() { + mockContextScoreboard(context -> { + var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team1", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.NEVER, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"player1"} + ) + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, context); + }); + } + + @Test + void playerVisibilityHideForOtherTeam() { + mockContextScoreboard(context -> { + var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team1", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.HIDE_FOR_OTHER_TEAMS, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"player1"} + ) + ); + // only hidden if session player (Tim203) is in a team as well + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix"); + return packet; + }, context); + assertNoNextPacket(context); + + // create another team and add Tim203 to it + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team2", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.NEVER, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"Tim203"} + ) + ); + // Tim203 is now in another team, so it should be hidden + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, context); + assertNoNextPacket(context); + + // add Tim203 to same team as player1, score should be visible again + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"}) + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix"); + return packet; + }, context); + }); + } + + @Test + void playerVisibilityHideForOwnTeam() { + mockContextScoreboard(context -> { + var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team1", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.HIDE_FOR_OWN_TEAM, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"player1"} + ) + ); + // Tim203 is not in a team (let alone the same team), so should be visible + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix"); + return packet; + }, context); + assertNoNextPacket(context); + + // Tim203 is now in the same team as player1, so should be hidden + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"}) + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, context); + assertNoNextPacket(context); + + // create another team and add Tim203 to there, score should be visible again + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team2", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.NEVER, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"Tim203"} + ) + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix"); + return packet; + }, context); + }); + } + + @Test + void playerVisibilityAlways() { + mockContextScoreboard(context -> { + var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team1", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.ALWAYS, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"player1"} + ) + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix"); + return packet; + }, context); + + // adding self to another team shouldn't make a difference + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team2", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.ALWAYS, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{"Tim203"} + ) + ); + assertNoNextPacket(context); + + // adding self to player1 team shouldn't matter + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"}) + ); + assertNoNextPacket(context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java new file mode 100644 index 000000000..5d8d8309f --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.belowname; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockAndAddPlayerEntity; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; +import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; +import org.junit.jupiter.api.Test; + +public class BasicBelownameScoreboardTests { + @Test + void displayWithNoPlayersAndRemove() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective") + ); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "") + ); + assertNoNextPacket(context); + }); + } + + @Test + void displayColorWithOnePlayer() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective", NamedTextColor.BLUE), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, "0 §r§9objective"); + return packet; + }, context); + }); + } + + @Test + void displayWithOnePlayerAndRemove() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective"); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, ""); + return packet; + }, context); + }); + } + + @Test + void overrideAndRemove() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + mockAndAddPlayerEntity(context, "player1", 2); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective1", + ObjectiveAction.ADD, + Component.text("objective1"), + ScoreType.INTEGER, + null + ) + ); + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective2", + ObjectiveAction.ADD, + Component.text("objective2"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective2") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective2"); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective1") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, ""); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective1"); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "") + ); + assertNextPacket(() -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.SCORE, ""); + return packet; + }, context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java new file mode 100644 index 000000000..a3d4ad671 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.playerlist; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; +import org.junit.jupiter.api.Test; + +/* +Identical to sidebar + */ +public class BasicPlayerlistScoreboardTests { + @Test + void display() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("list"); + packet.setSortOrder(1); + return packet; + }, context); + }); + } + + @Test + void displayNameColors() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective", Style.style(NamedTextColor.AQUA, TextDecoration.BOLD)), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("§b§lobjective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("list"); + packet.setSortOrder(1); + return packet; + }, context); + }); + } + + @Test + void overrideWithOneScore() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective1", + ObjectiveAction.ADD, + Component.text("objective1"), + ScoreType.INTEGER, + null + ) + ); + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective2", + ObjectiveAction.ADD, + Component.text("objective2"), + ScoreType.INTEGER, + null + ) + ); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective1", 1)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective2", 2)); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective2") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective2"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("list"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + // session player name is Tim203 + packet.setInfos(List.of(new ScoreInfo(1, "0", 2, ScoreInfo.ScorerType.PLAYER, 1))); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective1") + ); + assertNextPacket(() -> { + var packet = new RemoveObjectivePacket(); + packet.setObjectiveId("0"); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("2"); + packet.setDisplayName("objective1"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("list"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + // session player name is Tim203 + packet.setInfos(List.of(new ScoreInfo(3, "2", 1, ScoreInfo.ScorerType.PLAYER, 1))); + return packet; + }, context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java new file mode 100644 index 000000000..b3999303e --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.sidebar; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; +import org.junit.jupiter.api.Test; + +/* +Identical to playerlist + */ +public class BasicSidebarScoreboardTests { + @Test + void displayAndRemove() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("list"); + packet.setSortOrder(1); + return packet; + }, context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "") + ); + assertNextPacket(() -> { + var packet = new RemoveObjectivePacket(); + packet.setObjectiveId("0"); + return packet; + }, context); + }); + } + + @Test + void displayNameColors() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective", Style.style(NamedTextColor.AQUA, TextDecoration.BOLD)), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("§b§lobjective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + }); + } + + @Test + void override() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective1", + ObjectiveAction.ADD, + Component.text("objective1"), + ScoreType.INTEGER, + null + ) + ); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective2", + ObjectiveAction.ADD, + Component.text("objective2"), + ScoreType.INTEGER, + null + ) + ); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective1", 1)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective2", 2)); + assertNoNextPacket(context); + + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective2") + ); + + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective2"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "Tim203"))); + return packet; + }, context); + assertNoNextPacket(context); + + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective1") + ); + + assertNextPacket(() -> { + var packet = new RemoveObjectivePacket(); + packet.setObjectiveId("0"); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("2"); + packet.setDisplayName("objective1"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(3, "2", 1, "Tim203"))); + return packet; + }, context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java new file mode 100644 index 000000000..3e0be1c02 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java @@ -0,0 +1,533 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.sidebar; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import java.util.List; +import net.kyori.adventure.text.Component; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaResetScorePacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetPlayerTeamTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.CollisionRule; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundResetScorePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; +import org.junit.jupiter.api.Test; + +public class OrderAndLimitSidebarScoreboardTests { + @Test + void aboveDisplayLimit() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + var resetScoreTranslator = new JavaResetScorePacket(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + + // some are in an odd order to make sure that there is no bias for which score is send first, + // and to make sure that the score value also doesn't influence the order + context.translate(setScoreTranslator, new ClientboundSetScorePacket("a", "objective", 1)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("b", "objective", 2)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("c", "objective", 3)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("d", "objective", 5)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("e", "objective", 4)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("f", "objective", 6)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("g", "objective", 9)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("h", "objective", 8)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("i", "objective", 7)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("p", "objective", 10)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("o", "objective", 11)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("n", "objective", 12)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("m", "objective", 13)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("k", "objective", 14)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("l", "objective", 15)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("j", "objective", 16)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("q", "objective", 17)); + assertNoNextPacket(context); + + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(1, "0", 17, "q"), + new ScoreInfo(2, "0", 16, "j"), + new ScoreInfo(3, "0", 15, "l"), + new ScoreInfo(4, "0", 14, "k"), + new ScoreInfo(5, "0", 13, "m"), + new ScoreInfo(6, "0", 12, "n"), + new ScoreInfo(7, "0", 11, "o"), + new ScoreInfo(8, "0", 10, "p"), + new ScoreInfo(9, "0", 9, "g"), + new ScoreInfo(10, "0", 8, "h"), + new ScoreInfo(11, "0", 7, "i"), + new ScoreInfo(12, "0", 6, "f"), + new ScoreInfo(13, "0", 5, "d"), + new ScoreInfo(14, "0", 4, "e"), + new ScoreInfo(15, "0", 3, "c") + )); + return packet; + }, context); + assertNoNextPacket(context); + + // remove a score + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("m", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(5, "0", 13, "m"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b"))); + return packet; + }, context); + + // add a score + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("aa", "objective", 13) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(17, "0", 13, "aa"))); + return packet; + }, context); + + // add score with same score value (after) + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("ga", "objective", 9) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(15, "0", 3, "c"), + new ScoreInfo(9, "0", 9, "§0§rg") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(9, "0", 9, "§0§rg"), + new ScoreInfo(18, "0", 9, "§1§rga") + )); + return packet; + }, context); + + // add another score with same score value (before all) + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("ag", "objective", 9) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(14, "0", 4, "e"), + new ScoreInfo(9, "0", 9, "§1§rg"), + new ScoreInfo(18, "0", 9, "§2§rga") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(19, "0", 9, "§0§rag"), + new ScoreInfo(9, "0", 9, "§1§rg"), + new ScoreInfo(18, "0", 9, "§2§rga") + )); + return packet; + }, context); + + // remove score with same value + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("g", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(9, "0", 9, "§1§rg"), + new ScoreInfo(18, "0", 9, "§1§rga") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(18, "0", 9, "§1§rga"), + new ScoreInfo(20, "0", 4, "e") + )); + return packet; + }, context); + + // remove the other score with the same value + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("ga", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(18, "0", 9, "§1§rga"), + new ScoreInfo(19, "0", 9, "ag") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(19, "0", 9, "ag"), + new ScoreInfo(21, "0", 3, "c") + )); + return packet; + }, context); + }); + } + + @Test + void aboveDisplayLimitWithTeam() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + var resetScoreTranslator = new JavaResetScorePacket(); + var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + + // some are in an odd order to make sure that there is no bias for which score is send first, + // and to make sure that the score value also doesn't influence the order + context.translate(setScoreTranslator, new ClientboundSetScorePacket("a", "objective", 1)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("b", "objective", 2)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("c", "objective", 3)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("d", "objective", 5)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("e", "objective", 4)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("f", "objective", 6)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("g", "objective", 9)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("h", "objective", 8)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("i", "objective", 7)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("p", "objective", 10)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("o", "objective", 11)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("n", "objective", 12)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("m", "objective", 13)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("k", "objective", 14)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("l", "objective", 15)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("j", "objective", 16)); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("q", "objective", 17)); + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team1", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.ALWAYS, + CollisionRule.NEVER, + TeamColor.DARK_RED, + new String[]{ "f", "o" } + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(1, "0", 17, "q"), + new ScoreInfo(2, "0", 16, "j"), + new ScoreInfo(3, "0", 15, "l"), + new ScoreInfo(4, "0", 14, "k"), + new ScoreInfo(5, "0", 13, "m"), + new ScoreInfo(6, "0", 12, "n"), + new ScoreInfo(7, "0", 11, "§4prefix§r§4o§r§4suffix"), + new ScoreInfo(8, "0", 10, "p"), + new ScoreInfo(9, "0", 9, "g"), + new ScoreInfo(10, "0", 8, "h"), + new ScoreInfo(11, "0", 7, "i"), + new ScoreInfo(12, "0", 6, "§4prefix§r§4f§r§4suffix"), + new ScoreInfo(13, "0", 5, "d"), + new ScoreInfo(14, "0", 4, "e"), + new ScoreInfo(15, "0", 3, "c") + )); + return packet; + }, context); + assertNoNextPacket(context); + + // remove a score + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("m", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(5, "0", 13, "m"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b"))); + return packet; + }, context); + + // add a score + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("aa", "objective", 13) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(17, "0", 13, "aa"))); + return packet; + }, context); + + // add some teams for the upcoming score adds + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team2", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.ALWAYS, + CollisionRule.NEVER, + TeamColor.DARK_AQUA, + new String[]{ "oa" } + ) + ); + context.translate( + setPlayerTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "team3", + Component.text("displayName"), + Component.text("prefix"), + Component.text("suffix"), + false, + false, + NameTagVisibility.ALWAYS, + CollisionRule.NEVER, + TeamColor.DARK_PURPLE, + new String[]{ "ao" } + ) + ); + assertNoNextPacket(context); + + // add a score that on Java should be after 'o', but would be before on Bedrock without manual order + // due to the team color + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("oa", "objective", 11) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(15, "0", 3, "c"), + new ScoreInfo(7, "0", 11, "§0§r§4prefix§r§4o§r§4suffix") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(7, "0", 11, "§0§r§4prefix§r§4o§r§4suffix"), + new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix") + )); + return packet; + }, context); + + // add a score that on Java should be before 'o', but would be after on Bedrock without manual order + // due to the team color + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("ao", "objective", 11) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(14, "0", 4, "e"), + new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"), + new ScoreInfo(18, "0", 11, "§2§r§3prefix§r§3oa§r§3suffix") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(19, "0", 11, "§0§r§5prefix§r§5ao§r§5suffix"), + new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"), + new ScoreInfo(18, "0", 11, "§2§r§3prefix§r§3oa§r§3suffix") + )); + return packet; + }, context); + + // remove original 'o' score + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("o", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"), + new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix"), + new ScoreInfo(20, "0", 4, "e") + )); + return packet; + }, context); + + // remove the other score with the same value as 'o' + context.translate( + resetScoreTranslator, + new ClientboundResetScorePacket("oa", "objective") + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of( + new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix"), + new ScoreInfo(19, "0", 11, "§5prefix§r§5ao§r§5suffix") + )); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of( + new ScoreInfo(19, "0", 11, "§5prefix§r§5ao§r§5suffix"), + new ScoreInfo(21, "0", 3, "c") + )); + return packet; + }, context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java new file mode 100644 index 000000000..0a02a58d9 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.sidebar; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard; + +import java.util.List; +import net.kyori.adventure.text.Component; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator; +import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; +import org.junit.jupiter.api.Test; + +public class VanillaSidebarScoreboardTests { + @Test + void displayAndAddScore() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1)); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner"))); + return packet; + }, context); + }); + } + + @Test + void displayAndChangeScoreValue() { + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1)); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner"))); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 2)); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "owner"))); + return packet; + }, context); + }); + } + + @Test + void displayAndChangeScoreDisplayName() { + // this ensures that MCPE-143063 is properly handled + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1)); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner"))); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("owner", "objective", 1).withDisplay(Component.text("hi")) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "hi"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "hi"))); + return packet; + }, context); + }); + } + + @Test + void displayAndChangeScoreDisplayNameAndValue() { + // this ensures that MCPE-143063 is properly handled + mockContextScoreboard(context -> { + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "objective", + ObjectiveAction.ADD, + Component.text("objective"), + ScoreType.INTEGER, + null + ) + ); + context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1)); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("objective"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner"))); + return packet; + }, context); + assertNoNextPacket(context); + + context.translate( + setScoreTranslator, + new ClientboundSetScorePacket("owner", "objective", 2).withDisplay(Component.text("hi")) + ); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "hi"))); + return packet; + }, context); + assertNextPacket(() -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "hi"))); + return packet; + }, context); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java new file mode 100644 index 000000000..770131325 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.util; + +import java.util.Collections; +import java.util.function.Supplier; +import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; +import org.junit.jupiter.api.Assertions; + +public class AssertUtils { + public static void assertContextEquals(Supplier expected, T actual) { + if (actual == null) { + Assertions.fail("Expected another packet! " + expected.get()); + } + Assertions.assertEquals(expected.get(), actual); + } + + public static void assertNextPacket(Supplier expected, GeyserMockContext context) { + assertContextEquals(expected, context.nextPacket()); + } + + public static void assertNoNextPacket(GeyserMockContext context) { + Assertions.assertEquals( + Collections.emptyList(), + context.packets(), + "Expected no remaining packets, got " + context.packetCount() + ); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java new file mode 100644 index 000000000..f147e766d --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.util; + +import org.geysermc.geyser.GeyserLogger; + +public class EmptyGeyserLogger implements GeyserLogger { + @Override + public void severe(String message) { + + } + + @Override + public void severe(String message, Throwable error) { + + } + + @Override + public void error(String message) { + + } + + @Override + public void error(String message, Throwable error) { + + } + + @Override + public void warning(String message) { + + } + + @Override + public void info(String message) { + + } + + @Override + public void debug(String message) { + + } + + @Override + public void setDebug(boolean debug) { + + } + + @Override + public boolean isDebug() { + return false; + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java new file mode 100644 index 000000000..72515d714 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.util; + +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.configuration.GeyserConfiguration; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.translator.protocol.PacketTranslator; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +public class GeyserMockContext { + private final List mocksAndSpies = new ArrayList<>(); + private final List storedObjects = new ArrayList<>(); + private final List packets = Collections.synchronizedList(new ArrayList<>()); + private MockedStatic geyserImplMock; + + public static void mockContext(Consumer geyserContext) { + var context = new GeyserMockContext(); + + var geyserImpl = context.mock(GeyserImpl.class); + var config = context.mock(GeyserConfiguration.class); + + when(config.getScoreboardPacketThreshold()).thenReturn(1_000); + + when(geyserImpl.getConfig()).thenReturn(config); + + var logger = context.storeObject(new EmptyGeyserLogger()); + when(geyserImpl.getLogger()).thenReturn(logger); + + try (var mocked = mockStatic(GeyserImpl.class)) { + mocked.when(GeyserImpl::getInstance).thenReturn(geyserImpl); + context.geyserImplMock = mocked; + geyserContext.accept(context); + } + } + + public static void mockContext(Runnable runnable) { + mockContext(context -> runnable.run()); + } + + public T mock(Class type) { + return addMockOrSpy(Mockito.mock(type)); + } + + public T spy(T object) { + return addMockOrSpy(Mockito.spy(object)); + } + + private T addMockOrSpy(T mockOrSpy) { + mocksAndSpies.add(mockOrSpy); + return mockOrSpy; + } + + public T storeObject(T object) { + storedObjects.add(object); + return object; + } + + /** + * Retries the mock or spy that is an instance of the specified type. + * This is only really intended for classes where you only need a single instance of. + */ + public T mockOrSpy(Class type) { + for (Object mock : mocksAndSpies) { + if (type.isInstance(mock)) { + return type.cast(mock); + } + } + return null; + } + + public T storedObject(Class type) { + for (Object storedObject : storedObjects) { + if (type.isInstance(storedObject)) { + return type.cast(storedObject); + } + } + return null; + } + + public GeyserSession session() { + return mockOrSpy(GeyserSession.class); + } + + void addPacket(BedrockPacket packet) { + packets.add(packet); + } + + public int packetCount() { + return packets.size(); + } + + public BedrockPacket nextPacket() { + if (packets.isEmpty()) { + return null; + } + return packets.remove(0); + } + + public List packets() { + return Collections.unmodifiableList(packets); + } + + public void translate(PacketTranslator translator, T packet) { + translator.translate(session(), packet); + } + + public MockedStatic geyserImplMock() { + return geyserImplMock; + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java new file mode 100644 index 000000000..36ceeb79b --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.scoreboard.network.util; + +import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket; +import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContext.mockContext; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.function.Consumer; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataMap; +import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.entity.type.player.SessionPlayerEntity; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.EntityCache; +import org.geysermc.geyser.session.cache.WorldCache; +import org.mockito.stubbing.Answer; + +public class GeyserMockContextScoreboard { + public static void mockContextScoreboard(Consumer geyserContext) { + mockContext(context -> { + createSessionSpy(context); + geyserContext.accept(context); + + assertNoNextPacket(context); + }); + } + + private static void createSessionSpy(GeyserMockContext context) { + // GeyserSession has so many dependencies, it's easier to just mock it + var session = context.mock(GeyserSession.class); + + when(session.locale()).thenReturn("en_US"); + doAnswer((Answer) invocation -> { + context.addPacket(invocation.getArgument(0, BedrockPacket.class)); + return null; + }).when(session).sendUpstreamPacket(any()); + + // SessionPlayerEntity loads stuff in like blocks, which is not what we want + var playerEntity = context.mock(SessionPlayerEntity.class); + when(playerEntity.getGeyserId()).thenReturn(1L); + when(playerEntity.getUsername()).thenReturn("Tim203"); + when(session.getPlayerEntity()).thenReturn(playerEntity); + + var entityCache = context.spy(new EntityCache(session)); + when(session.getEntityCache()).thenReturn(entityCache); + + var worldCache = context.spy(new WorldCache(session)); + when(session.getWorldCache()).thenReturn(worldCache); + + // disable global scoreboard updater + when(worldCache.increaseAndGetScoreboardPacketsPerSecond()).thenReturn(0); + } + + public static PlayerEntity mockAndAddPlayerEntity(GeyserMockContext context, String username, long geyserId) { + var playerEntity = spy(new PlayerEntity(context.session(), geyserId, UUID.randomUUID(), username)); + // fake the player being spawned + when(playerEntity.isValid()).thenReturn(true); + + var entityCache = context.mockOrSpy(EntityCache.class); + entityCache.addPlayerEntity(playerEntity); + // called when the player spawns + entityCache.cacheEntity(playerEntity); + return playerEntity; + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e50756ef1..ae02a0079 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ neoforge-minecraft = "21.0.0-beta" mixin = "0.8.5" mixinextras = "0.3.5" minecraft = "1.21" +mockito = "5.+" # plugin versions indra = "3.1.3" @@ -124,6 +125,8 @@ protocol-connection = { group = "org.cloudburstmc.protocol", name = "bedrock-con math = { group = "org.cloudburstmc.math", name = "immutable", version = "2.0" } +mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } + # plugins indra = { group = "net.kyori", name = "indra-common", version.ref = "indra" } shadow = { group = "com.github.johnrengelman", name = "shadow", version.ref = "shadow" }