From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Mon, 16 Aug 2021 01:31:54 -0500 Subject: [PATCH] Add '/paper mobcaps' and '/paper playermobcaps' Add commands to get the mobcaps for a world, as well as the mobcaps for each player when per-player mob spawning is enabled. Also has a hover text on each mob category listing what entity types are in said category diff --git a/src/main/java/com/destroystokyo/paper/PaperCommand.java b/src/main/java/com/destroystokyo/paper/PaperCommand.java index fa0b824747c5b15cfb29e22b2d30b9c6932c1320..ca401a3203176578f9cd399aa740f07147e45de9 100644 --- a/src/main/java/com/destroystokyo/paper/PaperCommand.java +++ b/src/main/java/com/destroystokyo/paper/PaperCommand.java @@ -3,6 +3,7 @@ package com.destroystokyo.paper; import com.destroystokyo.paper.io.SyncLoadFinder; import com.google.common.base.Functions; import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; @@ -10,6 +11,13 @@ import com.google.common.collect.Maps; import com.google.gson.JsonObject; import com.google.gson.internal.Streams; import com.google.gson.stream.JsonWriter; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minecraft.core.Registry; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MCUtil; import net.minecraft.server.MinecraftServer; @@ -19,10 +27,12 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ThreadedLevelLightEngine; import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.ChunkPos; import net.minecraft.network.protocol.game.ClientboundLightUpdatePacket; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MCUtil; +import net.minecraft.world.level.NaturalSpawner; import org.apache.commons.lang3.tuple.MutablePair; import org.apache.commons.lang3.tuple.Pair; import org.bukkit.Bukkit; @@ -54,6 +64,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.ToIntFunction; import java.util.stream.Collectors; import static net.kyori.adventure.text.Component.text; @@ -66,7 +77,7 @@ import static net.kyori.adventure.text.format.NamedTextColor.YELLOW; public class PaperCommand extends Command { private static final String BASE_PERM = "bukkit.command.paper."; - private static final ImmutableSet SUBCOMMANDS = ImmutableSet.builder().add("heap", "entity", "reload", "version", "debug", "chunkinfo", "fixlight", "syncloadinfo", "dumpitem").build(); + private static final ImmutableSet SUBCOMMANDS = ImmutableSet.builder().add("heap", "entity", "reload", "version", "debug", "chunkinfo", "fixlight", "syncloadinfo", "dumpitem", "mobcaps", "playermobcaps").build(); public PaperCommand(String name) { super(name); @@ -99,6 +110,10 @@ public class PaperCommand extends Command { return getListMatchingLast(sender, args, "help", "chunks"); } break; + case "mobcaps": + return getListMatchingLast(sender, args, this.suggestMobcaps(sender, args)); + case "playermobcaps": + return getListMatchingLast(sender, args, this.suggestPlayerMobcaps(sender, args)); case "chunkinfo": List worldNames = new ArrayList<>(); worldNames.add("*"); @@ -195,6 +210,12 @@ public class PaperCommand extends Command { case "syncloadinfo": this.doSyncLoadInfo(sender, args); break; + case "mobcaps": + this.printMobcaps(sender, args); + break; + case "playermobcaps": + this.printPlayerMobcaps(sender, args); + break; case "ver": if (!testPermission(sender, "version")) break; // "ver" needs a special check because it's an alias. All other commands are checked up before the switch statement (because they are present in the SUBCOMMANDS set) case "version": @@ -253,6 +274,184 @@ public class PaperCommand extends Command { } } + public static final Map MOB_CATEGORY_COLORS = ImmutableMap.builder() + .put(MobCategory.MONSTER, NamedTextColor.RED) + .put(MobCategory.CREATURE, NamedTextColor.GREEN) + .put(MobCategory.AMBIENT, NamedTextColor.GRAY) + .put(MobCategory.AXOLOTLS, TextColor.color(0x7324FF)) + .put(MobCategory.UNDERGROUND_WATER_CREATURE, TextColor.color(0x3541E6)) + .put(MobCategory.WATER_CREATURE, TextColor.color(0x006EFF)) + .put(MobCategory.WATER_AMBIENT, TextColor.color(0x00B3FF)) + .put(MobCategory.MISC, TextColor.color(0x636363)) + .build(); + + private List suggestMobcaps(CommandSender sender, String[] args) { + if (args.length == 2) { + final List worlds = new ArrayList<>(Bukkit.getWorlds().stream().map(World::getName).toList()); + worlds.add("*"); + return worlds; + } + + return Collections.emptyList(); + } + + private List suggestPlayerMobcaps(CommandSender sender, String[] args) { + if (args.length == 2) { + final List list = new ArrayList<>(); + for (final Player player : Bukkit.getOnlinePlayers()) { + if (!(sender instanceof Player senderPlayer) || senderPlayer.canSee(player)) { + list.add(player.getName()); + } + } + return list; + } + + return Collections.emptyList(); + } + + private void printMobcaps(CommandSender sender, String[] args) { + final List worlds; + if (args.length == 1) { + if (sender instanceof Player player) { + worlds = List.of(player.getWorld()); + } else { + sender.sendMessage(Component.text("Must specify a world! ex: '/paper mobcaps world'", NamedTextColor.RED)); + return; + } + } else if (args.length == 2) { + final String input = args[1]; + if (input.equals("*")) { + worlds = Bukkit.getWorlds(); + } else { + final World world = Bukkit.getWorld(input); + if (world == null) { + sender.sendMessage(Component.text("'" + input + "' is not a valid world!", NamedTextColor.RED)); + return; + } else { + worlds = List.of(world); + } + } + } else { + sender.sendMessage(Component.text("Too many arguments!", NamedTextColor.RED)); + return; + } + + for (final World world : worlds) { + final ServerLevel level = ((CraftWorld) world).getHandle(); + final NaturalSpawner.SpawnState state = level.getChunkSource().getLastSpawnState(); + + final int chunks; + if (state == null) { + chunks = 0; + } else { + chunks = state.getSpawnableChunkCount(); + } + sender.sendMessage(Component.join(JoinConfiguration.noSeparators(), + Component.text("Mobcaps for world: "), + Component.text(world.getName(), NamedTextColor.AQUA), + Component.text(" (" + chunks + " spawnable chunks)") + )); + + sender.sendMessage(this.buildMobcapsComponent( + category -> { + if (state == null) { + return 0; + } else { + return state.getMobCategoryCounts().getOrDefault(category, 0); + } + }, + category -> NaturalSpawner.globalLimitForCategory(level, category, chunks) + )); + } + } + + private void printPlayerMobcaps(CommandSender sender, String[] args) { + final Player player; + if (args.length == 1) { + if (sender instanceof Player pl) { + player = pl; + } else { + sender.sendMessage(Component.text("Must specify a player! ex: '/paper playermobcount playerName'", NamedTextColor.RED)); + return; + } + } else if (args.length == 2) { + final String input = args[1]; + player = Bukkit.getPlayerExact(input); + if (player == null) { + sender.sendMessage(Component.text("Could not find player named '" + input + "'", NamedTextColor.RED)); + return; + } + } else { + sender.sendMessage(Component.text("Too many arguments!", NamedTextColor.RED)); + return; + } + + final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + final ServerLevel level = serverPlayer.getLevel(); + + if (!level.paperConfig.perPlayerMobSpawns) { + sender.sendMessage(Component.text("Use '/paper mobcaps' for worlds where per-player mob spawning is disabled.", NamedTextColor.RED)); + return; + } + + sender.sendMessage(Component.join(JoinConfiguration.noSeparators(), Component.text("Mobcaps for player: "), Component.text(player.getName(), NamedTextColor.GREEN))); + sender.sendMessage(this.buildMobcapsComponent( + category -> level.chunkSource.chunkMap.getMobCountNear(serverPlayer, category), + category -> level.getWorld().getSpawnLimitUnsafe(org.bukkit.craftbukkit.util.CraftSpawnCategory.toBukkit(category)) + )); + } + + private Component buildMobcapsComponent(final ToIntFunction countGetter, final ToIntFunction limitGetter) { + return MOB_CATEGORY_COLORS.entrySet().stream() + .map(entry -> { + final MobCategory category = entry.getKey(); + final TextColor color = entry.getValue(); + + final Component categoryHover = Component.join(JoinConfiguration.noSeparators(), + Component.text("Entity types in category ", TextColor.color(0xE0E0E0)), + Component.text(category.getName(), color), + Component.text(':', NamedTextColor.GRAY), + Component.newline(), + Component.newline(), + Registry.ENTITY_TYPE.entrySet().stream() + .filter(it -> it.getValue().getCategory() == category) + .map(it -> Component.translatable(it.getValue().getDescriptionId())) + .collect(Component.toComponent(Component.text(", ", NamedTextColor.GRAY))) + ); + + final Component categoryComponent = Component.text() + .content(" " + category.getName()) + .color(color) + .hoverEvent(categoryHover) + .build(); + + final TextComponent.Builder builder = Component.text() + .append( + categoryComponent, + Component.text(": ", NamedTextColor.GRAY) + ); + final int limit = limitGetter.applyAsInt(category); + if (limit != -1) { + builder.append( + Component.text(countGetter.applyAsInt(category)), + Component.text("/", NamedTextColor.GRAY), + Component.text(limit) + ); + } else { + builder.append(Component.text() + .append( + Component.text('n'), + Component.text("/", NamedTextColor.GRAY), + Component.text('a') + ) + .hoverEvent(Component.text("This category does not naturally spawn."))); + } + return builder; + }) + .map(ComponentLike::asComponent) + .collect(Component.toComponent(Component.newline())); + } + private void doChunkInfo(CommandSender sender, String[] args) { List worlds; if (args.length < 2 || args[1].equals("*")) { diff --git a/src/main/java/net/minecraft/world/level/NaturalSpawner.java b/src/main/java/net/minecraft/world/level/NaturalSpawner.java index 611dbcebd54604da5bd3e6ed510032db2a70401d..a04b8eeb9b4cbd700b95552c46fdf0da4c03b3eb 100644 --- a/src/main/java/net/minecraft/world/level/NaturalSpawner.java +++ b/src/main/java/net/minecraft/world/level/NaturalSpawner.java @@ -191,6 +191,16 @@ public final class NaturalSpawner { world.getProfiler().pop(); } + // Paper start + public static int globalLimitForCategory(final ServerLevel level, final MobCategory category, final int spawnableChunkCount) { + final int categoryLimit = level.getWorld().getSpawnLimitUnsafe(CraftSpawnCategory.toBukkit(category)); + if (categoryLimit < 1) { + return categoryLimit; + } + return categoryLimit * spawnableChunkCount / NaturalSpawner.MAGIC_NUMBER; + } + // Paper end + public static void spawnCategoryForChunk(MobCategory group, ServerLevel world, LevelChunk chunk, NaturalSpawner.SpawnPredicate checker, NaturalSpawner.AfterSpawnCallback runner) { // Paper start - add parameters and int ret type spawnCategoryForChunk(group, world, chunk, checker, runner, Integer.MAX_VALUE, null); diff --git a/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/src/main/java/org/bukkit/craftbukkit/CraftServer.java index 9486c53285fabb2d72b2bc3881673cd6640bd50e..a97565735bf6005b77859e8453171cd8eaefab81 100644 --- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java +++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java @@ -2131,6 +2131,11 @@ public final class CraftServer implements Server { @Override public int getSpawnLimit(SpawnCategory spawnCategory) { + // Paper start + return this.getSpawnLimitUnsafe(spawnCategory); + } + public int getSpawnLimitUnsafe(final SpawnCategory spawnCategory) { + // Paper end return this.spawnCategoryLimit.getOrDefault(spawnCategory, -1); } diff --git a/src/main/java/org/bukkit/craftbukkit/CraftWorld.java b/src/main/java/org/bukkit/craftbukkit/CraftWorld.java index 8187f1fd64add9b76e56df7f15024bb1eb7e393d..ed2c1f50fd8baee7c291675177f1bc2d86ee9d68 100644 --- a/src/main/java/org/bukkit/craftbukkit/CraftWorld.java +++ b/src/main/java/org/bukkit/craftbukkit/CraftWorld.java @@ -1706,9 +1706,14 @@ public class CraftWorld extends CraftRegionAccessor implements World { Validate.notNull(spawnCategory, "SpawnCategory cannot be null"); Validate.isTrue(CraftSpawnCategory.isValidForLimits(spawnCategory), "SpawnCategory." + spawnCategory + " are not supported."); + // Paper start + return this.getSpawnLimitUnsafe(spawnCategory); + } + public final int getSpawnLimitUnsafe(final SpawnCategory spawnCategory) { int limit = this.spawnCategoryLimit.getOrDefault(spawnCategory, -1); if (limit < 0) { - limit = this.server.getSpawnLimit(spawnCategory); + limit = this.server.getSpawnLimitUnsafe(spawnCategory); + // Paper end } return limit; } diff --git a/src/test/java/io/papermc/paper/PaperCommandTest.java b/src/test/java/io/papermc/paper/PaperCommandTest.java new file mode 100644 index 0000000000000000000000000000000000000000..4b5b368ef17bdb90f50e6ccc1f814cf93c7c0590 --- /dev/null +++ b/src/test/java/io/papermc/paper/PaperCommandTest.java @@ -0,0 +1,21 @@ +package io.papermc.paper; + +import com.destroystokyo.paper.PaperCommand; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.world.entity.MobCategory; +import org.junit.Assert; +import org.junit.Test; + +public class PaperCommandTest { + @Test + public void testMobCategoryColors() { + final Set missing = new HashSet<>(); + for (final MobCategory value : MobCategory.values()) { + if (!PaperCommand.MOB_CATEGORY_COLORS.containsKey(value)) { + missing.add(value.getName()); + } + } + Assert.assertTrue("PaperCommand.MOB_CATEGORY_COLORS map missing TextColors for [" + String.join(", ", missing + "]"), missing.isEmpty()); + } +}