diff options
| author | Freya Murphy <freya@freyacat.org> | 2026-03-26 23:15:33 -0400 |
|---|---|---|
| committer | Freya Murphy <freya@freyacat.org> | 2026-03-27 23:09:23 -0400 |
| commit | f8322cd21cde68a72b05efbad3a05b8e67c0bdd0 (patch) | |
| tree | d7e60bc8fedadc8fa7ae725571cad1f398eaf6dc /core/src | |
| download | kenshinshideandseek2-f8322cd21cde68a72b05efbad3a05b8e67c0bdd0.tar.gz kenshinshideandseek2-f8322cd21cde68a72b05efbad3a05b8e67c0bdd0.tar.bz2 kenshinshideandseek2-f8322cd21cde68a72b05efbad3a05b8e67c0bdd0.zip | |
initial
Diffstat (limited to 'core/src')
84 files changed, 5679 insertions, 0 deletions
diff --git a/core/src/Checks.kt b/core/src/Checks.kt new file mode 100644 index 0000000..cc3c4c7 --- /dev/null +++ b/core/src/Checks.kt @@ -0,0 +1,187 @@ +package cat.freya.khs + +import cat.freya.khs.game.Game +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Player +import cat.freya.khs.world.Position + +class Checks(val plugin: Khs, val player: Player) { + /// checks if there exists a map that is setup + fun gameMapExists() { + if (plugin.game.selectMap() == null) { + val msg = + if (plugin.maps.isEmpty()) plugin.locale.map.none else plugin.locale.map.noneSetup + error(msg) + } + } + + /// checks that the game is in progress + fun gameInProgress() { + if (!plugin.game.status.inProgress()) { + error(plugin.locale.game.notInProgress) + } + } + + /// checks that the game is not in progress + fun gameNotInProgress() { + if (plugin.game.status != Game.Status.LOBBY) { + error(plugin.locale.game.inProgress) + } + } + + /// checks that the caller is in the game + fun playerNotInGame() { + if (plugin.game.hasPlayer(player)) { + error(plugin.locale.game.inGame) + } + } + + /// checks that the caller is in the game + fun playerInGame() { + if (!plugin.game.hasPlayer(player)) { + error(plugin.locale.game.notInGame) + } + } + + /// check if the lobby has enough players to start + fun lobbyHasEnoughPlayers() { + if (plugin.game.size < plugin.config.minPlayers) { + error(plugin.locale.lobby.notEnoughPlayers.with(plugin.config.minPlayers)) + } + } + + /// check if the lobby is empty + fun lobbyEmpty() { + if (plugin.game.size > 0u) { + error(plugin.locale.lobby.inUse) + } + } + + /// cheks that the player is in the game world + fun inMapWorld(mapName: String) { + inMapWorld(plugin.maps.get(mapName)) + } + + /// cheks that the player is in the game world + fun inMapWorld(map: KhsMap?) { + if (map?.worldName != player.location.worldName) error(plugin.locale.map.wrongWorld) + } + + /// Checks that the map exists and is setup + fun mapSetup(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + if (!map.setup) error(plugin.locale.map.setup.not.with(map.name)) + } + + /// Checks if the map has bounds + fun mapHasBounds(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + if (map.bounds() == null) error(plugin.locale.map.error.bounds) + } + + /// Checks if the map has bounds + fun mapHasBounds(name: String) { + mapHasBounds(plugin.maps.get(name)) + } + + /// Checks if a map exists + fun mapExists(name: String) { + mapExists(plugin.maps.get(name)) + } + + /// Checks if a map exists + fun mapExists(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + } + + /// Checks if a map doesnt exists + fun mapDoesNotExist(name: String) { + if (plugin.maps.containsKey(name)) error(plugin.locale.map.exists) + } + + /// Checks if a map name is valid + fun mapNameValid(name: String) { + if (!name.matches(Regex("[a-zA-Z0-9]*")) || name.isEmpty()) + error(plugin.locale.map.invalidName) + } + + /// Checks if a world exists + fun worldExists(worldName: String) { + if (!plugin.shim.worlds.contains(worldName)) + error(plugin.locale.world.doesntExist.with(worldName)) + } + + /// Checks if a world doesnt exists + fun worldDoesNotExist(worldName: String) { + if (plugin.shim.worlds.contains(worldName)) + error(plugin.locale.world.exists.with(worldName)) + } + + /// Checks if a world is valid for a map + fun worldValid(worldName: String) { + worldExists(worldName) + if (worldName.startsWith("hs_")) error(plugin.locale.world.doesntExist.with(worldName)) + } + + /// Checks that a world is not in use + fun worldNotInUse(worldName: String) { + val map = + plugin.maps.values.find { it.worldName == worldName || it.gameWorldName == worldName } + if (map != null) error(plugin.locale.world.inUseBy.with(worldName, map.name)) + if (plugin.config.exit?.worldName == worldName) + error(plugin.locale.world.inUse.with(worldName)) + } + + /// Checks if blockhunt is supported + fun blockHuntSupported() { + if (!plugin.shim.supports(9)) error(plugin.locale.blockHunt.notSupported) + } + + /// Checks if a map has block hunt enabled + fun blockHuntEnabled(name: String) { + mapExists(name) + val map = plugin.maps.get(name) ?: return + if (!map.config.blockHunt.enabled) error(plugin.locale.blockHunt.notEnabled) + } + + private fun isSpawnInRange(map: KhsMap, position: Position?): Boolean { + if (position == null) return true // return true to not reset a null value + + // check world border (with in 100 blocks) + val border = map.config.worldBorder + if (border.enabled && border.pos?.distance(position) ?: 0.0 > 100.0) return false + + // check in bounds + if (map.bounds()?.inBounds(position.x, position.z) == false) return false + + return true + } + + /// Makes sure a spawn is in gane + fun spawnInRange(map: KhsMap, pos: Position) { + if (!isSpawnInRange(map, pos)) error(plugin.locale.map.error.notInRange) + } + + /// Makes sure spawns are in range + fun spawnsInRange(map: KhsMap) { + // check game spawn + if (!isSpawnInRange(map, map.gameSpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.gameSpawnReset) + map.gameSpawn = null + } + // check seeker spawn + if (!isSpawnInRange(map, map.seekerLobbySpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.seekerSpawnReset) + map.seekerLobbySpawn = null + } + // check lobby spawn + if (!isSpawnInRange(map, map.lobbySpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.lobbySpawnReset) + map.lobbySpawn = null + } + } +} + +fun runChecks(plugin: Khs, player: Player, fn: Checks.() -> Unit) { + fn(Checks(plugin, player)) +} diff --git a/core/src/Khs.kt b/core/src/Khs.kt new file mode 100644 index 0000000..6f82229 --- /dev/null +++ b/core/src/Khs.kt @@ -0,0 +1,170 @@ +package cat.freya.khs + +import cat.freya.khs.command.* +import cat.freya.khs.command.map.* +import cat.freya.khs.command.map.blockhunt.* +import cat.freya.khs.command.map.blockhunt.block.* +import cat.freya.khs.command.map.set.* +import cat.freya.khs.command.map.unset.* +import cat.freya.khs.command.util.CommandGroup +import cat.freya.khs.command.world.* +import cat.freya.khs.config.KhsBoardConfig +import cat.freya.khs.config.KhsConfig +import cat.freya.khs.config.KhsItemsConfig +import cat.freya.khs.config.KhsLocale +import cat.freya.khs.config.KhsMapsConfig +import cat.freya.khs.config.util.deserialize +import cat.freya.khs.config.util.serialize +import cat.freya.khs.db.Database +import cat.freya.khs.game.Game +import cat.freya.khs.game.KhsMap +import java.util.concurrent.ConcurrentHashMap + +/// Plugin wrapper +class Khs(val shim: KhsShim) { + + @Volatile var config: KhsConfig = KhsConfig() + @Volatile var itemsConfig: KhsItemsConfig = KhsItemsConfig() + @Volatile var boardConfig: KhsBoardConfig = KhsBoardConfig() + @Volatile var locale: KhsLocale = KhsLocale() + + // code should access maps.<name>.config instead + private var mapsConfig: KhsMapsConfig = KhsMapsConfig() + + val game: Game = Game(this) + val maps: MutableMap<String, KhsMap> = ConcurrentHashMap<String, KhsMap>() + @Volatile var database: Database? = null + + val commandGroup: CommandGroup = registerCommands() + + // if we are performing a map save right now + @Volatile var saving: Boolean = false + + fun init() { + shim.logger.info(" _ ___ _ ____") + shim.logger.info("| |/ / | | / ___|") + shim.logger.info("| ' /| |_| \\___ \\") + shim.logger.info("| . \\| _ |___) |") + shim.logger.info("|_|\\_\\_| |_|____/") + + val mcVersion = shim.mcVersion.joinToString(".") + shim.logger.info("Version ${shim.pluginVersion} running on ${mcVersion}-${shim.platform}") + + reloadConfig() + .onFailure { + shim.logger.warning("Plugin loaded with errors :(") + shim.disable() + } + .onSuccess { + shim.logger.info("Plugin loaded successfully!") + saveConfig() + } + } + + fun cleanup() { + for (uuid in game.UUIDs) game.leave(uuid) + } + + fun registerCommands(): CommandGroup { + return CommandGroup( + this, + "hs", + KhsConfirm(), + KhsDebug(), + KhsHelp(), + KhsJoin(), + KhsLeave(), + KhsReload(), + KhsSend(), + KhsSetExit(), + KhsStart(), + KhsStop(), + KhsTop(), + KhsWins(), + CommandGroup( + this, + "map", + KhsMapAdd(), + KhsMapGoTo(), + KhsMapList(), + KhsMapRemove(), + KhsMapSave(), + KhsMapStatus(), + CommandGroup( + this, + "blockhunt", + KhsMapBlockHuntDebug(), + KhsMapBlockHuntEnabled(), + CommandGroup( + this, + "block", + KhsMapBlockHuntBlockAdd(), + KhsMapBlockHuntBlockList(), + KhsMapBlockHuntBlockRemove(), + ), + ), + CommandGroup( + this, + "set", + KhsMapSetBorder(), + KhsMapSetBounds(), + KhsMapSetLobby(), + KhsMapSetSeekerLobby(), + KhsMapSetSpawn(), + ), + CommandGroup(this, "unset", KhsMapUnsetBorder()), + ), + CommandGroup( + this, + "world", + KhsWorldCreate(), + KhsWorldDelete(), + KhsWorldList(), + KhsWorldTp(), + ), + ) + } + + fun reloadConfig(): Result<Unit> = + runCatching { + shim.logger.info("Loading config...") + config = deserialize(KhsConfig::class, shim.readConfigFile("config.yml")) + shim.logger.info("Loading items...") + itemsConfig = deserialize(KhsItemsConfig::class, shim.readConfigFile("items.yml")) + shim.logger.info("Loading maps...") + mapsConfig = deserialize(KhsMapsConfig::class, shim.readConfigFile("maps.yml")) + shim.logger.info("Loading board locale...") + boardConfig = deserialize(KhsBoardConfig::class, shim.readConfigFile("board.yml")) + shim.logger.info("Loading locale...") + locale = deserialize(KhsLocale::class, shim.readConfigFile("locale.yml")) + shim.logger.info("Loading database...") + database = Database(this) + + // reload maps + // we need a seperate newMaps, in case one of the maps below fails + // to load + val newMaps = + mapsConfig.maps.mapValues { (name, mapConfig) -> KhsMap(name, mapConfig, this) } + + game.setMap(null) + maps.clear() + newMaps.forEach { maps[it.key] = it.value } + } + .onFailure { shim.logger.error("failed to reload config: ${it.message}") } + + fun saveConfig() { + runCatching { + val newMapsConfig = KhsMapsConfig(maps.mapValues { it.value.config }) + shim.writeConfigFile("config.yml", serialize(config)) + shim.writeConfigFile("items.yml", serialize(itemsConfig)) + shim.writeConfigFile("maps.yml", serialize(newMapsConfig)) + shim.writeConfigFile("board.yml", serialize(boardConfig)) + shim.writeConfigFile("locale.yml", serialize(locale)) + } + .onFailure { shim.logger.error("failed to save config: ${it.message}") } + } + + fun onTick() { + game.doTick() + } +} diff --git a/core/src/KhsShim.kt b/core/src/KhsShim.kt new file mode 100644 index 0000000..9a31523 --- /dev/null +++ b/core/src/KhsShim.kt @@ -0,0 +1,99 @@ +package cat.freya.khs + +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Board +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Effect +import cat.freya.khs.world.Item +import cat.freya.khs.world.World +import java.io.InputStream +import java.util.UUID + +// Logger wrapper +// (different baselines may use different logging systems) +interface Logger { + fun info(message: String) + + fun warning(message: String) + + fun error(message: String) +} + +// Plugin wrapper +interface KhsShim { + /// @returns the string of the plugin version + val pluginVersion: String + + /// @returns the release minecraft version (ignores the 1.) + val mcVersion: List<UInt> + + /// the platform this shim is for + val platform: String + + /// @returns the logger + val logger: Logger + + /// @returns list of online players + val players: List<Player> + + /// @returns list of world names + val worlds: List<String> + + /// were the khs.db is stored + val sqliteDatabasePath: String + + /// @returns a stream from a file in the systems config dir + fun readConfigFile(fileName: String): InputStream? + + /// write a config file + fun writeConfigFile(fileName: String, content: String) + + /// @returns a valid material for the current mc version given the name + fun parseMaterial(materialName: String): String? + + /// @returns a valid item given the config + fun parseItem(itemConfig: ItemConfig): Item? + + /// @returns a valid item given the config + fun parseEffect(effectConfig: EffectConfig): Effect? + + /// @returns a player that is online on the server right now + fun getPlayer(uuid: UUID): Player? + + fun getPlayer(name: String): Player? + + /// @returns a world on the server that exists with the given world name + fun getWorld(worldName: String): World? + + /// @returns a manager to load/unload a world + fun getWorldLoader(worldName: String): World.Loader + + /// create a new world + fun createWorld(worldName: String, type: World.Type): World? + + /// create a inventory to use for a player + fun createInventory(title: String, size: UInt): Inventory? + + /// @returns a new board + fun getBoard(name: String): Board? + + /// broadcast a message to everyone + fun broadcast(message: String) + + /// disable everything + fun disable() + + /// schedule an event to run at a later date + fun scheduleEvent(ticks: ULong, event: () -> Unit) + + fun supports(vararg versions: Int): Boolean { + val seq = versions.asSequence().map { it.toUInt() }.zip(mcVersion.asSequence()).toList() + for ((want, has) in seq) { + if (want < has) return true + if (want > has) return false + } + return true + } +} diff --git a/core/src/Request.kt b/core/src/Request.kt new file mode 100644 index 0000000..152be7b --- /dev/null +++ b/core/src/Request.kt @@ -0,0 +1,12 @@ +package cat.freya.khs + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class Request(val fn: () -> Unit, val lengthSeconds: Long) { + val start = System.currentTimeMillis() + val expired: Boolean + get() = (System.currentTimeMillis() - start) < lengthSeconds * 1000 +} + +val REQUESTS: MutableMap<UUID, Request> = ConcurrentHashMap<UUID, Request>() diff --git a/core/src/command/Confirm.kt b/core/src/command/Confirm.kt new file mode 100644 index 0000000..359f490 --- /dev/null +++ b/core/src/command/Confirm.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.REQUESTS +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsConfirm : Command { + override val label = "confirm" + override val usage = listOf<String>() + override val description = "Confirm a request of a previously run command" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val request = REQUESTS.remove(player.uuid) + + if (request == null) { + player.message(plugin.locale.prefix.error + plugin.locale.confirm.none) + return + } + + if (request.expired) { + player.message(plugin.locale.prefix.error + plugin.locale.confirm.timedOut) + return + } + + request.fn() + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Debug.kt b/core/src/command/Debug.kt new file mode 100644 index 0000000..d000d60 --- /dev/null +++ b/core/src/command/Debug.kt @@ -0,0 +1,24 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.inv.createDebugMenu +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsDebug : Command { + override val label = "debug" + override val usage = listOf<String>() + override val description = "Mess with/debug the current game" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { gameMapExists() } + + val inv = createDebugMenu(plugin) ?: return + player.showInventory(inv) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Help.kt b/core/src/command/Help.kt new file mode 100644 index 0000000..168b69c --- /dev/null +++ b/core/src/command/Help.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import kotlin.text.toUInt + +class KhsHelp : Command { + override val label = "help" + override val usage = listOf("*page") + override val description = "Lists the commands you can use" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val commands = plugin.commandGroup.commandsFor(player) + val pageSize = 4u + val pages = (commands.size.toUInt() + pageSize - 1u) / pageSize + val page = maxOf(minOf(args.firstOrNull().let { it?.toUIntOrNull() } ?: 0u, pages), 1u) + + player.message( + buildString { + appendLine( + "&b=================== &fHelp: Page ($page/$pages) &b===================" + ) + for ((label, command) in commands.chunked(pageSize.toInt()).get(page.toInt() - 1)) { + val cmd = label.substring(3) + val usage = command.usage.joinToString(" ") + val description = command.description + appendLine("&7?&f &b/hs &f$cmd &9$usage") + appendLine("&7?&f &7&o$description") + } + appendLine("&b=====================================================") + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf(parameter) + } +} diff --git a/core/src/command/Join.kt b/core/src/command/Join.kt new file mode 100644 index 0000000..88bb94c --- /dev/null +++ b/core/src/command/Join.kt @@ -0,0 +1,33 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsJoin : Command { + override val label = "join" + override val usage = listOf<String>("*map") + override val description = "Joins the game, and can set a map if the lobby is empty" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val mapName = args.firstOrNull() + val map = mapName?.let { plugin.maps.get(it) } + + runChecks(plugin, player) { + gameMapExists() + playerNotInGame() + if (mapName != null) mapSetup(map) + } + + if (plugin.game.size == 0u) plugin.game.setMap(map) + + plugin.game.join(player.uuid) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "*map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/Leave.kt b/core/src/command/Leave.kt new file mode 100644 index 0000000..752fae4 --- /dev/null +++ b/core/src/command/Leave.kt @@ -0,0 +1,22 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsLeave : Command { + override val label = "leave" + override val usage = listOf<String>() + override val description = "Leaves the game lobby" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { playerInGame() } + + plugin.game.leave(player.uuid) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Reload.kt b/core/src/command/Reload.kt new file mode 100644 index 0000000..eee5231 --- /dev/null +++ b/core/src/command/Reload.kt @@ -0,0 +1,33 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsReload : Command { + override val label = "reload" + override val usage = listOf<String>() + override val description = "Reload's the plugin config" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameNotInProgress() + lobbyEmpty() + } + + player.message(plugin.locale.prefix.default + plugin.locale.command.reloading) + plugin + .reloadConfig() + .onSuccess { + player.message(plugin.locale.prefix.default + plugin.locale.command.reloaded) + } + .onFailure { + player.message(plugin.locale.prefix.default + plugin.locale.command.errorReloading) + } + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Send.kt b/core/src/command/Send.kt new file mode 100644 index 0000000..bca4467 --- /dev/null +++ b/core/src/command/Send.kt @@ -0,0 +1,30 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsSend : Command { + override val label = "send" + override val usage = listOf("map") + override val description = "Send the current lobby to another map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val map = plugin.maps.get(args.first()) + + runChecks(plugin, player) { + gameNotInProgress() + playerInGame() + mapSetup(map) + } + + plugin.game.setMap(map) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/SetExit.kt b/core/src/command/SetExit.kt new file mode 100644 index 0000000..d3feb2c --- /dev/null +++ b/core/src/command/SetExit.kt @@ -0,0 +1,25 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsSetExit : Command { + override val label = "setexit" + override val usage = listOf<String>() + override val description = "Sets the plugins's exit location" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { gameNotInProgress() } + + plugin.config.exit = player.location + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.set.exit) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Start.kt b/core/src/command/Start.kt new file mode 100644 index 0000000..e24c505 --- /dev/null +++ b/core/src/command/Start.kt @@ -0,0 +1,34 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsStart : Command { + override val label = "start" + override val usage = listOf("*seekers...") + override val description = + "Starts the game either with a random set of seekers or a chosen list" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameMapExists() + gameNotInProgress() + playerInGame() + lobbyHasEnoughPlayers() + } + + val pool = + args + .map { plugin.shim.getPlayer(it)?.uuid } + .filterNotNull() + .filter { plugin.game.hasPlayer(it) } + + plugin.game.start(pool) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return plugin.game.players.map(Player::name) + } +} diff --git a/core/src/command/Stop.kt b/core/src/command/Stop.kt new file mode 100644 index 0000000..c0ca401 --- /dev/null +++ b/core/src/command/Stop.kt @@ -0,0 +1,27 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsStop : Command { + override val label = "stop" + override val usage = listOf<String>() + override val description = "Stops the game" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameMapExists() + gameInProgress() + } + + plugin.game.broadcast(plugin.locale.prefix.abort + plugin.locale.game.stop) + plugin.game.stop(Game.WinType.NONE) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Top.kt b/core/src/command/Top.kt new file mode 100644 index 0000000..900f52f --- /dev/null +++ b/core/src/command/Top.kt @@ -0,0 +1,49 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsTop : Command { + override val label = "top" + override val usage = listOf("*page") + override val description = "Shows the game leaderboard" + + private fun getColor(index: UInt): Char { + return when (index) { + 0u -> 'e' + 1u -> '7' + 2u -> '6' + else -> 'f' + } + } + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + var page = args.firstOrNull()?.toUIntOrNull() ?: 0u + page = maxOf(page, 1u) - 1u + + var pageSize = 5u + val entires = plugin.database?.getPlayers(page, pageSize) + if (entires == null || entires.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.database.noInfo) + return + } + + val message = buildString { + appendLine("&f------- &lLEADERBOARD &7(Page ${page + 1u}) &f-------") + for ((i, entry) in entires.withIndex()) { + val wins = entry.hiderWins + entry.seekerWins + val idx = (pageSize * page) + i.toUInt() + val color = getColor(idx) + val name = entry.name ?: continue + appendLine("&$color${idx + 1u}. &c$wins &f$name") + } + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf(parameter) + } +} diff --git a/core/src/command/Wins.kt b/core/src/command/Wins.kt new file mode 100644 index 0000000..02faf04 --- /dev/null +++ b/core/src/command/Wins.kt @@ -0,0 +1,41 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsWins : Command { + override val label = "wins" + override val usage = listOf("player") + override val description = "Shows stats for a given player" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + val data = plugin.database?.getPlayer(name) + if (data == null) { + player.message(plugin.locale.prefix.default + plugin.locale.database.noInfo) + return + } + + val message = buildString { + val wins = data.seekerWins + data.hiderWins + val games = wins + data.seekerLosses + data.hiderLosses + appendLine("&f&l" + "=".repeat(30)) + appendLine(plugin.locale.database.infoFor.with(name)) + appendLine("&bTOTAL WINS: &f$wins") + appendLine("&6HIDER WINS: &f${data.hiderWins}") + appendLine("&cSEEKER WINS: &f${data.seekerWins}") + appendLine("GAMES PLAYED: ${games}") + append("&f&l" + "=".repeat(30)) + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "player" -> plugin.database?.getPlayerNames(10u, typed) ?: listOf() + else -> listOf() + } + } +} diff --git a/core/src/command/map/Add.kt b/core/src/command/map/Add.kt new file mode 100644 index 0000000..8505849 --- /dev/null +++ b/core/src/command/map/Add.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.config.MapConfig +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapAdd : Command { + override val label = "add" + override val usage = listOf("name", "world") + override val description = "Add a map to the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, world) = args + runChecks(plugin, player) { + mapDoesNotExist(name) + mapNameValid(name) + worldValid(world) + } + + plugin.maps[name] = KhsMap(name, MapConfig(world), plugin) + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.created.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "name" -> listOf("name") + "world" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/GoTo.kt b/core/src/command/map/GoTo.kt new file mode 100644 index 0000000..20444cc --- /dev/null +++ b/core/src/command/map/GoTo.kt @@ -0,0 +1,40 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapGoTo : Command { + override val label = "goto" + override val usage = listOf("map", "spawn") + override val description = "Goes to a spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, spawn) = args + runChecks(plugin, player) { mapExists(name) } + + var map = plugin.maps.get(name) ?: return + val loc = + when (spawn) { + "spawn" -> map.gameSpawn + "lobby" -> map.lobbySpawn + "seekerlobby" -> map.seekerLobbySpawn + else -> null + } + + if (loc == null) { + player.message(plugin.locale.prefix.error + plugin.locale.map.error.locationNotSet) + return + } + + loc.teleport(player) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + "spawn" -> listOf("spawn", "lobby", "seekerlobby").filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/List.kt b/core/src/command/map/List.kt new file mode 100644 index 0000000..8bc7a81 --- /dev/null +++ b/core/src/command/map/List.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsMapList : Command { + override val label = "list" + override val usage = listOf<String>() + override val description = "List maps known to the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + if (plugin.maps.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.map.none) + return + } + + player.message( + buildString { + appendLine(plugin.locale.prefix.default + plugin.locale.map.list) + for ((name, map) in plugin.maps) { + append("&e- &f$name: ") + appendLine(if (map.setup) "&aSETUP" else "&cNOT SETUP") + } + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/map/Remove.kt b/core/src/command/map/Remove.kt new file mode 100644 index 0000000..f8aab4f --- /dev/null +++ b/core/src/command/map/Remove.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapRemove : Command { + override val label = "remove" + override val usage = listOf("map") + override val description = "Remove a map from the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + lobbyEmpty() + } + + plugin.maps.remove(name) + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.deleted.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/Save.kt b/core/src/command/map/Save.kt new file mode 100644 index 0000000..a68b6cc --- /dev/null +++ b/core/src/command/map/Save.kt @@ -0,0 +1,31 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.game.mapSave +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSave : Command { + override val label = "save" + override val usage = listOf("map") + override val description = "Save the map backup used for gameplay" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + lobbyEmpty() + } + + var map = plugin.maps.get(name) ?: return + mapSave(plugin, map) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/Status.kt b/core/src/command/map/Status.kt new file mode 100644 index 0000000..596f306 --- /dev/null +++ b/core/src/command/map/Status.kt @@ -0,0 +1,45 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapStatus : Command { + override val label = "status" + override val usage = listOf("map") + override val description = "Says what is needed to fully setup the map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { mapExists(name) } + + val map = plugin.maps.get(name) ?: return + + if (map.setup) { + player.message(plugin.locale.prefix.default + plugin.locale.map.setup.complete) + return + } + + player.message( + buildString { + appendLine(plugin.locale.map.setup.header) + if (map.gameSpawn == null) appendLine(plugin.locale.map.setup.game) + if (map.lobbySpawn == null) appendLine(plugin.locale.map.setup.lobby) + if (map.seekerLobbySpawn == null) appendLine(plugin.locale.map.setup.seekerLobby) + if (plugin.config.exit == null) appendLine(plugin.locale.map.setup.exit) + if (map.bounds() == null) appendLine(plugin.locale.map.setup.bounds) + if (plugin.config.mapSaveEnabled && !map.hasMapSave()) + appendLine(plugin.locale.map.setup.saveMap) + if (map.config.blockHunt.enabled && map.config.blockHunt.blocks.isEmpty()) + appendLine(plugin.locale.map.setup.blockHunt) + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/Debug.kt b/core/src/command/map/blockhunt/Debug.kt new file mode 100644 index 0000000..0620e3d --- /dev/null +++ b/core/src/command/map/blockhunt/Debug.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.command.map.blockhunt + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.inv.createBlockHuntPicker +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntDebug : Command { + override val label = "debug" + override val usage = listOf("map") + override val description = "Manually open the blockhunt picker for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + } + + val map = plugin.maps.get(name) ?: return + val inv = createBlockHuntPicker(plugin, map) ?: return + player.showInventory(inv) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/Enabled.kt b/core/src/command/map/blockhunt/Enabled.kt new file mode 100644 index 0000000..a2ccdb7 --- /dev/null +++ b/core/src/command/map/blockhunt/Enabled.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command.map.blockhunt + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntEnabled : Command { + override val label = "enabled" + override val usage = listOf("map", "bool") + override val description = "Enable/disable blockhunt on a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, enabled) = args + runChecks(plugin, player) { + blockHuntSupported() + mapExists(name) + gameNotInProgress() + } + + val map = plugin.maps.get(name) ?: return + map.config.blockHunt.enabled = (enabled.lowercase() == "true") + map.reloadConfig() + + val msg = + if (map.config.blockHunt.enabled) plugin.locale.blockHunt.enabled + else plugin.locale.blockHunt.disabled + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + msg) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + "bool" -> listOf("true", "false").filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/Add.kt b/core/src/command/map/blockhunt/block/Add.kt new file mode 100644 index 0000000..6ed17be --- /dev/null +++ b/core/src/command/map/blockhunt/block/Add.kt @@ -0,0 +1,55 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockAdd : Command { + override val label = "add" + override val usage = listOf("map", "block") + override val description = "Add a block to a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, blockName) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + gameNotInProgress() + lobbyEmpty() + } + + val material = plugin.shim.parseMaterial(blockName) + if (material == null) { + player.message(plugin.locale.prefix.error + plugin.locale.blockHunt.block.unknown) + return + } + + val map = plugin.maps.get(name) ?: return + if (map.config.blockHunt.blocks.contains(material)) { + player.message( + plugin.locale.prefix.error + plugin.locale.blockHunt.block.exists.with(material) + ) + return + } + + map.config.blockHunt.blocks += material + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.blockHunt.block.added.with(material) + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + "block" -> listOf(parameter) + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/List.kt b/core/src/command/map/blockhunt/block/List.kt new file mode 100644 index 0000000..b7df70d --- /dev/null +++ b/core/src/command/map/blockhunt/block/List.kt @@ -0,0 +1,46 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockList : Command { + override val label = "list" + override val usage = listOf("map") + override val description = "List blocks in use on a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + } + + val map = plugin.maps.get(name) ?: return + val blocks = map.config.blockHunt.blocks + if (blocks.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.blockHunt.block.none) + return + } + + val message = buildString { + appendLine(plugin.locale.blockHunt.block.list) + for (block in blocks) { + appendLine("&e- &f$block") + } + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/Remove.kt b/core/src/command/map/blockhunt/block/Remove.kt new file mode 100644 index 0000000..3e81371 --- /dev/null +++ b/core/src/command/map/blockhunt/block/Remove.kt @@ -0,0 +1,56 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockRemove : Command { + override val label = "remove" + override val usage = listOf("map", "block") + override val description = "Remove a block from a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, blockName) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + gameNotInProgress() + lobbyEmpty() + } + + val material = plugin.shim.parseMaterial(blockName) + if (material == null) { + player.message(plugin.locale.prefix.error + plugin.locale.blockHunt.block.unknown) + return + } + + val map = plugin.maps.get(name) ?: return + if (!map.config.blockHunt.blocks.contains(material)) { + player.message( + plugin.locale.prefix.error + + plugin.locale.blockHunt.block.doesntExist.with(material) + ) + return + } + + map.config.blockHunt.blocks -= material + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.blockHunt.block.removed.with(material) + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + "block" -> listOf(parameter) + else -> listOf() + } +} diff --git a/core/src/command/map/set/Border.kt b/core/src/command/map/set/Border.kt new file mode 100644 index 0000000..75a3f27 --- /dev/null +++ b/core/src/command/map/set/Border.kt @@ -0,0 +1,64 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetBorder : Command { + override val label = "border" + override val usage = listOf("map", "size", "delay", "move") + override val description = "Enable the world border for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, sizeS, delayS, moveS) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + val size = sizeS.toULong() + val delay = delayS.toULong() + val move = moveS.toULong() + + if (size < 100u) { + player.message(plugin.locale.prefix.error + plugin.locale.worldBorder.minSize) + return + } + + if (move < 1u) { + player.message(plugin.locale.prefix.error + plugin.locale.worldBorder.minChange) + return + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.worldBorder + config.enabled = true + config.pos = player.location.position + config.size = size + config.delay = delay + config.move = move + + runChecks(plugin, player) { + // note this is not error, only warn + spawnsInRange(map) + } + + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.worldBorder.enable.with(size, delay, move) + ) + + val loc = player.location.position + map.world?.border?.move(loc.x, loc.z, size, 0UL) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf(parameter) + } +} diff --git a/core/src/command/map/set/Bounds.kt b/core/src/command/map/set/Bounds.kt new file mode 100644 index 0000000..7c13802 --- /dev/null +++ b/core/src/command/map/set/Bounds.kt @@ -0,0 +1,57 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.config.BoundConfig +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetBounds : Command { + override val label = "bounds" + override val usage = listOf("map") + override val description = "Sets the map bounds for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.bounds + + val pos = player.location.position + val num: Int + + if (config.min == null || config.max != null) { + config.min = BoundConfig(pos.x, pos.z) + config.max == null + num = 1 + } else { + val minX = minOf(config.min?.x ?: 0.0, pos.x) + val minZ = minOf(config.min?.z ?: 0.0, pos.z) + val maxX = maxOf(config.min?.x ?: 0.0, pos.x) + val maxZ = maxOf(config.min?.z ?: 0.0, pos.z) + config.min = BoundConfig(minX, minZ) + config.max = BoundConfig(maxX, maxZ) + num = 2 + } + + runChecks(plugin, player) { + // note this is not error, only warn + spawnsInRange(map) + } + + map.reloadConfig() + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.bounds.with(num)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/Lobby.kt b/core/src/command/map/set/Lobby.kt new file mode 100644 index 0000000..a90259a --- /dev/null +++ b/core/src/command/map/set/Lobby.kt @@ -0,0 +1,37 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetLobby : Command { + override val label = "lobby" + override val usage = listOf("map") + override val description = "Sets the lobby spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.lobby = pos + map.reloadConfig() + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.lobby) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/SeekerLobby.kt b/core/src/command/map/set/SeekerLobby.kt new file mode 100644 index 0000000..71122cb --- /dev/null +++ b/core/src/command/map/set/SeekerLobby.kt @@ -0,0 +1,38 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetSeekerLobby : Command { + override val label = "seekerlobby" + override val usage = listOf("map") + override val description = "Sets the seeker lobby spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.seeker = pos + map.reloadConfig() + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.seekerSpawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/Spawn.kt b/core/src/command/map/set/Spawn.kt new file mode 100644 index 0000000..4eff730 --- /dev/null +++ b/core/src/command/map/set/Spawn.kt @@ -0,0 +1,38 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetSpawn : Command { + override val label = "spawn" + override val usage = listOf("map") + override val description = "Sets the game spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.game = pos.toLegacy() + map.reloadConfig() + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.gameSpawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/unset/Border.kt b/core/src/command/map/unset/Border.kt new file mode 100644 index 0000000..87e7b85 --- /dev/null +++ b/core/src/command/map/unset/Border.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command.map.unset + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapUnsetBorder : Command { + override val label = "border" + override val usage = listOf("map") + override val description = "Disable the world border for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.worldBorder + config.enabled = false + config.pos = null + config.size = null + config.delay = null + config.move = null + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.worldBorder.disable) + + map.world?.border?.move(0.0, 0.0, 30_000_000UL, 0UL) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/util/Command.kt b/core/src/command/util/Command.kt new file mode 100644 index 0000000..734305a --- /dev/null +++ b/core/src/command/util/Command.kt @@ -0,0 +1,17 @@ +package cat.freya.khs.command.util + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +interface CommandPart { + val label: String +} + +interface Command : CommandPart { + val usage: List<String> + val description: String + + fun execute(plugin: Khs, player: Player, args: List<String>) + + fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> +} diff --git a/core/src/command/util/CommandGroup.kt b/core/src/command/util/CommandGroup.kt new file mode 100644 index 0000000..72659d5 --- /dev/null +++ b/core/src/command/util/CommandGroup.kt @@ -0,0 +1,134 @@ +package cat.freya.khs.command.util + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +private data class CommandData(val command: Command, val permission: String, val args: List<String>) + +class CommandGroup(val plugin: Khs, override val label: String, vararg commands: CommandPart) : + CommandPart { + // set of commands to run in this group + private final val REGISTRY: Map<String, CommandPart> = + commands.associate { it.label.lowercase() to it } + + private fun getCommand(args: List<String>, permission: String): CommandData? { + val invoke = args.firstOrNull()?.lowercase() ?: return null + val command = REGISTRY.get(invoke) ?: return null + + return when (command) { + is Command -> CommandData(command, "$permission.$invoke", args.drop(1)) + is CommandGroup -> command.getCommand(args.drop(1), "$permission.$invoke") + else -> null + } + } + + private fun messageAbout(player: Player) { + val version = plugin.shim.pluginVersion + player.message( + "&b&lKenshin's Hide and Seek &7(&f$version&7)\n" + + "&7Author: &f[KenshinEto]\n" + + "&7Help Command: &b/hs &fhelp" + ) + } + + fun handleCommand(player: Player, args: List<String>) { + val data = getCommand(args, label) ?: return messageAbout(player) + + if (plugin.saving) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowedTemp) + return + } + + if (plugin.config.permissionsRequired && !player.hasPermission(data.permission)) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowed) + return + } + + val paramCount = data.command.usage.filter { it.firstOrNull() != '*' }.count() + if (data.args.size < paramCount) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notEnoughArguments) + return + } + + runCatching { data.command.execute(plugin, player, data.args) } + .onFailure { + player.message( + plugin.locale.prefix.error + (it.message ?: plugin.locale.command.unknownError) + ) + + if (plugin.config.debug) { + plugin.shim.logger.warning("=== KHS BEGIN DEBUG TRACE ===") + plugin.shim.logger.warning(it.stackTraceToString()) + plugin.shim.logger.warning("=== KHS END DEBUG TRACE ===") + } + } + } + + private fun handleTabComplete( + player: Player, + args: List<String>, + permission: String, + ): List<String> { + val invoke = args.firstOrNull()?.lowercase() ?: return listOf() + val command = REGISTRY.get(invoke) + return when { + command is Command -> { + if ( + plugin.config.permissionsRequired && + !player.hasPermission("$permission.$invoke") + ) + return listOf() + + var index = maxOf(args.size - 1, 1) + val typed = args.getOrNull(index) ?: return listOf() + + // handle last argument of usage being a varadic (...) + if ( + index >= command.usage.size && + command.usage.lastOrNull()?.endsWith("...") == true + ) + index = command.usage.size + + val parameter = command.usage.getOrNull(index - 1) ?: return listOf() + + command.autoComplete(plugin, parameter, typed) + } + command is CommandGroup -> + command.handleTabComplete(player, args.drop(1), "$permission.$invoke") + args.size == 1 -> REGISTRY.keys.filter { it.startsWith(invoke) } + else -> listOf() + } + } + + fun handleTabComplete(player: Player, args: List<String>): List<String> { + return handleTabComplete(player, args, label) + } + + private fun commandsFor( + player: Player, + label: String, + permission: String, + res: MutableList<Pair<String, Command>>, + ) { + for ((invoke, command) in REGISTRY) { + when (command) { + is Command -> { + if ( + plugin.config.permissionsRequired && + !player.hasPermission("$permission.$invoke") + ) + continue + res.add("$label $invoke" to command) + } + is CommandGroup -> + command.commandsFor(player, "$label $invoke", "$permission.$invoke", res) + } + } + } + + fun commandsFor(player: Player): List<Pair<String, Command>> { + val commands = mutableListOf<Pair<String, Command>>() + commandsFor(player, label, label, commands) + return commands + } +} diff --git a/core/src/command/world/Create.kt b/core/src/command/world/Create.kt new file mode 100644 index 0000000..2deece7 --- /dev/null +++ b/core/src/command/world/Create.kt @@ -0,0 +1,40 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks +import cat.freya.khs.world.World + +class KhsWorldCreate : Command { + override val label = "create" + override val usage = listOf("name", "type") + override val description = "Create a new world" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, typeStr) = args + runChecks(plugin, player) { worldDoesNotExist(name) } + + val type = + World.Type.values().find { it.name.lowercase() == typeStr.lowercase() } + ?: World.Type.NORMAL + + val world = plugin.shim.createWorld(name, type) + if (world == null) { + player.message(plugin.locale.prefix.error + plugin.locale.world.addedFailed.with(name)) + return + } + + player.teleport(world.spawn.withWorld(name)) + player.message(plugin.locale.prefix.default + plugin.locale.world.added.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> listOf(parameter) + "type" -> + World.Type.values().map { it.name.lowercase() }.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/command/world/Delete.kt b/core/src/command/world/Delete.kt new file mode 100644 index 0000000..64710a6 --- /dev/null +++ b/core/src/command/world/Delete.kt @@ -0,0 +1,50 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks +import java.io.File + +class KhsWorldDelete : Command { + override val label = "delete" + override val usage = listOf("name") + override val description = "Delete an existing world" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + worldExists(name) + worldNotInUse(name) + } + + val loader = plugin.shim.getWorldLoader(name) + + // sanity check + // for the love of god, make sure were rm -fr'ing a world, not like + // some ones home dir ;-; + val lock = File(loader.dir, "session.lock") + val data = File(loader.dir, "level.dat") + if (!lock.exists() || !data.exists()) { + player.message(plugin.locale.prefix.error + plugin.locale.world.doesntExist.with(name)) + return + } + + loader.unload() + if (!loader.dir.deleteRecursively()) { + player.message( + plugin.locale.prefix.error + plugin.locale.world.removedFailed.with(name) + ) + return + } + + player.message(plugin.locale.prefix.default + plugin.locale.world.removed.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/command/world/List.kt b/core/src/command/world/List.kt new file mode 100644 index 0000000..af48f03 --- /dev/null +++ b/core/src/command/world/List.kt @@ -0,0 +1,42 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.world.World + +class KhsWorldList : Command { + override val label = "list" + override val usage = listOf<String>() + override val description = "Teleport to a world's spawn" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val worlds = plugin.shim.worlds + if (worlds.isEmpty()) { + // uhhh, we have to be in a world to call this 0_0 + player.message(plugin.locale.prefix.error + plugin.locale.world.none) + return + } + + val message = buildString { + appendLine(plugin.locale.world.list) + for (worldName in worlds) { + val world = plugin.shim.getWorld(worldName) + val status = + when (world?.type) { + World.Type.NORMAL -> "&aNORMAL" + World.Type.FLAT -> "&aFLAT" + World.Type.NETHER -> "&cNETHER" + World.Type.END -> "&eEND" + else -> "&7NOT LOADED" + } + appendLine("&e- &f$worldName: $status") + } + } + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/world/Tp.kt b/core/src/command/world/Tp.kt new file mode 100644 index 0000000..c103a0c --- /dev/null +++ b/core/src/command/world/Tp.kt @@ -0,0 +1,36 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsWorldTp : Command { + override val label = "tp" + override val usage = listOf("name") + override val description = "Teleport to a world's spawn" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { worldExists(name) } + + val loader = plugin.shim.getWorldLoader(name) + loader.load() + + val world = plugin.shim.getWorld(name) + if (world == null) { + player.message(plugin.locale.prefix.error + plugin.locale.world.loadFailed.with(name)) + return + } + + val spawn = world.spawn.withWorld(name) + player.teleport(spawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/config/Board.kt b/core/src/config/Board.kt new file mode 100644 index 0000000..434bfaa --- /dev/null +++ b/core/src/config/Board.kt @@ -0,0 +1,94 @@ +package cat.freya.khs.config + +data class LobbyBoardConfig( + var title: String = "&eHIDE AND SEEK", + var content: List<String> = + listOf( + "{COUNTDOWN}", + "", + "Players: {COUNT}", + "", + "&cSEEKER % &f{SEEKER%}", + "&6HIDER % &f{HIDER%}", + "", + "Map: {MAP}", + ), +) + +data class GameBoardConfig( + var title: String = "&eHIDE AND SEEK", + var content: List<String> = + listOf( + "Map: {MAP}", + "Team: {TEAM}", + "", + "Time Left: &a{TIME}", + "", + "Taunt: &e{TAUNT}", + "Glow: {GLOW}", + "Border: &b{BORDER}", + "", + "&cSEEKERS: &f{#SEEKER}", + "&6HIDERS: &f{#HIDER}", + ), +) + +data class CountdownBoardConfig( + var waiting: String = "Waiting for players...", + @Comment("{1} - time in seconds till game start") + var startingIn: LocaleString1 = LocaleString1("Starting in: &a{1}s"), + @Comment("{1} - how many minutes till game end") + @Comment("{2} - how many seconds till game end") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), +) + +data class TauntBoardConfig( + @Comment("{1} - number of minutes till taunt event") + @Comment("{2} - number of seconds till taunt event") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), + var active: String = "Active", +) + +data class GlowBoardConfig(var active: String = "&aActive", var disabled: String = "&cDisabled") + +data class BorderBoardConfig( + @Comment("{1} - number of minutes till border event") + @Comment("{2} - number of seconds till border event") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), + var shrinking: String = "Shrinking", +) + +data class KhsBoardConfig( + @Section("Lobby") + @Comment("Change what is displayed on the scoreboard/leaderboard") + @Comment("while in the lobby") + @Comment("") + @Comment(" {COUNTDOWN} - Displays the time left until the game starts") + @Comment(" {COUNT} - The amount of players in the lobby") + @Comment(" {SEEKER%} - % chance that you will be a seeker") + @Comment(" {HIDER%} - % chance that you will be a hider") + @Comment(" {MAP} - The name of the current map") + var lobby: LobbyBoardConfig = LobbyBoardConfig(), + @Section("Game") + @Comment("Change what is displayed on the scoreboard/leaderboard") + @Comment("while playing the game") + @Comment("") + @Comment(" {TIME} - The time left in the game (MM:SS)") + @Comment(" {TEAM} - The team you are on") + @Comment(" {BORDER} - The current status of the world border event") + @Comment(" {TAUNT} - The current status of the taunt event") + @Comment(" {GLOW} - The current status of the glow powerup") + @Comment(" {#SEEKER} - The number of seekers in the game right now") + @Comment(" {#HIDER} - The number of hiders in the game right now") + @Comment(" {MAP} - The name of the current map") + var game: GameBoardConfig = GameBoardConfig(), + @Section("Templates") + @Comment("Locale strings for the {COUNTDOWN} display") + var countdown: CountdownBoardConfig = CountdownBoardConfig(), + @Comment("Locale strings for the {TAUNT} placeholder") + var taunt: TauntBoardConfig = TauntBoardConfig(), + @Comment("Locale strings for the {GLOW} placeholder") + var glow: GlowBoardConfig = GlowBoardConfig(), + @Comment("Locale strings for the {BORDER} placeholder") + var border: BorderBoardConfig = BorderBoardConfig(), +) diff --git a/core/src/config/Config.kt b/core/src/config/Config.kt new file mode 100644 index 0000000..5cb2406 --- /dev/null +++ b/core/src/config/Config.kt @@ -0,0 +1,326 @@ +package cat.freya.khs.config + +import cat.freya.khs.world.Location +import kotlin.UInt +import kotlin.annotation.AnnotationTarget + +@Target(AnnotationTarget.PROPERTY) annotation class Section(val text: String) + +@Repeatable @Target(AnnotationTarget.PROPERTY) annotation class Comment(val text: String) + +@Target(AnnotationTarget.PROPERTY) annotation class Omittable() + +@Target(AnnotationTarget.PROPERTY) annotation class KhsDeprecated(val since: String) + +enum class ConfigCountdownDisplay { + CHAT, + ACTIONBAR, + TITLE, +} + +enum class ConfigScoringMode { + ALL_HIDERS_FOUND, + LAST_HIDER_WINS, +} + +enum class ConfigLeaveType { + EXIT, + PROXY, +} + +data class DelayedRespawnConfig( + var enabled: Boolean = true, + @Comment("How long do players have to wait in seconds before respawning") var delay: UInt = 5u, +) + +enum class DatabaseType { + SQLITE, + MYSQL, + POSTGRES, +} + +data class DatabaseConfig( + @Comment("The type of database to store user data in") + @Comment("SQLITE - local file in plugin directory, fine for most small servers") + @Comment("MYSQL - remote sql server running mysql") + @Comment("POSTGRES - remote sql server running postgresql") + var type: DatabaseType = DatabaseType.SQLITE, + @Comment("The following options are only required for mysql or postgres") + var host: String = "localhost", + var port: ULong? = null, + var username: String = "postgres", + var password: String = "postgres", + var database: String = "postgres", +) + +data class ItemConfig( + @Omittable var name: String? = null, + var material: String = "DIRT", + var lore: List<String> = emptyList(), + var enchantments: Map<String, UInt> = emptyMap(), + @Omittable var unbreakable: Boolean? = null, + @Omittable var modelData: UInt? = null, + @Omittable var owner: String? = null, +) + +data class EffectConfig( + var type: String = "SPEED", + var duration: UInt = 60u, + var amplifier: UInt = 1u, + var ambient: Boolean = true, + var particles: Boolean = true, +) + +data class TauntConfig( + var enabled: Boolean = true, + @Comment("The delay in seconds between taunts, minimum is 60 seconds") var delay: ULong = 360u, + @Comment("If to disable the taunt when there is only a single hider left") + var disableForLastHider: Boolean = false, + @Comment("Show the countdown till next taunt for everyone") var showCountdown: Boolean = true, +) + +data class GlowConfig( + var enabled: Boolean = true, + @Comment("How long in seconds does the powerup last") var time: ULong = 30u, + @Comment("If multiple powerup uses can stack the time left") var stackable: Boolean = true, + @Comment("The config for the powerup item") + var item: ItemConfig = + ItemConfig( + "Glow Powerup", // Name + "SNOWBALL", // Material + listOf( + "Throw to make all seekers glow", + "Last 30s, all hiders can see it", + "Time stacks on multi use", + ), + ), // Lore +) + +data class LobbyConfig( + @Comment("Time in seconds that the lobby waits until game starts. Set to 0 to disable") + var countdown: ULong = 60u, + @Comment("Player threshold to speed up the countdown. Set to 0 to disable") + var changeCountdown: UInt = 5u, + @Comment("Minimum amount of players required to start the countdown") var min: UInt = 3u, + @Comment("Maximum amount of players allowed in a lobby") var max: UInt = 10u, + @Comment("Item for players to use to leave the lobby") + var leaveItem: ItemConfig = + ItemConfig( + "&c Leave Lobby", // Name + "BED", // Material + listOf("Go back to server hub"), + ), // Lore + @Comment("Item for admins to use to force start the game") + var startItem: ItemConfig = + ItemConfig( + "&bStart Game", // Name + "CLOCK", + ), // Material +) + +data class SpectatorItemsConfig( + /// Item for spectators to toggle flight + var flight: ItemConfig = + ItemConfig( + "&bToggle Flight", // Name + "FEATHER", // Material + listOf("Turns flying on and off"), + ), // Lore + + /// Item for spectators to teleport to other players + var teleport: ItemConfig = + ItemConfig( + "&bTeleport to Others", // Name + "COMPASS", // Material + listOf("Allows you to teleport to all other players in game"), + ), // Lore +) + +data class SeekerPingDistancesConfig( + var level1: UInt = 30u, + var level2: UInt = 20u, + var level3: UInt = 10u, +) + +data class SeekerPingConfigSounds( + @Comment("The noise for the heartbeat") + var heartbeatNoise: String = "BLOCK_NOTE_BLOCK_BASEDRUM", + @Comment("The noise for the ringing") var ringingNoise: String = "BLOCK_NOTE_BLOCK_PLING", + var leadingVolume: Double = 0.5, + var volume: Double = 0.3, + var pitch: Double = 1.0, +) + +data class SeekerPingConfig( + var enabled: Boolean = true, + @Comment("The distances for the volume to change") + var distances: SeekerPingDistancesConfig = SeekerPingDistancesConfig(), + @Comment("The sounds that players will hear") + var sounds: SeekerPingConfigSounds = SeekerPingConfigSounds(), +) + +data class KhsConfig( + /* General */ + + @Section("General") + @Comment("Allow players to drop their items in game") + var dropItems: Boolean = false, + @Comment("When the game is starting, the plugin will state there is x seconds left to hide.") + @Comment( + "You change where countdown messages are to be displayed: in the chat, action bar, or a title." + ) + @Comment("Below you can set CHAT, ACTIONBAR, or TITLE. Any invarid option will revert to CHAT.") + var countdownDisplay: ConfigCountdownDisplay = ConfigCountdownDisplay.CHAT, + @Comment( + "Allow Hiders to see their own teams nametags as well as seekers. Seekers can never see nametags regardless" + ) + var nametagsVisible: Boolean = false, + @Comment( + "Require bukkit permissions though a permission plugin to run commands, or require op, recommended on most servers" + ) + var permissionsRequired: Boolean = true, + @Comment("Minimum amount of players to start the game. Cannot go lower than 2.") + var minPlayers: UInt = 2u, + @Comment("Amount of initial seekers when the game starts, minimum of 1") + var startingSeekerCount: UInt = 1u, + @Comment( + "By default, when a HIDER dies they will join the SEEKER team. If enabled they will instead become a SPECTATOR." + ) + var respawnAsSpectator: Boolean = false, + @Comment("Along with a char message, display a title describing the game over") + var gameOverTitle: Boolean = true, + @Comment("Configure items given to spectators") + var spectatorItems: SpectatorItemsConfig = SpectatorItemsConfig(), + @Comment("Configure the sounds that plays when a seeker is near") + var seekerPing: SeekerPingConfig = SeekerPingConfig(), + @Comment("For developers") var debug: Boolean = false, + + /* Timing */ + + @Section("Timing") + @Comment("How long in seconds will the game last, set to 0 to make game length infinite") + var gameLength: ULong = 1200u, + @Comment("How long in seconds will the initial hiding period last, minimum is 10 seconds") + var hidingLength: ULong = 30u, + @Comment( + "The amount of seconds the game will wait until the players are teleported to the lobby after a game over" + ) + var endGameDelay: ULong = 5u, + @Comment( + "If you die in game, you will have to wait [delay] seconds until you respawn, so that if you were a seeker," + ) + @Comment( + "you cannot instantly go to where the Hider that killed you was. Or if you were a Hider and dies," + ) + @Comment("you can't instantly go to where you know other Hiders are. This can be disabled.") + var delayedRespawn: DelayedRespawnConfig = DelayedRespawnConfig(), + + /* Database */ + + @Section("Database") var database: DatabaseConfig = DatabaseConfig(), + + /* Scoring */ + + @Section("Scoring") + @Comment("The scoring mode decides the criteria for when the game has finished and who wins.") + @Comment( + "ALL_HIDERS_FOUND - The game will go until no hiders are left. If the timer runs out all hiders left will win." + ) + @Comment( + "LAST_HIDER_WINS - The game will go until there is only one hider left. If the timer runs out, all hiders left win. If there is only one hider left, all initial seekers win along with the last hider." + ) + var scoringMode: ConfigScoringMode = ConfigScoringMode.ALL_HIDERS_FOUND, + @Comment( + "When enabled, if the last hider or seeker quits the game, a wine type of NONE is given, which doesn't mark anyone as winning." + ) + @Comment( + "This can be used as a way to prevent players from quitting in a loop to get someone else points." + ) + var dontRewardQuit: Boolean = true, + + /* PVP */ + + @Section("PVP") + @Comment( + "This plugin by default functions as not tag to catch Hiders, but to pvp. All players are given weapons," + ) + @Comment( + "and seekers slightly better weapons (this can be changed in items.yml). If you want, you can disable this" + ) + @Comment( + "entire pvp functionality, and make Hiders get found on a single hit. Hiders would also not be able to fight" + ) + @Comment("back against Seekers if disabled.") + var pvp: Boolean = true, + @Comment("Allow players to regen health") var regenHealth: Boolean = false, + @Comment( + "If pvp is disabled, Hiders and Seekers can no longer take damage from natural causes unless this option is enabled." + ) + @Comment("Such natural causes could be fall damage or projectiles.") + var allowNaturalCauses: Boolean = false, + + /* Lobby */ + + @Section("Lobby") + @Comment("Players that join the server will automatically be added into a game lobby") + var autoJoin: Boolean = false, + @Comment( + "When players join the world contaning the lobby, teleport them to the designated exit position so that they don't spawn in the lobby while not in the queue." + ) + @Comment("This setting is ignored when autoJoin is set to true.") + var teleportStraysToExit: Boolean = false, + @Comment("How to handle players leaving a game lobby.") + @Comment("EXIT - Teleport the player to the designated exit location") + @Comment("PROXY - Teleport the player to another server in a bungeecord/velocity network") + var leaveType: ConfigLeaveType = ConfigLeaveType.EXIT, + @Comment("The server to teleport to when leaveType is set to PROXY") + var leaveServer: String = "lobby", + @Comment("If to leave the game lobby after a game ends") var leaveOnEnd: Boolean = false, + @Comment("Configure the \"waiting for players\" per map lobby") + var lobby: LobbyConfig = LobbyConfig(), + @Comment("Restore the players previously cleared inventory after leaving the game lobby") + var saveInventory: Boolean = false, + + /* Events */ + + @Section("Events") @Comment("Taunt event") var taunt: TauntConfig = TauntConfig(), + + /* Powerups */ + + @Section("Powerups") @Comment("Glow powerup") var glow: GlowConfig = GlowConfig(), + @Comment( + "Instead of having a glow powerup, always make seeker position's known the the hider at all times." + ) + var alwaysGlow: Boolean = false, + + /* Protections */ + + @Section("Protections") + @Comment( + "By default, the plugin forces you to use a map save to protect from changes to a map thought a game play though. It copies your" + ) + @Comment( + "hide-and-seek world to a separate world, and loads the game there to contain the game in an isolated and backed up map. This allows you to" + ) + @Comment( + "not worry about your hide-and-seek map from changing, as all changes are made are in a separate world file that doesn't get saved. Once the game" + ) + @Comment( + "ends, it unloads the map and doesn't save. Then reloads the duplicate to the original state, rolling back the map for the next game." + ) + @Comment( + "It is highly recommended that you keep this set to true unless you have other means of protecting your hide-and-seek map." + ) + var mapSaveEnabled: Boolean = true, + @Comment("Block these commands for players in a game. Good for blocking communication") + var blockedCommands: List<String> = listOf("msg", "reply", "me"), + @Comment("Dont allow players to interact with these blocks") + var blockedInteracts: List<String> = + listOf("FURNACE", "CRAFTING_TABLE", "ANVIL", "CHEST", "BARREL"), + + /* Auto Generated */ + + @Section("Auto Generated") + @Comment("Location where players are teleported to when they run (/hs leave).") + var exit: Location? = null, +) diff --git a/core/src/config/Items.kt b/core/src/config/Items.kt new file mode 100644 index 0000000..31feb95 --- /dev/null +++ b/core/src/config/Items.kt @@ -0,0 +1,115 @@ +package cat.freya.khs.config + +data class KhsItemsConfig( + @Section("Hider Items") + @Comment("Items that hiders are given") + var hiderItems: List<ItemConfig> = + listOf( + // Stone sword + ItemConfig( + "Hider Sword", // Name + "STONE_SWORD", // Material + listOf("This is the hider sword"), // Lore + mapOf("sharpness" to 2u), // Enchantments + true, + ), // Unbreakable + // Regen potion + ItemConfig( + null, // Name + "SPLASH_POTION:REGEN", + ), // Material + // Heal potion + ItemConfig( + null, // Name + "POTION:INSTANT_HEAL", + ), + ), // Material + var hiderHelmet: ItemConfig? = null, + var hiderChestplate: ItemConfig? = null, + var hiderLeggings: ItemConfig? = null, + var hiderBoots: ItemConfig? = null, + @Section("Seeker Items") + @Comment("Items that seekers are given") + var seekerItems: List<ItemConfig> = + listOf( + // Diamond sword + ItemConfig( + "Seeker Sword", // Name + "DIAMOND_SWORD", // Material + listOf("this is the seeker sword"), // Lore + mapOf("sharpness" to 1u), // Enchantments + true, + ), // Unbreakable + // Wacky stick + ItemConfig( + "Wacky Stick", // Name + "STICK", // Material + listOf("It will launch people very far", "Use wisely!"), // Lore + mapOf("knockback" to 3u), + ), + ), // Enchantments + + // Armor provided to seekers + var seekerHelmet: ItemConfig? = ItemConfig(null, "LEATHER_HELMET"), + var seekerChestplate: ItemConfig? = ItemConfig(null, "LEATHER_CHESTPLATE"), + var seekerLeggings: ItemConfig? = ItemConfig(null, "LEATHER_LEGGINGS"), + var seekerBoots: ItemConfig? = + ItemConfig( + null, // Name + "LEATHER_BOOTS", // Material + emptyList(), // Lore + mapOf("feather_falling" to 4u), + ), // Enchantments + @Section("Hider Effects") + @Comment("Effects hiders are given at the start of the round") + var hiderEffects: List<EffectConfig> = + listOf( + EffectConfig( + "WATER_BREATHING", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "DOLPHINS_GRACE", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), + ), // Particles + @Section("Seeker Effects") + @Comment("Effects seekers given at the start of the round and when they respawn") + var seekerEffects: List<EffectConfig> = + listOf( + EffectConfig( + "SPEED", // Type + 1000000u, // Duration + 2u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "JUMP", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "WATER_BREATHING", // Type + 1000000u, // Duration + 10u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "DOLPHINS_GRACE", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), + ), // Particles +) diff --git a/core/src/config/Locale.kt b/core/src/config/Locale.kt new file mode 100644 index 0000000..7821fbe --- /dev/null +++ b/core/src/config/Locale.kt @@ -0,0 +1,321 @@ +package cat.freya.khs.config + +@JvmInline +value class LocaleString1(val inner: String) { + fun with(arg1: Any): String { + return this.inner.replace("{1}", arg1.toString()) + } + + override fun toString(): String = "[LocaleString1]" +} + +@JvmInline +value class LocaleString2(val inner: String) { + fun with(arg1: Any, arg2: Any): String { + return this.inner.replace("{1}", arg1.toString()).replace("{2}", arg2.toString()) + } + + override fun toString(): String = "[LocaleString2]" +} + +@JvmInline +value class LocaleString3(val inner: String) { + fun with(arg1: Any, arg2: Any, arg3: Any): String { + return this.inner + .replace("{1}", arg1.toString()) + .replace("{2}", arg2.toString()) + .replace("{3}", arg3.toString()) + } + + override fun toString(): String = "[LocaleString3]" +} + +data class LocalePrefixConfig( + var default: String = "&9Hide and Seek > &f", + var warning: String = "&eWarning > &f", + var error: String = "&cError > &f", + var abort: String = "&cAbort > &f", + var taunt: String = "&eTaunt > &f", + var border: String = "&cWorld Border > &f", + var gameOver: String = "&aGame Over > &f", +) + +data class LocalePlaceholderConfig( + @Comment("Displayed string if the requested placeholder is invalid") + var invalid: String = "{Error}", + @Comment("Displayed string if the requested placeholder is empty") + var noData: String = "{No Data}", +) + +data class LocaleCommandConfig( + var playerOnly: String = "This command can only be run by a player", + var notAllowed: String = "You are not allowed to run this command", + var notAllowedTemp: String = "You are not allowed to run this command right now", + var unknownError: String = "An unknown error has occoured", + @Comment("{1} - position of invalid argument") + var invalidArgument: LocaleString1 = LocaleString1("Invalid argument: {1}"), + var notEnoughArguments: String = "This command requires more arguments to run", + @Comment("{1} - the invalid integer") + var invalidInteger: LocaleString1 = LocaleString1("Invalid integer: {1}"), + @Comment("{1} - the invalid player name") + var invalidPlayer: LocaleString1 = LocaleString1("Invalid player: {1}"), + var reloading: String = "Reloading the config...", + var reloaded: String = "Reloaded the config", + var errorReloading: String = "Error reloading config, please check the server logs!", +) + +data class LocaleGamePlayerConfig( + @Comment("{1} - name of the player who died") + var death: LocaleString1 = LocaleString1("&c{1}&f was killed"), + @Comment("{1} - name of the hider who was found") + var found: LocaleString1 = LocaleString1("&e{1}&f was found"), + @Comment("{1} - name of the hider who was found") + @Comment("{2} - name of the seeker who found the hider") + var foundBy: LocaleString2 = LocaleString2("&e{1}&f was found by &c{2}&f"), +) + +data class LocaleGameGameoverConfig( + var hidersFound: String = "All hiders have been found", + @Comment("{1} - the name of the last hider") + var lastHider: LocaleString1 = LocaleString1("The last hider, &e{1}&f, has won!"), + var seekerQuit: String = "All seekers have quit", + var hiderQuit: String = "All hiders have quit", + var time: String = "Seekers have run out of time. Hiders win!", +) + +data class LocaleGameTitleConfig( + var hidersWin: String = "&aHiders Win!", + @Comment("{1} - the name of the hider who won") + var singleHiderWin: LocaleString1 = LocaleString1("&a{1} Wins!"), + var singleHiderWinSubtitle: LocaleString1 = LocaleString1("{1} is the last hider alive!"), + var seekersWin: String = "&cSeekers Win!", + var noWin: String = "&bGame Over", +) + +data class LocaleGameCountdownConfig( + @Comment("{1} - the amount of seconds hiders have left to hide") + var notify: LocaleString1 = LocaleString1("Hiders have {1} seconds left to hide!"), + var last: String = "Hiders have 1 second left to hide", +) + +data class LocaleGameTeamConfig( + var hider: String = "&6&lHIDER &r", + var seeker: String = "&c&lSEEKER &r", + var spectator: String = "&8&lSPECTATOR", + var hiderSubtitle: String = "Hide from the seekers", + var seekerSubtitle: String = "Find the hiders", + var spectatorSubtitle: String = "You've joined mid-game", +) + +data class LocaleGameConfig( + var player: LocaleGamePlayerConfig = LocaleGamePlayerConfig(), + var gameOver: LocaleGameGameoverConfig = LocaleGameGameoverConfig(), + var title: LocaleGameTitleConfig = LocaleGameTitleConfig(), + var countdown: LocaleGameCountdownConfig = LocaleGameCountdownConfig(), + var team: LocaleGameTeamConfig = LocaleGameTeamConfig(), + var setup: String = + "There are no maps setup! Run /hs map status on a map to see what you needto do", + var inGame: String = "You are already in the lobby/game", + var notInGame: String = "You are not in a lobby/game", + var inProgress: String = "There is currently a game in progress", + var notInProgress: String = "There is no game in progress", + var join: String = "You have joined mid game and are not a spectator", + @Comment("{1} - the name of the player who left the game") + var leave: LocaleString1 = LocaleString1("{1} has left the game"), + var start: String = "Attention SEEKERS, it's time to find the hiders!", + var stop: String = "The game has been forcefully stopped", + @Comment("{1} - the time till respawn") + var respawn: LocaleString1 = LocaleString1("You will respawn in {1} seconds"), +) + +data class LocaleSpectatorConfig( + var flyingEnabled: String = "&l&bFlying enabled", + var flyingDisabled: String = "&l&bFlying disabled", +) + +data class LocaleLobbyConfig( + @Comment("{1} - the name of the player who joined the lobby") + var join: LocaleString1 = LocaleString1("{1} has joined the lobby"), + @Comment("{1} - the name of the player who left the lobby") + var leave: LocaleString1 = LocaleString1("{1} has left the lobby"), + var inUse: String = "Can't modify the lobby while players are in it", + var full: String = "You cannot join the lobby since it is full", + @Comment("{1} - the minimum number of players required to start the game") + var notEnoughPlayers: LocaleString1 = + LocaleString1("You must have at least {1} players to start"), +) + +data class LocaleMapSaveConfig( + var start: String = "Starting map save", + var warning: String = + "All commands will be disabled when the save is in progress. Do not turn of the server.", + var inProgress: String = "Map save is currently in progress! Try again later.", + var finished: String = "Map save complete", + @Comment("{1} - the error message") + var failed: LocaleString1 = LocaleString1("Map save failed with the following error: {1}"), + var failedLocate: String = "Map save failed. Could not locate the map to save!", + var failedLoad: String = "Map save failed. Could not load the map!", + @Comment("{1} - the name of the directory that could not be renamed") + var failedDir: LocaleString1 = LocaleString1("Failed to rename/delete directory: {1}"), + var disabled: String = "Map saves are disabled in config.yml", +) + +data class LocaleMapSetupConfig( + @Comment("{1} - the map that is not yet setup") + var not: LocaleString1 = LocaleString1("Map {1} is not setup (/hs map status <map>)"), + var header: String = "&f&lThe following is needed for setup...", + var game: String = "&c&l- &fGame spawn isn't setup, /hs map set spawn <map>", + var lobby: String = "&c&l- &fLobby spawn isn't setup, /hs map set lobby <map>", + var seekerLobby: String = + "&c&l- &fSeeker Lobby spawn isn't setup, /hs map set seekerLobby <map>", + var exit: String = "&c&l- &fQuit/exit teleport location isn't set, /hs setexit", + var saveMap: String = "&c&l- &FMap isn't saved, /hs map save <map>", + var bounds: String = + "&c&l- &fPlease set game bounds in 2 opposite corners of the game map, /hs map set bounds <map>", + var blockHunt: String = + "&c&l - &fSince block hunt is enabled, there needs to be at least 1 block set, /hs map blockHunt block add block <map> <block>", + var complete: String = "Everything is setup and ready to go!", +) + +data class LocaleMapErrorConfig( + var locationNotSet: String = + "This location is not set (run /hs map status <map> for more info)", + var notInRange: String = "This position is out of range (check bounds or world border)", + var bounds: String = "Please set map bounds first", +) + +data class LocaleMapWarnConfig( + var gameSpawnReset: String = "Game spawn has been reset due to being out of range", + var seekerSpawnReset: String = "Seeker spawn has been reset due to being out of range", + var lobbySpawnReset: String = "Lobby spawn has been reset due to being out of range", +) + +data class LocaleMapSetConfig( + var gameSpawn: String = "Set game spawn position to your current position", + var seekerSpawn: String = "Set seeker spawn position to your current position", + var lobby: String = "Set lobby position to your current position", + var exit: String = "Set exit position to your current position", + @Comment("{1} - if the 1st or 2nd bound position was set") + var bounds: LocaleString1 = + LocaleString1("Successfully set bounds at your current position ({1}/2)"), +) + +data class LocaleMapConfig( + var save: LocaleMapSaveConfig = LocaleMapSaveConfig(), + var setup: LocaleMapSetupConfig = LocaleMapSetupConfig(), + var error: LocaleMapErrorConfig = LocaleMapErrorConfig(), + var warn: LocaleMapWarnConfig = LocaleMapWarnConfig(), + var set: LocaleMapSetConfig = LocaleMapSetConfig(), + var list: String = "The current maps are:", + var none: String = "There are no maps known to the plugin (/hs map add <name> <world>)", + var noneSetup: String = "There are no maps setup and ready to play", + var invalidName: String = "A map name can only contain ascii numbers and letters", + var wrongWorld: String = "Please run this command in the game world", + var exists: String = "A map with this name already exists!", + var unknown: String = "That map does not exist", + @Comment("{1} - the name of the new map") + var created: LocaleString1 = LocaleString1("Created map: {1}"), + @Comment("{1} - the name of the deleted map") + var deleted: LocaleString1 = LocaleString1("Deleted map: {1}"), +) + +data class LocaleWorldBorderConfig( + var disable: String = "Disabled world border", + var minSize: String = "World border cannot be smaller than 100 blocks", + var minChange: String = "World border move be able to move", + var position: String = "Spawn position must be 100 from world border center", + @Comment("{1} - the new size of the world border") + @Comment("{2} - the new delay of the world border") + @Comment("{3} - how much the border changes at a time") + var enable: LocaleString3 = + LocaleString3( + "Set border center to current location, size to {1}, delay to {2}, and steps by {3} blocks" + ), + var warn: String = "World border will shrink in the next 30s!", + var shrinking: String = "&c&oWorld border is shrinking!", +) + +data class LocaleTauntConfig( + var chosen: String = "&c&oOh no! You have been chosen to be taunted", + var warning: String = "A random hider will be taunted in the next 30s", + var activate: String = "Taunt has been activated", +) + +data class LocaleBlockHuntBlockConfig( + @Comment("{1} - the block trying to be added to the block hunt map") + var exists: LocaleString1 = LocaleString1("{1} has already been added to this map"), + @Comment("{1} - the block trying to be removed from the block hunt map") + var doesntExist: LocaleString1 = LocaleString1("{1} is already not used for the map"), + @Comment("{1} - the block added to the block hunt map") + var added: LocaleString1 = LocaleString1("Added {1} as a disguise to the map"), + @Comment("{1} - the block removed from the block hunt map") + var removed: LocaleString1 = LocaleString1("Removed {1} as a disguise from the map"), + var list: String = "The block disguises for the map are:", + var none: String = "There are no block disguises in use for this map", + var unknown: String = "This block name does not exist", +) + +data class LocaleBlockHuntConfig( + var notEnabled: String = "Block hunt is not enabled on ths map", + var notSupported: String = "Block hunt does not work on 1.8", + var enabled: String = "Block hunt has been enabled", + var disabled: String = "Block hunt has been disabled", + var block: LocaleBlockHuntBlockConfig = LocaleBlockHuntBlockConfig(), +) + +data class LocaleWorldConfig( + @Comment("{1} - the world name") + var exists: LocaleString1 = LocaleString1("A world named {1} already exists"), + @Comment("{1} - the world name") + var doesntExist: LocaleString1 = LocaleString1("There is not world named {1}"), + @Comment("{1} - the world name") + var added: LocaleString1 = LocaleString1("Created a world named {1}"), + var addedFailed: LocaleString1 = LocaleString1("Failed to create a world named {1}"), + @Comment("{1} - the world name") + var removed: LocaleString1 = LocaleString1("Removed the world named {1}"), + var removedFailed: LocaleString1 = LocaleString1("Failed to remove the world named {1}"), + @Comment("{1} - the world name") + @Comment("{2} - the map using the world") + var inUseBy: LocaleString2 = LocaleString2("The world {1} is in use by map {2}"), + var inUse: LocaleString1 = LocaleString1("The world {1} is in use by the plugin"), + @Comment("{1} - the world name") + var loadFailed: LocaleString1 = LocaleString1("Failed to load: {1}"), + @Comment("{1} - the given world type") + var invalidType: LocaleString1 = LocaleString1("Invalid world type: {1}"), + var notEmpty: String = "World must be empty to be deleted", + var list: String = "The following worlds are", + var none: String = "Failed to fetch any worlds", +) + +data class LocaleDatabaseConfig( + var noInfo: String = "No gameplay info", + @Comment("{1} - the player associated with the following win information") + var infoFor: LocaleString1 = LocaleString1("Win information for {1}:"), +) + +data class LocaleConfirmConfig( + var none: String = "You have nothing to confirm", + var timedOut: String = "The confirmation has timed out", + var confirm: String = "Run /hs confirm within 10s to confirm", +) + +data class KhsLocale( + @Section("Language") @Comment("What language is this for?") var locale: String = "en_US", + @Section("Message prefixes") + @Comment("Specify prefixes for plugin chat messages.") + var prefix: LocalePrefixConfig = LocalePrefixConfig(), + @Section("Placeholder errors") + @Comment("PlaceholderAPI error strings") + var placeholder: LocalePlaceholderConfig = LocalePlaceholderConfig(), + @Section("Command responses") var command: LocaleCommandConfig = LocaleCommandConfig(), + @Section("Gameplay") var game: LocaleGameConfig = LocaleGameConfig(), + @Section("Spectator") var spectator: LocaleSpectatorConfig = LocaleSpectatorConfig(), + @Section("Lobby") var lobby: LocaleLobbyConfig = LocaleLobbyConfig(), + @Section("Map") var map: LocaleMapConfig = LocaleMapConfig(), + @Section("World Border") var worldBorder: LocaleWorldBorderConfig = LocaleWorldBorderConfig(), + @Section("Taunt event") var taunt: LocaleTauntConfig = LocaleTauntConfig(), + @Section("Block Hunt") var blockHunt: LocaleBlockHuntConfig = LocaleBlockHuntConfig(), + @Section("World") var world: LocaleWorldConfig = LocaleWorldConfig(), + @Section("Database") var database: LocaleDatabaseConfig = LocaleDatabaseConfig(), + @Section("Confirm") var confirm: LocaleConfirmConfig = LocaleConfirmConfig(), +) diff --git a/core/src/config/Maps.kt b/core/src/config/Maps.kt new file mode 100644 index 0000000..9282bea --- /dev/null +++ b/core/src/config/Maps.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.config + +import cat.freya.khs.world.Position + +data class LegacyPosition( + var x: Double = 0.0, + var y: Double = 0.0, + var z: Double = 0.0, + @Omittable @KhsDeprecated("2.0.0") var world: String? = null, +) { + fun toPosition(): Position = Position(x, y, z) +} + +data class SpawnsConfig( + // 1.x series of KHS stored game world in the positions + // so we need to load it in to be able to migrate + // it + var game: LegacyPosition? = null, + var lobby: Position? = null, + var seeker: Position? = null, +) + +data class BoundConfig(var x: Double = 0.0, var z: Double = 0.0) + +data class BoundsConfig(var min: BoundConfig? = null, var max: BoundConfig? = null) + +data class WorldBorderConfig( + var enabled: Boolean = false, + var pos: Position? = null, + var size: ULong? = null, + var delay: ULong? = null, + var move: ULong? = null, +) + +data class BlockHuntConfig(var enabled: Boolean = false, var blocks: List<String> = emptyList()) + +data class MapConfig( + var world: String? = null, + var spawns: SpawnsConfig = SpawnsConfig(), + var bounds: BoundsConfig = BoundsConfig(), + var worldBorder: WorldBorderConfig = WorldBorderConfig(), + var blockHunt: BlockHuntConfig = BlockHuntConfig(), +) { + fun migrate() { + // migrate from v1 world + if (world != null) return + + // move world name + world = spawns.game?.world + spawns.game?.world = null + } +} + +data class KhsMapsConfig( + @Comment("DO NOT EDIT THIS FILE - It is autogenerated") + @Comment("Please use /hs map ... commands instead") + var maps: Map<String, MapConfig> = emptyMap() +) diff --git a/core/src/config/util/Deserialize.kt b/core/src/config/util/Deserialize.kt new file mode 100644 index 0000000..8a5be59 --- /dev/null +++ b/core/src/config/util/Deserialize.kt @@ -0,0 +1,134 @@ +package cat.freya.khs.config.util + +import cat.freya.khs.config.LocaleString1 +import cat.freya.khs.config.LocaleString2 +import cat.freya.khs.config.LocaleString3 +import java.io.InputStream +import java.io.InputStreamReader +import java.io.Reader +import kotlin.reflect.KClass +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.createInstance +import kotlin.reflect.full.declaredFunctions +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import org.yaml.snakeyaml.Yaml + +fun <T : Any> deserializeClass(type: KClass<T>, data: Map<String, Any?>): T { + require(type.isData) { "$type is not a data class" } + + val propValues = + type.memberProperties.associateWith { prop -> + val value = data[prop.name] ?: return@associateWith null + val propType = prop.returnType.classifier as KClass<*> + val innerTypes = + prop.returnType.arguments.map { it.type?.classifier as? KClass<*> }.filterNotNull() + deserializeField(propType, innerTypes, prop.name, value) + } + + val instance = type.createInstance() + for ((prop, value) in propValues) { + if (value != null) { + (prop as? KMutableProperty1<*, *>)?.setter?.call(instance, value) + ?: error("${prop.name} is not mutable") + } + } + + val migrateFunction = instance::class.declaredFunctions.singleOrNull { it.name == "migrate" } + if (migrateFunction != null) migrateFunction.call(instance) + + return instance +} + +fun <T : Enum<*>> deserializeEnum(type: KClass<T>, key: String, value: String): T { + return type.java.enumConstants.firstOrNull { it.name == value } + ?: error("$key: invalid enum value of '$value'") +} + +fun <T : Any> deserializeList(innerType: KClass<T>, key: String, value: List<*>): List<T> { + return value.map { deserializeField<T>(innerType, null, key, it) } +} + +fun <K : Any, V : Any> deserializeMap( + keyType: KClass<K>, + valueType: KClass<V>, + key: String, + value: Map<*, *>, +): Map<String, V> { + if (keyType != String::class) error("maps may only contain strings as keys") + + return value + .mapKeys { deserializePrimitive(key, String::class, it.key ?: "") } + .mapValues { deserializeField(valueType, null, key, it.value) } +} + +@Suppress("UNCHECKED_CAST") +fun <T : Any> deserializePrimitive(key: String, expected: KClass<T>, value: Any): T { + return when { + expected == String::class && value is String -> value as T + expected == LocaleString1::class && value is String -> LocaleString1(value) as T + expected == LocaleString2::class && value is String -> LocaleString2(value) as T + expected == LocaleString3::class && value is String -> LocaleString3(value) as T + expected == Int::class && value is Number -> value.toInt() as T + expected == UInt::class && value is Number -> maxOf(0, value.toInt()).toUInt() as T + expected == Long::class && value is Number -> value.toLong() as T + expected == ULong::class && value is Number -> maxOf(0L, value.toLong()).toULong() as T + expected == Float::class && value is Number -> value.toFloat() as T + expected == Double::class && value is Number -> value.toDouble() as T + expected == Boolean::class && value is Boolean -> value as T + expected == Boolean::class && value is Number -> (value.toInt() != 0) as T + else -> error("$key: invalid value '$value' for type $expected") + } +} + +@Suppress("UNCHECKED_CAST") +fun <T : Any> deserializeField( + type: KClass<T>, + innerTypes: List<KClass<*>>?, + key: String, + value: Any?, +): T { + return when { + type.isData -> + deserializeClass<T>( + type, + value as? Map<String, Any?> ?: error("$key: expected map for data class $type"), + ) + + type.java.isEnum -> + deserializeEnum( + type as KClass<Enum<*>>, + key, + value as? String ?: error("$key: expected string for enum value"), + ) + as T + + type.isSubclassOf(List::class) -> + deserializeList( + innerTypes?.firstOrNull() ?: error("$key: innerType not set"), + key, + value as? List<*> ?: error("$key: expected list for type $type"), + ) + as T + + type.isSubclassOf(Map::class) -> + deserializeMap( + innerTypes?.firstOrNull() ?: error("key type not set"), + innerTypes.getOrNull(1) ?: error("value type not set"), + key, + value as? Map<*, *> ?: error("$key: expected map for type $type"), + ) + as T + + else -> deserializePrimitive(key, type, value ?: error("$key: value cannot be null")) + } +} + +fun <T : Any> deserialize(type: KClass<T>, ins: InputStream?): T { + val reader = ins?.let { InputStreamReader(it) } ?: return type.createInstance() + return deserialize(type, reader) +} + +fun <T : Any> deserialize(type: KClass<T>, ins: Reader): T { + return deserializeClass(type, Yaml().load(ins)) +} diff --git a/core/src/config/util/Serialize.kt b/core/src/config/util/Serialize.kt new file mode 100644 index 0000000..1ac5f1a --- /dev/null +++ b/core/src/config/util/Serialize.kt @@ -0,0 +1,190 @@ +package cat.freya.khs.config.util + +import cat.freya.khs.config.Comment +import cat.freya.khs.config.KhsDeprecated +import cat.freya.khs.config.LocaleString1 +import cat.freya.khs.config.LocaleString2 +import cat.freya.khs.config.LocaleString3 +import cat.freya.khs.config.Omittable +import cat.freya.khs.config.Section +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.text.buildString +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml + +fun typeInline(value: Any?): Boolean { + if (value == null) return true + + return when (value) { + is List<*> -> value.all { typeInline(it) } + is Map<*, *> -> value.isEmpty() + is Boolean -> true + value::class.isData -> false + else -> true + } +} + +fun serializeSection(section: Section): String { + val width = 100 + val prefixWidth = 3 + val headerWidth = section.text.length + val slugWidth = width - prefixWidth - headerWidth + + return buildString { + appendLine() // spacing + + // top line + append("#") + append(" ".repeat(prefixWidth)) + append("┌") + append("─".repeat(headerWidth + 2)) + appendLine("┐") + + // bottom line + append("#") + append("─".repeat(prefixWidth)) + append("┘ ${section.text} └") + appendLine("─".repeat(slugWidth)) + + appendLine() // spacing + } +} + +fun serializeComment(comment: Comment): String { + return buildString { + for (line in comment.text.lines()) { + appendLine("# $line") + } + } +} + +fun serializeDeprecated(deprecated: KhsDeprecated): String { + return "Warning: This field has been DEPRECATED since ${deprecated.since}" +} + +fun <T : Any> serializeClass(instance: T): String { + val type = instance::class + require(type.isData) { "$type is not a data class" } + + val propValues = + type.primaryConstructor!! + .parameters + .map { param -> type.memberProperties.find { it.name == param.name } } + .filterNotNull() + .associateWith { prop -> prop.getter.call(instance) } + + return buildString { + for ((prop, value) in propValues) { + if (value == null && prop.annotations.contains(Omittable())) continue + + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + + // append comments + for (annotation in prop.annotations) { + when (annotation) { + is Section -> append(serializeSection(annotation)) + is Comment -> append(serializeComment(annotation)) + is KhsDeprecated -> append(serializeDeprecated(annotation)) + } + } + + // no content, then skip + if (lines.isEmpty()) continue + + // no indentation if only a single item + if (lines.size == 1 && typeInline(value)) { + appendLine("${prop.name}: ${lines[0]}") + continue + } + + appendLine("${prop.name}:") + for (line in lines) { + appendLine(" $line") + } + } + } +} + +fun <T : Any?> serializeList(list: List<T>): String { + if (list.isEmpty()) return "[]" + + if (list.size == 1 && typeInline(list)) { + val text = serialize(list[0]) + return "[$text]" + } + + return buildString { + for (value in list) { + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + for ((i, line) in lines.withIndex()) { + append(if (i == 0) "- " else " ") + appendLine(line) + } + } + } +} + +fun <K : Any?, V : Any?> serializeMap(map: Map<K, V>): String { + if (map.isEmpty()) return "{}" + + return buildString { + for ((key, value) in map) { + if (key !is String) error("Map values must be strings") + val keyString = key.toString() + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + + if (lines.isEmpty()) continue + + if (lines.size == 1 && typeInline(value)) { + appendLine("$keyString: ${lines[0]}") + continue + } + + appendLine("$keyString:") + for (line in lines) { + append(" ") + appendLine(line) + } + } + } +} + +fun <T : Any> serializePrimitive(value: T): String { + val stringYaml = + Yaml( + DumperOptions().apply { + defaultScalarStyle = DumperOptions.ScalarStyle.SINGLE_QUOTED + splitLines = false + } + ) + val yaml = Yaml() + return when { + value is String -> stringYaml.dump(value) + value is LocaleString1 -> stringYaml.dump(value.inner) + value is LocaleString2 -> stringYaml.dump(value.inner) + value is LocaleString3 -> stringYaml.dump(value.inner) + value is Int -> yaml.dump(value) + value is UInt -> yaml.dump(value.toInt()) + value is Long -> yaml.dump(value) + value is ULong -> yaml.dump(value.toLong()) + value is Boolean -> yaml.dump(value) + value is Float -> yaml.dump(value) + value is Double -> yaml.dump(value) + else -> error("cannot serialize '$value'") + }.trim() +} + +fun <T : Any> serialize(value: T?): String { + if (value == null) return "null" + + val type = value::class + return when { + type.isData -> serializeClass(value) + type.java.isEnum -> value.toString() + type.isSubclassOf(List::class) -> serializeList(value as List<*>) + type.isSubclassOf(Map::class) -> serializeMap(value as Map<*, *>) + else -> serializePrimitive(value) + } +} diff --git a/core/src/db/Database.kt b/core/src/db/Database.kt new file mode 100644 index 0000000..27864b3 --- /dev/null +++ b/core/src/db/Database.kt @@ -0,0 +1,80 @@ +package cat.freya.khs.db + +import cat.freya.khs.Khs +import java.util.UUID +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.Database as Exposed +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +class Database(plugin: Khs) { + val driver = getDriver(plugin) + val source = driver.connect() + val db = Exposed.connect(source) + + init { + transaction(db) { SchemaUtils.create(Players) } + migrateLegacy() + } + + fun getPlayer(uuid: UUID): Player? = + transaction(db) { + val id = uuid.toString() + Players.selectAll().where { Players.uuid eq id }.map { it.toPlayer() }.singleOrNull() + } + + fun getPlayer(name: String): Player? = + transaction(db) { + Players.selectAll().where { Players.name eq name }.map { it.toPlayer() }.singleOrNull() + } + + fun getPlayers(page: UInt, pageSize: UInt): List<Player> = + transaction(db) { + val offset = page * pageSize + val wins = Players.hiderWins + Players.seekerWins + Players.selectAll() + .orderBy(wins to SortOrder.DESC) + .limit(pageSize.toInt()) + .offset(offset.toLong()) + .map { it.toPlayer() } + } + + fun getPlayerNames(limit: UInt, startsWith: String): List<String> = + transaction(db) { + Players.select(Players.name) + .where { Players.name like "$startsWith%" } + .orderBy(Players.name to SortOrder.ASC) + .limit(limit.toInt()) + .map { it[Players.name] } + .filterNotNull() + } + + fun upsertPlayer(player: Player) = transaction(db) { Players.upsert { it.fromPlayer(player) } } + + fun upsertName(u: UUID, n: String) = + transaction(db) { + Players.upsert { + it[uuid] = u.toString() + it[name] = n + } + } + + fun migrateLegacy() = + transaction(db) { + if (!LegacyPlayers.exists() || !LegacyNames.exists()) return@transaction + + val legacy = + LegacyPlayers.join( + LegacyNames, + JoinType.LEFT, + onColumn = LegacyPlayers.uuid, + otherColumn = LegacyNames.uuid, + ) + .selectAll() + .map { it.toLegacyPlayer() } + Players.insertIgnore { legacy.forEach { player -> it.fromPlayer(player) } } + + SchemaUtils.drop(LegacyPlayers) + SchemaUtils.drop(LegacyNames) + } +} diff --git a/core/src/db/Driver.kt b/core/src/db/Driver.kt new file mode 100644 index 0000000..c30e5c1 --- /dev/null +++ b/core/src/db/Driver.kt @@ -0,0 +1,79 @@ +package cat.freya.khs.db + +import cat.freya.khs.Khs +import cat.freya.khs.config.DatabaseConfig +import cat.freya.khs.config.DatabaseType +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import javax.sql.DataSource + +abstract class Driver { + abstract val driverClass: String + + abstract fun jdbcUrl(): String + + abstract fun configure(hikari: HikariConfig) + + fun connect(): DataSource { + // load driver for some reason + Class.forName(driverClass) + + val cores = Runtime.getRuntime().availableProcessors() + val hikari = + HikariConfig().apply { + jdbcUrl = jdbcUrl() + driverClassName = driverClass + maximumPoolSize = minOf(cores, 8) + configure(this) + } + return HikariDataSource(hikari) + } +} + +class SqliteDriver(val path: String) : Driver() { + override val driverClass = "org.sqlite.JDBC" + + override fun jdbcUrl(): String { + return "jdbc:sqlite:$path" + } + + override fun configure(hikari: HikariConfig) { + // sqlite is single threaded + hikari.maximumPoolSize = 1 + } +} + +class MysqlDriver(val config: DatabaseConfig) : Driver() { + override val driverClass = "com.mysql.cj.jdbc.Driver" + + override fun jdbcUrl(): String { + val port = config.port ?: 3006u + return "jdbc:mysql://${config.host}:${port}/${config.database}" + } + + override fun configure(hikari: HikariConfig) { + hikari.username = config.username + hikari.password = config.password + } +} + +class PostgresDriver(val config: DatabaseConfig) : Driver() { + override val driverClass = "org.postgresql.Driver" + + override fun jdbcUrl(): String { + val port = config.port ?: 5432u + return "jdbc:postgresql://${config.host}:${port}/${config.database}" + } + + override fun configure(hikari: HikariConfig) { + hikari.username = config.username + hikari.password = config.password + } +} + +fun getDriver(plugin: Khs): Driver = + when (plugin.config.database.type) { + DatabaseType.SQLITE -> SqliteDriver(plugin.shim.sqliteDatabasePath) + DatabaseType.MYSQL -> MysqlDriver(plugin.config.database) + DatabaseType.POSTGRES -> PostgresDriver(plugin.config.database) + } diff --git a/core/src/db/Legacy.kt b/core/src/db/Legacy.kt new file mode 100644 index 0000000..7f64ef2 --- /dev/null +++ b/core/src/db/Legacy.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.db + +import java.nio.ByteBuffer +import java.util.UUID +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table + +// tables introduced in version 1.7.0 +// pre 1.7.x tables are NOT SUPPORTED + +object LegacyNames : Table("hs_names") { + val uuid = binary("uuid", 16) + val name = varchar("name", 48).nullable() + override val primaryKey = PrimaryKey(uuid, name) +} + +object LegacyPlayers : Table("hs_data") { + val uuid = binary("uuid", 16) + val hiderWins = integer("hider_wins").nullable() + val seekerWins = integer("seeker_wins").nullable() + val hiderGames = integer("hider_games").nullable() + val seekerGames = integer("seeker_games").nullable() + val hiderKills = integer("hider_kills").nullable() + val seekerKills = integer("seeker_kills").nullable() + val hiderDeaths = integer("hider_deaths").nullable() + val seekerDeaths = integer("seeker_deaths").nullable() +} + +fun ResultRow.toLegacyPlayer(): Player { + val uuidBuffer = ByteBuffer.wrap(this[LegacyPlayers.uuid]) + val uuidHigh = uuidBuffer.long + val uuidLow = uuidBuffer.long + val uuid = UUID(uuidHigh, uuidLow) + + val hiderGames = this[LegacyPlayers.hiderGames] ?: 0 + val seekerGames = this[LegacyPlayers.seekerGames] ?: 0 + val hiderWins = this[LegacyPlayers.hiderWins] ?: 0 + val seekerWins = this[LegacyPlayers.seekerWins] ?: 0 + val hiderLosses = hiderGames - hiderWins + val seekerLosses = seekerGames - seekerWins + val hiderKills = this[LegacyPlayers.hiderKills] ?: 0 + val seekerKills = this[LegacyPlayers.seekerKills] ?: 0 + val hiderDeaths = this[LegacyPlayers.hiderDeaths] ?: 0 + val seekerDeaths = this[LegacyPlayers.seekerDeaths] ?: 0 + + return Player( + uuid, + name = this[LegacyNames.name], + seekerWins = maxOf(seekerWins, 0).toUInt(), + hiderWins = maxOf(hiderWins, 0).toUInt(), + hiderLosses = maxOf(hiderLosses, 0).toUInt(), + seekerLosses = maxOf(seekerLosses, 0).toUInt(), + seekerKills = maxOf(seekerKills, 0).toUInt(), + hiderKills = maxOf(hiderKills, 0).toUInt(), + seekerDeaths = maxOf(seekerDeaths, 0).toUInt(), + hiderDeaths = maxOf(hiderDeaths, 0).toUInt(), + ) +} diff --git a/core/src/db/Player.kt b/core/src/db/Player.kt new file mode 100644 index 0000000..ccebcaa --- /dev/null +++ b/core/src/db/Player.kt @@ -0,0 +1,62 @@ +package cat.freya.khs.db + +import java.util.UUID +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder + +object Players : Table("hs_players") { + val uuid = varchar("uuid", 36) + val name = text("name").nullable() + val seekerWins = integer("seeker_wins").default(0) + val hiderWins = integer("hider_wins").default(0) + val seekerLosses = integer("seeker_losses").default(0) + val hiderLosses = integer("hider_losses").default(0) + val seekerKills = integer("seeker_kills").default(0) + val hiderKills = integer("hider_kills").default(0) + val seekerDeaths = integer("seeker_deaths").default(0) + val hiderDeaths = integer("hider_deaths").default(0) + + override val primaryKey = PrimaryKey(uuid) +} + +data class Player( + val uuid: UUID, + var name: String? = null, + var seekerWins: UInt = 0u, + var hiderWins: UInt = 0u, + var seekerLosses: UInt = 0u, + var hiderLosses: UInt = 0u, + var seekerKills: UInt = 0u, + var hiderKills: UInt = 0u, + var seekerDeaths: UInt = 0u, + var hiderDeaths: UInt = 0u, +) + +fun ResultRow.toPlayer(): Player { + return Player( + uuid = UUID.fromString(this[Players.uuid]), + name = this[Players.name], + seekerWins = this[Players.seekerWins].toUInt(), + hiderWins = this[Players.hiderWins].toUInt(), + seekerLosses = this[Players.seekerLosses].toUInt(), + hiderLosses = this[Players.hiderLosses].toUInt(), + seekerKills = this[Players.seekerKills].toUInt(), + hiderKills = this[Players.hiderKills].toUInt(), + seekerDeaths = this[Players.seekerDeaths].toUInt(), + hiderDeaths = this[Players.hiderDeaths].toUInt(), + ) +} + +fun UpdateBuilder<*>.fromPlayer(player: Player) { + this[Players.uuid] = player.uuid.toString() + this[Players.name] = player.name + this[Players.seekerWins] = player.seekerWins.toInt() + this[Players.hiderWins] = player.hiderWins.toInt() + this[Players.seekerLosses] = player.seekerLosses.toInt() + this[Players.hiderLosses] = player.hiderLosses.toInt() + this[Players.seekerKills] = player.seekerKills.toInt() + this[Players.hiderKills] = player.hiderKills.toInt() + this[Players.seekerDeaths] = player.seekerDeaths.toInt() + this[Players.hiderDeaths] = player.hiderDeaths.toInt() +} diff --git a/core/src/events/Event.kt b/core/src/events/Event.kt new file mode 100644 index 0000000..87ba012 --- /dev/null +++ b/core/src/events/Event.kt @@ -0,0 +1,9 @@ +package cat.freya.khs.event + +abstract class Event { + var cancelled: Boolean = false + + fun cancel() { + cancelled = true + } +} diff --git a/core/src/events/onBreak.kt b/core/src/events/onBreak.kt new file mode 100644 index 0000000..2f2e490 --- /dev/null +++ b/core/src/events/onBreak.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class BreakEvent(val plugin: Khs, val player: Player, val material: String) : Event() + +fun onBreak(event: BreakEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onChat.kt b/core/src/events/onChat.kt new file mode 100644 index 0000000..4660365 --- /dev/null +++ b/core/src/events/onChat.kt @@ -0,0 +1,22 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class ChatEvent(val plugin: Khs, val player: Player, val msg: String) : Event() + +fun onChat(event: ChatEvent) { + val (plugin, player, msg) = event + val game = plugin.game + + if (!game.isSpectator(player)) return + + // only allow spectators to chat + // with eachother + event.cancel() + game.spectatorPlayers.forEach { + val team = plugin.locale.game.team.spectator + val name = player.name + it.message("$team&f <$name> $msg") + } +} diff --git a/core/src/events/onClick.kt b/core/src/events/onClick.kt new file mode 100644 index 0000000..a8883bd --- /dev/null +++ b/core/src/events/onClick.kt @@ -0,0 +1,75 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.inv.* +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item +import kotlin.text.startsWith + +data class ClickEvent( + val plugin: Khs, + val player: Player, + val inventory: Inventory, + val clicked: Item, +) : Event() + +private fun onClickSpectator(event: ClickEvent) { + val (plugin, player, _, item) = event + val name = item.name ?: return + event.cancel() + + // teleport to player + if (item.similar("PLAYER_HEAD")) { + player.closeInventory() + + val clicked = plugin.shim.getPlayer(name) ?: return + player.teleport(clicked.location) + return + } + + // change page + if (item.similar("ENCHANTED_BOOK") && name.startsWith("Page ")) { + player.closeInventory() + + val page = name.substring(5).toUIntOrNull() ?: return + val inv = createTeleportMenu(plugin, page - 1u) ?: return + player.showInventory(inv) + } +} + +private fun onClickDebug(event: ClickEvent) { + val (plugin, player, _, item) = event + event.cancel() + + if (item.similar(BECOME_SEEKER)) becomeSeeker(plugin, player) + else if (item.similar(BECOME_HIDER)) becomeHider(plugin, player) + else if (item.similar(BECOME_SPECTATOR)) becomeSpectator(plugin, player) + else if (item.similar(DIE_IN_GAME)) dieInGame(plugin, player) + else if (item.similar(REVEAL_DISGUISE)) player.revealDisguise() else return + + player.closeInventory() +} + +private fun onClickBlockHunt(event: ClickEvent) { + event.cancel() + + val material = event.clicked.material + event.player.disguise(material) + event.player.closeInventory() +} + +fun onClick(event: ClickEvent) { + val (plugin, player, inv, _) = event + val game = plugin.game + + // dont allow interactions in the lobby + if (game.hasPlayer(player) && game.status == Game.Status.LOBBY) event.cancel() + + if (game.isSpectator(player)) onClickSpectator(event) + + if (inv.title == DEBUG_TITLE) onClickDebug(event) + + if (inv.title?.startsWith("Select a Block: ") == true) onClickBlockHunt(event) +} diff --git a/core/src/events/onClose.kt b/core/src/events/onClose.kt new file mode 100644 index 0000000..697eae4 --- /dev/null +++ b/core/src/events/onClose.kt @@ -0,0 +1,21 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.inv.BLOCKHUNT_TITLE_PREFIX +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import kotlin.text.startsWith + +data class CloseEvent(val plugin: Khs, val player: Player, val inventory: Inventory) : Event() + +fun onClose(event: CloseEvent) { + val (plugin, player, inv) = event + val game = plugin.game + + // only block hunt matters here + if (inv.title?.startsWith(BLOCKHUNT_TITLE_PREFIX) != true) return + + val blocks = game.map?.config?.blockHunt?.blocks ?: return + val defaultBlock = blocks.firstOrNull() ?: return + if (!player.isDisguised()) player.disguise(defaultBlock) +} diff --git a/core/src/events/onCommand.kt b/core/src/events/onCommand.kt new file mode 100644 index 0000000..fc3dddb --- /dev/null +++ b/core/src/events/onCommand.kt @@ -0,0 +1,20 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player + +data class CommandEvent(val plugin: Khs, val player: Player, val msg: String) : Event() + +fun onCommand(event: CommandEvent) { + val (plugin, player, msg) = event + val game = plugin.game + + if (!game.hasPlayer(player) || game.status == Game.Status.LOBBY) return + + val invoke = msg.split(" ").firstOrNull()?.lowercase() ?: return + if (!plugin.config.blockedCommands.any { it.lowercase() == invoke }) return + + event.cancel() + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowedTemp) +} diff --git a/core/src/events/onDamage.kt b/core/src/events/onDamage.kt new file mode 100644 index 0000000..1377922 --- /dev/null +++ b/core/src/events/onDamage.kt @@ -0,0 +1,128 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player + +data class DamageEvent( + val plugin: Khs, + val player: Player, + val attacker: Player?, + val damage: Double, +) : Event() + +/// handles when a player in the game is damaged +fun onDamage(event: DamageEvent) { + val (plugin, player, attacker, damage) = event + val game = plugin.game + + // make sure that the attacker (if exists) is allowed to damage us + if (attacker != null) { + // players must both be in the game + if ( + (game.hasPlayer(player) && !game.hasPlayer(attacker)) || + (game.hasPlayer(attacker) && !game.hasPlayer(player)) + ) { + event.cancel() + return + } + + // players cant be on the same team + if (game.sameTeam(player.uuid, attacker.uuid)) { + event.cancel() + return + } + + // cannot attack spectators + if (game.isSpectator(player) || game.isSpectator(attacker)) { + event.cancel() + return + } + + // ignore if pvp is diabled, and a hider is trying to attack a seeker + if (!plugin.config.pvp && game.isHider(attacker) && game.isSeeker(player)) { + event.cancel() + return + } + // if there is no attacker, and the player is not in game, we do not care + } else if (!game.hasPlayer(player)) { + return + // if there is no attacker, it most of been by natural causes... + // if pvp is disabled, and config doesn't allow natural causes, cancel event + } else if (!plugin.config.pvp && !plugin.config.allowNaturalCauses) { + event.cancel() + return + } + + // spectators cannot take damage + if (game.isSpectator(player)) { + event.cancel() + val world = player.world ?: return + if (player.location.y < world.minY) { + // make sure they dont try to kill them self to the void lol + game.map?.gameSpawn?.teleport(player) + } + } + + // cant take damage until seeking + if (game.status != Game.Status.SEEKING) { + event.cancel() + return + } + + // check if player dies (pvp mode) + // if not then it is fine (if so we need to handle it) + if (plugin.config.pvp && player.health - damage >= 0.5) return + + /* handle death event (player was tagged or killed in pvp) */ + event.cancel() + + // play death sound + player.playSound( + if (plugin.shim.supports(9)) "ENTITY_PLAYER_DEATH" else "ENTITY_PLAYER_HURT", + 1.0, + 1.0, + ) + + // reveal a player if their disguised + player.revealDisguise() + + // respawn player + if (plugin.config.delayedRespawn.enabled && !plugin.config.respawnAsSpectator) { + val time = plugin.config.delayedRespawn.delay + game.map?.seekerLobbySpawn?.teleport(player) + player.message(plugin.locale.prefix.default + plugin.locale.game.respawn.with(time)) + plugin.shim.scheduleEvent(time * 20UL) { + if (game.status == Game.Status.SEEKING) game.map?.gameSpawn?.teleport(player) + } + } else { + game.map?.gameSpawn?.teleport(player) + } + + // update leaderboard + game.addDeath(player.uuid) + if (attacker != null) game.addKill(attacker.uuid) + + // broadcast death and update team + if (game.isSeeker(player)) { + game.broadcast(plugin.locale.game.player.death.with(player.name)) + } else { + val msg = + if (attacker == null) { + plugin.locale.game.player.found.with(player.name) + } else { + plugin.locale.game.player.foundBy.with(player.name, attacker.name) + } + game.broadcast(msg) + + // reset player team and items + if (plugin.config.respawnAsSpectator) { + game.setTeam(player.uuid, Game.Team.SPECTATOR) + game.loadSpectator(player) + } else { + game.setTeam(player.uuid, Game.Team.SEEKER) + game.resetPlayer(player) + game.giveSeekerItems(player) + } + } +} diff --git a/core/src/events/onDeath.kt b/core/src/events/onDeath.kt new file mode 100644 index 0000000..250b55c --- /dev/null +++ b/core/src/events/onDeath.kt @@ -0,0 +1,18 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class DeathEvent(val plugin: Khs, val player: Player) : Event() + +fun onDeath(event: DeathEvent) { + val (plugin, player) = event + val game = plugin.game + + // uh, if u dead, kinda arent disguised anymore lol + player.revealDisguise() + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onDrop.kt b/core/src/events/onDrop.kt new file mode 100644 index 0000000..3abc9ae --- /dev/null +++ b/core/src/events/onDrop.kt @@ -0,0 +1,16 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +data class DropEvent(val plugin: Khs, val player: Player, val item: Item) : Event() + +fun onDrop(event: DropEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (!plugin.config.dropItems) event.cancel() +} diff --git a/core/src/events/onHunger.kt b/core/src/events/onHunger.kt new file mode 100644 index 0000000..72995fc --- /dev/null +++ b/core/src/events/onHunger.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class HungerEvent(val plugin: Khs, val player: Player) : Event() + +fun onHunger(event: HungerEvent) { + val (plugin, player) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onInteract.kt b/core/src/events/onInteract.kt new file mode 100644 index 0000000..197d684 --- /dev/null +++ b/core/src/events/onInteract.kt @@ -0,0 +1,19 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class InteractEvent(val plugin: Khs, val player: Player, val block: String) : Event() + +fun onInteract(event: InteractEvent) { + val (plugin, player, block) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (plugin.config.blockedInteracts.any { it.lowercase() == block.lowercase() }) { + // this interaction is blocked! + event.cancel() + return + } +} diff --git a/core/src/events/onJoin.kt b/core/src/events/onJoin.kt new file mode 100644 index 0000000..1089cab --- /dev/null +++ b/core/src/events/onJoin.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class JoinEvent(val plugin: Khs, val player: Player) : Event() + +fun onJoin(event: JoinEvent) { + val (plugin, player) = event + val game = plugin.game + + // save name data for user + plugin.database?.upsertName(player.uuid, player.name) + + // uhhhh + if (game.hasPlayer(player)) game.leave(player.uuid) + + if (plugin.config.autoJoin) { + game.join(player.uuid) + return + } + + val worldName = player.world?.name ?: return + if ( + (plugin.config.teleportStraysToExit && worldName == game.map?.worldName) || + ((plugin.config.teleportStraysToExit || plugin.config.mapSaveEnabled) && + worldName == game.map?.gameWorldName) + ) { + // teleport to exit if inside game world(s) + plugin.config.exit?.let { + player.teleport(it) + player.setGameMode(Player.GameMode.ADVENTURE) + } + } +} diff --git a/core/src/events/onJump.kt b/core/src/events/onJump.kt new file mode 100644 index 0000000..d765ec7 --- /dev/null +++ b/core/src/events/onJump.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class JumpEvent(val plugin: Khs, val player: Player) : Event() + +fun onJump(event: JumpEvent) { + val (plugin, player) = event + val game = plugin.game + + if (!game.isSpectator(player)) return + + if (player.allowFlight) player.flying = true +} diff --git a/core/src/events/onKick.kt b/core/src/events/onKick.kt new file mode 100644 index 0000000..69e7eaa --- /dev/null +++ b/core/src/events/onKick.kt @@ -0,0 +1,20 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class KickEvent(val plugin: Khs, val player: Player, val reason: String) : Event() + +fun onKick(event: KickEvent) { + val (plugin, player, reason) = event + + // spectators are allowed to fly + // this also can be triggered by blockhunt + if (reason.lowercase().contains("flying")) { + event.cancel() + return + } + + // handle leave + onLeave(LeaveEvent(plugin, player)) +} diff --git a/core/src/events/onLeave.kt b/core/src/events/onLeave.kt new file mode 100644 index 0000000..d568171 --- /dev/null +++ b/core/src/events/onLeave.kt @@ -0,0 +1,13 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class LeaveEvent(val plugin: Khs, val player: Player) : Event() + +fun onLeave(event: LeaveEvent) { + val (plugin, player) = event + val game = plugin.game + + if (game.hasPlayer(player)) game.leave(player.uuid) +} diff --git a/core/src/events/onMove.kt b/core/src/events/onMove.kt new file mode 100644 index 0000000..55e5fb3 --- /dev/null +++ b/core/src/events/onMove.kt @@ -0,0 +1,21 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player +import cat.freya.khs.world.Position + +data class MoveEvent(val plugin: Khs, val player: Player, val to: Position) : Event() + +fun onMove(event: MoveEvent) { + val (plugin, player, to) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + val map = game.map ?: return + if (player.location.worldName != map.gameWorldName) return + + if (player.hasPermission("hs.leavebounds")) return + + if (map.bounds()?.inBounds(to) == false) event.cancel() +} diff --git a/core/src/events/onRegen.kt b/core/src/events/onRegen.kt new file mode 100644 index 0000000..8644e2c --- /dev/null +++ b/core/src/events/onRegen.kt @@ -0,0 +1,17 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class RegenEvent(val plugin: Khs, val player: Player, val natural: Boolean) : Event() + +fun onRegen(event: RegenEvent) { + val (plugin, player, natural) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (!natural || plugin.config.regenHealth) return + + event.cancel() +} diff --git a/core/src/events/onUse.kt b/core/src/events/onUse.kt new file mode 100644 index 0000000..9e758b8 --- /dev/null +++ b/core/src/events/onUse.kt @@ -0,0 +1,76 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.inv.createTeleportMenu +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +data class UseEvent(val plugin: Khs, val player: Player, val item: Item) : Event() + +private fun onUseLobby(event: UseEvent) { + val (plugin, player, item) = event + + // handle leave + if (item.similar(plugin.config.lobby.leaveItem)) { + event.cancel() + plugin.commandGroup.handleCommand(player, listOf("leave")) + } + + // handle start + if (item.similar(plugin.config.lobby.startItem)) { + event.cancel() + plugin.commandGroup.handleCommand(player, listOf("start")) + } +} + +private fun onUseInGame(event: UseEvent) { + val (plugin, player, item) = event + + if (item.similar(plugin.config.glow.item) && plugin.config.glow.enabled) { + event.cancel() + plugin.game.glow.start() + player.inventory.remove(item) + } +} + +private fun onUseSpectator(event: UseEvent) { + val (plugin, player, item) = event + + // toggle flight + if (item.similar(plugin.config.spectatorItems.flight)) { + event.cancel() + + // toggle flying + player.allowFlight = !player.flying + player.flying = player.allowFlight + player.actionBar( + if (player.flying) plugin.locale.spectator.flyingEnabled + else plugin.locale.spectator.flyingDisabled + ) + } + + // view teleport ui + if (item.similar(plugin.config.spectatorItems.teleport)) { + event.cancel() + + val inv = createTeleportMenu(plugin, 0u) ?: return + player.showInventory(inv) + } +} + +// for a right click interaction +fun onUse(event: UseEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + when (game.status) { + Game.Status.LOBBY -> onUseLobby(event) + Game.Status.SEEKING -> onUseInGame(event) + else -> {} + } + + if (game.isSpectator(player)) onUseSpectator(event) +} diff --git a/core/src/game/Board.kt b/core/src/game/Board.kt new file mode 100644 index 0000000..90136d3 --- /dev/null +++ b/core/src/game/Board.kt @@ -0,0 +1,159 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import java.util.UUID +import kotlin.math.roundToInt + +const val DISABLED_IDENT = "KHS_DISABLED_FILTER_ME_OUT" + +interface Board { + fun setText(title: String, text: List<String>) + + fun display(uuid: UUID) + + interface Team { + var prefix: String + + // options + var canCollide: Boolean + var nameTagsVisible: Boolean + + // players + var players: Set<UUID> + } + + // seeker/hider display + fun getTeam(name: String): Board.Team +} + +fun updateTeams(plugin: Khs, board: Board) { + val hider = board.getTeam("Hider") + val seeker = board.getTeam("Seeker") + + hider.players = plugin.game.hiderUUIDs + seeker.players = plugin.game.seekerUUIDs + + hider.nameTagsVisible = plugin.config.nametagsVisible + seeker.nameTagsVisible = plugin.config.nametagsVisible + + hider.canCollide = false + seeker.canCollide = false + + hider.prefix = plugin.locale.game.team.hider + seeker.prefix = plugin.locale.game.team.seeker +} + +fun getLobbyBoard(plugin: Khs, uuid: UUID): Board? { + return plugin.shim.getBoard("lobby-$uuid") +} + +fun reloadLobbyBoard(plugin: Khs, uuid: UUID) { + val timer = plugin.game.timer + val countdown = + when { + timer != null -> plugin.boardConfig.countdown.startingIn.with(timer) + else -> plugin.boardConfig.countdown.waiting + } + val count = plugin.game.size + val seekerPercent = (plugin.game.getSeekerChance(uuid) * 100).roundToInt() + val hiderPercent = 100 - seekerPercent + val map = plugin.game.map?.name ?: "" + + val board = getLobbyBoard(plugin, uuid) ?: return + updateTeams(plugin, board) + + val title = plugin.boardConfig.lobby.title + board.setText( + title, + plugin.boardConfig.lobby.content.map { + it.replace("{COUNTDOWN}", countdown) + .replace("{COUNT}", count.toString()) + .replace("{SEEKER%}", seekerPercent.toString()) + .replace("{HIDER%}", hiderPercent.toString()) + .replace("{MAP}", map) + }, + ) + board.display(uuid) +} + +fun getGameBoard(plugin: Khs, uuid: UUID): Board? { + return plugin.shim.getBoard("game-$uuid") +} + +private fun getBorderLocale(plugin: Khs): String { + val config = plugin.game.map?.config?.worldBorder + val border = plugin.game.border + + if (config?.enabled != true || border.expired) return DISABLED_IDENT + + if (border.state == Border.State.SHRINKING) return plugin.boardConfig.border.shrinking + + val m = border.timer / 60UL + val s = border.timer % 60UL + return plugin.boardConfig.border.timer.with(m, s) +} + +private fun getTauntLocale(plugin: Khs): String { + val config = plugin.config.taunt + val taunt = plugin.game.taunt + + if (!config.enabled || taunt.expired) return DISABLED_IDENT + + if (taunt.running) return plugin.boardConfig.taunt.active + + val m = taunt.timer / 60UL + val s = taunt.timer % 60UL + return plugin.boardConfig.taunt.timer.with(m, s) +} + +private fun getGlowLocale(plugin: Khs): String { + val config = plugin.config.glow + val always = plugin.config.alwaysGlow + val glow = plugin.game.glow + + if (always || !config.enabled) return DISABLED_IDENT + + if (glow.running) return plugin.boardConfig.glow.active + else return plugin.boardConfig.glow.disabled +} + +fun reloadGameBoard(plugin: Khs, uuid: UUID) { + val timer = plugin.game.timer + + val time = plugin.boardConfig.countdown.timer.with((timer ?: 0UL) / 60UL, (timer ?: 0UL) % 60UL) + val team = + when (plugin.game.getTeam(uuid)) { + Game.Team.HIDER -> plugin.locale.game.team.hider + Game.Team.SEEKER -> plugin.locale.game.team.seeker + else -> plugin.locale.game.team.spectator + } + + // border event + val border = getBorderLocale(plugin) + val taunt = getTauntLocale(plugin) + val glow = getGlowLocale(plugin) + val numSeeker = plugin.game.seekerSize + val numHider = plugin.game.hiderSize + val map = plugin.game.map?.name ?: "" + + val board = getGameBoard(plugin, uuid) ?: return + updateTeams(plugin, board) + + val title = plugin.boardConfig.game.title + board.setText( + title, + plugin.boardConfig.game.content + .map { + it.replace("{TIME}", time) + .replace("{TEAM}", team) + .replace("{BORDER}", border) + .replace("{TAUNT}", taunt) + .replace("{GLOW}", glow) + .replace("{#SEEKER}", numSeeker.toString()) + .replace("{#HIDER}", numHider.toString()) + .replace("{MAP}", map) + } + .filter { !it.contains(DISABLED_IDENT) }, + ) + board.display(uuid) +} diff --git a/core/src/game/Border.kt b/core/src/game/Border.kt new file mode 100644 index 0000000..f535681 --- /dev/null +++ b/core/src/game/Border.kt @@ -0,0 +1,78 @@ +package cat.freya.khs.game + +import cat.freya.khs.config.WorldBorderConfig +import cat.freya.khs.world.World + +class Border(val game: Game) { + + enum class State { + WAITING, + WARNED, + SHRINKING, + } + + @Volatile var timer: ULong = 0UL + + @Volatile var state: State = State.WAITING + + @Volatile private var enabled: Boolean = false + + private val border: World.Border? + get() = game.map?.gameWorld?.border + + private val borderConfig: WorldBorderConfig? + get() = game.map?.config?.worldBorder + + val expired: Boolean + get() = border?.size?.let { it <= 100.0 } != true + + fun reset() { + enabled = false + state = State.WAITING + + val border = border ?: return + val borderConfig = borderConfig ?: return + + val x = borderConfig.pos?.x ?: return + val z = borderConfig.pos?.z ?: return + val size = borderConfig.size ?: return + + border.move(x, z, size, 0UL) + } + + fun update() { + if (borderConfig?.enabled != true) return + + if (timer != 0UL) { + timer-- + return + } + + if (state == State.WARNED) { + // start the world border movement! + var amount = borderConfig?.move ?: return + val currentSize = border?.size?.toULong() ?: return + + if (amount >= currentSize) return + + if (amount - 100UL <= currentSize) amount = 100UL + + timer = 30UL + state = State.SHRINKING + + border?.move(amount, timer) + game.broadcast(game.plugin.locale.worldBorder.shrinking) + return + } + + if (state == State.SHRINKING) { + timer = borderConfig?.delay ?: return + state = State.WAITING + return + } + + game.broadcast(game.plugin.locale.prefix.border + game.plugin.locale.worldBorder.warn) + timer = 30UL + state = State.WARNED + } +} diff --git a/core/src/game/Game.kt b/core/src/game/Game.kt new file mode 100644 index 0000000..70187e3 --- /dev/null +++ b/core/src/game/Game.kt @@ -0,0 +1,757 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.config.ConfigCountdownDisplay +import cat.freya.khs.config.ConfigLeaveType +import cat.freya.khs.config.ConfigScoringMode +import cat.freya.khs.inv.createBlockHuntPicker +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min +import kotlin.math.round +import kotlin.random.Random +import kotlin.synchronized + +class Game(val plugin: Khs) { + /// represents what state the game is in + enum class Status { + LOBBY, + HIDING, + SEEKING, + FINISHED; + + fun inProgress(): Boolean = + when (this) { + LOBBY -> false + HIDING -> true + SEEKING -> true + FINISHED -> false + } + } + + /// what team a player is on + enum class Team { + HIDER, + SEEKER, + SPECTATOR, + } + + /// why was the game stopped? + enum class WinType { + NONE, + SEEKER_WIN, + HIDER_WIN, + } + + @Volatile + /// the state the game is in + var status: Status = Status.LOBBY + private set + + @Volatile + /// timer for current game status (lobby, hiding, seeking, finished) + var timer: ULong? = null + private set + + @Volatile + /// keep track till next second + private var gameTick: UInt = 0u + private val isSecond: Boolean + get() = gameTick % 20u == 0u + + @Volatile + /// if the last event was a hider leaving the game + private var hiderLeft: Boolean = false + + @Volatile + /// the current game round + private var round: Int = 0 + + val glow: Glow = Glow(this) + val taunt: Taunt = Taunt(this) + val border: Border = Border(this) + + @Volatile + var map: KhsMap? = null + private set + + private val mappings: MutableMap<UUID, Team> = ConcurrentHashMap<UUID, Team>() + + val players: List<Player> + get() = mappings.keys.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val UUIDs: Set<UUID> + get() = mappings.keys.toSet() + + val hiderUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.HIDER }.keys + + val hiderPlayers: List<Player> + get() = hiderUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val seekerUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.SEEKER }.keys + + val seekerPlayers: List<Player> + get() = seekerUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val spectatorUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.SPECTATOR }.keys + + val spectatorPlayers: List<Player> + get() = spectatorUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val size: UInt + get() = mappings.size.toUInt() + + val hiderSize: UInt + get() = hiderUUIDs.size.toUInt() + + val seekerSize: UInt + get() = seekerUUIDs.size.toUInt() + + val spectatorsSize: UInt + get() = spectatorUUIDs.size.toUInt() + + fun hasPlayer(uuid: UUID): Boolean = mappings.containsKey(uuid) + + fun hasPlayer(player: Player): Boolean = hasPlayer(player.uuid) + + fun isHider(uuid: UUID): Boolean = mappings[uuid] == Team.HIDER + + fun isHider(player: Player): Boolean = isHider(player.uuid) + + fun isSeeker(uuid: UUID): Boolean = mappings[uuid] == Team.SEEKER + + fun isSeeker(player: Player): Boolean = isSeeker(player.uuid) + + fun isSpectator(uuid: UUID): Boolean = mappings[uuid] == Team.SPECTATOR + + fun isSpectator(player: Player): Boolean = isSpectator(player.uuid) + + fun getTeam(uuid: UUID): Team? = mappings.get(uuid) + + fun setTeam(uuid: UUID, team: Team) { + mappings[uuid] = team + } + + fun sameTeam(a: UUID, b: UUID): Boolean = mappings[a] == mappings[b] + + // what round was the uuid last picked to be seeker + private val lastPicked: MutableMap<UUID, Int> = ConcurrentHashMap<UUID, Int>() + + @Volatile + // teams at the start of the game + private var initialTeams: Map<UUID, Team> = emptyMap() + + @Volatile + // stores saved inventories + private var savedInventories: MutableMap<UUID, List<Item?>> = + ConcurrentHashMap<UUID, List<Item?>>() + + // status for this round + private var hiderKills: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var seekerKills: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var hiderDeaths: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var seekerDeaths: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + + fun doTick() { + if (map?.setup != true) return + + when (status) { + Status.LOBBY -> whileWaiting() + Status.HIDING -> whileHiding() + Status.SEEKING -> whileSeeking() + Status.FINISHED -> whileFinished() + } + + gameTick++ + } + + fun selectMap(): KhsMap? { + map = map ?: plugin.maps.values.filter { it.setup }.randomOrNull() + return map + } + + fun setMap(map: KhsMap?) { + if (status != Status.LOBBY) return + + if (map == null && size > 0u) return + + this.map = map + players.forEach { player -> joinPlayer(player) } + } + + fun getSeekerWeight(uuid: UUID): Double { + val last = lastPicked[uuid] ?: -1000 + val weight = (round - last).toDouble() + return weight + } + + fun getSeekerChance(uuid: UUID): Double { + val weights = mappings.keys.map { getSeekerWeight(it) } + val totalWeight = weights.sum() + val weight = getSeekerWeight(uuid) + if (totalWeight == 0.0) return 0.0 + return weight / totalWeight + } + + private fun randomSeeker(pool: Set<UUID>): UUID { + val weights = pool.map { uuid -> uuid to getSeekerWeight(uuid) } + + val totalWeight = weights.sumOf { it.second } + var r = Random.nextDouble() * totalWeight + + for ((uuid, weight) in weights) { + r -= weight + if (r <= 0) { + lastPicked[uuid] = round + return uuid + } + } + + return pool.random() + } + + fun start() { + start(emptySet()) + } + + fun start(requestedPool: Collection<UUID>) { + val seekers = mutableSetOf<UUID>() + val pool = if (requestedPool.isEmpty()) mappings.keys else requestedPool.toMutableSet() + + while (pool.size >= 2 && seekers.size.toUInt() < plugin.config.startingSeekerCount) { + val uuid = randomSeeker(pool) + pool.remove(uuid) + seekers.add(uuid) + } + + if (seekers.isEmpty()) // warning here? + return + + startWithSeekers(seekers) + } + + private fun startWithSeekers(seekers: Set<UUID>) { + if (status != Status.LOBBY) return + + if (plugin.config.mapSaveEnabled) map?.loader?.rollback() + + synchronized(this) { + // set teams + mappings.forEach { mappings[it.key] = Team.HIDER } + seekers.forEach { mappings[it] = Team.SEEKER } + + // reset game state + initialTeams = mappings.toMap() + hiderKills.clear() + seekerKills.clear() + hiderDeaths.clear() + seekerDeaths.clear() + + // give items + loadHiders() + loadSeekers() + + // reload sidebar + reloadGameBoards() + + glow.reset() + taunt.reset() + border.reset() + + status = Status.HIDING + timer = null + } + } + + private fun updatePlayerInfo(uuid: UUID, reason: WinType) { + val team = initialTeams.get(uuid) ?: return + val data = plugin.database?.getPlayer(uuid) ?: return + + when (reason) { + WinType.SEEKER_WIN -> { + if (team == Team.SEEKER) data.seekerWins++ + if (team == Team.HIDER) data.hiderLosses++ + } + WinType.HIDER_WIN -> { + if (team == Team.SEEKER) data.seekerLosses++ + if (team == Team.HIDER) data.hiderWins++ + } + WinType.NONE -> {} + } + + data.seekerKills += seekerKills.getOrDefault(uuid, 0u) + data.hiderKills += hiderKills.getOrDefault(uuid, 0u) + data.seekerDeaths += seekerDeaths.getOrDefault(uuid, 0u) + data.hiderDeaths += hiderDeaths.getOrDefault(uuid, 0u) + + plugin.database?.upsertPlayer(data) + } + + fun stop(reason: WinType) { + if (!status.inProgress()) return + + // update database + mappings.keys.forEach { updatePlayerInfo(it, reason) } + + round++ + status = Status.FINISHED + timer = null + + if (plugin.config.leaveOnEnd) { + mappings.keys.forEach { leave(it) } + } + } + + fun join(uuid: UUID) { + val player = plugin.shim.getPlayer(uuid) ?: return + + if (map == null) selectMap() + + if (map == null) { + player.message(plugin.locale.prefix.error + plugin.locale.map.none) + return + } + + if (status != Status.LOBBY) { + mappings[uuid] = Team.SPECTATOR + loadSpectator(player) + reloadGameBoards() + player.message(plugin.locale.prefix.default + plugin.locale.game.join) + return + } + + if (plugin.config.saveInventory) savedInventories[uuid] = player.inventory.contents + + mappings[uuid] = Team.HIDER + joinPlayer(player) + reloadLobbyBoards() + + broadcast(plugin.locale.prefix.default + plugin.locale.lobby.join.with(player.name)) + } + + fun leave(uuid: UUID) { + val player = plugin.shim.getPlayer(uuid) ?: return + + broadcast(plugin.locale.prefix.default + plugin.locale.game.leave.with(player.name)) + + mappings.remove(uuid) + resetPlayer(player) + + if (plugin.config.saveInventory) + savedInventories.get(uuid)?.let { player.inventory.contents = it } + + // reload sidebar + player.hideBoards() + if (status.inProgress()) { + reloadGameBoards() + } else { + reloadLobbyBoards() + } + + when (plugin.config.leaveType) { + ConfigLeaveType.EXIT -> plugin.config.exit?.let { player.teleport(it) } + ConfigLeaveType.PROXY -> player.sendToServer(plugin.config.leaveServer) + } + } + + fun addKill(uuid: UUID) { + val team = mappings.get(uuid) ?: return + when (team) { + Team.HIDER -> hiderKills[uuid] = hiderKills.getOrDefault(uuid, 0u) + 1u + Team.SEEKER -> seekerKills[uuid] = seekerKills.getOrDefault(uuid, 0u) + 1u + else -> {} + } + } + + fun addDeath(uuid: UUID) { + val team = mappings.get(uuid) ?: return + when (team) { + Team.HIDER -> hiderDeaths[uuid] = hiderDeaths.getOrDefault(uuid, 0u) + 1u + Team.SEEKER -> seekerDeaths[uuid] = seekerDeaths.getOrDefault(uuid, 0u) + 1u + else -> {} + } + } + + private fun reloadLobbyBoards() { + mappings.keys.forEach { reloadLobbyBoard(plugin, it) } + } + + private fun reloadGameBoards() { + mappings.keys.forEach { reloadGameBoard(plugin, it) } + } + + /// during Status.LOBBY + private fun whileWaiting() { + val countdown = plugin.config.lobby.countdown + val changeCountdown = plugin.config.lobby.changeCountdown + + synchronized(this) { + // countdown is disabled when set to at 0s + if (countdown == 0UL || size < plugin.config.lobby.min) { + timer = null + return@synchronized + } + + var time = timer ?: countdown + if (size >= changeCountdown && changeCountdown != 0u) time = min(time, 10UL) + if (isSecond && time > 0UL) time-- + timer = time + } + + if (isSecond) reloadLobbyBoards() + + if (timer == 0UL) start() + } + + /// during Status.HIDING + private fun whileHiding() { + if (!isSecond) return + + if (timer != 0UL) checkWinConditions() + + if (isSecond) reloadGameBoards() + + val time: ULong + val message: String + synchronized(this) { + time = timer ?: plugin.config.hidingLength + when (time) { + 0UL -> { + message = plugin.locale.game.start + status = Status.SEEKING + timer = null + seekerPlayers.forEach { + giveSeekerItems(it) + map?.gameSpawn?.teleport(it) + } + hiderPlayers.forEach { giveHiderItems(it) } + } + 1UL -> message = plugin.locale.game.countdown.last + else -> message = plugin.locale.game.countdown.notify.with(time) + } + + if (status == Status.HIDING) timer = if (time > 0UL) (time - 1UL) else time + } + + if (time % 5UL == 0UL || time <= 5UL) { + val prefix = plugin.locale.prefix.default + players.forEach { player -> + when (plugin.config.countdownDisplay) { + ConfigCountdownDisplay.CHAT -> player.message(prefix + message) + ConfigCountdownDisplay.ACTIONBAR -> player.actionBar(prefix + message) + ConfigCountdownDisplay.TITLE -> { + if (time != 30UL) player.title(" ", message) + } + } + } + } + } + + /// @returns distnace to closest seeker to the player + private fun distanceToSeeker(player: Player): Double { + return seekerPlayers.map { seeker -> player.location.distance(seeker.location) }.minOrNull() + ?: Double.POSITIVE_INFINITY + } + + /// plays the seeker ping for a hider + private fun playSeekerPing(hider: Player) { + val distance = distanceToSeeker(hider) + + // read config values + val distances = plugin.config.seekerPing.distances + val sounds = plugin.config.seekerPing.sounds + + when (gameTick % 10u) { + 0u -> { + if (distance < distances.level1.toDouble()) + hider.playSound(sounds.heartbeatNoise, sounds.leadingVolume, sounds.pitch) + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 3u -> { + if (distance < distances.level1.toDouble()) + hider.playSound(sounds.heartbeatNoise, sounds.volume, sounds.pitch) + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 6u -> { + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 9u -> { + if (distance < distances.level2.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + } + } + + private fun checkWinConditions() { + var stopReason: WinType? = null + + val scoreMode = plugin.config.scoringMode + val notEnoughHiders = + when (scoreMode) { + ConfigScoringMode.ALL_HIDERS_FOUND -> hiderSize == 0u + ConfigScoringMode.LAST_HIDER_WINS -> hiderSize == 1u + } + val lastHider = hiderPlayers.firstOrNull() + + val doTitle = plugin.config.gameOverTitle + val prefix = plugin.locale.prefix + + when { + // time ran out + timer == 0UL -> { + broadcast(prefix.gameOver + plugin.locale.game.gameOver.time) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.hidersWin, + plugin.locale.game.gameOver.time, + ) + stopReason = WinType.HIDER_WIN + } + // all seekers quit + seekerSize < 1u -> { + broadcast(prefix.abort + plugin.locale.game.gameOver.seekerQuit) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.noWin, + plugin.locale.game.gameOver.seekerQuit, + ) + stopReason = if (plugin.config.dontRewardQuit) WinType.NONE else WinType.HIDER_WIN + } + // hiders quit + notEnoughHiders && hiderLeft -> { + broadcast(prefix.abort + plugin.locale.game.gameOver.hiderQuit) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.noWin, + plugin.locale.game.gameOver.hiderQuit, + ) + stopReason = if (plugin.config.dontRewardQuit) WinType.NONE else WinType.SEEKER_WIN + } + // all hiders found + notEnoughHiders && lastHider == null -> { + broadcast(prefix.gameOver + plugin.locale.game.gameOver.hidersFound) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.seekersWin, + plugin.locale.game.gameOver.hidersFound, + ) + stopReason = WinType.SEEKER_WIN + } + // last hider wins (depends on scoring more) + notEnoughHiders && lastHider != null -> { + val msg = plugin.locale.game.gameOver.lastHider.with(lastHider.name) + broadcast(prefix.gameOver + msg) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.singleHiderWin.with(lastHider.name), + msg, + ) + stopReason = WinType.HIDER_WIN + } + } + + if (stopReason != null) stop(stopReason) + + hiderLeft = false + } + + /// during Status.SEEKING + private fun whileSeeking() { + if (plugin.config.seekerPing.enabled) hiderPlayers.forEach { playSeekerPing(it) } + + synchronized(this) { + var time = timer + if (time == null && plugin.config.gameLength != 0UL) time = plugin.config.gameLength + + if (isSecond) { + if (time != null && time > 0UL) time-- + + taunt.update() + glow.update() + border.update() + } + + timer = time + } + + if (isSecond) reloadGameBoards() + + // update spectator flight + // (the toggle they have only changed allowed flight) + spectatorPlayers.forEach { it.flying = it.allowFlight } + + checkWinConditions() + } + + /// during Status.FINISHED + private fun whileFinished() { + synchronized(this) { + var time = timer ?: plugin.config.endGameDelay + if (time > 0UL) time-- + + timer = time + + if (time == 0UL) { + timer = null + map = null + selectMap() + + if (map == null) { + broadcast(plugin.locale.prefix.warning + plugin.locale.map.none) + return + } + + status = Status.LOBBY + + players.forEach { joinPlayer(it) } + } + } + } + + fun broadcast(message: String) { + players.forEach { it.message(message) } + } + + fun broadcastTitle(title: String, subTitle: String) { + players.forEach { it.title(title, subTitle) } + } + + private fun loadHiders() = hiderPlayers.forEach { loadHider(it) } + + private fun loadSeekers() = seekerPlayers.forEach { loadSeeker(it) } + + private fun hidePlayer(player: Player, hidden: Boolean) { + players.forEach { other -> if (other.uuid != player.uuid) other.setHidden(player, hidden) } + } + + fun resetPlayer(player: Player) { + player.flying = false + player.allowFlight = false + player.setGameMode(Player.GameMode.ADVENTURE) + player.inventory.clear() + player.clearEffects() + player.hunger = 20u + player.heal() + player.revealDisguise() + hidePlayer(player, false) + } + + fun loadHider(hider: Player) { + map?.gameSpawn?.teleport(hider) + resetPlayer(hider) + hider.setSpeed(5u) + hider.title(plugin.locale.game.team.hider, plugin.locale.game.team.hiderSubtitle) + + // open block hunt picker + if (map?.config?.blockHunt?.enabled == true) { + val map = map ?: return + val inv = createBlockHuntPicker(plugin, map) ?: return + hider.showInventory(inv) + } + } + + fun giveHiderItems(hider: Player) { + var items = plugin.itemsConfig.hiderItems.map { plugin.shim.parseItem(it) }.filterNotNull() + var effects = + plugin.itemsConfig.hiderEffects.map { plugin.shim.parseEffect(it) }.filterNotNull() + + hider.inventory.clear() + for ((i, item) in items.withIndex()) hider.inventory.set(i.toUInt(), item) + + // glow powerup + if (!plugin.config.alwaysGlow && plugin.config.glow.enabled) { + val item = plugin.shim.parseItem(plugin.config.glow.item) + item?.let { hider.inventory.set(items.size.toUInt(), it) } + } + + plugin.itemsConfig.hiderHelmet + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.helmet = it } + plugin.itemsConfig.hiderChestplate + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.chestplate = it } + plugin.itemsConfig.hiderLeggings + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.leggings = it } + plugin.itemsConfig.hiderBoots + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.boots = it } + + hider.clearEffects() + for (effect in effects) hider.giveEffect(effect) + } + + fun loadSeeker(seeker: Player) { + map?.seekerLobbySpawn?.teleport(seeker) + resetPlayer(seeker) + seeker.title(plugin.locale.game.team.seeker, plugin.locale.game.team.seekerSubtitle) + } + + fun giveSeekerItems(seeker: Player) { + var items = plugin.itemsConfig.seekerItems.map { plugin.shim.parseItem(it) }.filterNotNull() + var effects = + plugin.itemsConfig.seekerEffects.map { plugin.shim.parseEffect(it) }.filterNotNull() + + seeker.inventory.clear() + for ((i, item) in items.withIndex()) seeker.inventory.set(i.toUInt(), item) + + plugin.itemsConfig.seekerHelmet + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.helmet = it } + plugin.itemsConfig.seekerChestplate + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.chestplate = it } + plugin.itemsConfig.seekerLeggings + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.leggings = it } + plugin.itemsConfig.seekerBoots + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.boots = it } + + seeker.clearEffects() + for (effect in effects) seeker.giveEffect(effect) + } + + fun loadSpectator(spectator: Player) { + map?.gameSpawn?.teleport(spectator) + resetPlayer(spectator) + spectator.allowFlight = true + spectator.flying = true + + plugin.config.spectatorItems.teleport + .let { plugin.shim.parseItem(it) } + ?.let { spectator.inventory.set(3u, it) } + + plugin.config.spectatorItems.flight + .let { plugin.shim.parseItem(it) } + ?.let { spectator.inventory.set(6u, it) } + + hidePlayer(spectator, true) + } + + private fun joinPlayer(player: Player) { + map?.lobbySpawn?.teleport(player) + resetPlayer(player) + + plugin.config.lobby.leaveItem + .let { plugin.shim.parseItem(it) } + ?.let { player.inventory.set(0u, it) } + + if (player.hasPermission("hs.start")) { + plugin.config.lobby.startItem + .let { plugin.shim.parseItem(it) } + ?.let { player.inventory.set(8u, it) } + } + + if (getTeam(player.uuid) != Team.SPECTATOR) + spectatorPlayers.forEach { player.setHidden(it, true) } + } +} diff --git a/core/src/game/Glow.kt b/core/src/game/Glow.kt new file mode 100644 index 0000000..a2289ba --- /dev/null +++ b/core/src/game/Glow.kt @@ -0,0 +1,48 @@ +package cat.freya.khs.game + +class Glow(val game: Game) { + + @Volatile var timer: ULong = 0UL + @Volatile var running: Boolean = true + + fun start() { + running = true + if (game.plugin.config.glow.stackable) { + timer += game.plugin.config.glow.time + } else { + timer = game.plugin.config.glow.time + } + } + + fun reset() { + running = false + timer = 0UL + } + + private fun sendPackets(glow: Boolean) { + for (hider in game.hiderPlayers) for (seeker in game.seekerPlayers) hider.setGlow( + seeker, + glow, + ) + } + + fun update() { + if (!game.plugin.config.glow.enabled) return + + if (game.plugin.config.alwaysGlow) { + sendPackets(true) + return + } + + if (!running) return + + if (timer >= 0UL) timer-- + + if (timer == 0UL) { + running = false + sendPackets(false) + } else { + sendPackets(true) + } + } +} diff --git a/core/src/game/Map.kt b/core/src/game/Map.kt new file mode 100644 index 0000000..30b7c8b --- /dev/null +++ b/core/src/game/Map.kt @@ -0,0 +1,69 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.config.MapConfig +import cat.freya.khs.world.Location +import cat.freya.khs.world.Position +import cat.freya.khs.world.World + +class KhsMap(val name: String, var config: MapConfig, var plugin: Khs) { + + var worldName: String = "null" + var gameWorldName: String = "null" + + var gameSpawn: Location? = null + var lobbySpawn: Location? = null + var seekerLobbySpawn: Location? = null + + val world: World? + get() = plugin.shim.getWorld(worldName) + + val gameWorld: World? + get() = plugin.shim.getWorld(gameWorldName) + + val loader: World.Loader + get() = plugin.shim.getWorldLoader(gameWorldName) + + data class Bounds(val minX: Double, val minZ: Double, val maxX: Double, val maxZ: Double) { + fun inBounds(x: Double, z: Double): Boolean = + (x >= minX) || (x >= minZ) || (z <= maxX) || (z <= maxZ) + + fun inBounds(pos: Position): Boolean = inBounds(pos.x, pos.y) + } + + init { + reloadConfig() + } + + fun reloadConfig() { + worldName = config.world ?: error("map '$name' has no world set!") + gameWorldName = if (plugin.config.mapSaveEnabled) "hs_$worldName" else worldName + gameSpawn = config.spawns.game?.toPosition()?.withWorld(gameWorldName) + lobbySpawn = config.spawns.lobby?.withWorld(worldName) + seekerLobbySpawn = config.spawns.seeker?.withWorld(gameWorldName) + } + + fun bounds(): Bounds? { + val minX = config.bounds.min?.x ?: return null + val minZ = config.bounds.min?.z ?: return null + val maxX = config.bounds.max?.x ?: return null + val maxZ = config.bounds.max?.z ?: return null + + return Bounds(minX, minZ, maxX, maxZ) + } + + fun hasMapSave(): Boolean { + val loader = plugin.shim.getWorldLoader(worldName) + return loader.saveDir.exists() + } + + val setup: Boolean + get() = + (gameSpawn != null) && + (lobbySpawn != null) && + (seekerLobbySpawn != null) && + (plugin.config.exit != null) && + (bounds() != null) && + (hasMapSave() || !plugin.config.mapSaveEnabled) && + (!config.blockHunt.enabled || !config.blockHunt.blocks.isEmpty()) +} diff --git a/core/src/game/MapSave.kt b/core/src/game/MapSave.kt new file mode 100644 index 0000000..bead05b --- /dev/null +++ b/core/src/game/MapSave.kt @@ -0,0 +1,126 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.world.World +import java.io.File +import kotlin.error +import kotlin.io.deleteRecursively + +private fun copyWorldFolder( + plugin: Khs, + map: KhsMap, + loader: World.Loader, + name: String, + isMca: Boolean, +): Boolean { + val dir = loader.dir + val temp = loader.tempSaveDir + + val bounds = map.bounds() ?: return false + + val region = File(dir, name) + val tempRegion = File(temp, name) + + if (!tempRegion.exists() && tempRegion.mkdirs() == false) { + plugin.shim.logger.error("could not create directory: ${tempRegion.getPath()}") + return false + } + + val files = region.list() + if (files == null) { + plugin.shim.logger.error("could not access directory: ${region.getPath()}") + return false + } + + for (fileName in files) { + val parts = fileName.split("\\.") + if (isMca && parts.size > 1) { + if ( + (parts[1].toInt() < bounds.minX / 512) || + (parts[1].toInt() > bounds.maxX / 512) || + (parts[2].toInt() < bounds.minZ / 512) || + (parts[2].toInt() > bounds.maxZ / 512) + ) + continue + } + + val srcFile = File(region, fileName) + if (srcFile.isDirectory()) { + copyWorldFolder(plugin, map, loader, name + File.separator + fileName, false) + } else { + val destFile = File(tempRegion, fileName) + srcFile.copyTo(destFile, overwrite = true) + } + } + + return true +} + +private fun copyWorldFile(loader: World.Loader, name: String) { + val dir = loader.dir + val temp = loader.tempSaveDir + + val srcFile = File(dir, name) + val destFile = File(temp, name) + + srcFile.copyTo(destFile, overwrite = true) +} + +fun mapSave(plugin: Khs, map: KhsMap): Result<Unit> = + runCatching { + plugin.shim.logger.info("starting map save for: ${map.worldName}") + plugin.saving = true + + plugin.shim.broadcast(plugin.locale.prefix.default + plugin.locale.map.save.start) + plugin.shim.broadcast(plugin.locale.prefix.warning + plugin.locale.map.save.warning) + + if (plugin.config.mapSaveEnabled == false) error("map saves are disabled!") + + val loader = plugin.shim.getWorldLoader(map.worldName) + val mapSaveLoader = plugin.shim.getWorldLoader(map.gameWorldName) + val dir = loader.dir + + if (!dir.exists()) { + plugin.shim.broadcast( + plugin.locale.prefix.error + plugin.locale.map.save.failedLocate + ) + error("there is no map to save") + } + + mapSaveLoader.unload() + + copyWorldFolder(plugin, map, loader, "region", true) + copyWorldFolder(plugin, map, loader, "entities", true) + copyWorldFolder(plugin, map, loader, "datapacks", false) + copyWorldFolder(plugin, map, loader, "data", false) + copyWorldFile(loader, "level.dat") + + val dest = mapSaveLoader.dir + if (dest.exists() && !dest.deleteRecursively()) { + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failedDir.with(dest.toPath()) + ) + error("could not delete destination directory") + } + + val tempDest = loader.tempSaveDir + if (!tempDest.renameTo(dest)) { + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failedDir.with(tempDest.toPath()) + ) + error("could not rename: ${tempDest.toPath()}") + } + } + .onSuccess { + plugin.saving = false + plugin.shim.broadcast(plugin.locale.prefix.default + plugin.locale.map.save.finished) + } + .onFailure { + plugin.saving = false + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failed.with(it.message ?: "unknown error") + ) + } diff --git a/core/src/game/Taunt.kt b/core/src/game/Taunt.kt new file mode 100644 index 0000000..3a7d707 --- /dev/null +++ b/core/src/game/Taunt.kt @@ -0,0 +1,55 @@ +package cat.freya.khs.game + +import java.util.UUID + +class Taunt(val game: Game) { + + @Volatile var timer: ULong = 0UL + @Volatile var running: Boolean = true + @Volatile var taunted: UUID? = null + @Volatile var last: UUID? = null + + val expired: Boolean + get() = game.hiderSize <= 1UL + + fun reset() { + running = false + timer = game.plugin.config.taunt.delay + last = taunted + taunted = null + } + + fun update() { + if (!game.plugin.config.taunt.enabled || expired) return + + if (timer != 0UL) { + timer-- + return + } + + // running means we are to taunt! + if (running) { + // if player left, well, damn + if (taunted?.let { game.hasPlayer(it) } != true) { + reset() + return + } + + val player = taunted?.let { game.plugin.shim.getPlayer(it) } + player?.taunt() + + game.broadcast(game.plugin.locale.prefix.taunt + game.plugin.locale.taunt.activate) + reset() + return + } + + // select a hider to taunt + val hider = game.hiderPlayers.filter { it.uuid != last }.randomOrNull() ?: return + + game.broadcast(game.plugin.locale.prefix.taunt + game.plugin.locale.taunt.warning) + hider.message(game.plugin.locale.taunt.chosen) + timer = 30UL + running = true + taunted = hider.uuid + } +} diff --git a/core/src/inv/BlockHunt.kt b/core/src/inv/BlockHunt.kt new file mode 100644 index 0000000..615c746 --- /dev/null +++ b/core/src/inv/BlockHunt.kt @@ -0,0 +1,26 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Inventory + +const val BLOCKHUNT_TITLE_PREFIX = "Select a Block: " + +fun createBlockHuntPicker(plugin: Khs, map: KhsMap): Inventory? { + val blocks = map.config.blockHunt.blocks + + // make inv + val rows = (blocks.size.toUInt() + 8u) / 9u + val size = minOf(rows * 9u, 9u) + val inv = plugin.shim.createInventory("$BLOCKHUNT_TITLE_PREFIX${map.name}", size) ?: return null + + // add items + blocks + .map { plugin.shim.parseItem(ItemConfig(material = it)) } + .filterNotNull() + .withIndex() + .forEach { (i, item) -> inv.set(i.toUInt(), item) } + + return inv +} diff --git a/core/src/inv/Debug.kt b/core/src/inv/Debug.kt new file mode 100644 index 0000000..5e9d9d4 --- /dev/null +++ b/core/src/inv/Debug.kt @@ -0,0 +1,49 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Game +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player + +const val DEBUG_TITLE = "Teleport" +val BECOME_HIDER = ItemConfig("&6Become a &lHider", "LEATHER_CHESTPLATE") +val BECOME_SEEKER = ItemConfig("&cBecome a &lSEEKER", "GOLDEN_CHESTPLATE") +val BECOME_SPECTATOR = ItemConfig("&8Become a &lSPECTATOR", "IRON_CHESTPLATE") +val DIE_IN_GAME = ItemConfig("&cDie in game", "SKELETON_SKULL") +val REVEAL_DISGUISE = ItemConfig("&cReveal disguise", "BARRIER") + +fun becomeHider(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.HIDER) + plugin.game.loadHider(player) + if (plugin.game.status == Game.Status.SEEKING) plugin.game.giveHiderItems(player) +} + +fun becomeSeeker(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.SEEKER) + plugin.game.loadSeeker(player) + if (plugin.game.status == Game.Status.SEEKING) plugin.game.giveSeekerItems(player) +} + +fun becomeSpectator(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.SPECTATOR) + plugin.game.loadSpectator(player) +} + +fun dieInGame(plugin: Khs, player: Player) { + val team = plugin.game.getTeam(player.uuid) + if (team == null || team == Game.Team.SPECTATOR) return + if (plugin.game.status != Game.Status.SEEKING) return + player.health = 0.1 +} + +fun createDebugMenu(plugin: Khs): Inventory? { + val inv = plugin.shim.createInventory(DEBUG_TITLE, 9u) ?: return null + val items = listOf(BECOME_HIDER, BECOME_SEEKER, BECOME_SPECTATOR, DIE_IN_GAME, REVEAL_DISGUISE) + items + .map { plugin.shim.parseItem(it) } + .filterNotNull() + .withIndex() + .forEach { (i, item) -> inv.set(i.toUInt(), item) } + return inv +} diff --git a/core/src/inv/Teleport.kt b/core/src/inv/Teleport.kt new file mode 100644 index 0000000..2e5ddbb --- /dev/null +++ b/core/src/inv/Teleport.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Game +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +const val TELEPORT_TITLE = "Teleport to players" + +fun createPageItem(plugin: Khs, page: UInt): Item? { + val config = ItemConfig("Page ${page + 1u}", "ENCHANTED_BOOK") + return plugin.shim.parseItem(config) +} + +fun createPlayerItem(plugin: Khs, player: Player): Item? { + val team = plugin.game.getTeam(player.uuid) ?: return null + val teamName = + when (team) { + Game.Team.HIDER -> plugin.locale.game.team.hider + Game.Team.SEEKER -> plugin.locale.game.team.seeker + else -> "" + } + val config = + ItemConfig( + name = player.name, + material = "PLAYER_HEAD", + owner = player.name, + lore = listOf(teamName), + ) + return plugin.shim.parseItem(config) +} + +fun createTeleportMenu(plugin: Khs, page: UInt): Inventory? { + val pageSize = 7u + val offset = pageSize * page + + // make items + val players = (plugin.game.seekerPlayers + plugin.game.hiderPlayers) + val items = + players.drop(offset.toInt()).take(pageSize.toInt()).mapNotNull { + createPlayerItem(plugin, it) + } + val prev = if (page > 0u) createPageItem(plugin, page - 1u) else null + val next = + if (players.size.toUInt() > offset + pageSize) createPageItem(plugin, page + 1u) else null + + // create inv + val inv = plugin.shim.createInventory(TELEPORT_TITLE, 9u) ?: return null + for ((i, item) in items.withIndex()) { + inv.set(i.toUInt() + 1u, item) + } + if (prev != null) inv.set(0u, prev) + if (next != null) inv.set(8u, next) + + return inv +} diff --git a/core/src/player/Inventory.kt b/core/src/player/Inventory.kt new file mode 100644 index 0000000..a055e04 --- /dev/null +++ b/core/src/player/Inventory.kt @@ -0,0 +1,30 @@ +package cat.freya.khs.player + +import cat.freya.khs.world.Item + +// Inventory wrapper +interface Inventory { + val title: String? + + // update inventory items + fun get(index: UInt): Item? + + fun set(index: UInt, item: Item) + + fun remove(item: Item) + + // view into entire inventory + var contents: List<Item?> + + // removes all items + fun clear() +} + +// Player inventory wrapper +interface PlayerInventory : Inventory { + // update armor + var helmet: Item? + var chestplate: Item? + var leggings: Item? + var boots: Item? +} diff --git a/core/src/player/Player.kt b/core/src/player/Player.kt new file mode 100644 index 0000000..799bbff --- /dev/null +++ b/core/src/player/Player.kt @@ -0,0 +1,85 @@ +package cat.freya.khs.player + +import cat.freya.khs.world.Effect +import cat.freya.khs.world.Location +import cat.freya.khs.world.Position +import cat.freya.khs.world.World +import java.util.UUID + +// Player wrapper +interface Player { + // Metadata + val uuid: UUID + val name: String + + // Position + val location: Location + val world: World? + + // Stats + var health: Double + var hunger: UInt + + fun heal() + + // Flight + var allowFlight: Boolean + var flying: Boolean + + // Movement + fun teleport(position: Position) + + fun teleport(location: Location) + + fun sendToServer(server: String) + + // Inventory + val inventory: PlayerInventory + + fun showInventory(inv: Inventory) + + fun closeInventory() + + // Potions + fun clearEffects() + + fun giveEffect(effect: Effect) + + fun setSpeed(amplifier: UInt) + + fun setGlow(target: Player, glow: Boolean) + + fun setHidden(target: Player, hidden: Boolean) + + // Messaging + fun message(message: String) + + fun actionBar(message: String) + + fun title(title: String, subTitle: String) + + fun playSound(sound: String, volume: Double, pitch: Double) + + // Block Hunt + fun isDisguised(): Boolean + + fun disguise(material: String) + + fun revealDisguise() + + enum class GameMode { + CREATIVE, + SURVIVAL, + ADVENTURE, + SPECTATOR, + } + + // Other + fun hasPermission(permission: String): Boolean + + fun setGameMode(gameMode: GameMode) + + fun hideBoards() + + fun taunt() +} diff --git a/core/src/world/Item.kt b/core/src/world/Item.kt new file mode 100644 index 0000000..d407c1a --- /dev/null +++ b/core/src/world/Item.kt @@ -0,0 +1,23 @@ +package cat.freya.khs.world + +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig + +interface Item { + val name: String? + val material: String + val config: ItemConfig + + fun clone(): Item + + fun similar(config: ItemConfig): Boolean + + fun similar(material: String): Boolean +} + +interface Effect { + val name: String? + val config: EffectConfig + + fun clone(): Effect +} diff --git a/core/src/world/Location.kt b/core/src/world/Location.kt new file mode 100644 index 0000000..384c859 --- /dev/null +++ b/core/src/world/Location.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.world + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class Location( + var x: Double = 0.0, + var y: Double = 0.0, + var z: Double = 0.0, + var worldName: String = "world", +) { + /// Returns the position from this location + var position: Position + get() = Position(this.x, this.y, this.z) + set(new: Position) { + this.x = new.x + this.y = new.y + this.z = new.z + } + + /// Returns the world associated with this location + fun getWorld(khs: Khs): World? { + return khs.shim.getWorld(this.worldName) + } + + fun distance(other: Location): Double { + if (this.worldName != other.worldName) return Double.POSITIVE_INFINITY + + return position.distance(other.position) + } + + fun teleport(player: Player) { + player.teleport(this) + } +} diff --git a/core/src/world/Position.kt b/core/src/world/Position.kt new file mode 100644 index 0000000..daea3ab --- /dev/null +++ b/core/src/world/Position.kt @@ -0,0 +1,54 @@ +package cat.freya.khs.world + +import cat.freya.khs.config.LegacyPosition +import cat.freya.khs.player.Player +import kotlin.math.pow +import kotlin.math.sqrt + +data class Position(var x: Double = 0.0, var y: Double = 0.0, var z: Double = 0.0) { + + /// Create a new position of self + offset + fun move(offset: Position): Position { + return Position(this.x + offset.x, this.y + offset.y, this.z + offset.z) + } + + /// Translate self by offset + fun moveSelf(offset: Position) { + this.x += offset.x + this.y += offset.y + this.z += offset.z + } + + /// Create a new position of self.x + offset + fun moveX(offset: Double): Position { + return this.move(Position(offset, 0.0, 0.0)) + } + + /// Create a new position of self.y + offset + fun moveY(offset: Double): Position { + return this.move(Position(0.0, offset, 0.0)) + } + + /// Create a new position of self.z + offset + fun moveZ(offset: Double): Position { + return this.move(Position(0.0, 0.0, offset)) + } + + fun distance(other: Position): Double { + val dx = this.x - other.x + val dy = this.y - other.y + val dz = this.z - other.z + val distanceSquared = dx.pow(2) + dy.pow(2) + dz.pow(2) + return sqrt(distanceSquared) + } + + fun teleport(player: Player) { + player.teleport(this) + } + + fun withWorld(worldName: String): Location { + return Location(this.x, this.y, this.z, worldName) + } + + fun toLegacy(): LegacyPosition = LegacyPosition(x, y, z, null) +} diff --git a/core/src/world/World.kt b/core/src/world/World.kt new file mode 100644 index 0000000..4ab575f --- /dev/null +++ b/core/src/world/World.kt @@ -0,0 +1,60 @@ +package cat.freya.khs.world + +import java.io.File + +interface World { + /// The name of the minecraft world + val name: String + val type: Type + + enum class Type { + NORMAL, + FLAT, + NETHER, + END, + UNKNOWN, + } + + // The extent of the height + val minY: Int + val maxY: Int + + val spawn: Position + + /// Wrapper for world border values + interface Border { + val x: Double + val z: Double + val size: Double + + fun move(newX: Double, newZ: Double, newSize: ULong, delay: ULong) + + fun move(newSize: ULong, delay: ULong) + } + + // World border + val border: Border + + interface Loader { + val name: String + val world: World? + + // Returns the world folder + val dir: File + + // Returns the map save folder + val saveDir: File + + // Returns the temp map save folder + val tempSaveDir: File + + fun load() + + fun unload() + + fun rollback() + } + + // Returns the world loader + val loader: Loader +} |