Merge pull request #18 from SunNetservers/dev

Placeholders, and adjustments for multiple players
This commit is contained in:
Kristian Knarvik 2023-04-06 22:23:39 +00:00 committed by GitHub
commit 46e52812af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 458 additions and 58 deletions

View File

@ -97,4 +97,21 @@ You could use `/droppergroupswap Sea Savanna` to change the order to:
1. Forest
2. Savanna
3. Nether
4. Sea
4. Sea
## Record display placeholders
Player records can be displayed on a leaderboard by using PlaceholderAPI. The format for the built-in placeholders is as
follows:
`%dropper_record_recordType_gameModeType_identifierType_identifier_recordPlacing_infoType%`
| Variable | Values | Description |
|----------------|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| dropper_record | | Denotes that it's a placeholder for a dropper record. Must be present as-is. |
| recordType | deaths / time | Selects the type of record to get (deaths or time). |
| gameModeType | default / inverted / random | Selects the game-mode to get the record for. |
| identifierType | arena | This specifies that the following string is an arena identifier. |
| identifier | ? | An identifier (the name or UUID) for an arena or a group (whichever was chosen as identifierType). |
| recordPlacing | 1 / 2 / 3 / ... | The position of the record to get (1 = first place, 2 = second place, etc.). |
| infoType | player / value / combined | The type of info to get. Player gets the player name, Value gets the value of the achieved record. Combined gets "Player: Record". |

10
pom.xml
View File

@ -58,6 +58,10 @@
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>placeholderapi</id>
<url>https://repo.extendedclip.com/content/repositories/placeholderapi/</url>
</repository>
</repositories>
<dependencies>
@ -79,5 +83,11 @@
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>2.10.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -1,5 +1,6 @@
package net.knarcraft.dropper;
import net.knarcraft.dropper.arena.ArenaRecord;
import net.knarcraft.dropper.arena.DropperArenaData;
import net.knarcraft.dropper.arena.DropperArenaGroup;
import net.knarcraft.dropper.arena.DropperArenaHandler;
@ -25,7 +26,9 @@ import net.knarcraft.dropper.listener.CommandListener;
import net.knarcraft.dropper.listener.DamageListener;
import net.knarcraft.dropper.listener.MoveListener;
import net.knarcraft.dropper.listener.PlayerLeaveListener;
import net.knarcraft.dropper.placeholder.DropperRecordExpansion;
import net.knarcraft.dropper.property.ArenaGameMode;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.TabCompleter;
@ -94,6 +97,7 @@ public final class Dropper extends JavaPlugin {
ConfigurationSerialization.registerClass(DropperArenaData.class);
ConfigurationSerialization.registerClass(DropperArenaGroup.class);
ConfigurationSerialization.registerClass(ArenaGameMode.class);
ConfigurationSerialization.registerClass(ArenaRecord.class);
}
@Override
@ -121,6 +125,12 @@ public final class Dropper extends JavaPlugin {
registerCommand("dropperGroupSet", new GroupSetCommand(), null);
registerCommand("dropperGroupSwap", new GroupSwapCommand(), null);
registerCommand("dropperGroupList", new GroupListCommand(), null);
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
if (!new DropperRecordExpansion(this).register()) {
getLogger().log(Level.WARNING, "Unable to register PlaceholderAPI expansion!");
}
}
}
@Override

View File

@ -0,0 +1,52 @@
package net.knarcraft.dropper.arena;
import net.knarcraft.dropper.container.SerializableUUID;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* A record stored for an arena
*
* @param userId <p>The id of the player that achieved the record</p>
* @param record <p>The record achieved</p>
* @param <K> <p>The comparable type of the record</p>
*/
public record ArenaRecord<K extends Comparable<K>>(UUID userId, K record) implements Comparable<ArenaRecord<K>>,
ConfigurationSerializable {
@Override
public boolean equals(Object other) {
return other instanceof ArenaRecord<?> && userId.equals(((ArenaRecord<?>) other).userId);
}
@Override
public int compareTo(@NotNull ArenaRecord<K> other) {
return record.compareTo(other.record);
}
@NotNull
@Override
public Map<String, Object> serialize() {
Map<String, Object> data = new HashMap<>();
data.put("userId", new SerializableUUID(userId()));
data.put("record", record);
return data;
}
/**
* Deserializes the saved arena record
*
* @param data <p>The data to deserialize</p>
* @param <K> <p>The type of the deserialized record</p>
* @return <p>The deserialized data</p>
*/
@SuppressWarnings({"unused", "unchecked"})
public static <K extends Comparable<K>> ArenaRecord<K> deserialize(@NotNull Map<String, Object> data) {
return new ArenaRecord<>(((SerializableUUID) data.get("userId")).uuid(), (K) data.get("record"));
}
}

View File

@ -7,9 +7,11 @@ import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import java.util.concurrent.atomic.AtomicReference;
/**
* A registry keeping track of all records
@ -17,16 +19,16 @@ import java.util.stream.Stream;
public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
private final UUID arenaId;
private final @NotNull Map<UUID, Number> leastDeaths;
private final @NotNull Map<UUID, Number> shortestTimeMilliSeconds;
private final @NotNull Set<ArenaRecord<Integer>> leastDeaths;
private final @NotNull Set<ArenaRecord<Long>> shortestTimeMilliSeconds;
/**
* Instantiates a new empty records registry
*/
public DropperArenaRecordsRegistry(@NotNull UUID arenaId) {
this.arenaId = arenaId;
this.leastDeaths = new HashMap<>();
this.shortestTimeMilliSeconds = new HashMap<>();
this.leastDeaths = new HashSet<>();
this.shortestTimeMilliSeconds = new HashSet<>();
}
/**
@ -35,11 +37,11 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
* @param leastDeaths <p>The existing least death records to use</p>
* @param shortestTimeMilliSeconds <p>The existing leash time records to use</p>
*/
private DropperArenaRecordsRegistry(@NotNull UUID arenaId, @NotNull Map<UUID, Integer> leastDeaths,
@NotNull Map<UUID, Long> shortestTimeMilliSeconds) {
private DropperArenaRecordsRegistry(@NotNull UUID arenaId, @NotNull Set<ArenaRecord<Integer>> leastDeaths,
@NotNull Set<ArenaRecord<Long>> shortestTimeMilliSeconds) {
this.arenaId = arenaId;
this.leastDeaths = new HashMap<>(leastDeaths);
this.shortestTimeMilliSeconds = new HashMap<>(shortestTimeMilliSeconds);
this.leastDeaths = new HashSet<>(leastDeaths);
this.shortestTimeMilliSeconds = new HashSet<>(shortestTimeMilliSeconds);
}
/**
@ -47,12 +49,8 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
*
* @return <p>Existing death records</p>
*/
public Map<UUID, Integer> getLeastDeathsRecords() {
Map<UUID, Integer> leastDeathRecords = new HashMap<>();
for (Map.Entry<UUID, Number> entry : this.leastDeaths.entrySet()) {
leastDeathRecords.put(entry.getKey(), entry.getValue().intValue());
}
return leastDeathRecords;
public Set<ArenaRecord<Integer>> getLeastDeathsRecords() {
return new HashSet<>(this.leastDeaths);
}
/**
@ -60,12 +58,8 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
*
* @return <p>Existing time records</p>
*/
public Map<UUID, Long> getShortestTimeMilliSecondsRecords() {
Map<UUID, Long> leastTimeRecords = new HashMap<>();
for (Map.Entry<UUID, Number> entry : this.shortestTimeMilliSeconds.entrySet()) {
leastTimeRecords.put(entry.getKey(), entry.getValue().longValue());
}
return leastTimeRecords;
public Set<ArenaRecord<Long>> getShortestTimeMilliSecondsRecords() {
return new HashSet<>(this.shortestTimeMilliSeconds);
}
/**
@ -105,25 +99,26 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
* @param amount <p>The amount of whatever the player achieved</p>
* @return <p>The result of the player's record attempt</p>
*/
private @NotNull RecordResult registerRecord(@NotNull Map<UUID, Number> existingRecords, @NotNull UUID playerId,
Number amount) {
private <T extends Comparable<T>> @NotNull RecordResult registerRecord(@NotNull Set<ArenaRecord<T>> existingRecords,
@NotNull UUID playerId, T amount) {
RecordResult result;
Stream<Map.Entry<UUID, Number>> records = existingRecords.entrySet().stream();
long amountLong = amount.longValue();
if (records.allMatch((entry) -> amountLong < entry.getValue().longValue())) {
if (existingRecords.stream().allMatch((entry) -> amount.compareTo(entry.record()) < 0)) {
// If the given value is less than all other values, that's a world record!
result = RecordResult.WORLD_RECORD;
existingRecords.put(playerId, amount);
existingRecords.add(new ArenaRecord<>(playerId, amount));
save();
} else if (existingRecords.containsKey(playerId) && amountLong < existingRecords.get(playerId).longValue()) {
return result;
}
ArenaRecord<T> playerRecord = getRecord(existingRecords, playerId);
if (playerRecord != null && amount.compareTo(playerRecord.record()) < 0) {
// If the given value is less than the player's previous value, that's a personal best!
result = RecordResult.PERSONAL_BEST;
existingRecords.put(playerId, amount);
existingRecords.add(new ArenaRecord<>(playerId, amount));
save();
} else {
// Make sure to save the record if this is the user's first attempt
if (!existingRecords.containsKey(playerId)) {
if (playerRecord == null) {
save();
}
result = RecordResult.NONE;
@ -132,23 +127,24 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
return result;
}
private <T extends Comparable<T>> ArenaRecord<T> getRecord(@NotNull Set<ArenaRecord<T>> existingRecords,
@NotNull UUID playerId) {
AtomicReference<ArenaRecord<T>> record = new AtomicReference<>();
existingRecords.forEach((item) -> {
if (item.userId().equals(playerId)) {
record.set(item);
}
});
return record.get();
}
@NotNull
@Override
public Map<String, Object> serialize() {
Map<String, Object> data = new HashMap<>();
data.put("arenaId", new SerializableUUID(this.arenaId));
Map<SerializableUUID, Number> leastDeaths = new HashMap<>();
for (Map.Entry<UUID, Number> entry : this.leastDeaths.entrySet()) {
leastDeaths.put(new SerializableUUID(entry.getKey()), entry.getValue());
}
data.put("leastDeaths", leastDeaths);
Map<SerializableUUID, Number> shortestTimeMilliSeconds = new HashMap<>();
for (Map.Entry<UUID, Number> entry : this.shortestTimeMilliSeconds.entrySet()) {
shortestTimeMilliSeconds.put(new SerializableUUID(entry.getKey()), entry.getValue());
}
data.put("shortestTime", shortestTimeMilliSeconds);
data.put("leastDeaths", this.leastDeaths);
data.put("shortestTime", this.shortestTimeMilliSeconds);
return data;
}
@ -161,20 +157,10 @@ public class DropperArenaRecordsRegistry implements ConfigurationSerializable {
@SuppressWarnings({"unused", "unchecked"})
public static DropperArenaRecordsRegistry deserialize(Map<String, Object> data) {
UUID arenaId = ((SerializableUUID) data.get("arenaId")).uuid();
Map<SerializableUUID, Integer> leastDeathsData =
(Map<SerializableUUID, Integer>) data.getOrDefault("leastDeaths", new HashMap<>());
Map<UUID, Integer> leastDeaths = new HashMap<>();
for (Map.Entry<SerializableUUID, Integer> entry : leastDeathsData.entrySet()) {
leastDeaths.put(entry.getKey().uuid(), entry.getValue());
}
Map<SerializableUUID, Number> shortestTimeMillisecondsData =
(Map<SerializableUUID, Number>) data.getOrDefault("shortestTime", new HashMap<>());
Map<UUID, Long> shortestTimeMilliseconds = new HashMap<>();
for (Map.Entry<SerializableUUID, Number> entry : shortestTimeMillisecondsData.entrySet()) {
shortestTimeMilliseconds.put(entry.getKey().uuid(), entry.getValue().longValue());
}
Set<ArenaRecord<Integer>> leastDeaths =
(Set<ArenaRecord<Integer>>) data.getOrDefault("leastDeaths", new HashMap<>());
Set<ArenaRecord<Long>> shortestTimeMilliseconds =
(Set<ArenaRecord<Long>>) data.getOrDefault("shortestTime", new HashMap<>());
return new DropperArenaRecordsRegistry(arenaId, leastDeaths, shortestTimeMilliseconds);
}

View File

@ -4,6 +4,8 @@ import net.knarcraft.dropper.property.ArenaGameMode;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.jetbrains.annotations.NotNull;
/**
@ -19,6 +21,7 @@ public class PlayerEntryState {
private final boolean originalAllowFlight;
private final boolean originalInvulnerable;
private final boolean originalIsSwimming;
private final boolean originalCollideAble;
private final ArenaGameMode arenaGameMode;
/**
@ -36,6 +39,7 @@ public class PlayerEntryState {
this.originalInvulnerable = player.isInvulnerable();
this.originalIsSwimming = player.isSwimming();
this.arenaGameMode = arenaGameMode;
this.originalCollideAble = player.isCollidable();
}
/**
@ -48,6 +52,8 @@ public class PlayerEntryState {
this.player.setFlying(true);
this.player.setGameMode(GameMode.ADVENTURE);
this.player.setSwimming(false);
this.player.setCollidable(false);
this.player.addPotionEffect(new PotionEffect(PotionEffectType.INVISIBILITY, PotionEffect.INFINITE_DURATION, 3));
// If playing on the inverted game-mode, negate the horizontal velocity to swap the controls
if (arenaGameMode == ArenaGameMode.INVERTED) {
@ -67,6 +73,8 @@ public class PlayerEntryState {
this.player.setFlySpeed(this.originalFlySpeed);
this.player.setInvulnerable(this.originalInvulnerable);
this.player.setSwimming(this.originalIsSwimming);
this.player.setCollidable(this.originalCollideAble);
this.player.removePotionEffect(PotionEffectType.INVISIBILITY);
}
/**

View File

@ -0,0 +1,199 @@
package net.knarcraft.dropper.placeholder;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import net.knarcraft.dropper.Dropper;
import net.knarcraft.dropper.arena.ArenaRecord;
import net.knarcraft.dropper.arena.DropperArena;
import net.knarcraft.dropper.arena.DropperArenaHandler;
import net.knarcraft.dropper.arena.DropperArenaRecordsRegistry;
import net.knarcraft.dropper.placeholder.parsing.InfoType;
import net.knarcraft.dropper.placeholder.parsing.RecordType;
import net.knarcraft.dropper.placeholder.parsing.SelectionType;
import net.knarcraft.dropper.property.ArenaGameMode;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* A placeholder expansion for dropper record placeholders
*/
public class DropperRecordExpansion extends PlaceholderExpansion {
private final Dropper plugin;
public DropperRecordExpansion(Dropper plugin) {
this.plugin = plugin;
}
@Override
public String getIdentifier() {
return "dropper";
}
@Override
public String getAuthor() {
return "EpicKnarvik97";
}
@Override
public String getVersion() {
return "1.0.0";
}
@Override
public boolean persist() {
return true;
}
@Override
public String onRequest(OfflinePlayer player, String parameters) {
String[] parts = parameters.split("_");
// Record is used as the prefix for all record placeholders in case more placeholder types are added
if (parts.length < 7 || !parts[0].equals("record")) {
return parameters;
}
RecordType recordType = RecordType.getFromString(parts[1]);
ArenaGameMode gameMode = ArenaGameMode.matchGamemode(parts[2]);
SelectionType selectionType = SelectionType.getFromString(parts[3]);
String identifier = parts[4];
int recordNumber = Integer.parseInt(parts[5]) - 1;
InfoType infoType = InfoType.getFromString(parts[6]);
if (recordType == null || infoType == null) {
return parameters;
}
String info = null;
DropperArenaHandler arenaHandler = plugin.getArenaHandler();
if (selectionType == SelectionType.ARENA) {
info = getArenaRecord(arenaHandler, identifier, gameMode, recordType, recordNumber, infoType);
}
return Objects.requireNonNullElse(info, parameters);
}
/**
* Gets a piece of record information from a dropper arena
*
* @param arenaHandler <p>The arena handler to get the arena from</p>
* @param identifier <p>The identifier (name/uuid) selecting the arena</p>
* @param gameMode <p>The game-mode to get a record for</p>
* @param recordType <p>The type of record to get</p>
* @param recordNumber <p>The placing of the record to get (1st place, 2nd place, etc.)</p>
* @param infoType <p>The type of info (player, value, combined) to get</p>
* @return <p>The selected information about the record, or null if not found</p>
*/
private @Nullable String getArenaRecord(@NotNull DropperArenaHandler arenaHandler, @NotNull String identifier,
@NotNull ArenaGameMode gameMode, @NotNull RecordType recordType,
int recordNumber, @NotNull InfoType infoType) {
// Allow specifying the arena UUID or the arena name
DropperArena arena;
try {
arena = arenaHandler.getArena(UUID.fromString(identifier));
} catch (IllegalArgumentException exception) {
arena = arenaHandler.getArena(identifier);
}
if (arena == null) {
return null;
}
@NotNull Map<ArenaGameMode, DropperArenaRecordsRegistry> registries = arena.getData().recordRegistries();
DropperArenaRecordsRegistry recordsRegistry = registries.get(gameMode);
ArenaRecord<?> record = getRecord(recordsRegistry, recordType, recordNumber);
// If a record number is not found, leave it blank, so it looks neat
if (record == null) {
return "";
}
return getRecordData(infoType, record);
}
/**
* Gets the specified record
*
* @param recordsRegistry <p>The records registry to get the record from</p>
* @param recordType <p>The type of record to get</p>
* @param recordNumber <p>The placing of the record to get (1st place, 2nd place, etc.)</p>
* @return <p>The record, or null if not found</p>
*/
private @Nullable ArenaRecord<?> getRecord(@NotNull DropperArenaRecordsRegistry recordsRegistry,
@NotNull RecordType recordType, int recordNumber) {
return switch (recordType) {
case TIME -> getRecord(recordsRegistry.getShortestTimeMilliSecondsRecords(), recordNumber);
case DEATHS -> getRecord(recordsRegistry.getLeastDeathsRecords(), recordNumber);
};
}
/**
* Gets the record at the given index
*
* @param records <p>The records to search through</p>
* @param index <p>The index of the record to get</p>
* @param <K> <p>The type of record in the record list</p>
* @return <p>The record, or null if index is out of bounds</p>
*/
private <K extends Comparable<K>> @Nullable ArenaRecord<K> getRecord(Set<ArenaRecord<K>> records, int index) {
List<ArenaRecord<K>> sorted = getSortedRecords(records);
if (index < sorted.size() && index >= 0) {
return sorted.get(index);
} else {
return null;
}
}
/**
* Gets a piece of data from a record as a string
*
* @param infoType <p>The type of info to get data for</p>
* @param arenaRecord <p>The record to get the data from</p>
* @return <p>The requested data as a string, or null</p>
*/
private String getRecordData(@NotNull InfoType infoType, @NotNull ArenaRecord<?> arenaRecord) {
return switch (infoType) {
case PLAYER -> getPlayerName(arenaRecord.userId());
case VALUE -> arenaRecord.record().toString();
case COMBINED -> getPlayerName(arenaRecord.userId()) + ": " + arenaRecord.record().toString();
};
}
/**
* Gets the given set of records as a sorted list
*
* @param recordSet <p>The set of records to sort</p>
* @param <K> <p>The type of the records</p>
* @return <p>The sorted records</p>
*/
private <K extends Comparable<K>> @NotNull List<ArenaRecord<K>> getSortedRecords(
@NotNull Set<ArenaRecord<K>> recordSet) {
List<ArenaRecord<K>> records = new ArrayList<>(recordSet);
Collections.sort(records);
return records;
}
/**
* Gets the name of a player, given the player's UUID
*
* @param playerId <p>The id of the player to get the name for</p>
* @return <p>The name of the player, or a string representation of the UUID if not found</p>
*/
private String getPlayerName(@NotNull UUID playerId) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
return player.getName();
} else {
return playerId.toString();
}
}
}

View File

@ -0,0 +1,42 @@
package net.knarcraft.dropper.placeholder.parsing;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* The type of information returned by a placeholder
*/
public enum InfoType {
/**
* The player that achieved the record
*/
PLAYER,
/**
* The value of the record, whatever it is
*/
VALUE,
/**
* A combined PLAYER: VALUE
*/
COMBINED,
;
/**
* Gets the info type specified in the given string
*
* @param type <p>The string specifying the info type</p>
* @return <p>The info type, or null if not found</p>
*/
public static @Nullable InfoType getFromString(@NotNull String type) {
for (InfoType infoType : InfoType.values()) {
if (infoType.name().equalsIgnoreCase(type)) {
return infoType;
}
}
return null;
}
}

View File

@ -0,0 +1,37 @@
package net.knarcraft.dropper.placeholder.parsing;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* A type of record a player can achieve
*/
public enum RecordType {
/**
* A least-deaths record
*/
DEATHS,
/**
*
*/
TIME,
;
/**
* Gets the record type specified in the given string
*
* @param type <p>The string specifying the record type</p>
* @return <p>The record type, or null if not found</p>
*/
public static @Nullable RecordType getFromString(@NotNull String type) {
for (RecordType recordType : RecordType.values()) {
if (recordType.name().equalsIgnoreCase(type)) {
return recordType;
}
}
return null;
}
}

View File

@ -0,0 +1,37 @@
package net.knarcraft.dropper.placeholder.parsing;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* A type of selection performed by a placeholder
*/
public enum SelectionType {
/**
* The identifier is trying to select a group
*/
GROUP,
/**
* The identifier is trying to select an arena
*/
ARENA,
;
/**
* Gets the selection type specified in the given string
*
* @param type <p>The string specifying the selection type</p>
* @return <p>The selection type, or null if not found</p>
*/
public static @Nullable SelectionType getFromString(@NotNull String type) {
for (SelectionType selectionType : SelectionType.values()) {
if (selectionType.name().equalsIgnoreCase(type)) {
return selectionType;
}
}
return null;
}
}

View File

@ -3,6 +3,8 @@ version: '${project.version}'
main: net.knarcraft.dropper.Dropper
api-version: 1.19
description: A plugin for dropper mini-games
softdepend:
- PlaceholderAPI
# Note to self: Aliases must be lowercase!
commands: