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 2ef4b4c2ff81d0fa33d4630593266066d8e6a6f3..34bc24403a83ae578d2fc3956b4883894c618747 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,12 @@ 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.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 +26,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; @@ -55,11 +64,12 @@ 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; 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); @@ -92,6 +102,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("*"); @@ -188,6 +202,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": @@ -246,6 +266,183 @@ 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.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(TextComponent.ofChildren( + 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(TextComponent.ofChildren(Component.text("Mobcaps for player: "), Component.text(player.getName(), NamedTextColor.GREEN))); + sender.sendMessage(this.buildMobcapsComponent( + category -> level.chunkSource.chunkMap.getMobCountNear(serverPlayer, category), + category -> NaturalSpawner.limitForCategory(level, 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 = TextComponent.ofChildren( + 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 55dd04816886d27a62856ac952d2fc5d15bf40e6..790999fea74e4d03a80a4c0c9665af87bd683577 100644 --- a/src/main/java/net/minecraft/world/level/NaturalSpawner.java +++ b/src/main/java/net/minecraft/world/level/NaturalSpawner.java @@ -145,32 +145,16 @@ public final class NaturalSpawner { MobCategory enumcreaturetype = aenumcreaturetype[j]; // CraftBukkit start - Use per-world spawn limits boolean spawnThisTick = true; - int limit = enumcreaturetype.getMaxInstancesPerChunk(); + final int limit = limitForCategory(world, enumcreaturetype); // Paper switch (enumcreaturetype) { - case MONSTER: - spawnThisTick = spawnMonsterThisTick; - limit = world.getWorld().getMonsterSpawnLimit(); - break; - case CREATURE: - spawnThisTick = spawnAnimalThisTick; - limit = world.getWorld().getAnimalSpawnLimit(); - break; - case WATER_CREATURE: - spawnThisTick = spawnWaterThisTick; - limit = world.getWorld().getWaterAnimalSpawnLimit(); - break; - case UNDERGROUND_WATER_CREATURE: - spawnThisTick = spawnWaterUndergroundCreatureThisTick; - limit = world.getWorld().getWaterUndergroundCreatureSpawnLimit(); - break; - case AMBIENT: - spawnThisTick = spawnAmbientThisTick; - limit = world.getWorld().getAmbientSpawnLimit(); - break; - case WATER_AMBIENT: - spawnThisTick = spawnWaterAmbientThisTick; - limit = world.getWorld().getWaterAmbientSpawnLimit(); - break; + // Paper start - not mindiff so we get conflict on change + case MONSTER -> spawnThisTick = spawnMonsterThisTick; + case CREATURE -> spawnThisTick = spawnAnimalThisTick; + case WATER_CREATURE -> spawnThisTick = spawnWaterThisTick; + case UNDERGROUND_WATER_CREATURE -> spawnThisTick = spawnWaterUndergroundCreatureThisTick; + case AMBIENT -> spawnThisTick = spawnAmbientThisTick; + case WATER_AMBIENT -> spawnThisTick = spawnWaterAmbientThisTick; + // Paper end } if (!spawnThisTick || limit == 0) { @@ -209,6 +193,28 @@ public final class NaturalSpawner { world.getProfiler().pop(); } + // Paper start + public static int limitForCategory(final ServerLevel world, final MobCategory enumcreaturetype) { + return switch (enumcreaturetype) { + case MONSTER -> world.getWorld().getMonsterSpawnLimit(); + case CREATURE -> world.getWorld().getAnimalSpawnLimit(); + case WATER_CREATURE -> world.getWorld().getWaterAnimalSpawnLimit(); + case UNDERGROUND_WATER_CREATURE -> world.getWorld().getWaterUndergroundCreatureSpawnLimit(); + case AMBIENT -> world.getWorld().getAmbientSpawnLimit(); + case WATER_AMBIENT -> world.getWorld().getWaterAmbientSpawnLimit(); + default -> enumcreaturetype.getMaxInstancesPerChunk(); + }; + } + + public static int globalLimitForCategory(final ServerLevel level, final MobCategory category, final int spawnableChunkCount) { + final int categoryLimit = limitForCategory(level, category); + if (categoryLimit < 1) { + return categoryLimit; + } + return categoryLimit * spawnableChunkCount / NaturalSpawner.MAGIC_NUMBER; + } + // Paper end + // Paper start - add parameters and int ret type public static void spawnCategoryForChunk(MobCategory group, ServerLevel world, LevelChunk chunk, NaturalSpawner.SpawnPredicate checker, NaturalSpawner.AfterSpawnCallback runner) { spawnCategoryForChunk(group, world, chunk, checker, runner); 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()); + } +}