Implement polymorphic scoreboard backends with Folia-safe noop fallback

This commit is contained in:
nossr50
2026-06-14 14:36:53 -07:00
parent 127963e0b2
commit 7c65703547
16 changed files with 1054 additions and 220 deletions

View File

@ -6,26 +6,14 @@ import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
/**
* @deprecated No longer fired since mcMMO moved to a packet-based sidebar (scoreboard-library) for
* Folia/Canvas compatibility. This event exposes a Bukkit {@link Objective}, which the packet
* implementation does not have - sidebar lines are sent directly as packets and there is no Bukkit
* objective to register/unregister. The event class is kept only for binary compatibility with
* external plugins; it will never be called by mcMMO.
* Fired when mcMMO's Bukkit scoreboard backend registers or unregisters the sidebar objective.
* <p>
* Changing the field type from {@link Objective} to {@code String} to "revive" this event was
* considered but rejected: it would be a binary-incompatible API break (different method
* signature on {@link #getTargetObjective()}) that would crash any plugin compiled against the
* old API with {@code NoSuchMethodError}, and the cancellation semantics
* ({@link ScoreboardObjectiveEventReason#UNREGISTER_THIS_OBJECTIVE} /
* {@link ScoreboardObjectiveEventReason#REGISTER_NEW_OBJECTIVE}) no longer map to anything in the
* packet implementation.
* <p>
* <b>Suggestion for maintainers:</b> if a hook for sidebar title/content changes is needed in
* the packet era, please add a new dedicated event (e.g. {@code McMMOSidebarUpdateEvent}) that
* carries a {@code String} title and a list of lines. That keeps the old event's binary contract
* intact while giving the new API clean, unambiguous semantics.
* This event is backend-specific:
* <ul>
* <li>Fired by the Bukkit backend (non-Folia servers).</li>
* <li>Not fired by packet/no-op backends, which have no Bukkit {@link Objective}.</li>
* </ul>
*/
@Deprecated
public class McMMOScoreboardObjectiveEvent extends McMMOScoreboardEvent implements Cancellable {
protected boolean cancelled;

View File

@ -11,37 +11,42 @@ import com.gmail.nossr50.locale.LocaleLoader;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.util.LogUtils;
import com.gmail.nossr50.util.Misc;
import com.gmail.nossr50.util.PaperUtil;
import com.gmail.nossr50.util.player.UserManager;
import com.gmail.nossr50.util.scoreboards.backend.BukkitScoreboardBackend;
import com.gmail.nossr50.util.scoreboards.backend.NoopScoreboardBackend;
import com.gmail.nossr50.util.scoreboards.backend.PlayerBoard;
import com.gmail.nossr50.util.scoreboards.backend.PacketScoreboardBackend;
import com.gmail.nossr50.util.scoreboards.backend.ScoreboardBackend;
import com.gmail.nossr50.util.scoreboards.backend.ScoreboardBackendSelector;
import com.gmail.nossr50.util.scoreboards.backend.ScoreboardBackendType;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import net.megavex.scoreboardlibrary.api.ScoreboardLibrary;
import net.megavex.scoreboardlibrary.api.exception.NoPacketAdapterAvailableException;
import net.megavex.scoreboardlibrary.api.noop.NoopScoreboardLibrary;
import net.megavex.scoreboardlibrary.api.objective.ObjectiveDisplaySlot;
import net.megavex.scoreboardlibrary.api.objective.ObjectiveManager;
import net.megavex.scoreboardlibrary.api.objective.ScoreboardObjective;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manages the Scoreboards used to display a variety of mcMMO related information to the player
*/
public class ScoreboardManager {
static final Map<String, ScoreboardWrapper> PLAYER_SCOREBOARDS = new HashMap<>();
static final Map<String, ScoreboardWrapper> PLAYER_SCOREBOARDS = new ConcurrentHashMap<>();
// do not localize; these are internal identifiers
static final String SIDEBAR_OBJECTIVE = "mcmmo_sidebar";
public static final String SIDEBAR_OBJECTIVE = "mcmmo_sidebar";
// Randomized per server start so it can never collide with a leftover objective of the same
// name on a client - e.g. the old Bukkit implementation's 'mcmmo_pwrlvl' lingering after an
// in-place upgrade, or another plugin's objective. Minecraft caps objective names at 16 chars,
// so this stays well under: "mcmmo_" (6) + 8 hex = 14.
static final String POWER_OBJECTIVE = "mcmmo_" + java.util.UUID.randomUUID()
public static final String POWER_OBJECTIVE = "mcmmo_" + java.util.UUID.randomUUID()
.toString().replace("-", "").substring(0, 8);
static final String HEADER_STATS = LocaleLoader.getString("Scoreboard.Header.PlayerStats");
@ -64,22 +69,17 @@ public class ScoreboardManager {
static final Map<SuperAbilityType, String> abilityLabelsSkill;
public static final String DISPLAY_NAME = "powerLevel";
public static ScoreboardLibrary scoreboardLibrary;
private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacySection();
// Packet-based below-name power level tag (replaces the old Bukkit main-scoreboard objective)
private static ObjectiveManager powerLevelObjectiveManager;
private static ScoreboardObjective powerLevelObjective;
private static ScoreboardBackend backend;
private static ScoreboardBackendType backendType = ScoreboardBackendType.NOOP;
/*
* Initializes the static label maps for our scoreboards.
*
* Note: the packet ScoreboardLibrary is NOT loaded here. Loading it can fail on unsupported
* server versions, and doing that inside a static initializer would turn the failure into an
* unrecoverable ExceptionInInitializerError on first access to this class. Instead the library
* is loaded explicitly from the main class via {@link #init()} during onEnable, guarded by the
* scoreboards-enabled config, so it can fail gracefully.
* Note: backend resources are not loaded here. Loading packet adapters can fail on unsupported
* server versions, and doing that inside a static initializer would turn failures into
* ExceptionInInitializerError on first access. Backends are initialized explicitly via
* {@link #init()} during onEnable, guarded by the scoreboards-enabled config.
*/
static {
/*
@ -173,27 +173,55 @@ public class ScoreboardManager {
abilityLabelsSkill = abilityLabelSkillBuilder.build();
}
private static final List<String> dirtyPowerLevels = new ArrayList<>();
private static final Set<String> dirtyPowerLevels = ConcurrentHashMap.newKeySet();
/**
* Loads the packet-based {@link ScoreboardLibrary}. Call this once from the main class during
* onEnable (guarded by the scoreboards-enabled config).
* <p>
* If the running server version has no packet adapter, we fall back to a
* {@link NoopScoreboardLibrary} as recommended by the library author: scoreboards simply won't
* render, but nothing throws and the rest of the plugin keeps working.
*/
public static void init() {
if (scoreboardLibrary != null && !scoreboardLibrary.closed()) {
return; // already initialized
if (backend != null) {
return;
}
if (!mcMMO.p.getGeneralConfig().getScoreboardsEnabled()) {
backendType = ScoreboardBackendType.NOOP;
backend = new NoopScoreboardBackend();
backend.init();
return;
}
final boolean isFolia = PaperUtil.isFolia();
final ScoreboardBackendType selectedType = ScoreboardBackendSelector.select(
isFolia,
mcMMO.getMinecraftGameVersion());
backendType = selectedType;
switch (selectedType) {
case BUKKIT -> backend = new BukkitScoreboardBackend();
case PACKET -> backend = new PacketScoreboardBackend();
case NOOP -> {
backend = new NoopScoreboardBackend();
mcMMO.p.getLogger().warning(
"Folia detected on unsupported Minecraft version "
+ mcMMO.getMinecraftGameVersion().getVersionStr()
+ " (supported packet scoreboard max is "
+ ScoreboardBackendSelector.MAX_PACKET_VERSION_STRING
+ "). Scoreboards are disabled to avoid Folia async scoreboard issues. "
+ "Please update mcMMO.");
}
}
try {
scoreboardLibrary = ScoreboardLibrary.loadScoreboardLibrary(mcMMO.p);
} catch (NoPacketAdapterAvailableException e) {
scoreboardLibrary = new NoopScoreboardLibrary();
mcMMO.p.getLogger().warning(
"Server version unsupported, scoreboard functionality will not be visible!");
backend.init();
} catch (RuntimeException e) {
if (selectedType == ScoreboardBackendType.PACKET) {
mcMMO.p.getLogger().warning(
"Packet scoreboard backend failed to initialize ("
+ e.getMessage()
+ "). Falling back to a Folia-safe no-op scoreboard backend.");
backendType = ScoreboardBackendType.NOOP;
backend = new NoopScoreboardBackend();
backend.init();
} else {
throw e;
}
}
}
@ -234,48 +262,40 @@ public class ScoreboardManager {
// Called by PlayerJoinEvent listener
public static void setupPlayer(Player player) {
ensureBackendReady();
teardownPlayer(player);
PLAYER_SCOREBOARDS.put(player.getName(), makeNewScoreboard(player));
dirtyPowerLevels.add(player.getName());
// Make the player see the below-name power level tag, if enabled
if (mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled() && getPowerLevelObjective() != null) {
powerLevelObjectiveManager.addPlayer(player);
if (mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled() && backend.isPowerLevelTagActive()) {
backend.setupPowerLevelTag(player);
}
}
// Called by PlayerQuitEvent listener and OnPlayerTeleport under certain circumstances
public static void teardownPlayer(Player player) {
ensureBackendReady();
if (player == null) {
return;
}
// Only remove the player from the shared power-level ObjectiveManager while they are still
// online. scoreboard-library tracks players in a weak-key map and processes removals on an
// async tick; if we enqueue a removal for an already-offline player, their Player object can
// be garbage-collected before that tick runs, which makes the library's
// ObjectiveManagerImpl.tick() throw an NPE (requireNonNull on the now-evicted map entry).
// For offline players we let the library's weak-key map evict them on its own instead.
if (powerLevelObjectiveManager != null && !powerLevelObjectiveManager.closed()
&& player.isOnline()) {
powerLevelObjectiveManager.removePlayer(player);
// Remove the player's score entry so it doesn't accumulate for offline players.
// Without this, every player who ever joined adds a persistent entry to the scores map;
// when a new player connects, the library sends the full map to them (potentially
// thousands of entries) causing a large initial packet burst.
if (powerLevelObjective != null) {
powerLevelObjective.removeScore(player.getName());
}
if (isBukkitBackendActive() && player.isOnline() && player.isValid()
&& Bukkit.getScoreboardManager() != null) {
player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
}
backend.removePowerLevelTag(player);
ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.remove(player.getName());
if (wrapper != null) {
wrapper.close(); // cancels revertTask, cooldownTask, and updateTask internally
wrapper.close();
}
}
// Called in onDisable()
public static void teardownAll() {
ensureBackendReady();
ImmutableList<Player> onlinePlayers = ImmutableList.copyOf(
mcMMO.p.getServer().getOnlinePlayers());
LogUtils.debug(mcMMO.p.getLogger(),
@ -284,24 +304,18 @@ public class ScoreboardManager {
teardownPlayer(player);
}
// The external plugin never closes the library; do it here so reload/disable doesn't
// leak the packet listeners or registered players.
if (powerLevelObjectiveManager != null && !powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager.close();
if (backend != null) {
backend.shutdown();
}
powerLevelObjectiveManager = null;
powerLevelObjective = null;
if (scoreboardLibrary != null && !scoreboardLibrary.closed()) {
scoreboardLibrary.close();
}
scoreboardLibrary = null;
backend = null;
backendType = ScoreboardBackendType.NOOP;
}
// Called by ScoreboardWrapper when its Player logs off and an action tries to be performed
public static void cleanup(ScoreboardWrapper wrapper) {
ensureBackendReady();
PLAYER_SCOREBOARDS.remove(wrapper.playerName);
wrapper.close(); // cancels revertTask, cooldownTask, and updateTask internally
wrapper.close();
}
// Called by internal level-up event listener
@ -556,10 +570,10 @@ public class ScoreboardManager {
* @return false if power levels are disabled
*/
public static boolean powerLevelHeartbeat() {
ScoreboardObjective mainObjective = getPowerLevelObjective();
ensureBackendReady();
if (mainObjective == null) {
return false; // indicates
if (!backend.isPowerLevelTagActive()) {
return false;
}
for (String playerName : dirtyPowerLevels) {
@ -569,9 +583,8 @@ public class ScoreboardManager {
continue;
}
int power = mmoPlayer.getPowerLevel();
mainObjective.score(playerName, power);
final int power = mmoPlayer.getPowerLevel();
backend.setPowerLevel(playerName, power);
}
dirtyPowerLevels.clear();
@ -586,33 +599,8 @@ public class ScoreboardManager {
* @return the power level objective, or null if disabled
*/
public static @Nullable ScoreboardObjective getPowerLevelObjective() {
if (!mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled()) {
if (powerLevelObjectiveManager != null && !powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager.close();
LogUtils.debug(mcMMO.p.getLogger(),
"Removed leftover objects from Power Level Tags.");
}
powerLevelObjectiveManager = null;
powerLevelObjective = null;
return null;
}
if (scoreboardLibrary == null || scoreboardLibrary.closed()) {
return null;
}
if (powerLevelObjectiveManager == null || powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager = scoreboardLibrary.createObjectiveManager();
powerLevelObjective = powerLevelObjectiveManager.create(POWER_OBJECTIVE);
powerLevelObjective.value(LEGACY.deserialize(TAG_POWER_LEVEL));
powerLevelObjectiveManager.display(ObjectiveDisplaySlot.belowName(), powerLevelObjective);
// Show the tag to everyone currently online
powerLevelObjectiveManager.addPlayers(
new ArrayList<>(mcMMO.p.getServer().getOnlinePlayers()));
}
return powerLevelObjective;
ensureBackendReady();
return backend.getPacketPowerLevelObjective();
}
/**
@ -635,20 +623,29 @@ public class ScoreboardManager {
}
public static boolean isBoardShown(String playerName) {
return PLAYER_SCOREBOARDS.get(playerName).isBoardShown();
ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(playerName);
return wrapper != null && wrapper.isBoardShown();
}
public static void clearBoard(String playerName) {
PLAYER_SCOREBOARDS.get(playerName).tryRevertBoard();
ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(playerName);
if (wrapper != null) {
wrapper.tryRevertBoard();
}
}
public static void keepBoard(String playerName) {
PLAYER_SCOREBOARDS.get(playerName).cancelRevert();
ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(playerName);
if (wrapper != null) {
wrapper.cancelRevert();
}
}
public static void setRevertTimer(String playerName, int seconds) {
PLAYER_SCOREBOARDS.get(playerName)
.showBoardAndScheduleRevert(seconds * Misc.TICK_CONVERSION_FACTOR);
ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(playerName);
if (wrapper != null) {
wrapper.showBoardAndScheduleRevert(seconds * Misc.TICK_CONVERSION_FACTOR);
}
}
public static boolean isPlayerBoardSetup(@NotNull String playerName) {
@ -656,21 +653,51 @@ public class ScoreboardManager {
}
public static @NotNull ScoreboardWrapper makeNewScoreboard(Player player) {
// The board is now a packet-based sidebar overlay, so there is no Bukkit Scoreboard to
// create or swap. We still fire McMMOScoreboardMakeboardEvent so external plugins that
// listened for board creation keep getting notified.
//
// Compatibility note: this event was designed around Bukkit Scoreboards (target/current
// board). Since none exist here, we pass the player's current Bukkit scoreboard for both
// arguments purely as a non-null placeholder. The event's board fields are NOT consumed by
// the packet implementation - it is informational only, and setTargetBoard()/setTargetPlayer()
// no longer influence which board is shown.
ensureBackendReady();
final Scoreboard eventTargetBoard = backend.createEventTargetBoard(player);
final Scoreboard currentBoard = player.getScoreboard();
McMMOScoreboardMakeboardEvent event = new McMMOScoreboardMakeboardEvent(
player.getScoreboard(), player.getScoreboard(), player,
eventTargetBoard, currentBoard, player,
ScoreboardEventReason.CREATING_NEW_SCOREBOARD);
player.getServer().getPluginManager().callEvent(event);
// Honour any player reassignment the event performed; the sidebar follows that player.
return new ScoreboardWrapper(event.getTargetPlayer());
final Player targetPlayer = event.getTargetPlayer();
final Scoreboard targetBoard = event.getTargetBoard() == null
? currentBoard
: event.getTargetBoard();
final PlayerBoard playerBoard = backend.createPlayerBoard(targetPlayer, targetBoard);
return new ScoreboardWrapper(targetPlayer, playerBoard);
}
public static @NotNull ScoreboardBackend getBackend() {
ensureBackendReady();
return backend;
}
public static @NotNull ScoreboardBackendType getBackendType() {
ensureBackendReady();
return backendType;
}
public static boolean isBukkitBackendActive() {
return getBackendType() == ScoreboardBackendType.BUKKIT;
}
public static void onPlayerBoardClosed(@NotNull String playerName) {
ensureBackendReady();
backend.onPlayerBoardClosed(playerName);
}
private static void ensureBackendReady() {
if (backend == null) {
init();
}
if (backend == null) {
backend = new NoopScoreboardBackend();
backendType = ScoreboardBackendType.NOOP;
backend.init();
}
}
}

View File

@ -9,17 +9,17 @@ import com.gmail.nossr50.events.scoreboard.McMMOScoreboardRevertEvent;
import com.gmail.nossr50.events.scoreboard.ScoreboardEventReason;
import com.gmail.nossr50.locale.LocaleLoader;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.util.LogUtils;
import com.gmail.nossr50.util.Misc;
import com.gmail.nossr50.util.player.UserManager;
import com.gmail.nossr50.util.scoreboards.ScoreboardManager.SidebarType;
import com.gmail.nossr50.util.scoreboards.backend.PlayerBoard;
import com.gmail.nossr50.util.scoreboards.backend.SidebarLine;
import com.gmail.nossr50.util.skills.SkillTools;
import com.tcoded.folialib.wrapper.task.WrappedTask;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import net.megavex.scoreboardlibrary.api.objective.ScoreFormat;
import net.megavex.scoreboardlibrary.api.sidebar.Sidebar;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
@ -27,24 +27,18 @@ import java.util.List;
import java.util.Map;
/**
* Wraps a packet-based {@link Sidebar} (scoreboard-library) for a single player.
* <p>
* Unlike the previous Bukkit {@code Scoreboard} implementation, the sidebar here is sent via
* packets and overlays whatever scoreboard the player already has. Showing/hiding the board is
* just {@code sidebar.addPlayer/removePlayer}; there is no Bukkit board to swap or revert, which
* removes the objective register/unregister race that crashed on Folia/Canvas.
* Wraps a backend-specific player board for a single player.
*/
public class ScoreboardWrapper {
// scoreboard-library hard-caps a sidebar at 15 lines.
private static final int MAX_LINES = Sidebar.MAX_LINES;
private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacySection();
private static final int MAX_LINES = 15;
// Initialization variables
public final String playerName;
public final Player player;
private final Sidebar sidebar;
private final PlayerBoard playerBoard;
private boolean tippedKeep = false;
private boolean tippedClear = false;
private Scoreboard oldBoard = null;
// Internal usage variables (should exist)
private SidebarType sidebarType;
@ -59,15 +53,11 @@ public class ScoreboardWrapper {
private Map<PrimarySkillType, Integer> rankData = null;
private List<PlayerStat> leaderboardData = null;
public ScoreboardWrapper(Player player) {
public ScoreboardWrapper(Player player, PlayerBoard playerBoard) {
this.player = player;
this.playerName = player.getName();
this.sidebarType = SidebarType.NONE;
this.sidebar = ScoreboardManager.scoreboardLibrary.createSidebar(MAX_LINES);
}
private Component toComponent(String legacy) {
return LEGACY.deserialize(legacy == null ? "" : legacy);
this.playerBoard = playerBoard;
}
public WrappedTask updateTask = null;
@ -127,7 +117,9 @@ public class ScoreboardWrapper {
try {
cooldownTask.cancel();
} catch (Exception e) {
e.printStackTrace();
LogUtils.debug(mcMMO.p.getLogger(),
"Unable to cancel cooldown scoreboard task for " + playerName + ": "
+ e.getMessage());
}
cooldownTask = null;
@ -147,9 +139,9 @@ public class ScoreboardWrapper {
}
public void showBoardWithNoRevert() {
Player player = mcMMO.p.getServer().getPlayerExact(playerName);
final Player onlinePlayer = mcMMO.p.getServer().getPlayerExact(playerName);
if (player == null) {
if (onlinePlayer == null) {
ScoreboardManager.cleanup(this);
return;
}
@ -158,14 +150,24 @@ public class ScoreboardWrapper {
revertTask.cancel();
}
sidebar.addPlayer(player);
final boolean alreadyShown = playerBoard.isShown();
final Scoreboard previousBoard = playerBoard.show();
if (ScoreboardManager.isBukkitBackendActive()) {
if (alreadyShown && oldBoard == null && ScoreboardManager.getScoreboardManager() != null) {
oldBoard = ScoreboardManager.getScoreboardManager().getMainScoreboard();
} else if (!alreadyShown) {
oldBoard = previousBoard;
}
}
revertTask = null;
}
public void showBoardAndScheduleRevert(int ticks) {
Player player = mcMMO.p.getServer().getPlayerExact(playerName);
final Player onlinePlayer = mcMMO.p.getServer().getPlayerExact(playerName);
if (player == null) {
if (onlinePlayer == null) {
ScoreboardManager.cleanup(this);
return;
}
@ -174,15 +176,24 @@ public class ScoreboardWrapper {
revertTask.cancel();
}
sidebar.addPlayer(player);
final boolean alreadyShown = playerBoard.isShown();
final Scoreboard previousBoard = playerBoard.show();
if (ScoreboardManager.isBukkitBackendActive()) {
if (alreadyShown && oldBoard == null && ScoreboardManager.getScoreboardManager() != null) {
oldBoard = ScoreboardManager.getScoreboardManager().getMainScoreboard();
} else if (!alreadyShown) {
oldBoard = previousBoard;
}
}
revertTask = mcMMO.p.getFoliaLib().getScheduler()
.runAtEntityLater(player, new ScoreboardChangeTask(), ticks);
.runAtEntityLater(onlinePlayer, new ScoreboardChangeTask(), ticks);
if (UserManager.getPlayer(playerName) == null) {
return;
}
PlayerProfile profile = UserManager.getPlayer(player).getProfile();
PlayerProfile profile = UserManager.getPlayer(onlinePlayer).getProfile();
if (profile.getScoreboardTipsShown() >= mcMMO.p.getGeneralConfig().getTipsAmount()) {
return;
@ -190,43 +201,52 @@ public class ScoreboardWrapper {
if (!tippedKeep) {
tippedKeep = true;
player.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Keep"));
onlinePlayer.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Keep"));
} else if (!tippedClear) {
tippedClear = true;
player.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Clear"));
onlinePlayer.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Clear"));
profile.increaseTipsShown();
}
}
public void tryRevertBoard() {
Player player = mcMMO.p.getServer().getPlayerExact(playerName);
Player onlinePlayer = mcMMO.p.getServer().getPlayerExact(playerName);
if (player == null) {
if (onlinePlayer == null) {
ScoreboardManager.cleanup(this);
return;
}
// Fire McMMOScoreboardRevertEvent so external plugins still get notified when an mcMMO
// board is cleared. Previously this swapped the player's Bukkit scoreboard back to the
// saved "old board"; with the packet sidebar there is nothing to swap, so we only need to
// stop showing our overlay.
//
// Compatibility note: the event was built around Bukkit Scoreboards (target/current board).
// None exist here, so we pass the player's current Bukkit scoreboard for both arguments as
// a non-null placeholder. The board fields are informational only - the packet sidebar does
// not read them back, so setTargetBoard() no longer has any effect. setTargetPlayer() is
// still honoured to decide which player the overlay is removed from.
if (isBoardShown()) {
if (ScoreboardManager.isBukkitBackendActive()) {
if (oldBoard != null && isBoardShown()) {
McMMOScoreboardRevertEvent event = new McMMOScoreboardRevertEvent(
oldBoard,
onlinePlayer.getScoreboard(),
onlinePlayer,
ScoreboardEventReason.REVERTING_BOARD);
onlinePlayer.getServer().getPluginManager().callEvent(event);
onlinePlayer = event.getTargetPlayer();
playerBoard.hide(onlinePlayer, event.getTargetBoard());
oldBoard = null;
} else if (oldBoard != null) {
LogUtils.debug(mcMMO.p.getLogger(),
"Not reverting scoreboard for "
+ playerName
+ " - scoreboard was changed by another plugin.");
}
} else if (isBoardShown()) {
McMMOScoreboardRevertEvent event = new McMMOScoreboardRevertEvent(
player.getScoreboard(), player.getScoreboard(), player,
onlinePlayer.getScoreboard(),
onlinePlayer.getScoreboard(),
onlinePlayer,
ScoreboardEventReason.REVERTING_BOARD);
player.getServer().getPluginManager().callEvent(event);
player = event.getTargetPlayer();
onlinePlayer.getServer().getPluginManager().callEvent(event);
onlinePlayer = event.getTargetPlayer();
playerBoard.hide(onlinePlayer, event.getTargetBoard());
} else {
playerBoard.hide(onlinePlayer, null);
}
// Packet sidebar: hiding is just removing the player from the sidebar overlay.
sidebar.removePlayer(player);
cancelRevert();
sidebarType = SidebarType.NONE;
@ -239,14 +259,14 @@ public class ScoreboardWrapper {
}
public boolean isBoardShown() {
Player player = mcMMO.p.getServer().getPlayerExact(playerName);
Player onlinePlayer = mcMMO.p.getServer().getPlayerExact(playerName);
if (player == null) {
if (onlinePlayer == null) {
ScoreboardManager.cleanup(this);
return false;
}
return sidebar.players().contains(player);
return playerBoard.isShown();
}
public void cancelRevert() {
@ -259,8 +279,7 @@ public class ScoreboardWrapper {
}
/**
* Releases the underlying packet sidebar. Must be called when the player logs off or the
* plugin shuts down, otherwise the library keeps the player registered.
* Releases backend resources and scheduled tasks.
*/
public void close() {
try {
@ -274,9 +293,8 @@ public class ScoreboardWrapper {
// best-effort task cleanup
}
if (!sidebar.closed()) {
sidebar.close();
}
playerBoard.close();
ScoreboardManager.onPlayerBoardClosed(playerName);
}
// Board Type Changing 'API' methods
@ -401,40 +419,22 @@ public class ScoreboardWrapper {
* Sets the sidebar title and re-renders. Replaces the old register/unregister objective flow.
*/
protected void loadObjective(String displayName) {
if (displayName.length() > 32) {
displayName = displayName.substring(0, 32);
}
sidebar.title(toComponent(displayName));
playerBoard.setTitle(displayName);
render();
}
/**
* Holds one sidebar row: a legacy-formatted label and the integer shown on the right.
*/
private record SidebarLine(String label, int value) {
}
private ScoreFormat redScore(int value) {
return ScoreFormat.fixed(Component.text(value, net.kyori.adventure.text.format.NamedTextColor.RED));
}
/**
* Pushes an ordered list of rows to the sidebar. {@code lines.get(0)} is drawn at the top.
* scoreboard-library indexes line 0 at the bottom, so we map top rows to the highest indices.
* Pushes an ordered list of rows to the active backend board.
*/
private void drawLines(List<SidebarLine> lines) {
// Cap to the sidebar's hard line limit.
int count = Math.min(lines.size(), MAX_LINES);
List<SidebarLine> toRender = lines;
// Reset to a clean slate; the library diffs internally so only net changes are sent.
sidebar.clearLines();
for (int i = 0; i < count; i++) {
SidebarLine line = lines.get(i);
// scoreboard-library draws line index 0 at the TOP, so list order maps directly.
sidebar.line(i, toComponent(line.label()), redScore(line.value()));
if (count != lines.size()) {
toRender = new ArrayList<>(lines.subList(0, count));
}
playerBoard.draw(toRender);
}
/**
@ -446,7 +446,9 @@ public class ScoreboardWrapper {
try {
updateTask.cancel();
} catch (Exception e) {
e.printStackTrace();
LogUtils.debug(mcMMO.p.getLogger(),
"Unable to cancel sidebar update task for " + playerName + ": "
+ e.getMessage());
}
updateTask = null;

View File

@ -0,0 +1,175 @@
package com.gmail.nossr50.util.scoreboards.backend;
import com.gmail.nossr50.events.scoreboard.McMMOScoreboardObjectiveEvent;
import com.gmail.nossr50.events.scoreboard.ScoreboardEventReason;
import com.gmail.nossr50.events.scoreboard.ScoreboardObjectiveEventReason;
import com.gmail.nossr50.locale.LocaleLoader;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.util.player.UserManager;
import com.gmail.nossr50.datatypes.player.McMMOPlayer;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class BukkitPlayerBoard implements PlayerBoard {
private static final String SIDEBAR_OBJECTIVE = "mcmmo_sidebar";
private static final String SIDE_OBJECTIVE = "mcMMO_sideObjective";
private static final String POWER_OBJECTIVE = "mcmmo_pwrlvl";
private final @NotNull Player owner;
private @NotNull Scoreboard scoreboard;
private final Set<String> renderedEntries;
private @Nullable Objective sidebarObjective;
private @Nullable Objective powerObjective;
public BukkitPlayerBoard(final @NotNull Player owner, final @NotNull Scoreboard scoreboard) {
this.owner = owner;
this.scoreboard = scoreboard;
this.renderedEntries = new HashSet<>();
this.sidebarObjective = scoreboard.getObjective(SIDEBAR_OBJECTIVE);
this.powerObjective = null;
setupPowerObjectiveIfEnabled();
}
@Override
public @Nullable Scoreboard show() {
final Scoreboard previousBoard = owner.getScoreboard();
owner.setScoreboard(scoreboard);
return previousBoard;
}
@Override
public void hide(final @NotNull Player targetPlayer, final @Nullable Scoreboard targetBoard) {
if (targetBoard != null) {
targetPlayer.setScoreboard(targetBoard);
return;
}
if (Bukkit.getScoreboardManager() != null) {
targetPlayer.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
}
}
@Override
public boolean isShown() {
return owner.getScoreboard() == scoreboard;
}
@Override
public void setTitle(final @NotNull String displayName) {
final McMMOScoreboardObjectiveEvent unregisterEvent = callObjectiveEvent(
ScoreboardObjectiveEventReason.UNREGISTER_THIS_OBJECTIVE);
if (!unregisterEvent.isCancelled() && sidebarObjective != null) {
try {
sidebarObjective.unregister();
} catch (IllegalStateException e) {
mcMMO.p.getLogger().fine("Ignoring stale sidebar objective while updating bukkit scoreboard for "
+ owner.getName() + ": " + e.getMessage());
}
}
final McMMOScoreboardObjectiveEvent registerEvent = callObjectiveEvent(
ScoreboardObjectiveEventReason.REGISTER_NEW_OBJECTIVE);
if (!registerEvent.isCancelled()) {
scoreboard = registerEvent.getTargetBoard();
Objective existingObjective = scoreboard.getObjective(SIDEBAR_OBJECTIVE);
if (existingObjective != null) {
try {
existingObjective.unregister();
} catch (IllegalStateException e) {
mcMMO.p.getLogger().fine("Ignoring stale existing objective while replacing bukkit scoreboard for "
+ owner.getName() + ": " + e.getMessage());
}
}
sidebarObjective = scoreboard.registerNewObjective(SIDEBAR_OBJECTIVE, "dummy", SIDE_OBJECTIVE);
}
if (sidebarObjective == null) {
return;
}
final String safeDisplayName = displayName.length() > 32
? displayName.substring(0, 32)
: displayName;
sidebarObjective.setDisplayName(safeDisplayName);
sidebarObjective.setDisplaySlot(DisplaySlot.SIDEBAR);
resetRenderedEntries();
}
@Override
public void draw(final @NotNull List<SidebarLine> lines) {
if (sidebarObjective == null) {
return;
}
resetRenderedEntries();
final int count = Math.min(lines.size(), 15);
for (int i = 0; i < count; i++) {
final SidebarLine line = lines.get(i);
sidebarObjective.getScore(line.label()).setScore(line.value());
renderedEntries.add(line.label());
}
}
public void updatePowerLevel(final @NotNull String playerName, final int powerLevel) {
if (powerObjective != null) {
powerObjective.getScore(playerName).setScore(powerLevel);
}
}
private @NotNull McMMOScoreboardObjectiveEvent callObjectiveEvent(
final @NotNull ScoreboardObjectiveEventReason reason) {
final McMMOScoreboardObjectiveEvent event = new McMMOScoreboardObjectiveEvent(
sidebarObjective,
reason,
scoreboard,
scoreboard,
owner,
ScoreboardEventReason.OBJECTIVE);
owner.getServer().getPluginManager().callEvent(event);
return event;
}
private void resetRenderedEntries() {
for (String entry : renderedEntries) {
scoreboard.resetScores(entry);
}
renderedEntries.clear();
}
private void setupPowerObjectiveIfEnabled() {
if (!mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled()) {
return;
}
Objective existingPowerObjective = scoreboard.getObjective(POWER_OBJECTIVE);
if (existingPowerObjective == null) {
existingPowerObjective = scoreboard.registerNewObjective(POWER_OBJECTIVE, "dummy", "mcMMO_powerObjective");
}
existingPowerObjective.setDisplayName(LocaleLoader.getString("Scoreboard.Header.PowerLevel"));
existingPowerObjective.setDisplaySlot(DisplaySlot.BELOW_NAME);
for (McMMOPlayer mmoPlayer : UserManager.getPlayers()) {
existingPowerObjective.getScore(mmoPlayer.getProfile().getPlayerName())
.setScore(mmoPlayer.getPowerLevel());
}
this.powerObjective = existingPowerObjective;
}
@Override
public void close() {
renderedEntries.clear();
}
}

View File

@ -0,0 +1,140 @@
package com.gmail.nossr50.util.scoreboards.backend;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.util.LogUtils;
import com.gmail.nossr50.locale.LocaleLoader;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.megavex.scoreboardlibrary.api.objective.ScoreboardObjective;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class BukkitScoreboardBackend implements ScoreboardBackend {
private static final String POWER_OBJECTIVE = "mcmmo_" + java.util.UUID.randomUUID()
.toString().replace("-", "").substring(0, 8);
private static final String DISPLAY_NAME = "powerLevel";
private final Map<String, BukkitPlayerBoard> activeBoards = new ConcurrentHashMap<>();
private @Nullable Objective powerObjective;
@Override
public @NotNull ScoreboardBackendType getType() {
return ScoreboardBackendType.BUKKIT;
}
@Override
public void init() {
}
@Override
public @NotNull Scoreboard createEventTargetBoard(final @NotNull Player player) {
final ScoreboardManager scoreboardManager = Bukkit.getScoreboardManager();
if (scoreboardManager == null) {
return player.getScoreboard();
}
return scoreboardManager.getNewScoreboard();
}
@Override
public @NotNull PlayerBoard createPlayerBoard(final @NotNull Player player,
final @NotNull Scoreboard eventTargetBoard) {
final BukkitPlayerBoard playerBoard = new BukkitPlayerBoard(player, eventTargetBoard);
activeBoards.put(player.getName(), playerBoard);
return playerBoard;
}
@Override
public void setupPowerLevelTag(final @NotNull Player player) {
// No action required. BukkitPlayerBoard creates its local below-name objective during setup.
}
@Override
public void removePowerLevelTag(final @NotNull Player player) {
final Objective objective = getOrCreatePowerObjective();
if (objective != null) {
objective.getScoreboard().resetScores(player.getName());
}
}
@Override
public void setPowerLevel(final @NotNull String playerName, final int powerLevel) {
final Objective objective = getOrCreatePowerObjective();
if (objective != null) {
objective.getScore(playerName).setScore(powerLevel);
}
for (BukkitPlayerBoard board : activeBoards.values()) {
board.updatePowerLevel(playerName, powerLevel);
}
}
@Override
public boolean isPowerLevelTagActive() {
return getOrCreatePowerObjective() != null;
}
@Override
public void onPlayerBoardClosed(final @NotNull String playerName) {
activeBoards.remove(playerName);
}
@Override
public @Nullable ScoreboardObjective getPacketPowerLevelObjective() {
return null;
}
private @Nullable Objective getOrCreatePowerObjective() {
if (!mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled()) {
if (powerObjective != null) {
try {
powerObjective.unregister();
} catch (IllegalStateException ignored) {
}
}
powerObjective = null;
return null;
}
final ScoreboardManager scoreboardManager = Bukkit.getScoreboardManager();
if (scoreboardManager == null) {
return null;
}
final Scoreboard mainScoreboard = scoreboardManager.getMainScoreboard();
Objective objective = mainScoreboard.getObjective(POWER_OBJECTIVE);
if (objective == null) {
objective = mainScoreboard.registerNewObjective(POWER_OBJECTIVE, "dummy", DISPLAY_NAME);
objective.setDisplayName(LocaleLoader.getString("Scoreboard.Header.PowerLevel"));
objective.setDisplaySlot(DisplaySlot.BELOW_NAME);
}
powerObjective = objective;
return objective;
}
@Override
public void shutdown() {
for (BukkitPlayerBoard board : activeBoards.values()) {
board.close();
}
activeBoards.clear();
if (powerObjective != null) {
try {
powerObjective.unregister();
} catch (IllegalStateException e) {
LogUtils.debug(mcMMO.p.getLogger(), "Power objective was already unregistered.");
}
}
powerObjective = null;
}
}

View File

@ -0,0 +1,38 @@
package com.gmail.nossr50.util.scoreboards.backend;
import java.util.List;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class NoopPlayerBoard implements PlayerBoard {
public NoopPlayerBoard(final @NotNull Player player) {
}
@Override
public @Nullable Scoreboard show() {
return null;
}
@Override
public void hide(final @NotNull Player targetPlayer, final @Nullable Scoreboard targetBoard) {
}
@Override
public boolean isShown() {
return false;
}
@Override
public void setTitle(final @NotNull String displayName) {
}
@Override
public void draw(final @NotNull List<SidebarLine> lines) {
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,59 @@
package com.gmail.nossr50.util.scoreboards.backend;
import net.megavex.scoreboardlibrary.api.objective.ScoreboardObjective;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class NoopScoreboardBackend implements ScoreboardBackend {
@Override
public @NotNull ScoreboardBackendType getType() {
return ScoreboardBackendType.NOOP;
}
@Override
public void init() {
}
@Override
public @NotNull Scoreboard createEventTargetBoard(final @NotNull Player player) {
return player.getScoreboard();
}
@Override
public @NotNull PlayerBoard createPlayerBoard(final @NotNull Player player,
final @NotNull Scoreboard eventTargetBoard) {
return new NoopPlayerBoard(player);
}
@Override
public void setupPowerLevelTag(final @NotNull Player player) {
}
@Override
public void removePowerLevelTag(final @NotNull Player player) {
}
@Override
public void setPowerLevel(final @NotNull String playerName, final int powerLevel) {
}
@Override
public boolean isPowerLevelTagActive() {
return false;
}
@Override
public void onPlayerBoardClosed(final @NotNull String playerName) {
}
@Override
public @Nullable ScoreboardObjective getPacketPowerLevelObjective() {
return null;
}
@Override
public void shutdown() {
}
}

View File

@ -0,0 +1,66 @@
package com.gmail.nossr50.util.scoreboards.backend;
import java.util.List;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import net.megavex.scoreboardlibrary.api.objective.ScoreFormat;
import net.megavex.scoreboardlibrary.api.sidebar.Sidebar;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class PacketPlayerBoard implements PlayerBoard {
private final @NotNull Player owner;
private final @NotNull Sidebar sidebar;
private final LegacyComponentSerializer serializer = LegacyComponentSerializer.legacySection();
public PacketPlayerBoard(final @NotNull Player owner, final @NotNull Sidebar sidebar) {
this.owner = owner;
this.sidebar = sidebar;
}
@Override
public @Nullable Scoreboard show() {
sidebar.addPlayer(owner);
return null;
}
@Override
public void hide(final @NotNull Player targetPlayer, final @Nullable Scoreboard targetBoard) {
sidebar.removePlayer(targetPlayer);
}
@Override
public boolean isShown() {
return sidebar.players().contains(owner);
}
@Override
public void setTitle(final @NotNull String displayName) {
final String safeDisplayName = displayName.length() > 32
? displayName.substring(0, 32)
: displayName;
sidebar.title(serializer.deserialize(safeDisplayName));
}
@Override
public void draw(final @NotNull List<SidebarLine> lines) {
final int count = Math.min(lines.size(), Sidebar.MAX_LINES);
sidebar.clearLines();
for (int i = 0; i < count; i++) {
final SidebarLine line = lines.get(i);
final Component component = serializer.deserialize(line.label() == null ? "" : line.label());
sidebar.line(i, component, ScoreFormat.fixed(Component.text(line.value(), NamedTextColor.RED)));
}
}
@Override
public void close() {
if (!sidebar.closed()) {
sidebar.close();
}
}
}

View File

@ -0,0 +1,144 @@
package com.gmail.nossr50.util.scoreboards.backend;
import com.gmail.nossr50.locale.LocaleLoader;
import com.gmail.nossr50.mcMMO;
import java.util.ArrayList;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import net.megavex.scoreboardlibrary.api.ScoreboardLibrary;
import net.megavex.scoreboardlibrary.api.exception.NoPacketAdapterAvailableException;
import net.megavex.scoreboardlibrary.api.objective.ObjectiveDisplaySlot;
import net.megavex.scoreboardlibrary.api.objective.ObjectiveManager;
import net.megavex.scoreboardlibrary.api.objective.ScoreboardObjective;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class PacketScoreboardBackend implements ScoreboardBackend {
private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacySection();
private static final String POWER_OBJECTIVE = "mcmmo_power_" + java.util.UUID.randomUUID()
.toString().replace("-", "").substring(0, 5);
private @Nullable ScoreboardLibrary scoreboardLibrary;
private @Nullable ObjectiveManager powerLevelObjectiveManager;
private @Nullable ScoreboardObjective powerLevelObjective;
@Override
public @NotNull ScoreboardBackendType getType() {
return ScoreboardBackendType.PACKET;
}
@Override
public void init() {
try {
scoreboardLibrary = ScoreboardLibrary.loadScoreboardLibrary(mcMMO.p);
} catch (NoPacketAdapterAvailableException e) {
throw new RuntimeException("No packet adapter available for scoreboard-library", e);
}
}
@Override
public @NotNull Scoreboard createEventTargetBoard(final @NotNull Player player) {
return player.getScoreboard();
}
@Override
public @NotNull PlayerBoard createPlayerBoard(final @NotNull Player player,
final @NotNull Scoreboard eventTargetBoard) {
if (scoreboardLibrary == null || scoreboardLibrary.closed()) {
return new NoopPlayerBoard(player);
}
return new PacketPlayerBoard(player, scoreboardLibrary.createSidebar(15));
}
@Override
public void setupPowerLevelTag(final @NotNull Player player) {
final ScoreboardObjective objective = getOrCreatePowerLevelObjective();
if (objective == null || powerLevelObjectiveManager == null || powerLevelObjectiveManager.closed()) {
return;
}
powerLevelObjectiveManager.addPlayer(player);
}
@Override
public void removePowerLevelTag(final @NotNull Player player) {
if (powerLevelObjectiveManager == null || powerLevelObjectiveManager.closed() || !player.isOnline()) {
return;
}
powerLevelObjectiveManager.removePlayer(player);
if (powerLevelObjective != null) {
powerLevelObjective.removeScore(player.getName());
}
}
@Override
public void setPowerLevel(final @NotNull String playerName, final int powerLevel) {
final ScoreboardObjective objective = getOrCreatePowerLevelObjective();
if (objective != null) {
objective.score(playerName, powerLevel);
}
}
@Override
public boolean isPowerLevelTagActive() {
return getOrCreatePowerLevelObjective() != null;
}
@Override
public void onPlayerBoardClosed(final @NotNull String playerName) {
}
@Override
public @Nullable ScoreboardObjective getPacketPowerLevelObjective() {
return getOrCreatePowerLevelObjective();
}
private @Nullable ScoreboardObjective getOrCreatePowerLevelObjective() {
if (!mcMMO.p.getGeneralConfig().getPowerLevelTagsEnabled()) {
if (powerLevelObjectiveManager != null && !powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager.close();
}
powerLevelObjectiveManager = null;
powerLevelObjective = null;
return null;
}
if (scoreboardLibrary == null || scoreboardLibrary.closed()) {
return null;
}
if (powerLevelObjectiveManager == null || powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager = scoreboardLibrary.createObjectiveManager();
powerLevelObjective = powerLevelObjectiveManager.create(POWER_OBJECTIVE);
powerLevelObjective.value(LEGACY.deserialize(
LocaleLoader.getString("Scoreboard.Header.PowerLevel")));
powerLevelObjectiveManager.display(ObjectiveDisplaySlot.belowName(), powerLevelObjective);
powerLevelObjectiveManager.addPlayers(new ArrayList<>(mcMMO.p.getServer().getOnlinePlayers()));
}
return powerLevelObjective;
}
@Override
public void shutdown() {
if (powerLevelObjectiveManager != null && !powerLevelObjectiveManager.closed()) {
powerLevelObjectiveManager.close();
}
powerLevelObjectiveManager = null;
powerLevelObjective = null;
if (scoreboardLibrary != null && !scoreboardLibrary.closed()) {
scoreboardLibrary.close();
}
scoreboardLibrary = null;
}
}

View File

@ -0,0 +1,21 @@
package com.gmail.nossr50.util.scoreboards.backend;
import java.util.List;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public interface PlayerBoard {
@Nullable Scoreboard show();
void hide(@NotNull Player targetPlayer, @Nullable Scoreboard targetBoard);
boolean isShown();
void setTitle(@NotNull String displayName);
void draw(@NotNull List<SidebarLine> lines);
void close();
}

View File

@ -0,0 +1,31 @@
package com.gmail.nossr50.util.scoreboards.backend;
import net.megavex.scoreboardlibrary.api.objective.ScoreboardObjective;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public interface ScoreboardBackend {
@NotNull ScoreboardBackendType getType();
void init();
@NotNull Scoreboard createEventTargetBoard(@NotNull Player player);
@NotNull PlayerBoard createPlayerBoard(@NotNull Player player, @NotNull Scoreboard eventTargetBoard);
void setupPowerLevelTag(@NotNull Player player);
void removePowerLevelTag(@NotNull Player player);
void setPowerLevel(@NotNull String playerName, int powerLevel);
boolean isPowerLevelTagActive();
void onPlayerBoardClosed(@NotNull String playerName);
@Nullable ScoreboardObjective getPacketPowerLevelObjective();
void shutdown();
}

View File

@ -0,0 +1,34 @@
package com.gmail.nossr50.util.scoreboards.backend;
import com.gmail.nossr50.util.platform.MinecraftGameVersion;
import java.util.Objects;
import org.jetbrains.annotations.NotNull;
public final class ScoreboardBackendSelector {
public static final int MAX_PACKET_MAJOR = 26;
public static final int MAX_PACKET_MINOR = 1;
public static final int MAX_PACKET_PATCH = 2;
public static final String MAX_PACKET_VERSION_STRING = "26.1.2";
private ScoreboardBackendSelector() {
}
public static @NotNull ScoreboardBackendType select(final boolean isFolia,
final @NotNull MinecraftGameVersion version) {
final MinecraftGameVersion gameVersion = Objects.requireNonNull(version,
"version cannot be null");
if (!isFolia) {
return ScoreboardBackendType.BUKKIT;
}
final boolean versionWithinPacketSupport = !gameVersion.isAtLeast(
MAX_PACKET_MAJOR,
MAX_PACKET_MINOR,
MAX_PACKET_PATCH + 1);
return versionWithinPacketSupport
? ScoreboardBackendType.PACKET
: ScoreboardBackendType.NOOP;
}
}

View File

@ -0,0 +1,7 @@
package com.gmail.nossr50.util.scoreboards.backend;
public enum ScoreboardBackendType {
BUKKIT,
PACKET,
NOOP
}

View File

@ -0,0 +1,4 @@
package com.gmail.nossr50.util.scoreboards.backend;
public record SidebarLine(String label, int value) {
}

View File

@ -0,0 +1,35 @@
package com.gmail.nossr50.util.scoreboards.backend;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.junit.jupiter.api.Test;
class NoopScoreboardBackendTest {
@Test
void noopBackendMethodsShouldBeSafeNoOps() {
// Given - A noop backend with a mocked player and scoreboard
final NoopScoreboardBackend backend = new NoopScoreboardBackend();
final Player player = mock(Player.class);
final Scoreboard scoreboard = mock(Scoreboard.class);
when(player.getScoreboard()).thenReturn(scoreboard);
// When - Running no-op operations
backend.init();
final PlayerBoard playerBoard = backend.createPlayerBoard(player, scoreboard);
// Then - No operation throws and all expected no-op values are returned
assertThatCode(() -> playerBoard.setTitle("test")).doesNotThrowAnyException();
assertThatCode(() -> playerBoard.draw(java.util.List.of())).doesNotThrowAnyException();
assertThatCode(() -> playerBoard.show()).doesNotThrowAnyException();
assertThatCode(() -> playerBoard.hide(player, scoreboard)).doesNotThrowAnyException();
assertThatCode(playerBoard::close).doesNotThrowAnyException();
assertThat(playerBoard.isShown()).isFalse();
assertThat(backend.isPowerLevelTagActive()).isFalse();
assertThat(backend.getPacketPowerLevelObjective()).isNull();
}
}

View File

@ -0,0 +1,63 @@
package com.gmail.nossr50.util.scoreboards.backend;
import static org.assertj.core.api.Assertions.assertThat;
import com.gmail.nossr50.util.platform.MinecraftGameVersion;
import org.junit.jupiter.api.Test;
class ScoreboardBackendSelectorTest {
@Test
void selectShouldReturnBukkitWhenServerIsNotFolia() {
// Given - A non-Folia server with any version
final MinecraftGameVersion gameVersion = new MinecraftGameVersion(99, 9, 9);
// When - Selecting a backend
final ScoreboardBackendType backendType = ScoreboardBackendSelector.select(false, gameVersion);
// Then - Bukkit backend is always selected
assertThat(backendType).isEqualTo(ScoreboardBackendType.BUKKIT);
}
@Test
void selectShouldReturnPacketWhenFoliaAndVersionAtOrBelowSupportedCap() {
// Given - Folia with a version at the supported cap
final MinecraftGameVersion gameVersion = new MinecraftGameVersion(26, 1, 2);
// When - Selecting a backend
final ScoreboardBackendType backendType = ScoreboardBackendSelector.select(true, gameVersion);
// Then - Packet backend is selected
assertThat(backendType).isEqualTo(ScoreboardBackendType.PACKET);
}
@Test
void selectShouldReturnNoopWhenFoliaAndVersionAboveSupportedCap() {
// Given - Folia with an unsupported newer version
final MinecraftGameVersion gameVersion = new MinecraftGameVersion(26, 1, 3);
// When - Selecting a backend
final ScoreboardBackendType backendType = ScoreboardBackendSelector.select(true, gameVersion);
// Then - Noop backend is selected
assertThat(backendType).isEqualTo(ScoreboardBackendType.NOOP);
}
@Test
void selectShouldNeverReturnBukkitWhenServerIsFolia() {
// Given - Multiple Folia versions across supported and unsupported ranges
final MinecraftGameVersion oldVersion = new MinecraftGameVersion(1, 20, 5);
final MinecraftGameVersion supportedCap = new MinecraftGameVersion(26, 1, 2);
final MinecraftGameVersion unsupportedVersion = new MinecraftGameVersion(27, 0, 0);
// When - Selecting backends
final ScoreboardBackendType oldResult = ScoreboardBackendSelector.select(true, oldVersion);
final ScoreboardBackendType capResult = ScoreboardBackendSelector.select(true, supportedCap);
final ScoreboardBackendType unsupportedResult = ScoreboardBackendSelector.select(true,
unsupportedVersion);
// Then - Folia never selects Bukkit
assertThat(oldResult).isIn(ScoreboardBackendType.PACKET, ScoreboardBackendType.NOOP);
assertThat(capResult).isIn(ScoreboardBackendType.PACKET, ScoreboardBackendType.NOOP);
assertThat(unsupportedResult).isIn(ScoreboardBackendType.PACKET, ScoreboardBackendType.NOOP);
}
}