diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 4e91ad7b6..c94edc95b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -11,14 +11,17 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import me.totalfreedom.api.player.PlayerData; +import me.totalfreedom.api.rank.RankRole; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.admin.AdminList; import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import me.totalfreedom.api.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.PlayerList; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.api.rank.RankRole; +import me.totalfreedom.totalfreedommod.rank.RankManager; +import me.totalfreedom.totalfreedommod.title.TitleManager; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; @@ -211,8 +214,10 @@ public void convertAdminConsoleRanks() */ public void convertCosmeticRankHolders() { - if (plugin.admins() == null || plugin.ranks() == null || plugin.titles() - == null || plugin.players() == null) + if (plugin.services().get(AdminList.class).isEmpty() + || plugin.services().get(RankManager.class).isEmpty() + || plugin.services().get(TitleManager.class).isEmpty() + || plugin.services().get(PlayerList.class).isEmpty()) return; final String senior = seniorRankId(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/FreedomAggregate.java b/src/main/java/me/totalfreedom/totalfreedommod/FreedomAggregate.java index 9f330556e..629472836 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/FreedomAggregate.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/FreedomAggregate.java @@ -9,6 +9,7 @@ import me.totalfreedom.api.admin.IAdminList; import me.totalfreedom.api.banning.IBanManager; import me.totalfreedom.api.blocking.sweep.ISweepScheduler; +import me.totalfreedom.api.cmd.CommandLoader; import me.totalfreedom.api.config.IMainConfig; import me.totalfreedom.api.economy.IBank; import me.totalfreedom.api.economy.IEcoManager; @@ -39,7 +40,6 @@ import me.totalfreedom.totalfreedommod.bridge.LibsDisguisesBridge; import me.totalfreedom.totalfreedommod.bridge.WorldEditBridge; import me.totalfreedom.totalfreedommod.caging.Cager; -import me.totalfreedom.api.cmd.CommandLoader; import me.totalfreedom.totalfreedommod.config.MainConfig; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; import me.totalfreedom.totalfreedommod.disguise.DisallowedDisguises; @@ -49,6 +49,7 @@ import me.totalfreedom.totalfreedommod.fun.*; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.player.PlayerBlockList; import me.totalfreedom.totalfreedommod.player.PlayerList; import me.totalfreedom.totalfreedommod.rank.ConsoleSenderRegistry; import me.totalfreedom.totalfreedommod.rank.RankManager; @@ -147,6 +148,7 @@ public void enable() services.register(AntiSpam.class, AntiSpam::new); services.register(PlayerList.class, PlayerList::new); + services.register(PlayerBlockList.class, PlayerBlockList::new); services.register(EcoManager.class, EcoManager::new); services.register(JoinLeaveMessages.class, JoinLeaveMessages::new); services.register(Announcer.class, Announcer::new); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index b443f1f5c..2b943e4e5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -394,7 +394,7 @@ public void updateLastLogin(Player player) public boolean isAdminImpostor(Player player) { - return getEntryByName(player.getName()) != null && !isAdmin(player); + return getEntryByName(player.getName()) != null && getAdmin(player) == null; } public boolean isIdentityMatched(Player player) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_block.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_block.java new file mode 100644 index 000000000..ff24f7c54 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_block.java @@ -0,0 +1,265 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.*; +import java.util.stream.Stream; + +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.api.cmd.FCommand; +import me.totalfreedom.api.cmd.SourceType; +import me.totalfreedom.api.cmd.annotation.*; +import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; +import me.totalfreedom.totalfreedommod.player.PlayerBlockList; + +@Command( + name = "block", + description = "Hide a player from your client.", + usage = "/block [list [page] | clear | player | ]", + aliases = "ignore") +@Permission(permission = "tfm.player.block", source = SourceType.ONLY_IN_GAME) +public class Command_block extends FCommand +{ + private static final int MAX_COMPLETION_RESULTS = 100; + private static final int PAGE_SIZE = 10; + + @Callback + public void listDefault(final Player sender) + { + sendBlockList(sender, 1); + } + + @Callback + @Subcommand("list") + public void list(final Player sender) + { + sendBlockList(sender, 1); + } + + @Callback + @Subcommand("list") + public void listPage(final Player sender, final int page) + { + sendBlockList(sender, page); + } + + @Callback + @Subcommand("clear") + public void clear(final Player sender) + { + final Optional cleared = blockList().clear(sender.getUniqueId()); + if (cleared.isEmpty()) + { + sendUnavailable(sender); + return; + } + + final int clearedCount = cleared.orElseThrow(); + msg( + sender, + "Cleared player from your block list.", + Formatter.number("count", clearedCount), + Placeholder.unparsed("plural", clearedCount == 1 ? "" : "s")); + } + + @Callback + public void toggle( + final Player sender, + @Resolve(strategy = "hideUnknownPlayers") final OfflinePlayer target) + { + toggleTarget(sender, target); + } + + @Callback + @Subcommand("player") + public void toggleExplicit( + final Player sender, + @Resolve(strategy = "hideUnknownPlayers") final OfflinePlayer target) + { + toggleTarget(sender, target); + } + + @Completer(value = "", position = 0) + public List completeTarget(final Player sender, final String partial) + { + return completeTargetName(sender, partial); + } + + @Completer(value = "player", position = 0) + public List completeExplicitTarget(final Player sender, final String partial) + { + return completeTargetName(sender, partial); + } + + private static int pageCount(final int itemCount) + { + return itemCount == 0 ? 1 : ((itemCount - 1) / PAGE_SIZE) + 1; + } + + private static boolean isValidPage(final int page, final int itemCount) + { + return page >= 1 && page <= pageCount(itemCount); + } + + private static List mergeTargetSuggestions( + final Collection onlinePlayerNames, + final Collection blockedPlayerNames, + final String partial) + { + final Map candidates = new LinkedHashMap<>(); + Stream.concat(onlinePlayerNames.stream(), blockedPlayerNames.stream()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .forEach(candidate -> candidates.putIfAbsent( + candidate.toLowerCase(Locale.ROOT), + candidate)); + return FuzzyMatch.filter(List.copyOf(candidates.values()), partial) + .stream() + .limit(MAX_COMPLETION_RESULTS) + .toList(); + } + + private void toggleTarget(final Player sender, final OfflinePlayer target) + { + final UUID senderId = sender.getUniqueId(); + final UUID targetId = target.getUniqueId(); + if (senderId.equals(targetId)) + { + msg(sender, "You cannot block yourself."); + return; + } + + if (blockList().hasBlocked(senderId, targetId)) + { + final Optional unblocked = blockList().unblock(senderId, targetId); + if (unblocked.isEmpty()) + { + sendUnavailable(sender); + return; + } + if (!unblocked.orElseThrow()) + { + msg(sender, " was already unblocked.", + Placeholder.unparsed("player", displayName(target))); + return; + } + msg( + sender, + "Removed from your block list.", + Placeholder.unparsed("player", displayName(target))); + return; + } + + if (blockList().isProtectedTarget(target)) + { + msg(sender, "Admins, Developers, and the Owner cannot be blocked."); + return; + } + + final Optional blocked = blockList().block(senderId, targetId); + if (blocked.isEmpty()) + { + sendUnavailable(sender); + return; + } + if (!blocked.orElseThrow()) + { + msg(sender, " is already blocked.", + Placeholder.unparsed("player", displayName(target))); + return; + } + msg( + sender, + "Blocked . They are hidden from you while online.", + Placeholder.unparsed("player", displayName(target))); + } + + private List completeTargetName(final Player sender, final String partial) + { + if (!blockList().isAvailable()) + return List.of(); + + final UUID senderId = sender.getUniqueId(); + final List onlinePlayerNames = server().getOnlinePlayers() + .stream() + .filter(target -> blockList().hasBlocked(senderId, target.getUniqueId()) + || !blockList().isProtectedTarget(target) + && sender.canSee(target) + && plugin().vanish().canSee(sender, target)) + .map(Player::getName) + .toList(); + final List blockedPlayerNames = blockList().getBlockedPlayers(senderId) + .stream() + .map(server()::getOfflinePlayer) + .flatMap(player -> Optional.ofNullable(player.getName()).stream()) + .toList(); + return mergeTargetSuggestions(onlinePlayerNames, blockedPlayerNames, partial); + } + + private void sendBlockList(final Player sender, final int page) + { + if (!requireAvailable(sender)) + return; + + final List blockedPlayerNames = blockList().getBlockedPlayers(sender.getUniqueId()) + .stream() + .map(server()::getOfflinePlayer) + .map(this::displayName) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + if (blockedPlayerNames.isEmpty()) + { + msg(sender, "Your block list is empty."); + return; + } + + if (!isValidPage(page, blockedPlayerNames.size())) + { + msg( + sender, + "Page must be between 1 and .", + Formatter.number("pages", pageCount(blockedPlayerNames.size()))); + return; + } + + final int firstIndex = (page - 1) * PAGE_SIZE; + final int lastIndex = Math.min(blockedPlayerNames.size(), firstIndex + PAGE_SIZE); + final List pageEntries = blockedPlayerNames.subList(firstIndex, lastIndex); + msg( + sender, + "Your blocked players () - page /: ", + Formatter.number("count", blockedPlayerNames.size()), + Formatter.number("page", page), + Formatter.number("pages", pageCount(blockedPlayerNames.size())), + Placeholder.unparsed("players", String.join(", ", pageEntries))); + } + + private boolean requireAvailable(final Player sender) + { + if (blockList().isAvailable()) + return true; + + sendUnavailable(sender); + return false; + } + + private void sendUnavailable(final Player sender) + { + msg( + sender, + "Player blocks are temporarily unavailable because their data could not be loaded or saved safely."); + } + + private String displayName(final OfflinePlayer player) + { + return Optional.ofNullable(player.getName()) + .orElseGet(() -> player.getUniqueId().toString()); + } + + private PlayerBlockList blockList() + { + return plugin().services().require(PlayerBlockList.class); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockList.java new file mode 100644 index 000000000..c16fbcb19 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockList.java @@ -0,0 +1,677 @@ +package me.totalfreedom.totalfreedommod.player; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.Event.Result; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.*; +import org.bukkit.scheduler.BukkitTask; + +import me.totalfreedom.api.FreedomAPI; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + +public class PlayerBlockList extends FreedomService +{ + private static final String CONFIG_FILENAME = "player-blocks.yml"; + private static final long PROTECTED_TARGET_REFRESH_TICKS = 20L; + private static final long RETRY_DELAY_TICKS = 20L * 30L; + private static final long SAVE_DELAY_TICKS = 20L; + private static final long TEMPORARY_REVEAL_TICKS = 20L * 5L; + private static final double TEMPORARY_REVEAL_RADIUS = 10.0; + private static final double TEMPORARY_REVEAL_RADIUS_SQUARED = + TEMPORARY_REVEAL_RADIUS * TEMPORARY_REVEAL_RADIUS; + + private final Object fileWriteLock = new Object(); + private final Object lifecycleStateLock = new Object(); + private final Map revealTasks = new HashMap<>(); + private final PlayerBlockStorage storage; + private Optional pendingSaveTask = Optional.empty(); + private Optional protectedTargetRefreshTask = Optional.empty(); + private Set protectedOnlineTargets = Set.of(); + private volatile Map> blockedPlayers = Map.of(); + private volatile long lifecycleEpoch; + private long changeGeneration, lastWrittenGeneration; + // Keep unsaved block changes in memory if a reload flush fails. + private boolean retainGraphAfterFailedFlush, stopping; + private volatile boolean saveInFlight, storageAvailable; + + public PlayerBlockList(final FreedomAPI plugin) + { + super(plugin); + storage = new PlayerBlockStorage( + plugin.getDataFolder().toPath().resolve(CONFIG_FILENAME)); + } + + public boolean isAvailable() + { + return storageAvailable; + } + + public boolean hasBlocked(final UUID blocker, final UUID blocked) + { + Objects.requireNonNull(blocker, "blocker"); + Objects.requireNonNull(blocked, "blocked"); + return blockedPlayers.getOrDefault(blocker, Set.of()).contains(blocked); + } + + public Set getBlockedPlayers(final UUID blocker) + { + Objects.requireNonNull(blocker, "blocker"); + return blockedPlayers.getOrDefault(blocker, Set.of()); + } + + public boolean isProtectedTarget(final OfflinePlayer target) + { + Objects.requireNonNull(target, "target"); + + final boolean activeAdmin = Optional.ofNullable(target.getPlayer()) + .filter(plugin.admins()::isAdmin) + .isPresent() + || Optional.ofNullable(plugin.admins().getAdminByUuid(target.getUniqueId())) + .filter(Admin::isActive) + .isPresent(); + final Optional targetName = Optional.ofNullable(target.getName()); + final boolean activeAdminByName = targetName + .map(plugin.admins()::getEntryByName) + .filter(Admin::isActive) + .isPresent(); + return isProtectedIdentity( + activeAdmin || activeAdminByName, + targetName, + ConfigEntry.SERVER_OWNERS.getStringList(), + FUtil.DEVELOPERS); + } + + public Optional block(final UUID blocker, final UUID blocked) + { + Objects.requireNonNull(blocker, "blocker"); + Objects.requireNonNull(blocked, "blocked"); + requireMainThread(); + + if (!storageAvailable) + return Optional.empty(); + if (blocker.equals(blocked) + || hasBlocked(blocker, blocked) + || isProtectedTarget(server.getOfflinePlayer(blocked))) + return Optional.of(false); + + final Map> nextGraph = mutableGraph(); + final Set targets = new HashSet<>(nextGraph.getOrDefault(blocker, Set.of())); + targets.add(blocked); + nextGraph.put(blocker, Set.copyOf(targets)); + publishGraph(nextGraph); + refreshOnlinePair(blocker, blocked); + return Optional.of(true); + } + + public Optional unblock(final UUID blocker, final UUID blocked) + { + Objects.requireNonNull(blocker, "blocker"); + Objects.requireNonNull(blocked, "blocked"); + requireMainThread(); + + if (!storageAvailable) + return Optional.empty(); + if (!hasBlocked(blocker, blocked)) + return Optional.of(false); + + final Map> nextGraph = mutableGraph(); + final Set targets = new HashSet<>(nextGraph.getOrDefault(blocker, Set.of())); + targets.remove(blocked); + if (targets.isEmpty()) + nextGraph.remove(blocker); + else + nextGraph.put(blocker, Set.copyOf(targets)); + + publishGraph(nextGraph); + refreshOnlinePair(blocker, blocked); + return Optional.of(true); + } + + public Optional clear(final UUID blocker) + { + Objects.requireNonNull(blocker, "blocker"); + requireMainThread(); + + if (!storageAvailable) + return Optional.empty(); + + final Set removedTargets = getBlockedPlayers(blocker); + if (removedTargets.isEmpty()) + return Optional.of(0); + + final Map> nextGraph = mutableGraph(); + nextGraph.remove(blocker); + publishGraph(nextGraph); + removedTargets.forEach(blocked -> refreshOnlinePair(blocker, blocked)); + return Optional.of(removedTargets.size()); + } + + public void refreshVisibility(final Player viewer, final Player target) + { + Objects.requireNonNull(viewer, "viewer"); + Objects.requireNonNull(target, "target"); + requireMainThread(); + + if (viewer.getUniqueId().equals(target.getUniqueId())) + return; + + final VisibilityPair pair = new VisibilityPair( + viewer.getUniqueId(), + target.getUniqueId()); + final boolean blocked = hasEffectiveBlock(viewer, target); + if (!blocked) + clearTemporaryReveal(pair); + + final boolean temporarilyRevealed = blocked && revealTasks.containsKey(pair); + final boolean visibleThroughVanish = plugin.vanish().canSee(viewer, target); + if (shouldShow(blocked, temporarilyRevealed, visibleThroughVanish)) + viewer.showPlayer(plugin, target); + else + viewer.hidePlayer(plugin, target); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(final PlayerJoinEvent event) + { + final Player joinedPlayer = event.getPlayer(); + server.getOnlinePlayers() + .stream() + .filter(player -> !player.getUniqueId().equals(joinedPlayer.getUniqueId())) + .forEach(player -> + { + refreshVisibility(joinedPlayer, player); + refreshVisibility(player, joinedPlayer); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(final PlayerQuitEvent event) + { + clearTemporaryRevealsFor(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBreak(final BlockBreakEvent event) + { + revealNearbyAction(event.getPlayer(), event.getBlock().getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockPlace(final BlockPlaceEvent event) + { + if (!event.canBuild()) + return; + + revealNearbyAction(event.getPlayer(), event.getBlockPlaced().getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerInteract(final PlayerInteractEvent event) + { + final Optional clickedBlock = + Optional.ofNullable(event.getClickedBlock()); + if (!shouldRevealInteraction(clickedBlock.isPresent(), event.useInteractedBlock())) + return; + + revealNearbyAction( + event.getPlayer(), + clickedBlock.orElseThrow().getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBucketEmpty(final PlayerBucketEmptyEvent event) + { + revealNearbyAction(event.getPlayer(), event.getBlock().getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBucketFill(final PlayerBucketFillEvent event) + { + revealNearbyAction(event.getPlayer(), event.getBlock().getLocation()); + } + + @Override + public void onStart() + { + clearAllTemporaryReveals(); + synchronized (lifecycleStateLock) + { + lifecycleEpoch++; + stopping = false; + saveInFlight = false; + } + pendingSaveTask.ifPresent(BukkitTask::cancel); + pendingSaveTask = Optional.empty(); + startProtectedTargetRefresh(); + + if (retainGraphAfterFailedFlush) + { + storageAvailable = false; + refreshAllVisibility(); + scheduleSave(RETRY_DELAY_TICKS); + return; + } + + final Optional>> loadedGraph = storage.read(); + if (loadedGraph.isEmpty()) + { + storageAvailable = false; + FLog.error(String.format( + "Player blocks are unavailable until %s is repaired and services are restarted", + CONFIG_FILENAME)); + refreshAllVisibility(); + return; + } + + blockedPlayers = immutableGraph(loadedGraph.orElseThrow()); + changeGeneration = 0L; + lastWrittenGeneration = 0L; + retainGraphAfterFailedFlush = false; + storageAvailable = true; + refreshAllVisibility(); + } + + @Override + public void onStop() + { + clearAllTemporaryReveals(); + synchronized (lifecycleStateLock) + { + lifecycleEpoch++; + stopping = true; + } + pendingSaveTask.ifPresent(BukkitTask::cancel); + pendingSaveTask = Optional.empty(); + protectedTargetRefreshTask.ifPresent(BukkitTask::cancel); + protectedTargetRefreshTask = Optional.empty(); + protectedOnlineTargets = Set.of(); + + final boolean hasUnwrittenChanges = changeGeneration > lastWrittenGeneration; + if (hasUnwrittenChanges) + { + final boolean successful; + synchronized (fileWriteLock) + { + successful = storage.write(blockedPlayers); + } + storageAvailable = successful; + retainGraphAfterFailedFlush = shouldRetainGraphAfterRestart( + hasUnwrittenChanges, + successful); + if (successful) + lastWrittenGeneration = changeGeneration; + } + else + retainGraphAfterFailedFlush = false; + saveInFlight = false; + } + + private static boolean shouldShow( + final boolean blocked, + final boolean temporarilyRevealed, + final boolean visibleThroughVanish) + { + return (!blocked || temporarilyRevealed) && visibleThroughVanish; + } + + private static boolean isProtectedIdentity( + final boolean activeAdmin, + final Optional targetName, + final Collection ownerNames, + final Collection developerNames) + { + Objects.requireNonNull(targetName, "targetName"); + Objects.requireNonNull(ownerNames, "ownerNames"); + Objects.requireNonNull(developerNames, "developerNames"); + if (activeAdmin) + return true; + + return targetName + .filter(name -> ownerNames + .stream() + .anyMatch(name::equalsIgnoreCase) + || developerNames + .stream() + .anyMatch(name::equalsIgnoreCase)) + .isPresent(); + } + + private static Set changedProtectedTargets( + final Set previousTargets, + final Set currentTargets) + { + Objects.requireNonNull(previousTargets, "previousTargets"); + Objects.requireNonNull(currentTargets, "currentTargets"); + return Stream.concat( + previousTargets + .stream() + .filter(target -> !currentTargets.contains(target)), + currentTargets + .stream() + .filter(target -> !previousTargets.contains(target))) + .collect(Collectors.toUnmodifiableSet()); + } + + private static boolean isNearBlock( + final Location location, + final int blockX, + final int blockY, + final int blockZ) + { + final double xDistance = axisDistance(location.getX(), blockX, blockX + 1.0); + final double yDistance = axisDistance(location.getY(), blockY, blockY + 1.0); + final double zDistance = axisDistance(location.getZ(), blockZ, blockZ + 1.0); + return xDistance * xDistance + yDistance * yDistance + zDistance * zDistance + <= TEMPORARY_REVEAL_RADIUS_SQUARED; + } + + private static boolean shouldRevealInteraction( + final boolean hasClickedBlock, + final Result useInteractedBlock) + { + return hasClickedBlock && useInteractedBlock != Result.DENY; + } + + private static boolean shouldRetainGraphAfterRestart( + final boolean hasUnwrittenChanges, + final boolean flushSuccessful) + { + return hasUnwrittenChanges && !flushSuccessful; + } + + private Map> mutableGraph() + { + return new HashMap<>(blockedPlayers); + } + + private static Map> immutableGraph( + final Map> source) + { + return source.entrySet() + .stream() + .filter(entry -> !entry.getValue().isEmpty()) + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> Set.copyOf(entry.getValue()))); + } + + private static double axisDistance( + final double value, + final double minimum, + final double maximum) + { + if (value < minimum) + return minimum - value; + if (value > maximum) + return value - maximum; + return 0.0; + } + + private void publishGraph(final Map> nextGraph) + { + blockedPlayers = immutableGraph(nextGraph); + changeGeneration++; + scheduleSave(SAVE_DELAY_TICKS); + } + + private void refreshOnlinePair(final UUID viewerId, final UUID targetId) + { + Optional.ofNullable(server.getPlayer(viewerId)) + .ifPresent(viewer -> Optional.ofNullable(server.getPlayer(targetId)) + .ifPresent(target -> refreshVisibility(viewer, target))); + } + + private void refreshAllVisibility() + { + server.getOnlinePlayers() + .forEach(viewer -> server.getOnlinePlayers() + .stream() + .filter(target -> !viewer.getUniqueId().equals(target.getUniqueId())) + .forEach(target -> refreshVisibility(viewer, target))); + } + + private void startProtectedTargetRefresh() + { + protectedTargetRefreshTask.ifPresent(BukkitTask::cancel); + protectedOnlineTargets = protectedOnlineTargetIds(); + protectedTargetRefreshTask = Optional.of(server.getScheduler().runTaskTimer( + plugin, + FTask.guard( + "PlayerBlockList/protectedTargetRefresh", + this::refreshProtectedTargetTransitions), + PROTECTED_TARGET_REFRESH_TICKS, + PROTECTED_TARGET_REFRESH_TICKS)); + } + + private void refreshProtectedTargetTransitions() + { + final Set currentTargets = protectedOnlineTargetIds(); + final Set changedTargets = changedProtectedTargets( + protectedOnlineTargets, + currentTargets); + protectedOnlineTargets = currentTargets; + changedTargets.forEach(targetId -> Optional.ofNullable(server.getPlayer(targetId)) + .ifPresent(target -> server.getOnlinePlayers() + .stream() + .filter(viewer -> !viewer.getUniqueId().equals(targetId)) + .forEach(viewer -> refreshVisibility(viewer, target)))); + } + + private Set protectedOnlineTargetIds() + { + return server.getOnlinePlayers() + .stream() + .filter(this::isProtectedTarget) + .map(Player::getUniqueId) + .collect(Collectors.toUnmodifiableSet()); + } + + private void revealNearbyAction(final Player actor, final Location actionLocation) + { + Optional.ofNullable(actionLocation.getWorld()) + .ifPresent(world -> world.getPlayers() + .stream() + .filter(viewer -> hasEffectiveBlock(viewer, actor)) + .filter(viewer -> isNearBlock( + viewer.getLocation(), + actionLocation.getBlockX(), + actionLocation.getBlockY(), + actionLocation.getBlockZ())) + .forEach(viewer -> beginTemporaryReveal(viewer, actor))); + } + + private boolean hasEffectiveBlock(final Player viewer, final Player target) + { + return !isProtectedTarget(target) + && hasBlocked(viewer.getUniqueId(), target.getUniqueId()); + } + + private void beginTemporaryReveal(final Player viewer, final Player target) + { + final VisibilityPair pair = new VisibilityPair( + viewer.getUniqueId(), + target.getUniqueId()); + clearTemporaryReveal(pair); + + final BukkitTask expiryTask = server.getScheduler().runTaskLater( + plugin, + FTask.guard( + "PlayerBlockList/revealExpiry", + () -> finishTemporaryReveal(pair)), + TEMPORARY_REVEAL_TICKS); + revealTasks.put(pair, expiryTask); + refreshVisibility(viewer, target); + } + + private void finishTemporaryReveal(final VisibilityPair pair) + { + revealTasks.remove(pair); + Optional.ofNullable(server.getPlayer(pair.viewer())) + .ifPresent(viewer -> Optional.ofNullable(server.getPlayer(pair.target())) + .ifPresent(target -> refreshVisibility(viewer, target))); + } + + private void clearTemporaryReveal(final VisibilityPair pair) + { + Optional.ofNullable(revealTasks.remove(pair)) + .ifPresent(BukkitTask::cancel); + } + + private void clearTemporaryRevealsFor(final UUID player) + { + revealTasks.entrySet().removeIf(entry -> + { + final VisibilityPair pair = entry.getKey(); + if (!pair.viewer().equals(player) && !pair.target().equals(player)) + return false; + + entry.getValue().cancel(); + return true; + }); + } + + private void clearAllTemporaryReveals() + { + revealTasks.values().forEach(BukkitTask::cancel); + revealTasks.clear(); + } + + private void scheduleSave(final long delayTicks) + { + if (stopping || pendingSaveTask.isPresent() || saveInFlight) + return; + + pendingSaveTask = Optional.of(server.getScheduler().runTaskLater( + plugin, + FTask.guard("PlayerBlockList/save", this::dispatchSave), + delayTicks)); + } + + private void dispatchSave() + { + pendingSaveTask = Optional.empty(); + if (stopping || saveInFlight || changeGeneration <= lastWrittenGeneration) + return; + + saveInFlight = true; + final long epoch = lifecycleEpoch; + final long generation = changeGeneration; + final Map> snapshot = blockedPlayers; + server.getAsyncScheduler().runNow( + plugin, + FTask.guardAsync( + "PlayerBlockList/write", + task -> persistGeneration(epoch, generation, snapshot))); + } + + private void persistGeneration( + final long epoch, + final long generation, + final Map> snapshot) + { + boolean successful = false; + try + { + if (epoch == lifecycleEpoch) + { + synchronized (fileWriteLock) + { + if (epoch == lifecycleEpoch) + successful = storage.write(snapshot); + } + } + if (!successful && epoch == lifecycleEpoch) + storageAvailable = false; + } + finally + { + scheduleSaveCompletion(epoch, generation, successful); + } + } + + private void scheduleSaveCompletion( + final long epoch, + final long generation, + final boolean successful) + { + try + { + server.getScheduler().runTask( + plugin, + FTask.guard( + "PlayerBlockList/finishSave", + () -> finishSave(epoch, generation, successful))); + } + catch (RuntimeException ex) + { + synchronized (lifecycleStateLock) + { + if (epoch != lifecycleEpoch) + return; + + saveInFlight = false; + storageAvailable = false; + } + FLog.error(String.format( + "Could not schedule player block save completion: %s", + ex.getMessage())); + } + } + + private void finishSave( + final long epoch, + final long generation, + final boolean successful) + { + if (epoch != lifecycleEpoch) + return; + + saveInFlight = false; + if (successful) + { + lastWrittenGeneration = Math.max(lastWrittenGeneration, generation); + retainGraphAfterFailedFlush = false; + storageAvailable = true; + } + else + storageAvailable = false; + + if (stopping) + return; + + if (!successful) + { + scheduleSave(RETRY_DELAY_TICKS); + return; + } + + if (changeGeneration > lastWrittenGeneration) + scheduleSave(SAVE_DELAY_TICKS); + } + + private void requireMainThread() + { + if (!Bukkit.isPrimaryThread()) + throw new IllegalStateException("Player block mutations must run on the server thread."); + } + + private record VisibilityPair(UUID viewer, UUID target) + { + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockStorage.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockStorage.java new file mode 100644 index 000000000..cbadd3e96 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerBlockStorage.java @@ -0,0 +1,292 @@ +package me.totalfreedom.totalfreedommod.player; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import me.totalfreedom.totalfreedommod.util.FLog; + +final class PlayerBlockStorage +{ + private static final String BLOCKS_PATH = "blocks"; + private static final int CONFIG_VERSION = 1; + + private final Path configPath; + + PlayerBlockStorage(final Path configPath) + { + this.configPath = configPath; + } + + Optional>> read() + { + try + { + return readSafely(); + } + catch (RuntimeException ex) + { + FLog.error(String.format( + "Could not read %s safely; preserving the existing file: %s", + filename(), + ex.getMessage())); + return Optional.empty(); + } + } + + boolean write(final Map> blockedPlayers) + { + final Path temporaryPath = configPath.resolveSibling(String.format("%s.tmp", filename())); + try + { + return writeSafely(blockedPlayers, temporaryPath); + } + catch (RuntimeException ex) + { + FLog.error(String.format( + "Could not save %s safely: %s", + filename(), + ex.getMessage())); + removeTemporaryFile(temporaryPath); + return false; + } + } + + private Optional>> readSafely() + { + if (!Files.exists(configPath)) + return Optional.of(Map.of()); + + final YamlConfiguration config = new YamlConfiguration(); + try + { + config.load(configPath.toFile()); + } + catch (IOException | InvalidConfigurationException ex) + { + FLog.error(String.format( + "Could not load %s; preserving the existing file: %s", + filename(), + ex.getMessage())); + return Optional.empty(); + } + + if (config.getInt("version", -1) != CONFIG_VERSION) + { + FLog.error(String.format("Unsupported or missing version in %s", filename())); + return Optional.empty(); + } + + final Optional blocksSection = Optional.ofNullable( + config.getConfigurationSection(BLOCKS_PATH)); + if (blocksSection.isEmpty()) + { + FLog.error(String.format( + "Invalid or missing '%s' section in %s", + BLOCKS_PATH, + filename())); + return Optional.empty(); + } + + final AtomicBoolean semanticErrors = new AtomicBoolean(); + final Map> blockedPlayers = new HashMap<>(); + final ConfigurationSection resolvedBlocksSection = blocksSection.orElseThrow(); + resolvedBlocksSection.getKeys(false) + .stream() + .sorted() + .forEach(blockerValue -> loadBlocker( + resolvedBlocksSection, + blockerValue, + blockedPlayers, + semanticErrors)); + if (semanticErrors.get()) + { + FLog.error(String.format( + "Invalid entries were found in %s; preserving the existing file", + filename())); + return Optional.empty(); + } + + final Map> immutableGraph = new HashMap<>(); + blockedPlayers.forEach((blocker, targets) -> immutableGraph.put( + blocker, + Set.copyOf(targets))); + return Optional.of(Map.copyOf(immutableGraph)); + } + + private boolean writeSafely( + final Map> blockedPlayers, + final Path temporaryPath) + { + if (read().isEmpty()) + { + FLog.error(String.format( + "Refusing to overwrite unreadable player-block data in %s", + filename())); + return false; + } + + final YamlConfiguration config = new YamlConfiguration(); + config.set("version", CONFIG_VERSION); + final ConfigurationSection blocksSection = config.createSection(BLOCKS_PATH); + blockedPlayers.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> blocksSection.set( + entry.getKey().toString(), + entry.getValue() + .stream() + .sorted() + .map(UUID::toString) + .toList())); + + try + { + final Optional parent = Optional.ofNullable(configPath.getParent()); + if (parent.isPresent()) + Files.createDirectories(parent.orElseThrow()); + Files.writeString(temporaryPath, config.saveToString(), StandardCharsets.UTF_8); + replaceConfig(temporaryPath); + return true; + } + catch (IOException ex) + { + FLog.error(String.format("Could not save %s: %s", filename(), ex.getMessage())); + removeTemporaryFile(temporaryPath); + return false; + } + } + + private void loadBlocker( + final ConfigurationSection blocksSection, + final String blockerValue, + final Map> blockedPlayers, + final AtomicBoolean semanticErrors) + { + final Optional blocker = parseUuid(blockerValue, "blocker", semanticErrors); + if (blocker.isEmpty()) + return; + + final Object targetsValue = blocksSection.get(blockerValue); + if (!(targetsValue instanceof final List targets)) + { + semanticErrors.set(true); + FLog.warn(String.format( + "Ignoring non-list block entry for %s in %s", + blockerValue, + filename())); + return; + } + + final UUID blockerId = blocker.orElseThrow(); + final Set loadedTargets = new TreeSet<>(); + targets.forEach(target -> loadTarget( + blockerId, + target, + loadedTargets, + semanticErrors)); + if (!loadedTargets.isEmpty()) + blockedPlayers.put(blockerId, Set.copyOf(loadedTargets)); + } + + private void loadTarget( + final UUID blocker, + final Object targetValue, + final Set loadedTargets, + final AtomicBoolean semanticErrors) + { + if (!(targetValue instanceof final String targetString)) + { + semanticErrors.set(true); + FLog.warn(String.format( + "Ignoring non-string blocked player for %s in %s", + blocker, + filename())); + return; + } + + final Optional target = parseUuid(targetString, "blocked player", semanticErrors); + if (target.isEmpty()) + return; + + final UUID targetId = target.orElseThrow(); + if (blocker.equals(targetId)) + { + semanticErrors.set(true); + FLog.warn(String.format( + "Ignoring self-block entry for %s in %s", + blocker, + filename())); + return; + } + loadedTargets.add(targetId); + } + + private Optional parseUuid( + final String value, + final String description, + final AtomicBoolean semanticErrors) + { + try + { + return Optional.of(UUID.fromString(value)); + } + catch (IllegalArgumentException ex) + { + semanticErrors.set(true); + FLog.warn(String.format( + "Ignoring invalid %s UUID '%s' in %s", + description, + value, + filename())); + return Optional.empty(); + } + } + + private String filename() + { + return Optional.ofNullable(configPath.getFileName()) + .map(Path::toString) + .orElse("player-blocks.yml"); + } + + private void replaceConfig(final Path temporaryPath) throws IOException + { + try + { + Files.move( + temporaryPath, + configPath, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } + catch (AtomicMoveNotSupportedException ex) + { + Files.move(temporaryPath, configPath, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void removeTemporaryFile(final Path temporaryPath) + { + try + { + Files.deleteIfExists(temporaryPath); + } + catch (IOException ex) + { + FLog.warn(String.format( + "Could not remove temporary %s file: %s", + filename(), + ex.getMessage())); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vanish/VanishService.java b/src/main/java/me/totalfreedom/totalfreedommod/vanish/VanishService.java index 8115673a1..ee135ef61 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vanish/VanishService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vanish/VanishService.java @@ -1,13 +1,11 @@ package me.totalfreedom.totalfreedommod.vanish; -import me.totalfreedom.api.FreedomAPI; -import me.totalfreedom.api.vanish.IVanishService; - import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; +import com.destroystokyo.paper.event.server.PaperServerListPingEvent; import org.bukkit.command.CommandSender; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -22,9 +20,10 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.server.ServerListPingEvent; -import com.destroystokyo.paper.event.server.PaperServerListPingEvent; - +import me.totalfreedom.api.FreedomAPI; +import me.totalfreedom.api.vanish.IVanishService; import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.player.PlayerBlockList; /** * Single source of truth for vanish state. @@ -141,24 +140,14 @@ public boolean toggle(Player player) /** * Pushes {@code subject}'s current vanish state to every online viewer's client. */ - private void applyVisibility(Player subject) + private void applyVisibility(final Player subject) { - for (Player viewer : server.getOnlinePlayers()) - { - if (viewer.getUniqueId().equals(subject.getUniqueId())) - { - continue; - } - - if (canSee(viewer, subject)) - { - viewer.showPlayer(plugin, subject); - } - else - { - viewer.hidePlayer(plugin, subject); - } - } + server.getOnlinePlayers() + .stream() + .filter(viewer -> !viewer.getUniqueId().equals(subject.getUniqueId())) + .forEach(viewer -> plugin.services() + .require(PlayerBlockList.class) + .refreshVisibility(viewer, subject)); } /** diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index 3b170c302..41f334b22 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -26,6 +26,7 @@ "default" ], "permissions": [ + "tfm.player.block", "tfm.player.localspawn", "tfm.player.radar", "tfm.player.rank",