From ef4acb121f0f8746fa18a27f6acb1fc032ec7b2b Mon Sep 17 00:00:00 2001 From: Tim203 Date: Tue, 8 Oct 2024 19:26:46 +0200 Subject: [PATCH] Scoreboard rework (#4947) * Initial version of the great scoreboard rework * Fixed some issues and added some initial tests * Addressed review * Added CubeCraft's scoreboard as a test, and fixed a discovered bug * Removed var usage for primitives and String, removed star imports --- core/build.gradle.kts | 1 + .../geysermc/geyser/entity/type/Entity.java | 104 ++- .../geyser/entity/type/LivingEntity.java | 19 +- .../type/living/animal/RabbitEntity.java | 15 +- .../entity/type/player/PlayerEntity.java | 197 ++--- .../geysermc/geyser/scoreboard/Objective.java | 187 ++--- .../org/geysermc/geyser/scoreboard/Score.java | 199 ----- .../geyser/scoreboard/ScoreReference.java | 132 +++ .../geyser/scoreboard/Scoreboard.java | 419 ++++------ .../geyser/scoreboard/ScoreboardUpdater.java | 1 - .../org/geysermc/geyser/scoreboard/Team.java | 360 +++++---- .../display/score/BelownameDisplayScore.java | 56 ++ .../display/score/DisplayScore.java | 70 ++ .../display/score/PlayerlistDisplayScore.java | 61 ++ .../display/score/SidebarDisplayScore.java | 139 ++++ .../display/slot/BelownameDisplaySlot.java | 182 +++++ .../scoreboard/display/slot/DisplaySlot.java | 162 ++++ .../display/slot/PlayerlistDisplaySlot.java | 158 ++++ .../display/slot/SidebarDisplaySlot.java | 189 +++++ .../geyser/session/cache/EntityCache.java | 62 +- .../geyser/session/cache/WorldCache.java | 9 +- .../org/geysermc/geyser/text/ChatColor.java | 58 +- .../java/scoreboard/JavaResetScorePacket.java | 20 +- .../JavaSetObjectiveTranslator.java | 61 +- .../JavaSetPlayerTeamTranslator.java | 112 +-- .../scoreboard/JavaSetScoreTranslator.java | 46 +- .../translator/text/MessageTranslator.java | 70 +- .../org/geysermc/geyser/util/EntityUtils.java | 20 + .../network/NameVisibilityScoreboardTest.java | 270 +++++++ .../BasicBelownameScoreboardTests.java | 227 ++++++ .../BasicPlayerlistScoreboardTests.java | 204 +++++ .../server/CubecraftScoreboardTest.java | 756 ++++++++++++++++++ .../sidebar/BasicSidebarScoreboardTests.java | 218 +++++ .../OrderAndLimitSidebarScoreboardTests.java | 533 ++++++++++++ .../VanillaSidebarScoreboardTests.java | 265 ++++++ .../scoreboard/network/util/AssertUtils.java | 52 ++ .../network/util/EmptyGeyserLogger.java | 75 ++ .../network/util/GeyserMockContext.java | 143 ++++ .../util/GeyserMockContextScoreboard.java | 93 +++ gradle/libs.versions.toml | 3 + 40 files changed, 4838 insertions(+), 1110 deletions(-) delete mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/Score.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java create mode 100644 core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java create mode 100644 core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index d30e60298..b0ea5fdf6 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -61,6 +61,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 08e87dc03..983455da8 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 @@ -25,6 +25,12 @@ package org.geysermc.geyser.entity.type; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; @@ -35,12 +41,18 @@ import org.cloudburstmc.math.vector.Vector3f; import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; import org.cloudburstmc.protocol.bedrock.data.entity.EntityEventType; import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag; -import org.cloudburstmc.protocol.bedrock.packet.*; +import org.cloudburstmc.protocol.bedrock.packet.AddEntityPacket; +import org.cloudburstmc.protocol.bedrock.packet.EntityEventPacket; +import org.cloudburstmc.protocol.bedrock.packet.MoveEntityAbsolutePacket; +import org.cloudburstmc.protocol.bedrock.packet.MoveEntityDeltaPacket; +import org.cloudburstmc.protocol.bedrock.packet.RemoveEntityPacket; +import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; import org.geysermc.geyser.api.entity.type.GeyserEntity; 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; @@ -55,12 +67,9 @@ import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.IntEnt import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand; import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType; -import java.util.*; - @Getter @Setter public class Entity implements GeyserEntity { - private static final boolean PRINT_ENTITY_SPAWN_DEBUG = Boolean.parseBoolean(System.getProperty("Geyser.PrintEntitySpawnDebug", "false")); protected final GeyserSession session; @@ -68,6 +77,12 @@ public class Entity implements GeyserEntity { protected int entityId; protected final long geyserId; protected UUID uuid; + /** + * Do not call this setter directly! + * This will bypass the scoreboard and setting the metadata + */ + @Setter(AccessLevel.NONE) + protected String nametag = ""; protected Vector3f position; protected Vector3f motion; @@ -97,7 +112,7 @@ public class Entity implements GeyserEntity { @Setter(AccessLevel.NONE) private float boundingBoxWidth; @Setter(AccessLevel.NONE) - protected String nametag = ""; + private String displayName; @Setter(AccessLevel.NONE) protected boolean silent = false; /* Metadata end */ @@ -126,11 +141,12 @@ 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.definition = definition; + this.displayName = standardDisplayName(); this.entityId = entityId; this.geyserId = geyserId; this.uuid = uuid; - this.definition = definition; this.motion = motion; this.yaw = yaw; this.pitch = pitch; @@ -341,7 +357,7 @@ public class Entity implements GeyserEntity { * Sends the Bedrock metadata to the client */ public void updateBedrockMetadata() { - if (!valid) { + if (!isValid()) { return; } @@ -410,17 +426,81 @@ public class Entity implements GeyserEntity { return 300; } + public String teamIdentifier() { + return uuid.toString(); + } + public void setDisplayName(EntityMetadata, ?> entityMetadata) { + // 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()) { - nametag = MessageTranslator.convertMessage(name.get(), session.locale()); - dirtyMetadata.put(EntityDataTypes.NAME, nametag); - } else if (!nametag.isEmpty()) { - // Clear nametag - dirtyMetadata.put(EntityDataTypes.NAME, ""); + String displayName = MessageTranslator.convertMessage(name.get(), session.locale()); + this.displayName = displayName; + setNametag(displayName, 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 = standardDisplayName(); + setNametag(null, true); } + protected String standardDisplayName() { + return EntityUtils.translatedEntityName(definition.entityType(), session); + } + + protected void setNametag(@Nullable String nametag, boolean fromDisplayName) { + // 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 = ""; + } + boolean changed = !Objects.equals(this.nametag, nametag); + this.nametag = nametag; + // we only update metadata if the value has changed + if (!changed) { + return; + } + + dirtyMetadata.put(EntityDataTypes.NAME, nametag); + // if nametag (player with team) is hidden for player, so should the score (belowname) + 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) {} + public void setDisplayNameVisible(BooleanEntityMetadata entityMetadata) { dirtyMetadata.put(EntityDataTypes.NAMETAG_ALWAYS_SHOW, (byte) (entityMetadata.getPrimitiveValue() ? 1 : 0)); } 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 266189e63..626ceca5c 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; @@ -45,6 +50,7 @@ import org.geysermc.geyser.entity.vehicle.ClientVehicle; import org.geysermc.geyser.inventory.GeyserItemStack; import org.geysermc.geyser.item.Items; 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.util.AttributeUtils; @@ -65,12 +71,9 @@ 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.*; - @Getter @Setter public class LivingEntity extends Entity { - protected ItemData helmet = ItemData.AIR; protected ItemData chestplate = ItemData.AIR; protected ItemData leggings = ItemData.AIR; @@ -150,6 +153,16 @@ public class LivingEntity extends Entity { dirtyMetadata.put(EntityDataTypes.STRUCTURAL_INTEGRITY, 1); } + @Override + public void updateNametag(@Nullable Team team) { + // 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 void setLivingEntityFlags(ByteEntityMetadata entityMetadata) { byte xd = entityMetadata.getPrimitiveValue(); diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java index 0a108be73..fbfc5d40a 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java @@ -25,6 +25,7 @@ package org.geysermc.geyser.entity.type.living.animal; +import net.kyori.adventure.key.Key; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.math.vector.Vector3f; import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; @@ -32,11 +33,13 @@ import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag; import org.geysermc.geyser.entity.EntityDefinition; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.cache.tags.ItemTag; +import org.geysermc.geyser.util.EntityUtils; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.IntEntityMetadata; import java.util.UUID; public class RabbitEntity extends AnimalEntity { + private boolean isKillerBunny; public RabbitEntity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition definition, Vector3f position, Vector3f motion, float yaw, float pitch, float headYaw) { super(session, entityId, geyserId, uuid, definition, position, motion, yaw, pitch, headYaw); @@ -46,7 +49,7 @@ public class RabbitEntity extends AnimalEntity { int variant = entityMetadata.getPrimitiveValue(); // Change the killer bunny to display as white since it only exists on Java Edition - boolean isKillerBunny = variant == 99; + isKillerBunny = variant == 99; if (isKillerBunny) { variant = 1; } @@ -56,6 +59,14 @@ public class RabbitEntity extends AnimalEntity { dirtyMetadata.put(EntityDataTypes.VARIANT, variant); } + @Override + protected String standardDisplayName() { + if (isKillerBunny) { + return EntityUtils.translatedEntityName(Key.key("killer_bunny"), session); + } + return super.standardDisplayName(); + } + @Override protected float getAdultSize() { return 0.55f; @@ -71,4 +82,4 @@ public class RabbitEntity extends AnimalEntity { protected ItemTag getFoodTag() { return ItemTag.RABBIT_FOOD; } -} \ No newline at end of file +} 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 b326f2e04..85905d716 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,12 @@ package org.geysermc.geyser.entity.type.player; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.Setter; import net.kyori.adventure.text.Component; @@ -32,53 +38,35 @@ import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.math.vector.Vector3f; import org.cloudburstmc.math.vector.Vector3i; import org.cloudburstmc.nbt.NbtMap; -import org.cloudburstmc.nbt.NbtMapBuilder; import org.cloudburstmc.protocol.bedrock.data.Ability; 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.AddPlayerPacket; import org.cloudburstmc.protocol.bedrock.packet.MovePlayerPacket; -import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; import org.cloudburstmc.protocol.bedrock.packet.SetEntityLinkPacket; import org.cloudburstmc.protocol.bedrock.packet.UpdateAttributesPacket; 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; import org.geysermc.geyser.entity.type.LivingEntity; import org.geysermc.geyser.entity.type.living.animal.tameable.ParrotEntity; -import org.geysermc.geyser.scoreboard.Objective; -import org.geysermc.geyser.scoreboard.Score; -import org.geysermc.geyser.scoreboard.Team; -import org.geysermc.geyser.scoreboard.UpdateType; import org.geysermc.geyser.session.GeyserSession; -import org.geysermc.geyser.text.ChatColor; -import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.ChunkUtils; -import org.geysermc.mcprotocollib.protocol.codec.NbtComponentSerializer; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.BlankFormat; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.StyledFormat; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.EntityMetadata; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.Pose; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.BooleanEntityMetadata; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.ByteEntityMetadata; import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.FloatEntityMetadata; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; - -import java.util.Collections; -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 { @@ -96,6 +84,9 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { private String username; + private String cachedScore = ""; + private boolean scoreVisible = true; + /** * The textures property from the GameProfile. */ @@ -123,6 +114,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(); @@ -132,17 +148,6 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { @Override public void spawnEntity() { - // Check to see if the player should have a belowname counterpart added - Objective objective = session.getWorldCache().getScoreboard().getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME); - if (objective != null) { - setBelowNameText(objective); - } - - // Update in case this entity has been despawned, then respawned - this.nametag = this.username; - // The name can't be updated later (the entity metadata for it is ignored), so we need to check for this now - updateDisplayName(session.getWorldCache().getScoreboard().getTeamFor(username)); - AddPlayerPacket addPlayerPacket = new AddPlayerPacket(); addPlayerPacket.setUuid(uuid); addPlayerPacket.setUsername(username); @@ -177,6 +182,7 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { // Since we re-use player entities: Clear flags, held item, etc this.resetMetadata(); + this.nametag = username; this.hand = ItemData.AIR; this.offhand = ItemData.AIR; this.boots = ItemData.AIR; @@ -386,38 +392,30 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { } } + @Override + public String getDisplayName() { + return username; + } + @Override public void setDisplayName(EntityMetadata, ?> entityMetadata) { // Doesn't do anything for players } - //todo this will become common entity logic once UUID support is implemented for them - public void updateDisplayName(@Nullable Team team) { - boolean needsUpdate; - if (team != null) { - String newDisplayName; - if (team.isVisibleFor(session.getPlayerEntity().getUsername())) { - TeamColor color = team.getColor(); - String chatColor = MessageTranslator.toChatColor(color); - // We have to emulate what modern Java text already does for us and add the color to each section - String prefix = team.getCurrentData().getPrefix(); - String suffix = team.getCurrentData().getSuffix(); - newDisplayName = chatColor + prefix + chatColor + this.username + chatColor + suffix; - } else { - // The name is not visible to the session player; clear name - newDisplayName = ""; - } - needsUpdate = !newDisplayName.equals(this.nametag); - this.nametag = newDisplayName; - } else { - // The name has reset, if it was previously something else - needsUpdate = !this.nametag.equals(this.username); - this.nametag = this.username; - } + @Override + public String teamIdentifier() { + return username; + } - if (needsUpdate) { - dirtyMetadata.put(EntityDataTypes.NAME, this.nametag); + @Override + protected void setNametag(@Nullable String nametag, boolean fromDisplayName) { + // when fromDisplayName, LivingEntity will call scoreboard code. After that + // setNametag is called again with fromDisplayName on false + if (nametag == null && !fromDisplayName) { + // nametag = null means reset, so reset it back to username + nametag = username; } + super.setNametag(nametag, fromDisplayName); } @Override @@ -425,6 +423,33 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { // Doesn't do anything for players } + public void setBelowNameText(String text) { + if (text == null) { + text = ""; + } + + boolean changed = !Objects.equals(cachedScore, text); + cachedScore = text; + if (isScoreVisible() && changed) { + dirtyMetadata.put(EntityDataTypes.SCORE, text); + } + } + + @Override + protected void scoreVisibility(boolean show) { + boolean visibilityChanged = scoreVisible != show; + scoreVisible = show; + 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 protected void setDimensions(Pose pose) { float height; @@ -451,64 +476,6 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity { setBoundingBoxHeight(height); } - public void setBelowNameText(Objective objective) { - if (objective != null && objective.getUpdateType() != UpdateType.REMOVE) { - Score score = objective.getScores().get(username); - String numberString; - NumberFormat numberFormat; - int amount; - if (score != null) { - amount = score.getScore(); - numberFormat = score.getNumberFormat(); - if (numberFormat == null) { - numberFormat = objective.getNumberFormat(); - } - } else { - amount = 0; - numberFormat = objective.getNumberFormat(); - } - - if (numberFormat instanceof BlankFormat) { - numberString = ""; - } else if (numberFormat instanceof FixedFormat fixedFormat) { - numberString = MessageTranslator.convertMessage(fixedFormat.getValue()); - } else if (numberFormat instanceof StyledFormat styledFormat) { - NbtMapBuilder styledAmount = styledFormat.getStyle().toBuilder(); - styledAmount.putString("text", String.valueOf(amount)); - - numberString = MessageTranslator.convertJsonMessage( - NbtComponentSerializer.tagComponentToJson(styledAmount.build()).toString(), session.locale()); - } else { - numberString = String.valueOf(amount); - } - - String displayString = numberString + " " + ChatColor.RESET + objective.getDisplayName(); - - if (valid) { - // Already spawned - we still need to run the rest of this code because the spawn packet will be - // providing the information - SetEntityDataPacket packet = new SetEntityDataPacket(); - packet.setRuntimeEntityId(geyserId); - packet.getMetadata().put(EntityDataTypes.SCORE, displayString); - session.sendUpstreamPacket(packet); - } else { - // Not spawned yet, store score value in dirtyMetadata to be picked up by #spawnEntity - dirtyMetadata.put(EntityDataTypes.SCORE, displayString); - } - } else { - if (valid) { - SetEntityDataPacket packet = new SetEntityDataPacket(); - packet.setRuntimeEntityId(geyserId); - packet.getMetadata().put(EntityDataTypes.SCORE, ""); - session.sendUpstreamPacket(packet); - } else { - // Not spawned yet, store score value in dirtyMetadata to be picked up by #spawnEntity - dirtyMetadata.put(EntityDataTypes.SCORE, ""); - } - } - - } - /** * @return the UUID that should be used when dealing with Bedrock's tab list. */ 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 6c1389ef5..f3b7f20d2 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java @@ -25,185 +25,100 @@ package org.geysermc.geyser.scoreboard; -import lombok.Getter; -import lombok.Setter; -import net.kyori.adventure.text.Component; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; - +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import lombok.Getter; +import net.kyori.adventure.text.Component; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; +import org.geysermc.geyser.translator.text.MessageTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType; @Getter public final class Objective { private final Scoreboard scoreboard; - private final long id; - private boolean active = true; + private final List activeSlots = new ArrayList<>(); - @Setter - private UpdateType updateType = UpdateType.ADD; + private final String objectiveName; + private final Map scores = new ConcurrentHashMap<>(); - private String objectiveName; - private ScoreboardPosition displaySlot; - private String displaySlotName; - private String displayName = "unknown"; + private String displayName; private NumberFormat numberFormat; - private int type = 0; // 0 = integer, 1 = heart + private ScoreType type; - private Map scores = new ConcurrentHashMap<>(); - - private Objective(Scoreboard scoreboard) { - this.id = scoreboard.getNextId().getAndIncrement(); - this.scoreboard = scoreboard; - } - - /** - * /!\ This method is made for temporary objectives until the real objective is received - * - * @param scoreboard the scoreboard - * @param objectiveName the name of the objective - */ public Objective(Scoreboard scoreboard, String objectiveName) { - this(scoreboard); + this.scoreboard = scoreboard; this.objectiveName = objectiveName; - this.active = false; - } - - public Objective(Scoreboard scoreboard, String objectiveName, ScoreboardPosition displaySlot, String displayName, int type) { - this(scoreboard); - this.objectiveName = objectiveName; - this.displaySlot = displaySlot; - this.displaySlotName = translateDisplaySlot(displaySlot); - this.displayName = displayName; - this.type = type; - } - - private static String translateDisplaySlot(ScoreboardPosition displaySlot) { - return switch (displaySlot) { - case BELOW_NAME -> "belowname"; - case PLAYER_LIST -> "list"; - default -> "sidebar"; - }; } public void registerScore(String id, int score, Component displayName, NumberFormat numberFormat) { - if (!scores.containsKey(id)) { - long scoreId = scoreboard.getNextId().getAndIncrement(); - Score scoreObject = new Score(scoreId, id) - .setScore(score) - .setTeam(scoreboard.getTeamFor(id)) - .setDisplayName(displayName) - .setNumberFormat(numberFormat) - .setUpdateType(UpdateType.ADD); - scores.put(id, scoreObject); + if (scores.containsKey(id)) { + return; + } + var reference = new ScoreReference(scoreboard, id, score, displayName, numberFormat); + scores.put(id, reference); + + for (var slot : activeSlots) { + slot.addScore(reference); } } public void setScore(String id, int score, Component displayName, NumberFormat numberFormat) { - Score stored = scores.get(id); + ScoreReference stored = scores.get(id); if (stored != null) { - stored.setScore(score) - .setDisplayName(displayName) - .setNumberFormat(numberFormat) - .setUpdateType(UpdateType.UPDATE); + stored.updateProperties(scoreboard, score, displayName, numberFormat); return; } registerScore(id, score, displayName, numberFormat); } public void removeScore(String id) { - Score stored = scores.get(id); + ScoreReference stored = scores.remove(id); if (stored != null) { - stored.setUpdateType(UpdateType.REMOVE); + stored.markDeleted(); } } - /** - * Used internally to remove a score from the score map - */ - public void removeScore0(String id) { - scores.remove(id); - } + public void updateProperties(Component displayNameComponent, ScoreType type, NumberFormat format) { + String displayName = MessageTranslator.convertMessageRaw(displayNameComponent, scoreboard.session().locale()); + boolean changed = !Objects.equals(this.displayName, displayName) || this.type != type; - public Objective setDisplayName(String displayName) { this.displayName = displayName; - if (updateType == UpdateType.NOTHING) { - updateType = UpdateType.UPDATE; - } - return this; - } + this.type = type; - public Objective setNumberFormat(NumberFormat numberFormat) { - if (Objects.equals(this.numberFormat, numberFormat)) { - return this; - } - - this.numberFormat = numberFormat; - if (updateType == UpdateType.NOTHING) { - updateType = UpdateType.UPDATE; - } - - // Update the number format for scores that are following this objective's number format - for (Score score : scores.values()) { - if (score.getNumberFormat() == null) { - score.setUpdateType(UpdateType.UPDATE); + if (!Objects.equals(this.numberFormat, format)) { + this.numberFormat = format; + // update the number format for scores that are following this objective's number format, + // but only if the objective itself doesn't need to be updated. + // When the objective itself has to update all scores are updated anyway + if (!changed) { + for (ScoreReference score : scores.values()) { + if (score.numberFormat() == null) { + score.markChanged(); + } + } } } - return this; - } - - public Objective setType(int type) { - this.type = type; - if (updateType == UpdateType.NOTHING) { - updateType = UpdateType.UPDATE; - } - return this; - } - - public void setActive(ScoreboardPosition displaySlot) { - if (!active) { - active = true; - this.displaySlot = displaySlot; - displaySlotName = translateDisplaySlot(displaySlot); + if (changed) { + for (DisplaySlot slot : activeSlots) { + slot.markNeedsUpdate(); + } } } - /** - * The objective will be removed on the next update - */ - public void pendingRemove() { - updateType = UpdateType.REMOVE; + public boolean hasDisplaySlot() { + return !activeSlots.isEmpty(); } - public @Nullable TeamColor getTeamColor() { - return switch (displaySlot) { - case SIDEBAR_TEAM_RED -> TeamColor.RED; - case SIDEBAR_TEAM_AQUA -> TeamColor.AQUA; - case SIDEBAR_TEAM_BLUE -> TeamColor.BLUE; - case SIDEBAR_TEAM_GOLD -> TeamColor.GOLD; - case SIDEBAR_TEAM_GRAY -> TeamColor.GRAY; - case SIDEBAR_TEAM_BLACK -> TeamColor.BLACK; - case SIDEBAR_TEAM_GREEN -> TeamColor.GREEN; - case SIDEBAR_TEAM_WHITE -> TeamColor.WHITE; - case SIDEBAR_TEAM_YELLOW -> TeamColor.YELLOW; - case SIDEBAR_TEAM_DARK_RED -> TeamColor.DARK_RED; - case SIDEBAR_TEAM_DARK_AQUA -> TeamColor.DARK_AQUA; - case SIDEBAR_TEAM_DARK_BLUE -> TeamColor.DARK_BLUE; - case SIDEBAR_TEAM_DARK_GRAY -> TeamColor.DARK_GRAY; - case SIDEBAR_TEAM_DARK_GREEN -> TeamColor.DARK_GREEN; - case SIDEBAR_TEAM_DARK_PURPLE -> TeamColor.DARK_PURPLE; - case SIDEBAR_TEAM_LIGHT_PURPLE -> TeamColor.LIGHT_PURPLE; - default -> null; - }; + public void addDisplaySlot(DisplaySlot slot) { + activeSlots.add(slot); } - public void removed() { - active = false; - updateType = UpdateType.REMOVE; - scores = null; + public void removeDisplaySlot(DisplaySlot slot) { + activeSlots.remove(slot); } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java deleted file mode 100644 index 9a26b7f77..000000000 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2019-2022 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; - -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat; -import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; -import net.kyori.adventure.text.Component; -import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; -import lombok.Getter; -import lombok.experimental.Accessors; -import org.geysermc.geyser.text.ChatColor; -import org.geysermc.geyser.translator.text.MessageTranslator; - -import java.util.Objects; - -@Getter -@Accessors(chain = true) -public final class Score { - private final long id; - private final String name; - private ScoreInfo cachedInfo; - - /** - * Changes that have been made since the last cached data. - */ - private final Score.ScoreData currentData; - /** - * The data that is currently displayed to the Bedrock client. - */ - private Score.ScoreData cachedData; - - public Score(long id, String name) { - this.id = id; - this.name = name; - this.currentData = new ScoreData(); - } - - public String getDisplayName() { - String displayName = cachedData.displayName; - if (displayName != null) { - return displayName; - } - Team team = cachedData.team; - if (team != null) { - return team.getDisplayName(name); - } - return name; - } - - public int getScore() { - return currentData.getScore(); - } - - public Score setScore(int score) { - currentData.score = score; - return this; - } - - public Team getTeam() { - return currentData.team; - } - - public Score setTeam(Team team) { - if (currentData.team != null && team != null) { - if (!currentData.team.equals(team)) { - currentData.team = team; - setUpdateType(UpdateType.UPDATE); - } - return this; - } - // simplified from (this.team != null && team == null) || (this.team == null && team != null) - if (currentData.team != null || team != null) { - currentData.team = team; - setUpdateType(UpdateType.UPDATE); - } - return this; - } - - public Score setDisplayName(Component displayName) { - if (currentData.displayName != null && displayName != null) { - String convertedDisplayName = MessageTranslator.convertMessage(displayName); - if (!currentData.displayName.equals(convertedDisplayName)) { - currentData.displayName = convertedDisplayName; - setUpdateType(UpdateType.UPDATE); - } - return this; - } - // simplified from (this.displayName != null && displayName == null) || (this.displayName == null && displayName != null) - if (currentData.displayName != null || displayName != null) { - currentData.displayName = MessageTranslator.convertMessage(displayName); - setUpdateType(UpdateType.UPDATE); - } - return this; - } - - public NumberFormat getNumberFormat() { - return currentData.numberFormat; - } - - public Score setNumberFormat(NumberFormat numberFormat) { - if (!Objects.equals(currentData.numberFormat, numberFormat)) { - currentData.numberFormat = numberFormat; - setUpdateType(UpdateType.UPDATE); - } - return this; - } - - public UpdateType getUpdateType() { - return currentData.updateType; - } - - public Score setUpdateType(UpdateType updateType) { - if (updateType != UpdateType.NOTHING) { - currentData.changed = true; - } - currentData.updateType = updateType; - return this; - } - - public boolean shouldUpdate() { - return cachedData == null || currentData.changed || - (currentData.team != null && currentData.team.shouldUpdate()); - } - - public void update(Objective objective) { - if (cachedData == null) { - cachedData = new ScoreData(); - cachedData.updateType = UpdateType.ADD; - if (currentData.updateType == UpdateType.REMOVE) { - cachedData.updateType = UpdateType.REMOVE; - } - } else { - cachedData.updateType = currentData.updateType; - } - - currentData.changed = false; - cachedData.team = currentData.team; - cachedData.score = currentData.score; - cachedData.displayName = currentData.displayName; - cachedData.numberFormat = currentData.numberFormat; - - String name = this.name; - if (cachedData.displayName != null) { - name = cachedData.displayName; - } else if (cachedData.team != null) { - cachedData.team.prepareUpdate(); - name = cachedData.team.getDisplayName(name); - } - - NumberFormat numberFormat = cachedData.numberFormat; - if (numberFormat == null) { - numberFormat = objective.getNumberFormat(); - } - if (numberFormat instanceof FixedFormat fixedFormat) { - name += " " + ChatColor.RESET + MessageTranslator.convertMessage(fixedFormat.getValue()); - } - - cachedInfo = new ScoreInfo(id, objective.getObjectiveName(), cachedData.score, name); - } - - @Getter - public static final class ScoreData { - private UpdateType updateType; - private boolean changed; - - private Team team; - private int score; - - private String displayName; - private NumberFormat numberFormat; - - private ScoreData() { - updateType = UpdateType.ADD; - } - } -} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java new file mode 100644 index 000000000..c26a59899 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2019-2022 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; + +import java.util.Objects; +import net.kyori.adventure.text.Component; +import org.geysermc.geyser.translator.text.MessageTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; + +public final class ScoreReference { + public static final long LAST_UPDATE_DEFAULT = -1; + private static final long LAST_UPDATE_REMOVE = -2; + + private final String name; + private final boolean hidden; + + private String displayName; + private int score; + private NumberFormat numberFormat; + + private long lastUpdate; + + public ScoreReference( + Scoreboard scoreboard, String name, int score, Component displayName, NumberFormat format) { + this.name = name; + // hidden is a sidebar exclusive feature + this.hidden = name.startsWith("#"); + + updateProperties(scoreboard, score, displayName, format); + this.lastUpdate = LAST_UPDATE_DEFAULT; + } + + public String name() { + return name; + } + + public boolean hidden() { + return hidden; + } + + public String displayName() { + return displayName; + } + + public void displayName(Component displayName, Scoreboard scoreboard) { + if (this.displayName != null && displayName != null) { + String convertedDisplayName = MessageTranslator.convertMessage(displayName, scoreboard.session().locale()); + if (!this.displayName.equals(convertedDisplayName)) { + this.displayName = convertedDisplayName; + markChanged(); + } + return; + } + // simplified from (this.displayName != null && displayName == null) || (this.displayName == null && displayName != null) + if (this.displayName != null || displayName != null) { + this.displayName = MessageTranslator.convertMessage(displayName, scoreboard.session().locale()); + markChanged(); + } + } + + public int score() { + return score; + } + + private void score(int score) { + boolean changed = this.score != score; + this.score = score; + if (changed) { + markChanged(); + } + } + + public NumberFormat numberFormat() { + return numberFormat; + } + + private void numberFormat(NumberFormat numberFormat) { + if (Objects.equals(numberFormat(), numberFormat)) { + return; + } + this.numberFormat = numberFormat; + markChanged(); + } + + public void updateProperties(Scoreboard scoreboard, int score, Component displayName, NumberFormat numberFormat) { + score(score); + displayName(displayName, scoreboard); + numberFormat(numberFormat); + } + + public long lastUpdate() { + return lastUpdate; + } + + public boolean isRemoved() { + return lastUpdate == LAST_UPDATE_REMOVE; + } + + public void markChanged() { + if (lastUpdate == LAST_UPDATE_REMOVE) { + return; + } + lastUpdate = System.currentTimeMillis(); + } + + public void markDeleted() { + lastUpdate = -1; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java index acce86f4d..6e0867ddc 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java @@ -25,43 +25,72 @@ package org.geysermc.geyser.scoreboard; +import static org.geysermc.geyser.scoreboard.UpdateType.REMOVE; + import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.Getter; +import net.kyori.adventure.text.Component; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; import org.cloudburstmc.protocol.bedrock.data.command.CommandEnumConstraint; -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.GeyserImpl; import org.geysermc.geyser.GeyserLogger; import org.geysermc.geyser.entity.type.Entity; import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.display.slot.BelownameDisplaySlot; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; +import org.geysermc.geyser.scoreboard.display.slot.PlayerlistDisplaySlot; +import org.geysermc.geyser.scoreboard.display.slot.SidebarDisplaySlot; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility; import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; import org.jetbrains.annotations.Contract; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static org.geysermc.geyser.scoreboard.UpdateType.*; - +/** + * Here follows some information about how scoreboards work in Java Edition, that is related to the workings of this + * class: + *

+ * Objectives can be divided in two states: inactive and active. + * Inactive objectives is the default state for objectives that have been created using the SetObjective packet. + * Scores can be added, updated and removed, but as long as they're inactive they aren't shown to the player. + * An objective becomes active when a SetDisplayObjective packet is received, which contains the slot that + * the objective should be displayed at. + *

+ * While Bedrock can handle showing one objective on multiple slots at the same time, we have to help Bedrock a bit + * for example by limiting the amount of sidebar scores to the amount of lines that can be shown + * (otherwise Bedrock may lag) and only showing online players in the playerlist (otherwise it's too cluttered.) + * This fact is the biggest contributor for the class being structured like it is. + */ public final class Scoreboard { private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "true")); private static final boolean ADD_TEAM_SUGGESTIONS = Boolean.parseBoolean(System.getProperty("Geyser.AddTeamSuggestions", "true")); private final GeyserSession session; private final GeyserLogger logger; - @Getter private final AtomicLong nextId = new AtomicLong(0); private final Map objectives = new ConcurrentHashMap<>(); @Getter - private final Map objectiveSlots = new EnumMap<>(ScoreboardPosition.class); + private final Map objectiveSlots = Collections.synchronizedMap(new EnumMap<>(ScoreboardPosition.class)); + private final List removedSlots = Collections.synchronizedList(new ArrayList<>()); + private final Map teams = new ConcurrentHashMap<>(); // updated on multiple threads /** * Required to preserve vanilla behavior, which also uses a map. @@ -71,6 +100,7 @@ public final class Scoreboard { @Getter private final Map playerToTeam = new Object2ObjectOpenHashMap<>(); + private final AtomicBoolean updateLockActive = new AtomicBoolean(false); private int lastAddScoreCount = 0; private int lastRemoveScoreCount = 0; @@ -80,24 +110,22 @@ public final class Scoreboard { } public void removeScoreboard() { - Iterator iterator = objectives.values().iterator(); - while (iterator.hasNext()) { - Objective objective = iterator.next(); - iterator.remove(); + var copy = new HashMap<>(objectiveSlots); + objectiveSlots.clear(); - deleteObjective(objective, false); + for (DisplaySlot slot : copy.values()) { + slot.remove(); } } public @Nullable Objective registerNewObjective(String objectiveId) { Objective objective = objectives.get(objectiveId); if (objective != null) { - // we have no other choice, or we have to make a new map? - // if the objective hasn't been deleted, we have to force it - if (objective.getUpdateType() != REMOVE) { - return null; + // matches vanilla behaviour + if (SHOW_SCOREBOARD_LOGS) { + logger.warning("An objective with the same name '" + objectiveId + "' already exists! Ignoring new objective!"); } - deleteObjective(objective, true); + return null; } objective = new Objective(this, objectiveId); @@ -105,273 +133,162 @@ public final class Scoreboard { return objective; } - public void displayObjective(String objectiveId, ScoreboardPosition displaySlot) { + public void displayObjective(String objectiveId, ScoreboardPosition slot) { + if (objectiveId.isEmpty()) { + // matches vanilla behaviour + var display = objectiveSlots.get(slot); + if (display != null) { + removedSlots.add(display); + objectiveSlots.remove(slot, display); + var objective = display.objective(); + objective.removeDisplaySlot(display); + } + return; + } + Objective objective = objectives.get(objectiveId); if (objective == null) { return; } - if (!objective.isActive()) { - objective.setActive(displaySlot); - // for reactivated objectives - objective.setUpdateType(ADD); + var display = objectiveSlots.get(slot); + if (display != null && display.objective() != objective) { + removedSlots.add(display); } - Objective storedObjective = objectiveSlots.get(displaySlot); - if (storedObjective != null && storedObjective != objective) { - storedObjective.pendingRemove(); - } - objectiveSlots.put(displaySlot, objective); - - if (displaySlot == ScoreboardPosition.BELOW_NAME) { - // Display the below name score option to all players - // Of note: unlike Bedrock, if there is an objective in the below name slot, everyone has a display - for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) { - if (!entity.isValid()) { - // Player hasn't spawned yet - don't bother, it'll be done then - continue; - } - - entity.setBelowNameText(objective); - } - } + display = switch (DisplaySlot.slotCategory(slot)) { + case SIDEBAR -> new SidebarDisplaySlot(session, objective, slot); + case BELOW_NAME -> new BelownameDisplaySlot(session, objective); + case PLAYER_LIST -> new PlayerlistDisplaySlot(session, objective); + default -> throw new IllegalStateException("Unexpected value: " + slot); + }; + objectiveSlots.put(slot, display); + objective.addDisplaySlot(display); } - public Team registerNewTeam(String teamName, String[] players) { + public void registerNewTeam( + String teamName, + String[] players, + Component name, + Component prefix, + Component suffix, + NameTagVisibility visibility, + TeamColor color + ) { Team team = teams.get(teamName); if (team != null) { if (SHOW_SCOREBOARD_LOGS) { logger.info(GeyserLocale.getLocaleStringLog("geyser.network.translator.team.failed_overrides", teamName)); } - return team; + return; } - team = new Team(this, teamName); - team.addEntities(players); + team = new Team(this, teamName, players, name, prefix, suffix, visibility, color); teams.put(teamName, team); // Update command parameters - is safe to send even if the command enum doesn't exist on the client (as of 1.19.51) if (ADD_TEAM_SUGGESTIONS) { - session.addCommandEnum("Geyser_Teams", team.getId()); + session.addCommandEnum("Geyser_Teams", team.id()); } - return team; } public void onUpdate() { + // if an update is already running, let it finish + if (updateLockActive.getAndSet(true)) { + return; + } + List addScores = new ArrayList<>(lastAddScoreCount); List removeScores = new ArrayList<>(lastRemoveScoreCount); - List removedObjectives = new ArrayList<>(); Team playerTeam = getTeamFor(session.getPlayerEntity().getUsername()); - Objective correctSidebar = null; + DisplaySlot correctSidebarSlot = null; - for (Objective objective : objectives.values()) { - // objective has been deleted - if (objective.getUpdateType() == REMOVE) { - removedObjectives.add(objective); + for (DisplaySlot slot : objectiveSlots.values()) { + // slot has been removed + if (slot.updateType() == REMOVE) { continue; } - // there's nothing we can do with inactive objectives - // after checking if the objective has been deleted, - // except waiting for the objective to become activated (: - if (!objective.isActive()) { - continue; - } - - if (playerTeam != null && playerTeam.getColor() == objective.getTeamColor()) { - correctSidebar = objective; + if (playerTeam != null && playerTeam.color() == slot.teamColor()) { + correctSidebarSlot = slot; } } - if (correctSidebar == null) { - correctSidebar = objectiveSlots.get(ScoreboardPosition.SIDEBAR); + if (correctSidebarSlot == null) { + correctSidebarSlot = objectiveSlots.get(ScoreboardPosition.SIDEBAR); } - for (Objective objective : removedObjectives) { + var actualRemovedSlots = new ArrayList<>(removedSlots); + for (var slot : actualRemovedSlots) { // Deletion must be handled before the active objectives are handled - otherwise if a scoreboard display is changed before the current // scoreboard is removed, the client can crash - deleteObjective(objective, true); + slot.remove(); } + removedSlots.removeAll(actualRemovedSlots); - handleObjective(objectiveSlots.get(ScoreboardPosition.PLAYER_LIST), addScores, removeScores); - handleObjective(correctSidebar, addScores, removeScores); - handleObjective(objectiveSlots.get(ScoreboardPosition.BELOW_NAME), addScores, removeScores); - - Iterator teamIterator = teams.values().iterator(); - while (teamIterator.hasNext()) { - Team current = teamIterator.next(); - - switch (current.getCachedUpdateType()) { - case ADD, UPDATE -> current.markUpdated(); - case REMOVE -> teamIterator.remove(); - } - } + handleDisplaySlot(objectiveSlots.get(ScoreboardPosition.PLAYER_LIST), addScores, removeScores); + handleDisplaySlot(correctSidebarSlot, addScores, removeScores); + handleDisplaySlot(objectiveSlots.get(ScoreboardPosition.BELOW_NAME), addScores, removeScores); if (!removeScores.isEmpty()) { - SetScorePacket setScorePacket = new SetScorePacket(); - setScorePacket.setAction(SetScorePacket.Action.REMOVE); - setScorePacket.setInfos(removeScores); - session.sendUpstreamPacket(setScorePacket); + SetScorePacket packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.REMOVE); + packet.setInfos(removeScores); + session.sendUpstreamPacket(packet); } if (!addScores.isEmpty()) { - SetScorePacket setScorePacket = new SetScorePacket(); - setScorePacket.setAction(SetScorePacket.Action.SET); - setScorePacket.setInfos(addScores); - session.sendUpstreamPacket(setScorePacket); + SetScorePacket packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(addScores); + session.sendUpstreamPacket(packet); } lastAddScoreCount = addScores.size(); lastRemoveScoreCount = removeScores.size(); + updateLockActive.set(false); } - private void handleObjective(Objective objective, List addScores, List removeScores) { - if (objective == null || objective.getUpdateType() == REMOVE) { - return; + private void handleDisplaySlot(DisplaySlot slot, List addScores, List removeScores) { + if (slot != null) { + slot.render(addScores, removeScores); } - - // hearts can't hold teams, so we treat them differently - if (objective.getType() == 1) { - for (Score score : objective.getScores().values()) { - boolean update = score.shouldUpdate(); - - if (update) { - score.update(objective); - } - - if (score.getUpdateType() != REMOVE && update) { - addScores.add(score.getCachedInfo()); - } - if (score.getUpdateType() != ADD && update) { - removeScores.add(score.getCachedInfo()); - } - } - return; - } - - boolean objectiveAdd = objective.getUpdateType() == ADD; - boolean objectiveUpdate = objective.getUpdateType() == UPDATE; - - for (Score score : objective.getScores().values()) { - if (score.getUpdateType() == REMOVE) { - ScoreInfo cachedInfo = score.getCachedInfo(); - // cachedInfo can be null here when ScoreboardUpdater is being used and a score is added and - // removed before a single update cycle is performed - if (cachedInfo != null) { - removeScores.add(cachedInfo); - } - // score is pending to be removed, so we can remove it from the objective - objective.removeScore0(score.getName()); - break; - } - - Team team = score.getTeam(); - - boolean add = objectiveAdd || objectiveUpdate; - - if (team != null) { - if (team.getUpdateType() == REMOVE || !team.hasEntity(score.getName())) { - score.setTeam(null); - add = true; - } - } - - if (score.shouldUpdate()) { - score.update(objective); - add = true; - } - - if (add) { - addScores.add(score.getCachedInfo()); - } - - // 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 && score.getUpdateType() != ADD && !(objectiveUpdate || objectiveAdd)) { - removeScores.add(score.getCachedInfo()); - } - - score.setUpdateType(NOTHING); - } - - if (objectiveUpdate) { - RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket(); - removeObjectivePacket.setObjectiveId(objective.getObjectiveName()); - session.sendUpstreamPacket(removeObjectivePacket); - } - - if (objectiveAdd || objectiveUpdate) { - SetDisplayObjectivePacket displayObjectivePacket = new SetDisplayObjectivePacket(); - displayObjectivePacket.setObjectiveId(objective.getObjectiveName()); - displayObjectivePacket.setDisplayName(objective.getDisplayName()); - displayObjectivePacket.setCriteria("dummy"); - displayObjectivePacket.setDisplaySlot(objective.getDisplaySlotName()); - displayObjectivePacket.setSortOrder(1); // 0 = ascending, 1 = descending - session.sendUpstreamPacket(displayObjectivePacket); - } - - objective.setUpdateType(NOTHING); - } - - /** - * @param remove if we should remove the objective from the objectives map. - */ - public void deleteObjective(Objective objective, boolean remove) { - if (remove) { - objectives.remove(objective.getObjectiveName()); - } - objectiveSlots.remove(objective.getDisplaySlot(), objective); - - objective.removed(); - - RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket(); - removeObjectivePacket.setObjectiveId(objective.getObjectiveName()); - session.sendUpstreamPacket(removeObjectivePacket); } public Objective getObjective(String objectiveName) { return objectives.get(objectiveName); } - public Collection getObjectives() { - return objectives.values(); - } - - public void unregisterObjective(String objectiveName) { - Objective objective = getObjective(objectiveName); - if (objective != null) { - objective.pendingRemove(); + public void removeObjective(Objective objective) { + objectives.remove(objective.getObjectiveName()); + for (DisplaySlot slot : objective.getActiveSlots()) { + objectiveSlots.remove(slot.position(), slot); + removedSlots.add(slot); } } - public Objective getSlot(ScoreboardPosition slot) { - return objectiveSlots.get(slot); + public void resetPlayerScores(String playerNameOrEntityUuid) { + for (Objective objective : objectives.values()) { + objective.removeScore(playerNameOrEntityUuid); + } } public Team getTeam(String teamName) { return teams.get(teamName); } - public Team getTeamFor(String entity) { - return playerToTeam.get(entity); + public Team getTeamFor(String playerNameOrEntityUuid) { + return playerToTeam.get(playerNameOrEntityUuid); } public void removeTeam(String teamName) { Team remove = teams.remove(teamName); - if (remove != null) { - remove.setUpdateType(REMOVE); - // We need to use the direct entities list here, so #refreshSessionPlayerDisplays also updates accordingly - // With the player's lack of a team in visibility checks - updateEntityNames(remove, remove.getEntities(), true); - for (String name : remove.getEntities()) { - // 1.19.3 Mojmap Scoreboard#removePlayerTeam(PlayerTeam) - playerToTeam.remove(name); - } - - session.removeCommandEnum("Geyser_Teams", remove.getId()); + if (remove == null) { + return; } + remove.remove(); + session.removeCommandEnum("Geyser_Teams", remove.id()); } @Contract("-> new") @@ -381,48 +298,46 @@ public final class Scoreboard { (o1, o2) -> o1, LinkedHashMap::new)); } - /** - * Updates the display names of all entities in a given team. - * @param teamChange the players have either joined or left the team. Used for optimizations when just the display name updated. - */ - public void updateEntityNames(Team team, boolean teamChange) { - Set names = new HashSet<>(team.getEntities()); - updateEntityNames(team, names, teamChange); + public void playerRegistered(PlayerEntity player) { + for (DisplaySlot slot : objectiveSlots.values()) { + slot.playerRegistered(player); + } } - /** - * Updates the display name of a set of entities within a given team. The team may also be null if the set is being removed - * from a team. - */ - public void updateEntityNames(@Nullable Team team, Set names, boolean teamChange) { - if (names.remove(session.getPlayerEntity().getUsername()) && teamChange) { - // If the player's team changed, then other entities' teams may modify their visibility based on team status - refreshSessionPlayerDisplays(); + public void playerRemoved(PlayerEntity player) { + for (DisplaySlot slot : objectiveSlots.values()) { + slot.playerRemoved(player); } - if (!names.isEmpty()) { - for (Entity entity : session.getEntityCache().getEntities().values()) { - // This more complex logic is for the future to iterate over all entities, not just players - if (entity instanceof PlayerEntity player && names.remove(player.getUsername())) { - player.updateDisplayName(team); - player.updateBedrockMetadata(); - if (names.isEmpty()) { - break; - } - } + } + + public void entityRegistered(Entity entity) { + var team = getTeamFor(entity.teamIdentifier()); + if (team != null) { + team.onEntitySpawn(entity); + } + } + + public void entityRemoved(Entity entity) { + var team = getTeamFor(entity.teamIdentifier()); + if (team != null) { + team.onEntityRemove(entity); + } + } + + public void setTeamFor(Team team, Set entities) { + for (DisplaySlot slot : objectiveSlots.values()) { + // only sidebar slots use teams + if (slot instanceof SidebarDisplaySlot sidebar) { + sidebar.setTeamFor(team, entities); } } } - /** - * If the team's player was refreshed, then we need to go through every entity and check... - */ - private void refreshSessionPlayerDisplays() { - for (Entity entity : session.getEntityCache().getEntities().values()) { - if (entity instanceof PlayerEntity player) { - Team playerTeam = session.getWorldCache().getScoreboard().getTeamFor(player.getUsername()); - player.updateDisplayName(playerTeam); - player.updateBedrockMetadata(); - } - } + public long nextId() { + return nextId.getAndIncrement(); + } + + public GeyserSession session() { + return session; } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java index 395eb9576..18a4bce39 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java @@ -173,7 +173,6 @@ public final class ScoreboardUpdater extends Thread { @Getter public static final class ScoreboardSession { private final GeyserSession session; - @SuppressWarnings("WriteOnlyObject") private final AtomicInteger pendingPacketsPerSecond = new AtomicInteger(0); private int packetsPerSecond; private long lastUpdate; 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 cdf2e247e..d7c06ac4f 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java @@ -25,48 +25,66 @@ package org.geysermc.geyser.scoreboard; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.Setter; -import lombok.experimental.Accessors; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; - import java.util.HashSet; 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.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; -@Getter -@Accessors(chain = true) public final class Team { + public static final long LAST_UPDATE_DEFAULT = -1; + private static final long LAST_UPDATE_REMOVE = -2; + private final Scoreboard scoreboard; private final String id; - @Getter(AccessLevel.PACKAGE) private final Set entities; + private final Set managedEntities; @NonNull private NameTagVisibility nameTagVisibility = NameTagVisibility.ALWAYS; - @Setter private TeamColor color; + private TeamColor color; - private final TeamData currentData; - private TeamData cachedData; + private String name; + private String prefix; + private String suffix; + private long lastUpdate; - private boolean updating; - - public Team(Scoreboard scoreboard, String id) { + public Team( + Scoreboard scoreboard, + String id, + String[] players, + Component name, + Component prefix, + Component suffix, + NameTagVisibility visibility, + TeamColor color + ) { this.scoreboard = scoreboard; this.id = id; - currentData = new TeamData(); - entities = new ObjectOpenHashSet<>(); + this.entities = new ObjectOpenHashSet<>(); + this.managedEntities = new ObjectOpenHashSet<>(); + this.lastUpdate = LAST_UPDATE_DEFAULT; + + // doesn't call entity update + updateProperties(name, prefix, suffix, visibility, color); + // calls entity update + addEntities(players); + lastUpdate = LAST_UPDATE_DEFAULT; } - public Set addEntities(String... names) { + public void addEntities(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. @@ -78,26 +96,15 @@ public final class Team { } if (added.isEmpty()) { - return added; + return; } - // we don't have to change the updateType, - // because the scores itself need updating, not the team - for (Objective objective : scoreboard.getObjectives()) { - for (String addedEntity : added) { - Score score = objective.getScores().get(addedEntity); - if (score != null) { - score.setTeam(this); - } - } - } - - return added; + // we don't have to change our updateType, + // because the scores themselves need updating, not the team + scoreboard.setTeamFor(this, added); + addAddedEntities(added); } - /** - * @return all removed entities from this team - */ - public Set removeEntities(String... names) { + public void removeEntities(String... names) { Set removed = new HashSet<>(); for (String name : names) { if (entities.remove(name)) { @@ -105,87 +112,22 @@ public final class Team { } scoreboard.getPlayerToTeam().remove(name, this); } - return removed; + removeRemovedEntities(removed); } public boolean hasEntity(String name) { return entities.contains(name); } - public Team setName(String name) { - currentData.name = name; - return this; - } - - public Team setPrefix(String prefix) { - // replace "null" to an empty string, - // we do this here to improve the performance of Score#getDisplayName - if (prefix.length() == 4 && "null".equals(prefix)) { - currentData.prefix = ""; - return this; + public String displayName(String score) { + String 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 = ""; } - currentData.prefix = prefix; - return this; - } - - public Team setSuffix(String suffix) { - // replace "null" to an empty string, - // we do this here to improve the performance of Score#getDisplayName - if (suffix.length() == 4 && "null".equals(suffix)) { - currentData.suffix = ""; - return this; - } - currentData.suffix = suffix; - return this; - } - - public String getDisplayName(String score) { - return cachedData != null ? - cachedData.getDisplayName(score) : - currentData.getDisplayName(score); - } - - public void markUpdated() { - updating = false; - } - - public boolean shouldUpdate() { - return updating || cachedData == null || currentData.changed; - } - - public void prepareUpdate() { - if (updating) { - return; - } - updating = true; - - if (cachedData == null) { - cachedData = new TeamData(); - cachedData.updateType = currentData.updateType != UpdateType.REMOVE ? UpdateType.ADD : UpdateType.REMOVE; - } else { - cachedData.updateType = currentData.updateType; - } - - currentData.changed = false; - cachedData.name = currentData.name; - cachedData.prefix = currentData.prefix; - cachedData.suffix = currentData.suffix; - } - - public UpdateType getUpdateType() { - return currentData.updateType; - } - - public UpdateType getCachedUpdateType() { - return cachedData != null ? cachedData.updateType : currentData.updateType; - } - - public Team setUpdateType(UpdateType updateType) { - if (updateType != UpdateType.NOTHING) { - currentData.changed = true; - } - currentData.updateType = updateType; - return this; + // 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) { @@ -201,34 +143,178 @@ public final class Team { }; } - public Team setNameTagVisibility(@Nullable NameTagVisibility nameTagVisibility) { - if (nameTagVisibility != null) { - // Null check like this (and this.nameTagVisibility defaults to ALWAYS) as of Java 1.19.4 - this.nameTagVisibility = nameTagVisibility; + public void updateProperties(Component name, Component prefix, Component suffix, NameTagVisibility visibility, TeamColor color) { + // this shouldn't happen but hey! + if (lastUpdate == LAST_UPDATE_REMOVE) { + return; } - return this; + + String oldName = this.name; + String oldPrefix = this.prefix; + String oldSuffix = this.suffix; + boolean oldVisible = isVisibleFor(playerName()); + var oldColor = this.color; + + 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) { + this.nameTagVisibility = visibility; + } + this.color = color; + + if (lastUpdate == LAST_UPDATE_DEFAULT) { + // 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(); + } + return; + } + + if (!this.name.equals(oldName) + || !this.prefix.equals(oldPrefix) + || !this.suffix.equals(oldSuffix) + || color != oldColor) { + markChanged(); + updateEntities(); + return; + } + + if (isVisibleFor(playerName()) != oldVisible) { + // if just the visibility changed, we only have to update the entities. + // We don't have to mark it as changed + updateEntities(); + } + } + + public boolean shouldRemove() { + return lastUpdate == LAST_UPDATE_REMOVE; + } + + public void markChanged() { + if (lastUpdate == LAST_UPDATE_REMOVE) { + return; + } + lastUpdate = System.currentTimeMillis(); + } + + public void remove() { + lastUpdate = LAST_UPDATE_REMOVE; + + for (String name : entities()) { + // 1.19.3 Mojmap Scoreboard#removePlayerTeam(PlayerTeam) + scoreboard.getPlayerToTeam().remove(name); + } + + if (entities().contains(playerName())) { + refreshAllEntities(); + return; + } + for (Entity entity : managedEntities) { + entity.updateNametag(null); + entity.updateBedrockMetadata(); + } + } + + private void updateEntities() { + for (Entity entity : managedEntities) { + entity.updateNametag(this); + entity.updateBedrockMetadata(); + } + } + + public void onEntitySpawn(Entity entity) { + // I've basically ported addAddedEntities + if (entities.contains(entity.teamIdentifier())) { + managedEntities.add(entity); + // onEntitySpawn includes all entities but players, so it cannot contain self + entity.updateNametag(this); + entity.updateBedrockMetadata(); + } + } + + public void onEntityRemove(Entity entity) { + // we don't have to update anything, since the player is removed. + managedEntities.remove(entity); + } + + private void addAddedEntities(Set names) { + // can't contain self if none are added + if (names.isEmpty()) { + return; + } + boolean containsSelf = names.contains(playerName()); + + for (Entity entity : session().getEntityCache().getEntities().values()) { + if (names.contains(entity.teamIdentifier())) { + managedEntities.add(entity); + if (!containsSelf) { + entity.updateNametag(this); + entity.updateBedrockMetadata(); + } + } + } + + if (containsSelf) { + refreshAllEntities(); + } + } + + private void removeRemovedEntities(Set names) { + boolean containsSelf = names.contains(playerName()); + + var iterator = managedEntities.iterator(); + while (iterator.hasNext()) { + var entity = iterator.next(); + if (names.contains(entity.teamIdentifier())) { + iterator.remove(); + if (!containsSelf) { + entity.updateNametag(null); + entity.updateBedrockMetadata(); + } + } + } + + if (containsSelf) { + refreshAllEntities(); + } + } + + private void refreshAllEntities() { + for (Entity entity : session().getEntityCache().getEntities().values()) { + entity.updateNametag(scoreboard.getTeamFor(entity.teamIdentifier())); + entity.updateBedrockMetadata(); + } + } + + private GeyserSession session() { + return scoreboard.session(); + } + + private String playerName() { + return session().getPlayerEntity().getUsername(); + } + + public String id() { + return id; + } + + public TeamColor color() { + return color; + } + + public long lastUpdate() { + return lastUpdate; + } + + public Set entities() { + return entities; } @Override public int hashCode() { return id.hashCode(); } - - @Getter - public static final class TeamData { - private UpdateType updateType; - private boolean changed; - - private String name; - private String prefix; - private String suffix; - - private TeamData() { - updateType = UpdateType.ADD; - } - - public String getDisplayName(String score) { - return prefix + score + suffix; - } - } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java new file mode 100644 index 000000000..8e101d66a --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java @@ -0,0 +1,56 @@ +/* + * 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.display.score; + +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; + +public class BelownameDisplayScore extends DisplayScore { + private final PlayerEntity player; + + public BelownameDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference, PlayerEntity player) { + super(slot, scoreId, reference); + this.player = player; + } + + @Override + public void update(Objective objective) {} + + public PlayerEntity player() { + return player; + } + + @Override + public void markUpdated() { + super.markUpdated(); + } + + public ScoreReference reference() { + return reference; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java new file mode 100644 index 000000000..c6d70bb96 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java @@ -0,0 +1,70 @@ +/* + * 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.display.score; + +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; + +public abstract class DisplayScore { + protected final DisplaySlot slot; + protected final long id; + protected final ScoreReference reference; + + protected long lastTeamUpdate; + protected long lastUpdate; + + public DisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference) { + this.slot = slot; + this.id = scoreId; + this.reference = reference; + } + + public boolean shouldUpdate() { + return reference.lastUpdate() != lastUpdate; + } + + public abstract void update(Objective objective); + + public String name() { + return reference.name(); + } + + public int score() { + return reference.score(); + } + + public boolean referenceRemoved() { + return reference.isRemoved(); + } + + protected void markUpdated() { + // with the last update (also for team) we rather have an old lastUpdate + // (and have to update again the next cycle) than potentially losing information + // by fetching the lastUpdate after update was performed + this.lastUpdate = reference.lastUpdate(); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java new file mode 100644 index 000000000..c4d8d91be --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java @@ -0,0 +1,61 @@ +/* + * 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.display.score; + +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; + +public final class PlayerlistDisplayScore extends DisplayScore { + private final long playerId; + private ScoreInfo cachedInfo; + + public PlayerlistDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference, long playerId) { + super(slot, scoreId, reference); + this.playerId = playerId; + } + + @Override + public boolean shouldUpdate() { + // for player references the player's name is shown, + // so we only have to update when the score has changed + return cachedInfo == null || cachedInfo.getScore() != reference.score(); + } + + @Override + public void update(Objective objective) { + cachedInfo = new ScoreInfo(id, slot.objectiveId(), reference.score(), ScoreInfo.ScorerType.PLAYER, playerId); + } + + public ScoreInfo cachedInfo() { + return cachedInfo; + } + + public boolean exists() { + return cachedInfo != null; + } +} 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 new file mode 100644 index 000000000..42c0dbbf7 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java @@ -0,0 +1,139 @@ +/* + * 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.display.score; + +import java.util.Objects; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.Team; +import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot; +import org.geysermc.geyser.text.ChatColor; +import org.geysermc.geyser.translator.text.MessageTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; + +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); + team(slot.objective().getScoreboard().getTeamFor(reference.name())); + } + + @Override + public boolean shouldUpdate() { + return super.shouldUpdate() || shouldTeamUpdate(); + } + + private boolean shouldTeamUpdate() { + return team != null && team.lastUpdate() != lastTeamUpdate; + } + + @Override + public void update(Objective objective) { + markUpdated(); + + String finalName = reference.name(); + String displayName = reference.displayName(); + + if (displayName != null) { + finalName = displayName; + } else if (team != null) { + this.lastTeamUpdate = team.lastUpdate(); + finalName = team.displayName(reference.name()); + } + + NumberFormat numberFormat = reference.numberFormat(); + if (numberFormat == null) { + numberFormat = objective.getNumberFormat(); + } + if (numberFormat instanceof FixedFormat fixedFormat) { + finalName += " " + ChatColor.RESET + MessageTranslator.convertMessage(fixedFormat.getValue(), objective.getScoreboard().session().locale()); + } + + if (order != null) { + finalName = order + ChatColor.RESET + finalName; + } + + if (cachedInfo != null) { + onlyScoreValueChanged = finalName.equals(cachedInfo.getName()); + } + cachedInfo = new ScoreInfo(id, slot.objectiveId(), reference.score(), finalName); + } + + public String order() { + return order; + } + + public DisplayScore order(String order) { + if (Objects.equals(this.order, order)) { + return this; + } + this.order = order; + // this guarantees an update + requestUpdate(); + return this; + } + + public Team team() { + return team; + } + + public void team(Team team) { + if (this.team != null && team != null) { + if (!this.team.equals(team)) { + this.team = team; + requestUpdate(); + } + return; + } + // simplified from (this.team != null && team == null) || (this.team == null && team != null) + if (this.team != null || team != null) { + this.team = team; + requestUpdate(); + } + } + + private void requestUpdate() { + this.lastUpdate = 0; + } + + public ScoreInfo cachedInfo() { + return cachedInfo; + } + + 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 new file mode 100644 index 000000000..42a1e8c3f --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java @@ -0,0 +1,182 @@ +/* + * 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.display.slot; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.util.List; +import org.cloudburstmc.nbt.NbtMapBuilder; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.UpdateType; +import org.geysermc.geyser.scoreboard.display.score.BelownameDisplayScore; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.text.ChatColor; +import org.geysermc.geyser.translator.text.MessageTranslator; +import org.geysermc.mcprotocollib.protocol.codec.NbtComponentSerializer; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.BlankFormat; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat; +import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.StyledFormat; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; + +public class BelownameDisplaySlot extends DisplaySlot { + private final Long2ObjectMap displayScores = new Long2ObjectOpenHashMap<>(); + + public BelownameDisplaySlot(GeyserSession session, Objective objective) { + super(session, objective, ScoreboardPosition.BELOW_NAME); + } + + @Override + protected void render0(List addScores, List removeScores) { + // how belowname works is that if the player itself has belowname as a display slot, + // every player entity will show a score below their name. + // 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 + + // 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())); + } + updateType = UpdateType.NOTHING; + return; + } + + for (var score : displayScores.values()) { + // we don't have to worry about a score not existing, because that's handled by both + // this method when an objective is added and addScore/playerRegistered. + // we only have to update them, if they have changed + // (or delete them, if the score no longer exists) + if (!score.shouldUpdate()) { + continue; + } + + if (score.referenceRemoved()) { + clearBelowNameText(score.player()); + continue; + } + + score.markUpdated(); + setBelowNameText(score.player(), score.reference()); + } + } + + @Override + public void remove() { + updateType = UpdateType.REMOVE; + for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) { + clearBelowNameText(player); + } + } + + @Override + public void addScore(ScoreReference reference) { + addDisplayScore(reference); + } + + @Override + public void playerRegistered(PlayerEntity player) { + var reference = scoreFor(player.getUsername()); + setBelowNameText(player, reference); + // keep track of score when the player is active + if (reference != null) { + // we already set the text, so we only have to update once the score does + addDisplayScore(player, reference).markUpdated(); + } + } + + @Override + public void playerRemoved(PlayerEntity player) { + displayScores.remove(player.getGeyserId()); + } + + private void addDisplayScore(ScoreReference reference) { + var players = session.getEntityCache().getPlayersByName(reference.name()); + for (PlayerEntity player : players) { + addDisplayScore(player, reference); + } + } + + private BelownameDisplayScore addDisplayScore(PlayerEntity player, ScoreReference reference) { + var score = new BelownameDisplayScore(this, objective.getScoreboard().nextId(), reference, player); + displayScores.put(player.getGeyserId(), score); + return score; + } + + private void setBelowNameText(PlayerEntity player, ScoreReference reference) { + player.setBelowNameText(calculateBelowNameText(reference)); + player.updateBedrockMetadata(); + } + + private void clearBelowNameText(PlayerEntity player) { + player.setBelowNameText(null); + player.updateBedrockMetadata(); + } + + private String calculateBelowNameText(ScoreReference reference) { + String numberString; + NumberFormat numberFormat = null; + // even if the player doesn't have a score, as long as belowname is on the client Java behaviour is + // to show them with a score of 0 + int score = 0; + if (reference != null) { + score = reference.score(); + numberFormat = reference.numberFormat(); + } + if (numberFormat == null) { + numberFormat = objective.getNumberFormat(); + } + + if (numberFormat instanceof BlankFormat) { + numberString = ""; + } else if (numberFormat instanceof FixedFormat fixedFormat) { + numberString = MessageTranslator.convertMessage(fixedFormat.getValue(), session.locale()); + } else if (numberFormat instanceof StyledFormat styledFormat) { + NbtMapBuilder styledAmount = styledFormat.getStyle().toBuilder(); + styledAmount.putString("text", String.valueOf(score)); + + numberString = MessageTranslator.convertJsonMessage( + NbtComponentSerializer.tagComponentToJson(styledAmount.build()).toString(), session.locale()); + } else { + numberString = String.valueOf(score); + } + + return numberString + " " + ChatColor.RESET + objective.getDisplayName(); + } + + private ScoreReference scoreFor(String username) { + return objective.getScores().get(username); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java new file mode 100644 index 000000000..bac79e23e --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java @@ -0,0 +1,162 @@ +/* + * 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.display.slot; + +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.UpdateType; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; + +public abstract class DisplaySlot { + protected final GeyserSession session; + protected final Objective objective; + /** + * Use this instead of objective name because one objective can be shared in multiple slots, + * but each slot has its own logic and might not contain all scores + */ + protected final String objectiveId; + protected final ScoreboardPosition slot; + protected final TeamColor teamColor; + protected final String positionName; + + protected UpdateType updateType = UpdateType.ADD; + + public DisplaySlot(GeyserSession session, Objective objective, ScoreboardPosition slot) { + this.session = session; + this.objective = objective; + this.objectiveId = String.valueOf(objective.getScoreboard().nextId()); + this.slot = slot; + this.teamColor = teamColor(slot); + this.positionName = positionName(slot); + } + + public final void render(List addScores, List removeScores) { + if (updateType == UpdateType.REMOVE) { + return; + } + render0(addScores, removeScores); + } + + protected abstract void render0(List addScores, List removeScores); + + public abstract void addScore(ScoreReference reference); + + public abstract void playerRegistered(PlayerEntity player); + public abstract void playerRemoved(PlayerEntity player); + + public void remove() { + updateType = UpdateType.REMOVE; + sendRemoveObjective(); + } + + public void markNeedsUpdate() { + if (updateType == UpdateType.NOTHING) { + updateType = UpdateType.UPDATE; + } + } + + protected void sendDisplayObjective() { + SetDisplayObjectivePacket packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId(objectiveId()); + packet.setDisplayName(objective.getDisplayName()); + packet.setCriteria("dummy"); + packet.setDisplaySlot(positionName); + packet.setSortOrder(1); // 0 = ascending, 1 = descending + session.sendUpstreamPacket(packet); + } + + protected void sendRemoveObjective() { + RemoveObjectivePacket packet = new RemoveObjectivePacket(); + packet.setObjectiveId(objectiveId()); + session.sendUpstreamPacket(packet); + } + + public Objective objective() { + return objective; + } + + public String objectiveId() { + return objectiveId; + } + + public ScoreboardPosition position() { + return slot; + } + + public @Nullable TeamColor teamColor() { + return teamColor; + } + + public UpdateType updateType() { + return updateType; + } + + public static ScoreboardPosition slotCategory(ScoreboardPosition slot) { + return switch (slot) { + case BELOW_NAME -> ScoreboardPosition.BELOW_NAME; + case PLAYER_LIST -> ScoreboardPosition.PLAYER_LIST; + default -> ScoreboardPosition.SIDEBAR; + }; + } + + private static String positionName(ScoreboardPosition slot) { + return switch (slot) { + case BELOW_NAME -> "belowname"; + case PLAYER_LIST -> "list"; + default -> "sidebar"; + }; + } + + private static @Nullable TeamColor teamColor(ScoreboardPosition slot) { + return switch (slot) { + case SIDEBAR_TEAM_RED -> TeamColor.RED; + case SIDEBAR_TEAM_AQUA -> TeamColor.AQUA; + case SIDEBAR_TEAM_BLUE -> TeamColor.BLUE; + case SIDEBAR_TEAM_GOLD -> TeamColor.GOLD; + case SIDEBAR_TEAM_GRAY -> TeamColor.GRAY; + case SIDEBAR_TEAM_BLACK -> TeamColor.BLACK; + case SIDEBAR_TEAM_GREEN -> TeamColor.GREEN; + case SIDEBAR_TEAM_WHITE -> TeamColor.WHITE; + case SIDEBAR_TEAM_YELLOW -> TeamColor.YELLOW; + case SIDEBAR_TEAM_DARK_RED -> TeamColor.DARK_RED; + case SIDEBAR_TEAM_DARK_AQUA -> TeamColor.DARK_AQUA; + case SIDEBAR_TEAM_DARK_BLUE -> TeamColor.DARK_BLUE; + case SIDEBAR_TEAM_DARK_GRAY -> TeamColor.DARK_GRAY; + case SIDEBAR_TEAM_DARK_GREEN -> TeamColor.DARK_GREEN; + case SIDEBAR_TEAM_DARK_PURPLE -> TeamColor.DARK_PURPLE; + case SIDEBAR_TEAM_LIGHT_PURPLE -> TeamColor.LIGHT_PURPLE; + default -> null; + }; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java new file mode 100644 index 000000000..6fd83ab8d --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java @@ -0,0 +1,158 @@ +/* + * 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.display.slot; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMaps; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.UpdateType; +import org.geysermc.geyser.scoreboard.display.score.PlayerlistDisplayScore; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; + +public class PlayerlistDisplaySlot extends DisplaySlot { + private final Long2ObjectMap displayScores = + Long2ObjectMaps.synchronize(new Long2ObjectOpenHashMap<>()); + private final List removedScores = Collections.synchronizedList(new ArrayList<>()); + + public PlayerlistDisplaySlot(GeyserSession session, Objective objective) { + super(session, objective, ScoreboardPosition.PLAYER_LIST); + registerExisting(); + } + + @Override + protected void render0(List addScores, List removeScores) { + boolean objectiveAdd = updateType == UpdateType.ADD; + boolean objectiveUpdate = updateType == UpdateType.UPDATE; + boolean objectiveNothing = updateType == UpdateType.NOTHING; + + // if 'add' the scores aren't present, if 'update' the objective is re-added so the scores don't have to be + // manually removed, if 'remove' the scores are removed anyway + if (objectiveNothing) { + var removedScoresCopy = new ArrayList<>(removedScores); + for (var removedScore : removedScoresCopy) { + //todo idk if this if-statement is needed + if (removedScore.cachedInfo() != null) { + removeScores.add(removedScore.cachedInfo()); + } + } + removedScores.removeAll(removedScoresCopy); + } else { + removedScores.clear(); + } + + for (var score : displayScores.values()) { + if (score.referenceRemoved()) { + ScoreInfo cachedInfo = score.cachedInfo(); + // cachedInfo can be null here when ScoreboardUpdater is being used and a score is added and + // removed before a single update cycle is performed + if (cachedInfo != null) { + removeScores.add(cachedInfo); + } + continue; + } + + //todo does an animated title exist on tab? + boolean add = objectiveAdd || objectiveUpdate; + boolean exists = score.exists(); + + if (score.shouldUpdate()) { + score.update(objective); + add = true; + } + + if (add) { + addScores.add(score.cachedInfo()); + } + + // 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 && objectiveNothing) { + removeScores.add(score.cachedInfo()); + } + } + + if (objectiveUpdate) { + sendRemoveObjective(); + } + + if (objectiveAdd || objectiveUpdate) { + sendDisplayObjective(); + } + + updateType = UpdateType.NOTHING; + } + + @Override + public void addScore(ScoreReference reference) { + // while it breaks a lot of stuff in Java, scoreboard do work fine with multiple players having + // the same username + var players = session.getEntityCache().getPlayersByName(reference.name()); + var selfPlayer = session.getPlayerEntity(); + if (reference.name().equals(selfPlayer.getUsername())) { + players.add(selfPlayer); + } + + for (PlayerEntity player : players) { + var score = + new PlayerlistDisplayScore(this, objective.getScoreboard().nextId(), reference, player.getGeyserId()); + displayScores.put(player.getGeyserId(), score); + } + } + + private void registerExisting() { + playerRegistered(session.getPlayerEntity()); + session.getEntityCache().getAllPlayerEntities().forEach(this::playerRegistered); + } + + @Override + public void playerRegistered(PlayerEntity player) { + var reference = objective.getScores().get(player.getUsername()); + if (reference == null) { + return; + } + var score = + new PlayerlistDisplayScore(this, objective.getScoreboard().nextId(), reference, player.getGeyserId()); + displayScores.put(player.getGeyserId(), score); + } + + @Override + public void playerRemoved(PlayerEntity player) { + var score = displayScores.remove(player.getGeyserId()); + if (score == null) { + return; + } + removedScores.add(score); + } +} 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 new file mode 100644 index 000000000..24cc81f78 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java @@ -0,0 +1,189 @@ +/* + * 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.display.slot; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.scoreboard.Objective; +import org.geysermc.geyser.scoreboard.ScoreReference; +import org.geysermc.geyser.scoreboard.Team; +import org.geysermc.geyser.scoreboard.UpdateType; +import org.geysermc.geyser.scoreboard.display.score.SidebarDisplayScore; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.text.ChatColor; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; + +public final class SidebarDisplaySlot extends DisplaySlot { + private static final int SCORE_DISPLAY_LIMIT = 15; + private static final Comparator SCORE_DISPLAY_ORDER = + Comparator.comparing(ScoreReference::score) + .reversed() + .thenComparing(ScoreReference::name, String.CASE_INSENSITIVE_ORDER); + + private List displayScores = new ArrayList<>(SCORE_DISPLAY_LIMIT); + + public SidebarDisplaySlot(GeyserSession session, Objective objective, ScoreboardPosition position) { + super(session, objective, position); + } + + @Override + protected void render0(List addScores, List removeScores) { + // while one could argue that we may not have to do this fancy Java filter when there are fewer scores than the + // line limit, we would lose the correct order of the scores if we don't + var newDisplayScores = + objective.getScores().values().stream() + .filter(score -> !score.hidden()) + .sorted(SCORE_DISPLAY_ORDER) + .limit(SCORE_DISPLAY_LIMIT) + .map(reference -> { + // pretty much an ArrayList#remove + var iterator = this.displayScores.iterator(); + while (iterator.hasNext()) { + var score = iterator.next(); + if (score.name().equals(reference.name())) { + iterator.remove(); + return score; + } + } + + // new score, so it should be added + return new SidebarDisplayScore(this, objective.getScoreboard().nextId(), reference); + }).collect(Collectors.toList()); + + // in newDisplayScores we removed the items that were already present from displayScores, + // meaning that the items that remain are items that are no longer displayed + for (var score : this.displayScores) { + removeScores.add(score.cachedInfo()); + } + + // preserves the new order + this.displayScores = newDisplayScores; + + // fixes ordering issues with multiple entries with same score + if (!this.displayScores.isEmpty()) { + SidebarDisplayScore lastScore = null; + int count = 0; + for (var score : this.displayScores) { + if (lastScore == null) { + lastScore = score; + continue; + } + + if (score.score() == lastScore.score()) { + // something to keep in mind is that Bedrock doesn't support some legacy color codes and adds some + // codes as well, so if the line limit is every increased keep that in mind + if (count == 0) { + lastScore.order(ChatColor.styleOrder(count++)); + } + score.order(ChatColor.styleOrder(count++)); + } else { + if (count == 0) { + lastScore.order(null); + } + count = 0; + } + lastScore = score; + } + + if (count == 0 && lastScore != null) { + lastScore.order(null); + } + } + + boolean objectiveAdd = updateType == UpdateType.ADD; + boolean objectiveUpdate = updateType == UpdateType.UPDATE; + + for (var score : this.displayScores) { + Team team = score.team(); + boolean add = objectiveAdd || objectiveUpdate; + boolean exists = score.exists(); + + if (team != null) { + // entities are mostly removed from teams without notifying the scores. + if (team.shouldRemove() || !team.hasEntity(score.name())) { + score.team(null); + add = true; + } + } + + if (score.shouldUpdate()) { + score.update(objective); + add = true; + } + + if (add) { + addScores.add(score.cachedInfo()); + } + + // 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) && !score.onlyScoreValueChanged()) { + removeScores.add(score.cachedInfo()); + } + } + + if (objectiveUpdate) { + sendRemoveObjective(); + } + + if (objectiveAdd || objectiveUpdate) { + sendDisplayObjective(); + } + + updateType = UpdateType.NOTHING; + } + + @Override + public void addScore(ScoreReference reference) { + // we handle them a bit different: we sort the scores, and we add them ourselves + } + + @Override + public void playerRegistered(PlayerEntity player) { + + } + + @Override + public void playerRemoved(PlayerEntity player) { + + } + + public void setTeamFor(Team team, Set entities) { + // we only have to worry about scores that are currently displayed, + // because the constructor of the display score fetches the team + for (var score : displayScores) { + if (entities.contains(score.name())) { + score.team(team); + } + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java index 3affa12cf..a80ed3e3a 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java @@ -31,15 +31,18 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; import lombok.Getter; import org.geysermc.geyser.entity.type.Entity; import org.geysermc.geyser.entity.type.Tickable; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; - /** * Each session has its own EntityCache in the occasion that an entity packet is sent specifically * for that player (e.g. seeing vanished players from /vanish) @@ -68,6 +71,10 @@ public class EntityCache { if (cacheEntity(entity)) { entity.spawnEntity(); + // start tracking newly spawned entities. + // This is however not called for players, that's done in addPlayerEntity + session.getWorldCache().getScoreboard().entityRegistered(entity); + if (entity instanceof Tickable) { // Start ticking it tickableEntities.add((Tickable) entity); @@ -86,21 +93,24 @@ public class EntityCache { } public void removeEntity(Entity entity) { + if (entity == null) { + return; + } + if (entity instanceof PlayerEntity player) { session.getPlayerWithCustomHeads().remove(player.getUuid()); } - if (entity != null) { - if (entity.isValid()) { - entity.despawnEntity(); - } + if (entity.isValid()) { + entity.despawnEntity(); + } + entities.remove(entityIdTranslations.remove(entity.getEntityId())); - long geyserId = entityIdTranslations.remove(entity.getEntityId()); - entities.remove(geyserId); + // don't track the entity anymore, now that it's removed + session.getWorldCache().getScoreboard().entityRemoved(entity); - if (entity instanceof Tickable) { - tickableEntities.remove(entity); - } + if (entity instanceof Tickable) { + tickableEntities.remove(entity); } } @@ -126,15 +136,39 @@ public class EntityCache { public void addPlayerEntity(PlayerEntity entity) { // putIfAbsent matches the behavior of playerInfoMap in Java as of 1.19.3 - playerEntities.putIfAbsent(entity.getUuid(), entity); + boolean exists = playerEntities.putIfAbsent(entity.getUuid(), entity) != null; + if (exists) { + return; + } + + // notify scoreboard for new entity + var scoreboard = session.getWorldCache().getScoreboard(); + scoreboard.playerRegistered(entity); + // spawnPlayer's entityRegistered is not called for players + scoreboard.entityRegistered(entity); } public PlayerEntity getPlayerEntity(UUID uuid) { return playerEntities.get(uuid); } + public List getPlayersByName(String name) { + var list = new ArrayList(); + for (PlayerEntity player : playerEntities.values()) { + if (name.equals(player.getUsername())) { + list.add(player); + } + } + return list; + } + public PlayerEntity removePlayerEntity(UUID uuid) { - return playerEntities.remove(uuid); + var player = playerEntities.remove(uuid); + if (player != null) { + // notify scoreboard + session.getWorldCache().getScoreboard().playerRemoved(player); + } + return player; } public Collection getAllPlayerEntities() { diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java index 86cb69314..5927963c0 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java @@ -31,6 +31,7 @@ import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import lombok.Getter; import lombok.Setter; +import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.math.vector.Vector3i; import org.cloudburstmc.protocol.bedrock.packet.SetTitlePacket; @@ -49,7 +50,7 @@ public final class WorldCache { @Getter private final ScoreboardSession scoreboardSession; @Getter - private Scoreboard scoreboard; + private @NonNull Scoreboard scoreboard; @Getter @Setter private Difficulty difficulty = Difficulty.EASY; @@ -81,10 +82,8 @@ public final class WorldCache { } public void removeScoreboard() { - if (scoreboard != null) { - scoreboard.removeScoreboard(); - scoreboard = new Scoreboard(session); - } + scoreboard.removeScoreboard(); + scoreboard = new Scoreboard(session); } public int increaseAndGetScoreboardPacketsPerSecond() { 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 49178f033..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"; @@ -84,4 +86,58 @@ public class ChatColor { string = string.replace(WHITE, (char) 0x1b + "[37;1m"); return string; } -} \ No newline at end of file + + 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; + case 2 -> DARK_GREEN; + case 3 -> DARK_AQUA; + case 4 -> DARK_RED; + case 5 -> DARK_PURPLE; + case 6 -> GOLD; + case 7 -> GRAY; + case 8 -> DARK_GRAY; + case 9 -> BLUE; + case 10 -> GREEN; + case 11 -> AQUA; + case 12 -> RED; + case 13 -> LIGHT_PURPLE; + case 14 -> YELLOW; + case 15 -> WHITE; + case 16 -> OBFUSCATED; + case 17 -> BOLD; + 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/protocol/java/scoreboard/JavaResetScorePacket.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java index e8d307c90..cf688bbfd 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java @@ -32,40 +32,22 @@ import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.cache.WorldCache; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundResetScorePacket; @Translator(packet = ClientboundResetScorePacket.class) public class JavaResetScorePacket extends PacketTranslator { - @Override public void translate(GeyserSession session, ClientboundResetScorePacket packet) { WorldCache worldCache = session.getWorldCache(); Scoreboard scoreboard = worldCache.getScoreboard(); int pps = worldCache.increaseAndGetScoreboardPacketsPerSecond(); - Objective belowName = scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME); - if (packet.getObjective() == null) { // No objective name means all scores are reset for that player (/scoreboard players reset PLAYERNAME) - for (Objective otherObjective : scoreboard.getObjectives()) { - otherObjective.removeScore(packet.getOwner()); - } - - // as described below - if (belowName != null) { - JavaSetScoreTranslator.setBelowName(session, belowName, packet.getOwner()); - } + scoreboard.resetPlayerScores(packet.getOwner()); } else { Objective objective = scoreboard.getObjective(packet.getObjective()); objective.removeScore(packet.getOwner()); - - // If this is the objective that is in use to show the below name text, we need to update the player - // attached to this score. - if (objective == belowName) { - // Update the score on this player to now reflect 0 - JavaSetScoreTranslator.setBelowName(session, objective, packet.getOwner()); - } } // ScoreboardUpdater will handle it for us if the packets per second diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java index 85d93c0b5..0a7c6131f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java @@ -25,72 +25,45 @@ package org.geysermc.geyser.translator.protocol.java.scoreboard; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; -import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; -import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.GeyserLogger; -import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.scoreboard.Objective; import org.geysermc.geyser.scoreboard.Scoreboard; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; -import org.geysermc.geyser.scoreboard.UpdateType; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.cache.WorldCache; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; -import org.geysermc.geyser.translator.text.MessageTranslator; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket; @Translator(packet = ClientboundSetObjectivePacket.class) public class JavaSetObjectiveTranslator extends PacketTranslator { - private final GeyserLogger logger = GeyserImpl.getInstance().getLogger(); - @Override public void translate(GeyserSession session, ClientboundSetObjectivePacket packet) { WorldCache worldCache = session.getWorldCache(); Scoreboard scoreboard = worldCache.getScoreboard(); int pps = worldCache.increaseAndGetScoreboardPacketsPerSecond(); - Objective objective = scoreboard.getObjective(packet.getName()); - if (objective != null && objective.getUpdateType() != UpdateType.REMOVE && packet.getAction() == ObjectiveAction.ADD) { - // matches vanilla behaviour - logger.warning("An objective with the same name '" + packet.getName() + "' already exists! Ignoring packet"); + Objective objective; + if (packet.getAction() == ObjectiveAction.ADD) { + objective = scoreboard.registerNewObjective(packet.getName()); + } else { + objective = scoreboard.getObjective(packet.getName()); + } + + // matches vanilla + if (objective == null) { return; } - if ((objective == null || objective.getUpdateType() == UpdateType.REMOVE) && packet.getAction() != ObjectiveAction.REMOVE) { - objective = scoreboard.registerNewObjective(packet.getName()); - } - switch (packet.getAction()) { - case ADD, UPDATE -> { - objective.setDisplayName(MessageTranslator.convertMessage(packet.getDisplayName())) - .setNumberFormat(packet.getNumberFormat()) - .setType(packet.getType().ordinal()); - if (objective == scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME)) { - // Update the score tag of all players - for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) { - if (entity.isValid()) { - entity.setBelowNameText(objective); - } - } - } - } - case REMOVE -> { - scoreboard.unregisterObjective(packet.getName()); - if (objective != null && objective == scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME)) { - // Clear the score tag from all players - for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) { - // Other places we check for the entity being valid, - // but we must set the below name text as null for all players - // or else PlayerEntity#spawnEntity will find a null objective and not touch EntityData#SCORE_TAG - entity.setBelowNameText(null); - } - } - } + case ADD, UPDATE -> + objective.updateProperties(packet.getDisplayName(), packet.getType(), packet.getNumberFormat()); + case REMOVE -> scoreboard.removeObjective(objective); } - if (objective == null || !objective.isActive()) { + // Scoreboard#removeObjective doesn't touch the display slot(s) that were attached to it. + // So Objective#hasDisplaySlot will be true as long as it's currently present on the Bedrock client + if (!objective.hasDisplaySlot()) { return; } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java index 999edcc8c..3a1ee6373 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java @@ -25,23 +25,17 @@ package org.geysermc.geyser.translator.protocol.java.scoreboard; -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 java.util.Arrays; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.GeyserLogger; import org.geysermc.geyser.scoreboard.Scoreboard; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; import org.geysermc.geyser.scoreboard.Team; -import org.geysermc.geyser.scoreboard.UpdateType; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; -import org.geysermc.geyser.translator.text.MessageTranslator; - -import java.util.Arrays; -import java.util.Set; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket; @Translator(packet = ClientboundSetPlayerTeamPacket.class) public class JavaSetPlayerTeamTranslator extends PacketTranslator { @@ -60,83 +54,45 @@ public class JavaSetPlayerTeamTranslator extends PacketTranslator { - team = scoreboard.registerNewTeam(packet.getTeamName(), packet.getPlayers()) - .setName(MessageTranslator.convertMessage(packet.getDisplayName())) - .setColor(packet.getColor()) - .setNameTagVisibility(packet.getNameTagVisibility()) - .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.locale())) - .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.locale())); - if (packet.getPlayers().length != 0) { - if ((team.getNameTagVisibility() != NameTagVisibility.ALWAYS && !team.isVisibleFor(session.getPlayerEntity().getUsername())) - || team.getColor() != TeamColor.RESET - || !team.getCurrentData().getPrefix().isEmpty() - || !team.getCurrentData().getSuffix().isEmpty()) { - // Something is here that would modify entity names - scoreboard.updateEntityNames(team, true); - } + if (packet.getAction() == TeamAction.CREATE) { + scoreboard.registerNewTeam( + packet.getTeamName(), + packet.getPlayers(), + packet.getDisplayName(), + packet.getPrefix(), + packet.getSuffix(), + packet.getNameTagVisibility(), + packet.getColor() + ); + } else { + Team team = scoreboard.getTeam(packet.getTeamName()); + if (team == null) { + if (logger.isDebug()) { + logger.debug("Error while translating Team Packet " + packet.getAction() + + "! Scoreboard Team " + packet.getTeamName() + " is not registered." + ); } + return; } - case UPDATE -> { - if (team == null) { - if (logger.isDebug()) { - logger.debug("Error while translating Team Packet " + packet.getAction() - + "! Scoreboard Team " + packet.getTeamName() + " is not registered." - ); - } - return; - } - TeamColor oldColor = team.getColor(); - NameTagVisibility oldVisibility = team.getNameTagVisibility(); - String oldPrefix = team.getCurrentData().getPrefix(); - String oldSuffix = team.getCurrentData().getSuffix(); - - team.setName(MessageTranslator.convertMessage(packet.getDisplayName())) - .setColor(packet.getColor()) - .setNameTagVisibility(packet.getNameTagVisibility()) - .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.locale())) - .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.locale())) - .setUpdateType(UpdateType.UPDATE); - - if (oldVisibility != team.getNameTagVisibility() - || oldColor != team.getColor() - || !oldPrefix.equals(team.getCurrentData().getPrefix()) - || !oldSuffix.equals(team.getCurrentData().getSuffix())) { - // Update entities attached to this team as something about their nameplates have changed - scoreboard.updateEntityNames(team, false); + switch (packet.getAction()) { + case UPDATE -> { + team.updateProperties( + packet.getDisplayName(), + packet.getPrefix(), + packet.getSuffix(), + packet.getNameTagVisibility(), + packet.getColor() + ); } + case ADD_PLAYER -> team.addEntities(packet.getPlayers()); + case REMOVE_PLAYER -> team.removeEntities(packet.getPlayers()); + case REMOVE -> scoreboard.removeTeam(packet.getTeamName()); } - case ADD_PLAYER -> { - if (team == null) { - if (logger.isDebug()) { - logger.debug("Error while translating Team Packet " + packet.getAction() - + "! Scoreboard Team " + packet.getTeamName() + " is not registered." - ); - } - return; - } - Set added = team.addEntities(packet.getPlayers()); - scoreboard.updateEntityNames(team, added, true); - } - case REMOVE_PLAYER -> { - if (team == null) { - if (logger.isDebug()) { - logger.debug("Error while translating Team Packet " + packet.getAction() - + "! Scoreboard Team " + packet.getTeamName() + " is not registered." - ); - } - return; - } - Set removed = team.removeEntities(packet.getPlayers()); - scoreboard.updateEntityNames(null, removed, true); - } - case REMOVE -> scoreboard.removeTeam(packet.getTeamName()); } + // ScoreboardUpdater will handle it for us if the packets per second // (for score and team packets) is higher than the first threshold if (pps < ScoreboardUpdater.FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD) { diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java index d1645b496..989f0f2cb 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java @@ -25,12 +25,8 @@ package org.geysermc.geyser.translator.protocol.java.scoreboard; -import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition; -import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; -import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.GeyserLogger; -import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.scoreboard.Objective; import org.geysermc.geyser.scoreboard.Scoreboard; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; @@ -39,6 +35,7 @@ import org.geysermc.geyser.session.cache.WorldCache; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket; @Translator(packet = ClientboundSetScorePacket.class) public class JavaSetScoreTranslator extends PacketTranslator { @@ -63,16 +60,7 @@ public class JavaSetScoreTranslator extends PacketTranslator TEAM_COLORS = new EnumMap<>(TeamColor.class); - // Legacy formatting character private static final String BASE = "\u00a7"; @@ -81,31 +74,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() @@ -157,13 +125,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); @@ -172,7 +158,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) { @@ -185,7 +171,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); } @@ -378,16 +364,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..b7f4e7d76 100644 --- a/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java @@ -25,6 +25,7 @@ package org.geysermc.geyser.util; +import net.kyori.adventure.key.Key; import org.cloudburstmc.math.vector.Vector3f; import org.cloudburstmc.protocol.bedrock.data.GameType; import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; @@ -38,6 +39,8 @@ import org.geysermc.geyser.entity.type.living.animal.AnimalEntity; import org.geysermc.geyser.entity.type.living.animal.horse.CamelEntity; import org.geysermc.geyser.inventory.GeyserItemStack; import org.geysermc.geyser.item.Items; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.text.MinecraftLocale; import org.geysermc.mcprotocollib.protocol.data.game.entity.Effect; import org.geysermc.mcprotocollib.protocol.data.game.entity.player.GameMode; import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand; @@ -290,6 +293,23 @@ public final class EntityUtils { }; } + private static String translatedEntityName(String namespace, String name, GeyserSession session) { + return MinecraftLocale.getLocaleString("entity." + namespace + "." + name, session.locale()); + } + + public static String translatedEntityName(Key type, GeyserSession session) { + return translatedEntityName(type.namespace(), type.value(), session); + } + + public static String translatedEntityName(EntityType type, GeyserSession session) { + if (type == EntityType.PLAYER) { + return "Player"; // the player's name is always shown instead + } + // this works at least with all 1.20.5 entities, except the killer bunny since that's not an entity type. + String typeName = type.name().toLowerCase(Locale.ROOT); + return translatedEntityName("minecraft", typeName, session); + } + 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/server/CubecraftScoreboardTest.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java new file mode 100644 index 000000000..dd693022c --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java @@ -0,0 +1,756 @@ +/* + * 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.server; + +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 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.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.cloudburstmc.protocol.bedrock.data.ScoreInfo; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes; +import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket; +import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; +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.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.TeamAction; +import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor; +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 CubecraftScoreboardTest { + @Test + void test() { + mockContextScoreboard(context -> { + var setTeamTranslator = new JavaSetPlayerTeamTranslator(); + var setObjectiveTranslator = new JavaSetObjectiveTranslator(); + var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator(); + var setScoreTranslator = new JavaSetScoreTranslator(); + + // unused + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("SB_NoName", Component.text("SB_NoName"), Component.empty(), Component.empty(), true, true, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.RESET, new String[0])); + assertNoNextPacket(context); + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "sidebar", + ObjectiveAction.ADD, + Component.text("sidebar"), + ScoreType.INTEGER, + null + ) + ); + assertNoNextPacket(context); + + context.translate( + setDisplayObjectiveTranslator, + new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "sidebar") + ); + assertNextPacket(() -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("sidebar"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, context); + + + // Now they're going to create a bunch of teams and add players to those teams in a very inefficient way. + // Presumably this is a leftover from an old system, as these don't seem to do anything but hide their nametags. + // For which you could just use a single team. + + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.DARK_GRAY)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "A_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", TeamAction.ADD_PLAYER, new String[] { "B_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "C_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "D_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", TeamAction.ADD_PLAYER, new String[] { "E_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "F_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "G_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.BLUE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", TeamAction.ADD_PLAYER, new String[] { "H_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "I_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", TeamAction.ADD_PLAYER, new String[] { "J_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "K_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.AQUA)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", TeamAction.ADD_PLAYER, new String[] { "L_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", TeamAction.ADD_PLAYER, new String[] { "M_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "N_Player" })); + + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0])); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.LIGHT_PURPLE)); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", TeamAction.ADD_PLAYER, new String[] { "O_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "P_Player" })); + context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "Q_Player" })); + + assertNoNextPacket(context); + + + // Now that those teams are created and people added to it, they set the final sidebar name and add the lines to it. + // They're also not doing this efficiently, because they don't add the players when the team is created. + // Instead, they send an additional packet. + + + context.translate( + setObjectiveTranslator, + new ClientboundSetObjectivePacket( + "sidebar", + ObjectiveAction.UPDATE, + Component.empty() + .append(Component.text( + "CubeCraft", Style.style(NamedTextColor.WHITE, TextDecoration.BOLD))), + ScoreType.INTEGER, + null)); + assertNextPacket( + () -> { + var packet = new RemoveObjectivePacket(); + packet.setObjectiveId("0"); + return packet; + }, + context); + assertNextPacket( + () -> { + var packet = new SetDisplayObjectivePacket(); + packet.setObjectiveId("0"); + packet.setDisplayName("§f§lCubeCraft"); + packet.setCriteria("dummy"); + packet.setDisplaySlot("sidebar"); + packet.setSortOrder(1); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-0", + Component.text("SB_l-0"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-0", TeamAction.ADD_PLAYER, new String[] {"§0§0"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-0", + Component.text("SB_l-0"), + Component.empty().append(Component.text("", Style.style(NamedTextColor.BLACK))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§0", "sidebar", 10)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(1, "0", 10, "§r§0§0§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-1", + Component.text("SB_l-1"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-1", TeamAction.ADD_PLAYER, new String[] {"§0§1"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-1", + Component.text("SB_l-1"), + Component.empty() + .append(Component.textOfChildren( + Component.text("User: ", TextColor.color(0x3aa9ff)), + Component.text("Tim203", NamedTextColor.WHITE))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§1", "sidebar", 9)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(2, "0", 9, "§bUser: §r§fTim203§r§0§1§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-2", + Component.text("SB_l-2"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-2", TeamAction.ADD_PLAYER, new String[] {"§0§2"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-2", + Component.text("SB_l-2"), + Component.empty() + .append(Component.textOfChildren( + Component.text("Rank: ", TextColor.color(0x3aa9ff)), + Component.text("\uE1AB ", NamedTextColor.WHITE))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§2", "sidebar", 8)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(3, "0", 8, "§bRank: §r§f\uE1AB §r§0§2§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-3", + Component.text("SB_l-3"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-3", TeamAction.ADD_PLAYER, new String[] {"§0§3"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-3", + Component.text("SB_l-3"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§3", "sidebar", 7)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(4, "0", 7, "§r§0§3§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-4", + Component.text("SB_l-4"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-4", TeamAction.ADD_PLAYER, new String[] {"§0§4"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-4", + Component.text("SB_l-4"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§4", "sidebar", 6)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(5, "0", 6, "§r§0§4§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-5", + Component.text("SB_l-5"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-5", TeamAction.ADD_PLAYER, new String[] {"§0§5"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-5", + Component.text("SB_l-5"), + Component.empty().append(Component.text("", NamedTextColor.DARK_BLUE)), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§5", "sidebar", 5)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(6, "0", 5, "§r§0§5§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-6", + Component.text("SB_l-6"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-6", TeamAction.ADD_PLAYER, new String[] {"§0§6"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-6", + Component.text("SB_l-6"), + Component.empty() + .append(Component.textOfChildren( + Component.text("Lobby: ", TextColor.color(0x3aa9ff)), + Component.text("EU #10", NamedTextColor.WHITE))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§6", "sidebar", 4)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(7, "0", 4, "§bLobby: §r§fEU #10§r§0§6§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-7", + Component.text("SB_l-7"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-7", TeamAction.ADD_PLAYER, new String[] {"§0§7"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-7", + Component.text("SB_l-7"), + Component.empty() + .append(Component.textOfChildren( + Component.text("Players: ", TextColor.color(0x3aa9ff)), + Component.text("783", NamedTextColor.WHITE))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§7", "sidebar", 3)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(8, "0", 3, "§bPlayers: §r§f783§r§0§7§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-8", + Component.text("SB_l-8"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-8", TeamAction.ADD_PLAYER, new String[] {"§0§8"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-8", + Component.text("SB_l-8"), + Component.empty().append(Component.text("", NamedTextColor.DARK_GREEN)), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§8", "sidebar", 2)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(9, "0", 2, "§r§0§8§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-9", + Component.text("SB_l-9"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-9", TeamAction.ADD_PLAYER, new String[] {"§0§9"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-9", + Component.text("SB_l-9"), + Component.empty().append(Component.text("24/09/24 (g2208)", TextColor.color(0x777777))), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§9", "sidebar", 1)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(10, "0", 1, "§824/09/24 (g2208)§r§0§9§r"))); + return packet; + }, + context); + + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-10", + Component.text("SB_l-10"), + Component.empty(), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET, + new String[0])); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket("SB_l-10", TeamAction.ADD_PLAYER, new String[] {"§0§a"})); + context.translate( + setTeamTranslator, + new ClientboundSetPlayerTeamPacket( + "SB_l-10", + Component.text("SB_l-10"), + Component.empty().append(Component.text("play.cubecraft.net", NamedTextColor.GOLD)), + Component.empty(), + true, + true, + NameTagVisibility.ALWAYS, + CollisionRule.ALWAYS, + TeamColor.RESET)); + assertNoNextPacket(context); + + context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§a", "sidebar", 0)); + assertNextPacket( + () -> { + var packet = new SetScorePacket(); + packet.setAction(SetScorePacket.Action.SET); + packet.setInfos(List.of(new ScoreInfo(11, "0", 0, "§6play.cubecraft.net§r§0§a§r"))); + return packet; + }, + context); + + // after this we get a ClientboundPlayerInfoUpdatePacket with the action UPDATE_DISPLAY_NAME, + // but that one is only shown in the tablist so we don't have to handle that. + // And after that we get each player's ClientboundPlayerInfoUpdatePacket with also a UPDATE_DISPLAY_NAME, + // which is also not interesting for us. + // CubeCraft seems to use two armor stands per player: 1 for the rank badge and 1 for the player name. + // So the only thing we have to verify is that the nametag is hidden + + mockAndAddPlayerEntity(context, "A_Player", 2); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(2); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "B_Player", 3); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(3); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "E_Player", 4); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(4); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "H_Player", 5); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(5); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "J_Player", 6); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(6); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "K_Player", 7); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(7); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "L_Player", 8); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(8); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + return packet; + }, + context); + + mockAndAddPlayerEntity(context, "O_Player", 9); + assertNextPacket( + () -> { + var packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(9); + packet.getMetadata().put(EntityDataTypes.NAME, ""); + 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 70d6e915d..51c76803a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ neoforge-minecraft = "21.1.1" mixin = "0.8.5" mixinextras = "0.3.5" minecraft = "1.21.1" +mockito = "5.+" # plugin versions indra = "3.1.3" @@ -133,6 +134,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 lombok = { group = "io.freefair.gradle", name = "lombok-plugin", version.ref = "lombok" } indra = { group = "net.kyori", name = "indra-common", version.ref = "indra" }