Introducing MobArena Labs.

This commit introduces the concept of MobArena Labs as well as the first
explicitly experimental feature in the form of custom entity type lists
for the cleanup procedure.

MobArena Labs is an attempt at a compromise between the otherwise rigid
approach to functionality due to maintainability concerns and some of
the esoteric feature requests we see on Github. The compromise manifests
itself as follows:

- All experimental functionality is opt-in and set up primarily in a new
  file, `labs.yml`, which must be created manually. Nothing is provided
  for users by default, so the "I know what I'm doing"-attitude required
  for this functionality to work hopefully helps convey the user-facing
  downside; that experimental functionality is much more likely to break
  between releases, and that compatibility issues are ignored.

- The other side of the coin is that we can try new things as long as
  they are _local to MobArena_, i.e. no external dependencies. Some of
  the esoteric and niche feature requests we see on Github could make it
  into the plugin as experimental features. If enough servers make use
  of them and provide feedback, they could end up as regular features
  with stability and robustness concerns on par with core parts of the
  plugin. We use bStats to help visualize feature usage (or lack thereof
  so we can remove unused functionality if it is problematic).

The first experimental feature is the ability to customize the list of
entity types that MobArena looks for when clearing the arena region of
residual entities such as arrows and experience orbs. This is called the
Housekeeper, and it has its own section in `labs.yml`. It can be toggled
on or off with the `enabled` flag, and the list of entities is specified
with the `entities` list. Because the original code was hardcoded in the
ArenaImpl class, no effort has been made to make the Housekeeper and its
settings per-arena configurable.

Closes #667
This commit is contained in:
Andreas Troelsen 2021-05-16 21:50:18 +02:00
parent 52226fa1c9
commit 252c2b4c01
17 changed files with 614 additions and 37 deletions

View File

@ -6,6 +6,7 @@ import com.garbagemule.MobArena.ScoreboardManager.NullScoreboardManager;
import com.garbagemule.MobArena.announce.Announcer;
import com.garbagemule.MobArena.announce.MessengerAnnouncer;
import com.garbagemule.MobArena.announce.TitleAnnouncer;
import com.garbagemule.MobArena.housekeeper.Housekeeper;
import com.garbagemule.MobArena.steps.Step;
import com.garbagemule.MobArena.steps.StepFactory;
import com.garbagemule.MobArena.steps.PlayerJoinArena;
@ -35,7 +36,6 @@ import com.garbagemule.MobArena.waves.SheepBouncer;
import com.garbagemule.MobArena.waves.WaveManager;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
@ -148,6 +148,7 @@ public class ArenaImpl implements Arena
private StepFactory playerSpecArena;
private SpawnsPets spawnsPets;
private Housekeeper housekeeper;
/**
* Primary constructor. Requires a name and a world.
@ -257,6 +258,8 @@ public class ArenaImpl implements Arena
this.playerSpecArena = PlayerSpecArena.create(this);
this.spawnsPets = plugin.getArenaMaster().getSpawnsPets();
this.housekeeper = plugin.getLabs().housekeeper;
}
@ -1355,45 +1358,10 @@ public class ArenaImpl implements Arena
}
private void cleanup() {
removeMonsters();
removeBlocks();
removeEntities();
housekeeper.clean(this);
clearPlayers();
}
private void removeMonsters() {
monsterManager.clear();
}
private void removeBlocks() {
for (Block b : blocks) {
b.setType(Material.AIR);
}
blocks.clear();
}
private void removeEntities() {
List<Chunk> chunks = region.getChunks();
for (Chunk c : chunks) {
for (Entity e : c.getEntities()) {
if (e == null) {
continue;
}
switch (e.getType()) {
case DROPPED_ITEM:
case EXPERIENCE_ORB:
case ARROW:
case MINECART:
case BOAT:
case SHULKER_BULLET:
e.remove();
}
}
}
}
private void clearPlayers() {
arenaPlayers.clear();
arenaPlayerMap.clear();

View File

@ -8,6 +8,8 @@ import com.garbagemule.MobArena.formula.FormulaMacros;
import com.garbagemule.MobArena.formula.FormulaManager;
import com.garbagemule.MobArena.framework.Arena;
import com.garbagemule.MobArena.framework.ArenaMaster;
import com.garbagemule.MobArena.labs.Labs;
import com.garbagemule.MobArena.labs.LabsChart;
import com.garbagemule.MobArena.listeners.MAGlobalListener;
import com.garbagemule.MobArena.metrics.ArenaCountChart;
import com.garbagemule.MobArena.metrics.ClassChestsChart;
@ -69,6 +71,8 @@ public class MobArena extends JavaPlugin
private SignListeners signListeners;
private Labs labs;
@Override
public void onLoad() {
thingman = new ThingManager(this);
@ -180,6 +184,8 @@ public class MobArena extends JavaPlugin
metrics.addCustomChart(new IsolatedChatChart(this));
metrics.addCustomChart(new MonsterInfightChart(this));
metrics.addCustomChart(new PvpEnabledChart(this));
metrics.addCustomChart(new LabsChart(this, "labs_housekeeper_pie", config -> config.housekeeper));
}
public void reload() {
@ -188,6 +194,7 @@ public class MobArena extends JavaPlugin
try {
reloadConfig();
reloadLabs();
reloadGlobalMessenger();
reloadFormulaMacros();
reloadArenaMaster();
@ -210,6 +217,15 @@ public class MobArena extends JavaPlugin
config = loadsConfigFile.load();
}
private void reloadLabs() {
try {
labs = Labs.create(this);
} catch (IOException e) {
getLogger().log(Level.WARNING, "There was an error loading MobArena Labs!", e);
labs = Labs.createDefault();
}
}
private void reloadGlobalMessenger() {
String prefix = config.getString("global-settings.prefix", "");
if (prefix.isEmpty()) {
@ -317,4 +333,8 @@ public class MobArena extends JavaPlugin
public FormulaMacros getFormulaMacros() {
return macros;
}
public Labs getLabs() {
return labs;
}
}

View File

@ -0,0 +1,24 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
import org.bukkit.Material;
class BlockCleaner implements Housekeeper {
private static final BlockCleaner DEFAULT = new BlockCleaner();
private BlockCleaner() {
// OK BOSS
}
@Override
public void clean(Arena arena) {
arena.getBlocks().forEach(block -> block.setType(Material.AIR));
arena.getBlocks().clear();
}
static BlockCleaner getDefault() {
return DEFAULT;
}
}

View File

@ -0,0 +1,21 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
import java.util.Arrays;
import java.util.List;
class CompositeHousekeeper implements Housekeeper {
private final List<Housekeeper> minions;
CompositeHousekeeper(Housekeeper... minions) {
this.minions = Arrays.asList(minions);
}
@Override
public void clean(Arena arena) {
minions.forEach(minion -> minion.clean(arena));
}
}

View File

@ -0,0 +1,76 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
import com.garbagemule.MobArena.region.ArenaRegion;
import org.bukkit.Chunk;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import java.util.EnumSet;
import java.util.Objects;
import java.util.logging.Logger;
import java.util.stream.Collectors;
class EntityCleaner implements Housekeeper {
private static final EntityCleaner DEFAULT = new EntityCleaner(EnumSet.of(
EntityType.ARROW,
EntityType.BOAT,
EntityType.DROPPED_ITEM,
EntityType.EXPERIENCE_ORB,
EntityType.MINECART,
EntityType.SHULKER_BULLET
));
private final EnumSet<EntityType> entities;
EntityCleaner(EnumSet<EntityType> entities) {
this.entities = entities;
}
@Override
public void clean(Arena arena) {
ArenaRegion region = arena.getRegion();
for (Chunk chunk : region.getChunks()) {
for (Entity entity : chunk.getEntities()) {
if (entity == null) {
continue;
}
if (entities.contains(entity.getType())) {
entity.remove();
}
}
}
}
static EntityCleaner getDefault() {
return DEFAULT;
}
static EntityCleaner create(HousekeeperConfig config, Logger log) {
if (config.entities == null || config.entities.isEmpty()) {
return EntityCleaner.getDefault();
}
EnumSet<EntityType> entities = config.entities
.stream()
.map(value -> parse(value, log))
.filter(Objects::nonNull)
.collect(Collectors.toCollection(() -> EnumSet.noneOf(EntityType.class)));
return new EntityCleaner(entities);
}
private static EntityType parse(String value, Logger log) {
try {
return EntityType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
if (log != null) {
log.warning("Unknown housekeeper entity type '" + value + "', skipping...");
}
return null;
}
}
}

View File

@ -0,0 +1,9 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
public interface Housekeeper {
void clean(Arena arena);
}

View File

@ -0,0 +1,75 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.labs.LabsConfigSection;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class HousekeeperConfig extends LabsConfigSection {
public final Set<String> entities;
private HousekeeperConfig(boolean enabled, Set<String> entities) {
super(enabled);
this.entities = Collections.unmodifiableSet(entities);
}
public static HousekeeperConfig parse(Map<String, Object> section) {
boolean enabled = parseEnabled(section);
Set<String> entities = parseEntities(section);
return new HousekeeperConfig(enabled, entities);
}
private static boolean parseEnabled(Map<String, Object> section) {
if (section == null) {
return false;
}
Object raw = section.get("enabled");
if (raw == null) {
return false;
}
if (raw instanceof Boolean) {
return (Boolean) raw;
}
if (raw instanceof String) {
return Boolean.parseBoolean((String) raw);
}
throw new IllegalArgumentException("Unexpected 'enabled' value in housekeeper config");
}
private static Set<String> parseEntities(Map<String, Object> section) {
if (section == null) {
return Collections.emptySet();
}
Object raw = section.get("entities");
if (raw == null) {
return Collections.emptySet();
}
if (raw instanceof List) {
return ((List<?>) raw)
.stream()
.map(String::valueOf)
.collect(Collectors.toSet());
}
if (raw instanceof String) {
String value = (String) raw;
String[] parts = value.split(",");
return Arrays.stream(parts)
.map(String::trim)
.collect(Collectors.toSet());
}
throw new IllegalArgumentException("Unexpected 'entities' value in housekeeper config");
}
}

View File

@ -0,0 +1,25 @@
package com.garbagemule.MobArena.housekeeper;
import java.util.logging.Logger;
public final class Housekeepers {
private static final Housekeeper DEFAULT = new CompositeHousekeeper(
MonsterCleaner.getDefault(),
BlockCleaner.getDefault(),
EntityCleaner.getDefault()
);
public static Housekeeper getDefault() {
return DEFAULT;
}
public static Housekeeper create(HousekeeperConfig config, Logger log) {
MonsterCleaner monsters = MonsterCleaner.getDefault();
BlockCleaner blocks = BlockCleaner.getDefault();
EntityCleaner entities = EntityCleaner.create(config, log);
return new CompositeHousekeeper(monsters, blocks, entities);
}
}

View File

@ -0,0 +1,22 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
class MonsterCleaner implements Housekeeper {
private static final MonsterCleaner DEFAULT = new MonsterCleaner();
private MonsterCleaner() {
// OK BOSS
}
@Override
public void clean(Arena arena) {
arena.getMonsterManager().clear();
}
static MonsterCleaner getDefault() {
return DEFAULT;
}
}

View File

@ -0,0 +1,77 @@
package com.garbagemule.MobArena.labs;
import com.garbagemule.MobArena.MobArena;
import com.garbagemule.MobArena.housekeeper.Housekeeper;
import com.garbagemule.MobArena.housekeeper.HousekeeperConfig;
import com.garbagemule.MobArena.housekeeper.Housekeepers;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Map;
import java.util.logging.Logger;
public class Labs {
public final LabsConfig config;
public final Housekeeper housekeeper;
private Labs(
LabsConfig config,
Housekeeper housekeeper
) {
this.config = config;
this.housekeeper = housekeeper;
}
public static Labs create(MobArena plugin) throws IOException {
Path labsFile = plugin.getDataFolder().toPath().resolve("labs.yml");
if (!Files.exists(labsFile)) {
return createDefault();
}
Logger log = plugin.getLogger();
String[] lines = {
"---==[ MobArena Labs ]==---",
"Labs is a set of experimental opt-in features that are exempt from",
"the goals of stability and robustness that are usually imposed on",
"functionality in the plugin. This means that breaking changes are",
"to be expected. No effort is made to ensure compatibility between",
"different iterations of Labs features.",
};
Arrays.stream(lines).forEach(log::info);
Yaml yaml = new Yaml();
byte[] bytes = Files.readAllBytes(labsFile);
Map<String, Object> map = yaml.load(new String(bytes));
LabsConfig config = LabsConfig.parse(map);
Housekeeper housekeeper = createHousekeeper(config, log);
log.info("---");
return new Labs(config, housekeeper);
}
private static Housekeeper createHousekeeper(LabsConfig root, Logger log) {
HousekeeperConfig config = root.housekeeper;
if (config == null || !config.enabled) {
return Housekeepers.getDefault();
}
Housekeeper housekeeper = Housekeepers.create(config, log);
log.info("Custom housekeeper created.");
return housekeeper;
}
public static Labs createDefault() {
return new Labs(
LabsConfig.parse(null),
Housekeepers.getDefault()
);
}
}

View File

@ -0,0 +1,35 @@
package com.garbagemule.MobArena.labs;
import com.garbagemule.MobArena.MobArena;
import org.bstats.charts.SimplePie;
import java.util.function.Function;
public class LabsChart extends SimplePie {
public LabsChart(
MobArena plugin,
String chartId,
Function<LabsConfig, LabsConfigSection> getter
) {
super(chartId, () -> usesFeature(plugin, getter) ? "Yes" : "No");
}
private static boolean usesFeature(
MobArena plugin,
Function<LabsConfig, LabsConfigSection> getter
) {
Labs labs = plugin.getLabs();
if (labs == null) {
return false;
}
LabsConfigSection section = getter.apply(labs.config);
if (section == null) {
return false;
}
return section.enabled;
}
}

View File

@ -0,0 +1,42 @@
package com.garbagemule.MobArena.labs;
import com.garbagemule.MobArena.housekeeper.HousekeeperConfig;
import java.util.Map;
import java.util.function.Function;
public class LabsConfig {
public final HousekeeperConfig housekeeper;
LabsConfig(
HousekeeperConfig housekeeper
) {
this.housekeeper = housekeeper;
}
static LabsConfig parse(Map<String, Object> root) {
return new LabsConfig(
parse(root, "housekeeper", HousekeeperConfig::parse)
);
}
@SuppressWarnings("SameParameterValue")
private static <C> C parse(
Map<String, Object> root,
String key,
Function<Map<String, Object>, C> parse
) {
if (root == null) {
return parse.apply(null);
}
Object raw = root.get(key);
@SuppressWarnings("unchecked")
Map<String, Object> section = (Map<String, Object>) raw;
return parse.apply(section);
}
}

View File

@ -0,0 +1,11 @@
package com.garbagemule.MobArena.labs;
public abstract class LabsConfigSection {
public final boolean enabled;
protected LabsConfigSection(boolean enabled) {
this.enabled = enabled;
}
}

View File

@ -2,6 +2,14 @@ package com.garbagemule.MobArena.util;
public class Enums
{
public static <E extends Enum<E>> E valueOf(Class<E> type, String value) {
try {
return Enum.valueOf(type, value);
} catch (IllegalArgumentException e) {
return null;
}
}
/**
* Get the enum value of a string, null if it doesn't exist.
*/

View File

@ -0,0 +1,36 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.StrictStubs.class)
public class BlockCleanerTest {
@Test
public void defaultCleanerSetsArenaBlocksToAir() {
Block block1 = mock(Block.class);
Block block2 = mock(Block.class);
Block block3 = mock(Block.class);
Set<Block> blocks = new HashSet<>(Arrays.asList(block1, block2, block3));
Arena arena = mock(Arena.class);
when(arena.getBlocks()).thenReturn(blocks);
BlockCleaner subject = BlockCleaner.getDefault();
subject.clean(arena);
verify(block1).setType(Material.AIR);
verify(block2).setType(Material.AIR);
verify(block3).setType(Material.AIR);
}
}

View File

@ -0,0 +1,102 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.framework.Arena;
import com.garbagemule.MobArena.region.ArenaRegion;
import org.bukkit.Chunk;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.StrictStubs.class)
public class EntityCleanerTest {
@Test
public void defaultCleanerRemovesDefaultsOnly() {
Entity[] defaults = new Entity[] {
fake(EntityType.ARROW),
fake(EntityType.BOAT),
fake(EntityType.DROPPED_ITEM),
fake(EntityType.EXPERIENCE_ORB),
fake(EntityType.MINECART),
fake(EntityType.SHULKER_BULLET),
};
Entity[] extras = new Entity[] {
fake(EntityType.ARMOR_STAND),
fake(EntityType.PIG),
};
Arena arena = mock(Arena.class);
ArenaRegion region = mock(ArenaRegion.class);
Chunk chunk = mock(Chunk.class);
when(arena.getRegion()).thenReturn(region);
when(region.getChunks()).thenReturn(Collections.singletonList(chunk));
when(chunk.getEntities()).thenReturn(concat(defaults, extras));
EntityCleaner subject = EntityCleaner.getDefault();
subject.clean(arena);
for (Entity entity : defaults) {
verify(entity).remove();
}
for (Entity entity : extras) {
verify(entity, never()).remove();
}
}
@Test
public void customCleanerRemovesProvidedTypesOnly() {
Entity[] removed = new Entity[] {
fake(EntityType.DROPPED_ITEM),
fake(EntityType.DROPPED_ITEM),
fake(EntityType.ARMOR_STAND),
fake(EntityType.DROPPED_ITEM),
};
Entity[] retained = new Entity[] {
fake(EntityType.MINECART),
fake(EntityType.EXPERIENCE_ORB),
fake(EntityType.EXPERIENCE_ORB),
fake(EntityType.EXPERIENCE_ORB),
fake(EntityType.ARROW),
fake(EntityType.ARROW),
};
Arena arena = mock(Arena.class);
ArenaRegion region = mock(ArenaRegion.class);
Chunk chunk = mock(Chunk.class);
when(arena.getRegion()).thenReturn(region);
when(region.getChunks()).thenReturn(Collections.singletonList(chunk));
when(chunk.getEntities()).thenReturn(concat(removed, retained));
EntityCleaner subject = new EntityCleaner(EnumSet.of(
EntityType.DROPPED_ITEM,
EntityType.ARMOR_STAND
));
subject.clean(arena);
for (Entity entity : removed) {
verify(entity).remove();
}
for (Entity entity : retained) {
verify(entity, never()).remove();
}
}
private static Entity fake(EntityType type) {
Entity entity = mock(Entity.class);
when(entity.getType()).thenReturn(type);
return entity;
}
private static Entity[] concat(Entity[] a, Entity[] b) {
Entity[] result = Arrays.copyOf(a, a.length + b.length);
System.arraycopy(b, 0, result, a.length, b.length);
return result;
}
}

View File

@ -0,0 +1,26 @@
package com.garbagemule.MobArena.housekeeper;
import com.garbagemule.MobArena.MonsterManager;
import com.garbagemule.MobArena.framework.Arena;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.StrictStubs.class)
public class MonsterCleanerTest {
@Test
public void defaultCleanerClearsMonsterManager() {
Arena arena = mock(Arena.class);
MonsterManager monsters = mock(MonsterManager.class);
when(arena.getMonsterManager()).thenReturn(monsters);
MonsterCleaner subject = MonsterCleaner.getDefault();
subject.clean(arena);
verify(monsters).clear();
}
}