1
0
Fork 0

Add map voting

This commit is contained in:
Moritz Ruth 2020-06-20 17:28:09 +02:00
parent 98ff252eba
commit 1b2fc74c65
No known key found for this signature in database
GPG key ID: AFD57E23E753841B
22 changed files with 353 additions and 177 deletions

View file

@ -21,7 +21,6 @@ repositories {
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7")
implementation("commons-codec:commons-codec:1.14")
compileOnly("com.comphenix.protocol", "ProtocolLib", "4.5.0")
compileOnly("org.spigotmc", "spigot-api", "1.15.2-R0.1-SNAPSHOT")

View file

@ -5,7 +5,7 @@ import de.moritzruth.spigot_ttt.game.InfoCommand
import de.moritzruth.spigot_ttt.game.ReviveCommand
import de.moritzruth.spigot_ttt.game.StartCommand
import de.moritzruth.spigot_ttt.game.items.AddItemSpawnCommand
import de.moritzruth.spigot_ttt.worlds.WorldCommand
import de.moritzruth.spigot_ttt.game.worlds.VotingCommand
object CommandManager {
fun initializeCommands() {
@ -16,6 +16,6 @@ object CommandManager {
ResourcepackCommand()
ReloadTTTConfigCommand()
InfoCommand()
WorldCommand()
VotingCommand()
}
}

View file

@ -13,6 +13,7 @@ object Settings {
val preparingPhaseDuration get() = plugin.config.getInt("duration.preparing", 20)
val combatPhaseDuration get() = plugin.config.getInt("duration.combat", 480) // 8 minutes
val overPhaseDuration get() = plugin.config.getInt("duration.over", 10)
val mapVotingDuration get() = plugin.config.getInt("duration.map-voting", 10)
val initialCredits get() = plugin.config.getInt("initial-credits", 2)
val creditsPerKill get() = plugin.config.getInt("credits-per-kill", 1)
}

View file

@ -1,7 +1,8 @@
package de.moritzruth.spigot_ttt
import de.moritzruth.spigot_ttt.game.GameManager
import de.moritzruth.spigot_ttt.worlds.WorldManager
import de.moritzruth.spigot_ttt.game.worlds.MapVoting
import de.moritzruth.spigot_ttt.game.worlds.WorldManager
import org.bukkit.ChatColor
import org.bukkit.plugin.java.JavaPlugin
@ -13,8 +14,8 @@ class TTTPlugin: JavaPlugin() {
override fun onEnable() {
saveDefaultConfig()
WorldManager.removeNeglectedWorlds()
WorldManager.initialize()
MapVoting.registerListener()
CommandManager.initializeCommands()
GameManager.initialize()
@ -25,7 +26,7 @@ class TTTPlugin: JavaPlugin() {
}
override fun onDisable() {
GameManager.resetWorld()
GameManager.reset()
}
fun broadcast(message: String, withPrefix: Boolean = true) {

View file

@ -18,10 +18,11 @@ class AbortCommand: CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
if (GameManager.phase === null) {
val tttWorld = GameManager.tttWorld
if (tttWorld == null)
sender.sendMessage("$COMMAND_RESPONSE_PREFIX${ChatColor.RED}Zurzeit läuft kein Spiel.")
} else {
GameManager.abortGame(true)
}
else tttWorld.unload()
} else GameManager.abortGame(true)
return true
}

View file

@ -12,11 +12,15 @@ import de.moritzruth.spigot_ttt.game.items.shop.Shop
import de.moritzruth.spigot_ttt.game.items.shop.ShopListener
import de.moritzruth.spigot_ttt.game.players.PlayerManager
import de.moritzruth.spigot_ttt.game.players.Role
import de.moritzruth.spigot_ttt.game.worlds.TTTWorld
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.call
import de.moritzruth.spigot_ttt.utils.heartsToHealth
import de.moritzruth.spigot_ttt.utils.teleportToWorldSpawn
import org.bukkit.*
import org.bukkit.GameRule
import org.bukkit.Material
import org.bukkit.Sound
import org.bukkit.SoundCategory
import org.bukkit.block.Block
import kotlin.random.Random
@ -29,9 +33,13 @@ object GameManager {
ScoreboardHelper.forEveryScoreboard { it.updateEverything(); it.showCorrectSidebarScoreboard() }
}
val world = plugin.server.getWorld("world")!!
var tttWorld: TTTWorld? = null
set(value) {
field = value
if (value != null) adjustWorld()
}
val destroyedBlocks = mutableMapOf<Location, Material>()
val world get() = tttWorld?.world ?: throw IllegalStateException("The world was not set or is not loaded")
private val listeners = ItemManager.listeners
.plus(GeneralGameListener)
@ -43,8 +51,6 @@ object GameManager {
.plus(GeneralGameListener.packetListener)
fun initialize() {
adjustWorld()
listeners.forEach { plugin.server.pluginManager.registerEvents(it, plugin) }
packetListeners.forEach { ProtocolLibrary.getProtocolManager().addPacketListener(it) }
}
@ -64,7 +70,7 @@ object GameManager {
GameEndEvent(false).call()
phase = null
resetWorld()
reset()
PlayerManager.resetAfterGame()
}
@ -92,21 +98,11 @@ object GameManager {
}
}
fun resetWorld() {
fun reset() {
CorpseManager.destroyAll()
ItemManager.reset()
destroyedBlocks.forEach { (location, material) ->
world.getBlockAt(location).type = material
}
destroyedBlocks.clear()
world.run {
setStorm(false)
time = 0
setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false)
setGameRule(GameRule.DO_WEATHER_CYCLE, false)
}
tttWorld?.unload()
tttWorld = null
}
fun abortGame(broadcast: Boolean = false) {
@ -115,8 +111,8 @@ object GameManager {
GameEndEvent(true).call()
phase = null
Timers.cancelCurrentTask()
resetWorld()
PlayerManager.resetAfterGame()
reset()
if (broadcast) {
GameMessenger.aborted()
@ -179,9 +175,8 @@ object GameManager {
}
}
fun destroyBlock(block: Block) {
fun destroyBlockIfAllowed(block: Block) {
if (phase != null && block.type.toString().contains("glass_pane", true)) {
destroyedBlocks[block.location] = block.type
block.type = Material.AIR
world.playSound(
block.location,

View file

@ -122,7 +122,7 @@ object GeneralGameListener : Listener {
@EventHandler
fun onPlayerInteract(event: PlayerInteractEvent) {
if (event.player.inventory.itemInMainHand.type == Material.AIR && event.action == Action.LEFT_CLICK_BLOCK) {
GameManager.destroyBlock(event.clickedBlock!!)
GameManager.destroyBlockIfAllowed(event.clickedBlock!!)
}
}

View file

@ -14,7 +14,7 @@ class InfoCommand: CommandExecutor {
init {
val command = plugin.getCommand("info")!!
command.tabCompleter = createTabCompleter { _, index ->
if (index == 1) PlayerManager.tttPlayers.map { it.player.name }
if (index == 0) PlayerManager.tttPlayers.map { it.player.name }
else null
}
command.setExecutor(this)

View file

@ -23,14 +23,14 @@ class ReviveCommand: CommandExecutor {
.map { it.player.name }
if (sender is Player) when (index) {
1 -> getPlayers().filter { it != sender.name }.run {
0 -> getPlayers().filter { it != sender.name }.run {
if (TTTPlayer.of(sender)?.alive == false) plus("here")
else this
}
2 -> listOf("here")
1 -> listOf("here")
else -> null
} else when (index) {
1 -> getPlayers()
0 -> getPlayers()
else -> null
}
}

View file

@ -18,6 +18,13 @@ class StartCommand: CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
if (GameManager.phase === null) {
if (GameManager.tttWorld == null) {
sender.sendMessage("$COMMAND_RESPONSE_PREFIX${ChatColor.RED}Bitte starte zuerst das Map-Voting mit " +
"${ChatColor.WHITE}/voting")
return true
}
try {
GameManager.startPreparingPhase()
} catch (e: PlayerManager.NotEnoughPlayersException) {

View file

@ -98,7 +98,7 @@ abstract class Gun(
if (rayTraceResult !== null) {
val hitBlock = rayTraceResult.hitBlock
if (hitBlock != null) GameManager.destroyBlock(hitBlock)
if (hitBlock != null) GameManager.destroyBlockIfAllowed(hitBlock)
val entity = rayTraceResult.hitEntity

View file

@ -20,7 +20,11 @@ import kotlin.random.Random
object PlayerManager {
val tttPlayers = mutableListOf<TTTPlayer>()
private fun getAvailablePlayers() = plugin.server.onlinePlayers.filter { it.gameMode === GameMode.SURVIVAL }
fun isAvailable(player: Player): Boolean {
return player.gameMode === GameMode.SURVIVAL
}
fun getAvailablePlayers() = plugin.server.onlinePlayers.filter { isAvailable(it) }
private fun getStillLivingRoleGroups() = getStillLivingRoles().map { it.group }.toSet()
private fun getStillLivingRoles() = tttPlayers.filter {
it.alive || SecondChance.getInstance(it)?.preventRoundEnd == true

View file

@ -0,0 +1,181 @@
package de.moritzruth.spigot_ttt.game.worlds
import de.moritzruth.spigot_ttt.Settings
import de.moritzruth.spigot_ttt.TTTPlugin
import de.moritzruth.spigot_ttt.game.GameManager
import de.moritzruth.spigot_ttt.game.players.PlayerManager
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.*
import org.bukkit.ChatColor
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.event.inventory.InventoryType
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerGameModeChangeEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.scheduler.BukkitTask
import java.util.*
import kotlin.math.max
class MapVoting private constructor() {
private var secondsRemaining = Settings.mapVotingDuration
private val maps = WorldManager.tttWorlds.toList()
private var timerTask: BukkitTask
private val inventory = plugin.server.createInventory(
null,
InventoryType.CHEST,
"${ChatColor.BLUE}${ChatColor.BOLD}Map-Voting"
)
private val votes = mutableMapOf<UUID, TTTWorld>()
private fun createMapItemStack(map: TTTWorld): ItemStack {
val config = map.config
val iconMaterialString = config.getString("icon") ?: "GRASS_BLOCK"
val votesForThisMap = votes.values.count { it === map }
return ItemStack(Material.valueOf(iconMaterialString), max(1, votesForThisMap)).applyMeta {
setDisplayName("${config.getString("title")}${ChatColor.RESET} ${ChatColor.GRAY}($votesForThisMap)")
lore = listOf("").plus(config.getStringList("description").map { "${ChatColor.RESET}$it" })
hideInfo()
}
}
init {
maps.forEachIndexed { index, map -> inventory.setItem(index, createMapItemStack(map)) }
timerTask = plugin.server.scheduler.runTaskTimer(plugin, fun() {
if (secondsRemaining == 0) {
stop()
val votedMaps = votes.values
val winnerMap =
if (votedMaps.count() == 0) maps.random()
else {
val mapsSortedByVotes = votedMaps.sortedBy { votedMap -> votedMaps.count { it === votedMap } }
mapsSortedByVotes[0]
}
plugin.broadcast("${ChatColor.GREEN}Ausgewählte Map: " +
winnerMap.config.getString("title"))
if (winnerMap.world != null) winnerMap.unload()
winnerMap.load()
GameManager.tttWorld = winnerMap
plugin.server.onlinePlayers.forEach {
it.teleport(winnerMap.world!!.spawnLocation)
}
} else {
inventory.setItem(26, ItemStack(Material.CLOCK, secondsRemaining).applyMeta {
setDisplayName("${ChatColor.GREEN}Verbleibende Zeit: ${ChatColor.WHITE}${secondsRemaining}s")
})
secondsRemaining -= 1
}
}, 0, secondsToTicks(1).toLong())
PlayerManager.getAvailablePlayers().forEach {
giveVoteItem(it)
it.sendMessage("${TTTPlugin.prefix}${ChatColor.GREEN}Das Map-Voting wurde gestartet.")
}
}
fun vote(player: Player, map: TTTWorld) {
votes[player.uniqueId] = map
inventory.setItem(maps.indexOf(map), createMapItemStack(map))
}
fun cancel() {
PlayerManager.getAvailablePlayers().forEach {
it.sendMessage("${TTTPlugin.prefix}${ChatColor.RED}Das Map-Voting wurde abgebrochen.")
}
stop()
}
private fun stop() {
timerTask.cancel()
current = null
PlayerManager.getAvailablePlayers().forEach { removeVoteItem(it) }
plugin.server.onlinePlayers.forEach { if (it.openInventory.topInventory === inventory) it.closeInventory() }
}
companion object {
var current: MapVoting? = null; private set
private val voteItem = ItemStack(Material.PAPER).applyMeta {
setDisplayName("${ChatColor.RESET}${ChatColor.BOLD}Map-Voting")
hideInfo()
}
private fun giveVoteItem(player: Player) {
player.inventory.setItem(8, voteItem)
}
private fun removeVoteItem(player: Player) {
player.inventory.clear(8)
}
fun start(): MapVoting? {
if (current != null) throw IllegalStateException("There is already a map voting in progress")
return MapVoting().also { current = it }
}
private val listener = object : Listener {
@EventHandler
fun onPlayerInteract(event: PlayerInteractEvent) {
if (event.item?.type == Material.PAPER && event.action.isRightClick) {
val voting = current ?: return
event.player.openInventory(voting.inventory)
}
}
@EventHandler
fun onInventoryClick(event: InventoryClickEvent) {
val whoClicked = event.whoClicked
if (whoClicked !is Player) return
val voting = current ?: return
if (event.clickedInventory != voting.inventory) return
event.isCancelled = true
if (event.click.isLeftClick) {
val map = voting.maps.getOrNull(event.slot) ?: return
voting.vote(whoClicked, map)
}
}
@EventHandler
fun onPlayerDropItem(event: PlayerDropItemEvent) {
if (current != null && event.itemDrop.itemStack.type == Material.PAPER) {
event.isCancelled = true
}
}
@EventHandler
fun onPlayerGameModeChange(event: PlayerGameModeChangeEvent) {
nextTick {
if (current != null) {
if (PlayerManager.isAvailable(event.player)) giveVoteItem(event.player)
else removeVoteItem(event.player)
}
}
}
@EventHandler
fun onPlayerJoinEvent(event: PlayerJoinEvent) {
if (PlayerManager.isAvailable(event.player)) {
if (current == null) removeVoteItem(event.player)
else giveVoteItem(event.player)
}
}
}
fun registerListener() {
plugin.server.pluginManager.registerEvents(listener, plugin)
}
}
}

View file

@ -0,0 +1,58 @@
package de.moritzruth.spigot_ttt.game.worlds
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.ConfigurationFile
import org.bukkit.World
import org.bukkit.WorldCreator
import java.io.File
import java.io.FileNotFoundException
class TTTWorld(private val sourceWorldDir: File) {
init {
if (!sourceWorldDir.exists()) throw FileNotFoundException()
}
private val name: String = sourceWorldDir.name
val config = ConfigurationFile(sourceWorldDir.resolve("config.yml"))
private val actualWorldName = "${WORLD_PREFIX}${name}"
private val worldDir = plugin.server.worldContainer.resolve("./$actualWorldName")
var world: World? = plugin.server.getWorld(actualWorldName); private set
init {
if (world != null) unloadWorld()
if (worldDir.exists()) worldDir.deleteRecursively()
}
fun load() {
if (world != null) throw IllegalStateException("The world is already loaded")
sourceWorldDir.copyRecursively(worldDir)
loadWorld()
}
private fun loadWorld() {
world = plugin.server.getWorld(actualWorldName)
?: plugin.server.createWorld(WorldCreator.name(actualWorldName))
}
fun unload() {
if (world == null) throw IllegalStateException("The world is not loaded")
unloadWorld()
worldDir.deleteRecursively()
}
private fun unloadWorld() {
world!!.players.forEach { it.teleport(plugin.server.getWorld("world")!!.spawnLocation) }
plugin.server.unloadWorld(actualWorldName, false)
world = null
}
companion object {
fun createForSourceWorlds(): Set<TTTWorld> =
WORLDS_DIR.listFiles(File::isDirectory)!!.map { TTTWorld(it) }.toSet()
const val WORLD_PREFIX = "tempworld_"
private val WORLDS_DIR = plugin.dataFolder.resolve("./worlds").also { it.mkdirs() }
}
}

View file

@ -0,0 +1,45 @@
package de.moritzruth.spigot_ttt.game.worlds
import de.moritzruth.spigot_ttt.COMMAND_RESPONSE_PREFIX
import de.moritzruth.spigot_ttt.game.GameManager
import de.moritzruth.spigot_ttt.game.GamePhase
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.EmptyTabCompleter
import org.bukkit.ChatColor
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
class VotingCommand: CommandExecutor {
init {
val command = plugin.getCommand("voting")!!
command.setExecutor(this)
command.tabCompleter = EmptyTabCompleter
}
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
if (args.count() == 1 && args[0].equals("cancel", true)) {
val voting = MapVoting.current
if (voting == null) {
sender.sendMessage("$COMMAND_RESPONSE_PREFIX${ChatColor.RED}Zurzeit läuft kein Map-Voting.")
} else {
voting.cancel()
}
return true
}
if (MapVoting.current == null) {
if (GameManager.phase == null || GameManager.phase == GamePhase.OVER) {
MapVoting.start()
} else {
sender.sendMessage("$COMMAND_RESPONSE_PREFIX${ChatColor.RED}Du kannst das Map-Voting nicht starten, " +
"während das Spiel läuft.")
}
} else {
sender.sendMessage("$COMMAND_RESPONSE_PREFIX${ChatColor.RED}Es läuft bereits ein Map-Voting.")
}
return true
}
}

View file

@ -0,0 +1,9 @@
package de.moritzruth.spigot_ttt.game.worlds
object WorldManager {
lateinit var tttWorlds: Set<TTTWorld>
fun initialize() {
tttWorlds = TTTWorld.createForSourceWorlds()
}
}

View file

@ -8,10 +8,10 @@ fun createTabCompleter(fn: (sender: CommandSender, index: Int) -> List<String>?)
fun createTabCompleter(fn: (sender: CommandSender, index: Int, args: List<String>) -> List<String>?) =
TabCompleter { sender, _, _, args ->
val index = args.count()
val index = args.count() - 1
val completions =
if (index == 0) emptyList()
if (index < 0) emptyList()
else fn(sender, index, args.toList()) ?: emptyList()
completions.filter { it.startsWith(args.last(), true) }

View file

@ -1,73 +0,0 @@
package de.moritzruth.spigot_ttt.worlds
import de.moritzruth.spigot_ttt.plugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.bukkit.WorldCreator
class TTTWorld(val sourceWorld: WorldManager.SourceWorld) {
var state: State = State.NOT_COPIED; private set
enum class State {
NOT_COPIED,
COPYING,
COPIED,
LOADED,
UNLOADING
}
val id = WorldManager.tttWorlds.count()
val actualWorldName = "${WORLD_PREFIX}${id}"
private val worldDir = plugin.server.worldContainer.resolve("./$actualWorldName")
init {
WorldManager.tttWorlds.add(this)
}
suspend fun copy() {
if (state != State.NOT_COPIED) throw IllegalStateException("The world was already copied")
state = State.COPYING
coroutineScope {
launch(Dispatchers.IO) {
sourceWorld.dir.copyRecursively(worldDir)
}
}
}
fun load() {
if (state != State.COPIED) throw IllegalStateException("The world was not copied yet or already loaded")
plugin.server.createWorld(WorldCreator.name(actualWorldName))
}
suspend fun save() {
if (state != State.LOADED) throw IllegalStateException("The world must be loaded")
coroutineScope {
launch(Dispatchers.IO) {
val tempWorldDir = WorldManager.worldsDir.resolve("./${sourceWorld.name}_$id")
worldDir.copyRecursively(tempWorldDir)
sourceWorld.dir.deleteRecursively()
tempWorldDir.renameTo(sourceWorld.dir)
state = State.NOT_COPIED
}
}
}
suspend fun unloadAndRemove() {
if (state != State.LOADED) throw IllegalStateException("The world must be loaded")
state = State.UNLOADING
plugin.server.unloadWorld(actualWorldName, false)
coroutineScope {
launch(Dispatchers.IO) {
worldDir.deleteRecursively()
state = State.NOT_COPIED
}
}
}
companion object {
const val WORLD_PREFIX = "tempworld_"
}
}

View file

@ -1,29 +0,0 @@
package de.moritzruth.spigot_ttt.worlds
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.createTabCompleter
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
class WorldCommand: CommandExecutor {
init {
val command = plugin.getCommand("world")!!
command.setExecutor(this)
command.tabCompleter = createTabCompleter { _, index, args ->
return@createTabCompleter when(index) {
0 -> listOf("load", "save", "join", "list")
1 -> when(args[0].toLowerCase()) {
"load" -> WorldManager.sourceWorlds.map { it.name }
"save", "join" -> WorldManager.tttWorlds.map { it.id.toString() }
else -> null
}
else -> null
}
}
}
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
TODO("Not yet implemented")
}
}

View file

@ -1,24 +0,0 @@
package de.moritzruth.spigot_ttt.worlds
import de.moritzruth.spigot_ttt.plugin
import de.moritzruth.spigot_ttt.utils.ConfigurationFile
import java.io.File
object WorldManager {
data class SourceWorld(val dir: File) {
val name: String = dir.name
val config = ConfigurationFile(dir.resolve("config.yml"))
}
val worldsDir = plugin.dataFolder.resolve("./worlds").also { it.mkdirs() }
val sourceWorlds = worldsDir.listFiles(File::isDirectory)!!.map { SourceWorld(it) }
val tttWorlds = mutableSetOf<TTTWorld>()
fun removeNeglectedWorlds() {
plugin.server.worldContainer.listFiles { file ->
file.isDirectory && file.name.startsWith(TTTWorld.WORLD_PREFIX) &&
tttWorlds.find { it.actualWorldName == file.name } != null
}!!.forEach { it.deleteRecursively() }
}
}

View file

@ -12,6 +12,7 @@ duration:
preparing: 20
combat: 480
over: 10
map-voting: 10
initial-credits: 2
credits-per-kill: 1

View file

@ -7,15 +7,20 @@ depend:
- ProtocolLib
commands:
voting:
usage: /voting ['cancel']
permission: ttt.voting
description: Start (or cancel) the map voting
start:
usage: /start
permission: ttt.start
description: Starts the TTT game
description: Start the TTT game
abort:
usage: /abort
permission: ttt.abort
description: Aborts the TTT game
description: Go back to the lobby world and abort the TTT game, if it is running
additemspawn:
usage: /additemspawn
@ -23,12 +28,12 @@ commands:
description: Add an item spawn
revive:
usage: /revive [Player] ['here']
usage: /revive [player] ['here']
permission: ttt.revive
description: Revive yourself or another player at the world spawn or at your location
info:
usage: /info [Player]
usage: /info [player]
permission: ttt.info
description: Show information about all players or a specific player
@ -45,8 +50,3 @@ commands:
permission: ttt.reload
aliases:
- rt
world:
usage: /world load <Name> OR /world <'join'|'save'> <World ID> OR /world list
description: Perform world operations
permission: ttt.world