mirror of
https://github.com/GayPizzaSpecifications/foundation.git
synced 2025-09-23 19:21:32 +00:00
Initial renaming pass.
This commit is contained in:
@ -0,0 +1,28 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.OfflinePlayer
|
||||
import org.bukkit.Server
|
||||
import org.bukkit.Statistic
|
||||
import org.bukkit.entity.EntityType
|
||||
|
||||
val Server.allPlayers: List<OfflinePlayer>
|
||||
get() = listOf(onlinePlayers, offlinePlayers.filter { !isPlayerOnline(it) }.toList()).flatten()
|
||||
|
||||
fun Server.isPlayerOnline(player: OfflinePlayer) =
|
||||
onlinePlayers.any { onlinePlayer -> onlinePlayer.name == player.name }
|
||||
|
||||
fun Server.allPlayerStatisticsOf(
|
||||
statistic: Statistic,
|
||||
material: Material? = null,
|
||||
entityType: EntityType? = null,
|
||||
order: SortOrder = SortOrder.Ascending
|
||||
) = allPlayers.map { player ->
|
||||
player to if (material != null) {
|
||||
player.getStatistic(statistic, material)
|
||||
} else if (entityType != null) {
|
||||
player.getStatistic(statistic, entityType)
|
||||
} else {
|
||||
player.getStatistic(statistic)
|
||||
}
|
||||
}.sortedBy(order) { it.second }
|
@ -0,0 +1,8 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
fun <T, R : Comparable<R>> Collection<T>.sortedBy(order: SortOrder, selector: (T) -> R?): List<T> =
|
||||
if (order == SortOrder.Ascending) {
|
||||
sortedBy(selector)
|
||||
} else {
|
||||
sortedByDescending(selector)
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.FoundationPlugin
|
||||
import gay.pizza.foundation.core.features.backup.BackupFeature
|
||||
import gay.pizza.foundation.core.features.dev.DevFeature
|
||||
import gay.pizza.foundation.core.features.gameplay.GameplayFeature
|
||||
import gay.pizza.foundation.core.features.persist.PersistenceFeature
|
||||
import gay.pizza.foundation.core.features.player.PlayerFeature
|
||||
import gay.pizza.foundation.core.features.scheduler.SchedulerFeature
|
||||
import gay.pizza.foundation.core.features.stats.StatsFeature
|
||||
import gay.pizza.foundation.core.features.update.UpdateFeature
|
||||
import gay.pizza.foundation.core.features.world.WorldFeature
|
||||
import org.koin.dsl.module
|
||||
import java.nio.file.Path
|
||||
|
||||
class FoundationCorePlugin : FoundationPlugin() {
|
||||
private lateinit var _pluginDataPath: Path
|
||||
|
||||
var pluginDataPath: Path
|
||||
/**
|
||||
* Data path of the core plugin.
|
||||
* Can be used as a sanity check of sorts for dependencies to be sure the plugin is loaded.
|
||||
*/
|
||||
get() {
|
||||
if (!::_pluginDataPath.isInitialized) {
|
||||
throw Exception("FoundationCore is not loaded!")
|
||||
}
|
||||
return _pluginDataPath
|
||||
}
|
||||
private set(value) {
|
||||
_pluginDataPath = value
|
||||
}
|
||||
|
||||
override fun onEnable() {
|
||||
// Create core plugin directory.
|
||||
pluginDataPath = dataFolder.toPath()
|
||||
pluginDataPath.toFile().mkdir()
|
||||
|
||||
super.onEnable()
|
||||
}
|
||||
|
||||
override fun createFeatures() = listOf(
|
||||
SchedulerFeature(),
|
||||
PersistenceFeature(),
|
||||
BackupFeature(),
|
||||
DevFeature(),
|
||||
GameplayFeature(),
|
||||
PlayerFeature(),
|
||||
StatsFeature(),
|
||||
UpdateFeature(),
|
||||
WorldFeature(),
|
||||
)
|
||||
|
||||
override fun createModule() = module {
|
||||
single { this@FoundationCorePlugin }
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
enum class SortOrder {
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
import net.kyori.adventure.text.format.TextColor
|
||||
|
||||
object TextColors {
|
||||
val AMARANTH_PINK = TextColor.fromHexString("#F7A8B8")!!
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
package gay.pizza.foundation.core
|
||||
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.format.TextColor
|
||||
import org.slf4j.Logger
|
||||
import java.nio.file.Path
|
||||
|
||||
object Util {
|
||||
private val leftBracket: Component = Component.text('[')
|
||||
private val rightBracket: Component = Component.text(']')
|
||||
private val whitespace: Component = Component.text(' ')
|
||||
private val foundationName: Component = Component.text("Foundation")
|
||||
|
||||
fun formatSystemMessage(message: String): Component {
|
||||
return formatSystemMessage(TextColors.AMARANTH_PINK, message)
|
||||
}
|
||||
|
||||
fun formatSystemMessage(prefixColor: TextColor, message: String): Component {
|
||||
return leftBracket
|
||||
.append(foundationName.color(prefixColor))
|
||||
.append(rightBracket)
|
||||
.append(whitespace)
|
||||
.append(Component.text(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the default configuration from the resource [resourceName] into the directory [targetPath].
|
||||
* @param targetPath The output directory as a path, it must exist before calling this.
|
||||
* @param resourceName Path to resource, it should be in the root of the `resources` directory,
|
||||
* without the leading slash.
|
||||
*/
|
||||
inline fun <reified T> copyDefaultConfig(log: Logger, targetPath: Path, resourceName: String): Path {
|
||||
if (resourceName.startsWith("/")) {
|
||||
throw IllegalArgumentException("resourceName starts with slash")
|
||||
}
|
||||
|
||||
if (!targetPath.toFile().exists()) {
|
||||
throw Exception("Configuration output path does not exist!")
|
||||
}
|
||||
val outPath = targetPath.resolve(resourceName)
|
||||
val outFile = outPath.toFile()
|
||||
if (outFile.exists()) {
|
||||
log.debug("Configuration file already exists.")
|
||||
return outPath
|
||||
}
|
||||
|
||||
val resourceStream = T::class.java.getResourceAsStream("/$resourceName")
|
||||
?: throw Exception("Configuration resource does not exist!")
|
||||
val outputStream = outFile.outputStream()
|
||||
|
||||
resourceStream.use {
|
||||
outputStream.use {
|
||||
log.info("Copied default configuration to $outPath")
|
||||
resourceStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return outPath
|
||||
}
|
||||
|
||||
fun isPlatformWindows(): Boolean {
|
||||
val os = System.getProperty("os.name")
|
||||
return os != null && os.lowercase().startsWith("windows")
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package gay.pizza.foundation.core.abstraction
|
||||
|
||||
interface CoreFeature {
|
||||
fun enable()
|
||||
fun disable()
|
||||
fun module() = org.koin.dsl.module {}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package gay.pizza.foundation.core.abstraction
|
||||
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.TabCompleter
|
||||
import org.bukkit.event.Listener
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.dsl.module
|
||||
import org.quartz.Scheduler
|
||||
|
||||
abstract class Feature : CoreFeature, KoinComponent, Listener {
|
||||
protected val plugin by inject<FoundationCorePlugin>()
|
||||
protected val scheduler by inject<Scheduler>()
|
||||
|
||||
override fun enable() {}
|
||||
override fun disable() {}
|
||||
override fun module() = module {}
|
||||
|
||||
protected fun registerCommandExecutor(name: String, executor: CommandExecutor) {
|
||||
registerCommandExecutor(listOf(name), executor)
|
||||
}
|
||||
|
||||
protected fun registerCommandExecutor(names: List<String>, executor: CommandExecutor) {
|
||||
for (name in names) {
|
||||
val command = plugin.getCommand(name) ?: throw Exception("Failed to get $name command")
|
||||
command.setExecutor(executor)
|
||||
if (executor is TabCompleter) {
|
||||
command.tabCompleter = executor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
package gay.pizza.foundation.core.abstraction
|
||||
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.koin.core.KoinApplication
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
abstract class FoundationPlugin : JavaPlugin() {
|
||||
private lateinit var pluginModule: Module
|
||||
private lateinit var pluginApplication: KoinApplication
|
||||
private lateinit var features: List<CoreFeature>
|
||||
private lateinit var module: Module
|
||||
|
||||
override fun onEnable() {
|
||||
pluginModule = module {
|
||||
single { this@FoundationPlugin }
|
||||
single { server }
|
||||
single { config }
|
||||
single { slF4JLogger }
|
||||
}
|
||||
|
||||
features = createFeatures()
|
||||
module = createModule()
|
||||
|
||||
// TODO: If we have another plugin using Koin, we may need to use context isolation and ensure
|
||||
// it uses the same context so they can fetch stuff from us.
|
||||
// https://insert-koin.io/docs/reference/koin-core/context-isolation
|
||||
pluginApplication = startKoin {
|
||||
modules(pluginModule)
|
||||
modules(module)
|
||||
}
|
||||
|
||||
// This is probably a bit of a hack.
|
||||
pluginApplication.modules(module {
|
||||
single { pluginApplication }
|
||||
})
|
||||
|
||||
features.forEach {
|
||||
pluginApplication.modules(it.module())
|
||||
}
|
||||
|
||||
features.forEach {
|
||||
try {
|
||||
slF4JLogger.info("Enabling feature: ${it.javaClass.simpleName}")
|
||||
it.enable()
|
||||
// TODO: May replace this check with a method in the interface, CoreFeature would no-op.
|
||||
if (it is Feature) {
|
||||
server.pluginManager.registerEvents(it, this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
slF4JLogger.error("Failed to enable feature: ${it.javaClass.simpleName}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
features.forEach {
|
||||
it.disable()
|
||||
}
|
||||
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
protected open fun createModule() = module {}
|
||||
protected abstract fun createFeatures(): List<CoreFeature>
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
package gay.pizza.foundation.core.features.backup
|
||||
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import gay.pizza.foundation.core.Util
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.format.TextColor
|
||||
import org.bukkit.Server
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import software.amazon.awssdk.services.s3.S3Client
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
// TODO: Clean up dependency injection.
|
||||
class BackupCommand(
|
||||
private val plugin: FoundationCorePlugin,
|
||||
private val backupsPath: Path,
|
||||
private val config: BackupConfig,
|
||||
private val s3Client: S3Client,
|
||||
) : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender, command: Command, label: String, args: Array<String>
|
||||
): Boolean {
|
||||
if (RUNNING.get()) {
|
||||
sender.sendMessage(
|
||||
Component
|
||||
.text("Backup is already running.")
|
||||
.color(TextColor.fromHexString("#FF0000"))
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
val server = sender.server
|
||||
server.scheduler.runTaskAsynchronously(plugin) { ->
|
||||
runBackup(server, sender)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO: Pull backup creation code into a separate service.
|
||||
private fun runBackup(server: Server, sender: CommandSender? = null) = try {
|
||||
RUNNING.set(true)
|
||||
|
||||
server.scheduler.runTask(plugin) { ->
|
||||
server.sendMessage(Util.formatSystemMessage("Backup started."))
|
||||
}
|
||||
|
||||
val backupTime = Instant.now()
|
||||
val backupIdentifier = if (Util.isPlatformWindows()) {
|
||||
backupTime.toEpochMilli().toString()
|
||||
} else {
|
||||
backupTime.toString()
|
||||
}
|
||||
val backupFileName = String.format("backup-%s.zip", backupIdentifier)
|
||||
val backupPath = backupsPath.resolve(backupFileName)
|
||||
val backupFile = backupPath.toFile()
|
||||
|
||||
FileOutputStream(backupFile).use { zipFileStream ->
|
||||
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
|
||||
backupPlugins(server, zipStream)
|
||||
backupWorlds(server, zipStream)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Pull upload code out into a separate service.
|
||||
if (config.s3.accessKeyId.isNotEmpty()) {
|
||||
s3Client.putObject(
|
||||
PutObjectRequest.builder().apply {
|
||||
bucket(config.s3.bucket)
|
||||
key("${config.s3.baseDirectory}/$backupFileName")
|
||||
}.build(),
|
||||
backupPath
|
||||
)
|
||||
}
|
||||
Unit
|
||||
} catch (e: Exception) {
|
||||
if (sender != null) {
|
||||
server.scheduler.runTask(plugin) { ->
|
||||
sender.sendMessage(String.format("Failed to backup: %s", e.message))
|
||||
}
|
||||
}
|
||||
plugin.slF4JLogger.warn("Failed to backup.", e)
|
||||
} finally {
|
||||
RUNNING.set(false)
|
||||
server.scheduler.runTask(plugin) { ->
|
||||
server.sendMessage(Util.formatSystemMessage("Backup finished."))
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupPlugins(server: Server, zipStream: ZipOutputStream) {
|
||||
try {
|
||||
addDirectoryToZip(zipStream, server.pluginsFolder.toPath())
|
||||
} catch (e: IOException) {
|
||||
// TODO: Add error handling.
|
||||
plugin.slF4JLogger.warn("Failed to backup plugins.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupWorlds(server: Server, zipStream: ZipOutputStream) {
|
||||
val worlds = server.worlds
|
||||
for (world in worlds) {
|
||||
val worldPath = world.worldFolder.toPath()
|
||||
|
||||
// Save the world, must be run on the main thread.
|
||||
server.scheduler.runTask(plugin, Runnable {
|
||||
world.save()
|
||||
})
|
||||
|
||||
// Disable auto saving to prevent any world corruption while creating a ZIP.
|
||||
world.isAutoSave = false
|
||||
try {
|
||||
addDirectoryToZip(zipStream, worldPath)
|
||||
} catch (e: IOException) {
|
||||
// TODO: Add error handling.
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// Re-enable auto saving for this world.
|
||||
world.isAutoSave = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDirectoryToZip(zipStream: ZipOutputStream, directoryPath: Path) {
|
||||
val matchers = config.ignore.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||
val paths = Files.walk(directoryPath)
|
||||
.filter { path: Path -> Files.isRegularFile(path) }
|
||||
.filter { path -> !matchers.any { it.matches(Paths.get(path.normalize().toString())) } }
|
||||
.toList()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
val backupsPath = backupsPath.toRealPath()
|
||||
|
||||
for (path in paths) {
|
||||
val realPath = path.toRealPath()
|
||||
|
||||
if (realPath.startsWith(backupsPath)) {
|
||||
plugin.slF4JLogger.info("Skipping file for backup: {}", realPath)
|
||||
continue
|
||||
}
|
||||
|
||||
FileInputStream(path.toFile()).use { fileStream ->
|
||||
val entry = ZipEntry(path.toString())
|
||||
zipStream.putNextEntry(entry)
|
||||
|
||||
var n: Int
|
||||
while (fileStream.read(buffer).also { n = it } > -1) {
|
||||
zipStream.write(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val RUNNING = AtomicBoolean()
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package gay.pizza.foundation.core.features.backup
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BackupConfig(
|
||||
val schedule: ScheduleConfig = ScheduleConfig(),
|
||||
val ignore: List<String> = listOf(
|
||||
"plugins/dynmap/web/**"
|
||||
),
|
||||
val s3: S3Config = S3Config(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScheduleConfig(
|
||||
val cron: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class S3Config(
|
||||
val accessKeyId: String = "",
|
||||
val secretAccessKey: String = "",
|
||||
val region: String = "",
|
||||
val endpointOverride: String = "",
|
||||
val bucket: String = "",
|
||||
val baseDirectory: String = "",
|
||||
)
|
@ -0,0 +1,84 @@
|
||||
package gay.pizza.foundation.core.features.backup
|
||||
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import gay.pizza.foundation.core.Util
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
import gay.pizza.foundation.core.features.scheduler.cancel
|
||||
import gay.pizza.foundation.core.features.scheduler.cron
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.dsl.module
|
||||
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||
import software.amazon.awssdk.regions.Region
|
||||
import software.amazon.awssdk.services.s3.S3Client
|
||||
import java.net.URI
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
class BackupFeature : Feature() {
|
||||
private val s3Client by inject<S3Client>()
|
||||
private val config by inject<BackupConfig>()
|
||||
private lateinit var scheduleId: String
|
||||
|
||||
override fun enable() {
|
||||
// Create backup directory.
|
||||
val backupPath = plugin.pluginDataPath.resolve(BACKUPS_DIRECTORY)
|
||||
backupPath.toFile().mkdir()
|
||||
|
||||
registerCommandExecutor("fbackup", BackupCommand(plugin, backupPath, config, s3Client))
|
||||
|
||||
if (config.schedule.cron.isNotEmpty()) {
|
||||
// Assume user never wants to modify second. I'm not sure why this is enforced in Quartz.
|
||||
val expr = "0 ${config.schedule.cron}"
|
||||
scheduleId = scheduler.cron(expr) {
|
||||
plugin.server.scheduler.runTask(plugin) { ->
|
||||
plugin.server.dispatchCommand(plugin.server.consoleSender, "fbackup")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
if (::scheduleId.isInitialized) {
|
||||
scheduler.cancel(scheduleId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun module() = module {
|
||||
single {
|
||||
val configPath = Util.copyDefaultConfig<FoundationCorePlugin>(
|
||||
plugin.slF4JLogger,
|
||||
plugin.pluginDataPath,
|
||||
"backup.yaml",
|
||||
)
|
||||
return@single Yaml.default.decodeFromStream(
|
||||
BackupConfig.serializer(),
|
||||
configPath.inputStream()
|
||||
)
|
||||
}
|
||||
single {
|
||||
val config = get<BackupConfig>()
|
||||
|
||||
val creds = StaticCredentialsProvider.create(
|
||||
AwsSessionCredentials.create(config.s3.accessKeyId, config.s3.secretAccessKey, "")
|
||||
)
|
||||
val builder = S3Client.builder().credentialsProvider(creds)
|
||||
|
||||
if (config.s3.endpointOverride.isNotEmpty()) {
|
||||
builder.endpointOverride(URI.create(config.s3.endpointOverride))
|
||||
}
|
||||
|
||||
if (config.s3.region.isNotEmpty()) {
|
||||
builder.region(Region.of(config.s3.region))
|
||||
} else {
|
||||
builder.region(Region.US_WEST_1)
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BACKUPS_DIRECTORY = "backups"
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package gay.pizza.foundation.core.features.dev
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
|
||||
class DevFeature : Feature() {
|
||||
private lateinit var devUpdateServer: DevUpdateServer
|
||||
|
||||
override fun enable() {
|
||||
devUpdateServer = DevUpdateServer(plugin)
|
||||
devUpdateServer.enable()
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
devUpdateServer.disable()
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package gay.pizza.foundation.core.features.dev
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class DevUpdateConfig(
|
||||
val port: Int = 8484,
|
||||
val token: String,
|
||||
val ipAllowList: List<String> = listOf("*")
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
package gay.pizza.foundation.core.features.dev
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
class DevUpdatePayload(
|
||||
@SerialName("object_kind")
|
||||
val objectKind: String,
|
||||
@SerialName("object_attributes")
|
||||
val objectAttributes: Map<String, JsonElement>
|
||||
)
|
@ -0,0 +1,117 @@
|
||||
package gay.pizza.foundation.core.features.dev
|
||||
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import gay.pizza.foundation.core.Util
|
||||
import gay.pizza.foundation.core.features.update.UpdateService
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.InetSocketAddress
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
class DevUpdateServer(val plugin: FoundationCorePlugin) {
|
||||
private lateinit var config: DevUpdateConfig
|
||||
private var server: HttpServer? = null
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = " "
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun enable() {
|
||||
val configPath = Util.copyDefaultConfig<FoundationCorePlugin>(
|
||||
plugin.slF4JLogger,
|
||||
plugin.pluginDataPath,
|
||||
"devupdate.yaml"
|
||||
)
|
||||
|
||||
config = Yaml.default.decodeFromStream(DevUpdateConfig.serializer(), configPath.inputStream())
|
||||
start()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (config.token.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.token.length < 8) {
|
||||
plugin.slF4JLogger.warn("DevUpdateServer Token was too short (must be 8 or more characters)")
|
||||
return
|
||||
}
|
||||
|
||||
val server = HttpServer.create()
|
||||
server.createContext("/").setHandler { exchange ->
|
||||
handle(exchange)
|
||||
}
|
||||
server.bind(InetSocketAddress("0.0.0.0", config.port), 0)
|
||||
server.start()
|
||||
this.server = server
|
||||
plugin.slF4JLogger.info("DevUpdateServer listening on port ${config.port}")
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
val ip = exchange.remoteAddress.address.hostAddress
|
||||
if (!config.ipAllowList.contains("*") && !config.ipAllowList.contains(ip)) {
|
||||
plugin.slF4JLogger.warn("DevUpdateServer received request from IP $ip which is not allowed.")
|
||||
exchange.close()
|
||||
return
|
||||
}
|
||||
|
||||
plugin.slF4JLogger.info("DevUpdateServer Request $ip ${exchange.requestMethod} ${exchange.requestURI.path}")
|
||||
if (exchange.requestMethod != "POST") {
|
||||
exchange.respond(405, "Method not allowed.")
|
||||
return
|
||||
}
|
||||
|
||||
if (exchange.requestURI.path != "/webhook/update") {
|
||||
exchange.respond(404, "Not Found.")
|
||||
return
|
||||
}
|
||||
|
||||
if (exchange.requestURI.query != config.token) {
|
||||
exchange.respond(401, "Unauthorized.")
|
||||
return
|
||||
}
|
||||
|
||||
val payload: DevUpdatePayload
|
||||
try {
|
||||
payload = json.decodeFromStream(exchange.requestBody)
|
||||
} catch (e: Exception) {
|
||||
plugin.slF4JLogger.error("Failed to decode request body.", e)
|
||||
exchange.respond(400, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.objectKind != "pipeline" ||
|
||||
payload.objectAttributes["ref"]?.jsonPrimitive?.content != "main" ||
|
||||
payload.objectAttributes["status"]?.jsonPrimitive?.content != "success"
|
||||
) {
|
||||
exchange.respond(200, "Event was not relevant for update.")
|
||||
return
|
||||
}
|
||||
|
||||
exchange.respond(200, "Success.")
|
||||
plugin.slF4JLogger.info("DevUpdate Started")
|
||||
UpdateService.updatePlugins(plugin.server.consoleSender) {
|
||||
plugin.server.scheduler.runTask(plugin) { ->
|
||||
plugin.server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disable() {
|
||||
server?.stop(1)
|
||||
}
|
||||
|
||||
private fun HttpExchange.respond(code: Int, content: String) {
|
||||
val encoded = content.encodeToByteArray()
|
||||
sendResponseHeaders(code, encoded.size.toLong())
|
||||
responseBody.write(encoded)
|
||||
responseBody.close()
|
||||
close()
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package gay.pizza.foundation.core.features.gameplay
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GameplayConfig(
|
||||
val mobs: MobsConfig = MobsConfig(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MobsConfig(
|
||||
val disableEndermanGriefing: Boolean = false,
|
||||
val disableFreezeDamage: Boolean = false,
|
||||
val allowLeads: Boolean = false,
|
||||
)
|
@ -0,0 +1,93 @@
|
||||
package gay.pizza.foundation.core.features.gameplay
|
||||
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import gay.pizza.foundation.core.Util
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.EntityType
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Mob
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.entity.EntityChangeBlockEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.dsl.module
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
class GameplayFeature : Feature() {
|
||||
private val config by inject<GameplayConfig>()
|
||||
|
||||
override fun module() = module {
|
||||
single {
|
||||
val configPath = Util.copyDefaultConfig<FoundationCorePlugin>(
|
||||
plugin.slF4JLogger,
|
||||
plugin.pluginDataPath,
|
||||
"gameplay.yaml",
|
||||
)
|
||||
return@single Yaml.default.decodeFromStream(
|
||||
GameplayConfig.serializer(),
|
||||
configPath.inputStream()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
private fun onEntityDamage(e: EntityDamageEvent) {
|
||||
// If freeze damage is disabled, cancel the event.
|
||||
if (config.mobs.disableFreezeDamage) {
|
||||
if (e.entity is Mob && e.cause == EntityDamageEvent.DamageCause.FREEZE) {
|
||||
e.isCancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
private fun onEntityChangeBlock(event: EntityChangeBlockEvent) {
|
||||
// If enderman griefing is disabled, cancel the event.
|
||||
if (config.mobs.disableEndermanGriefing) {
|
||||
if (event.entity.type == EntityType.ENDERMAN) {
|
||||
event.isCancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
private fun onPlayerInteractEntity(event: PlayerInteractEntityEvent) {
|
||||
val mainHandItem = event.player.inventory.itemInMainHand
|
||||
val hasLead = mainHandItem.type == Material.LEAD
|
||||
val isLivingEntity = event.rightClicked is LivingEntity
|
||||
|
||||
// If leads are allowed on all mobs, then start leading the mob.
|
||||
if (config.mobs.allowLeads && hasLead && isLivingEntity) {
|
||||
val livingEntity = event.rightClicked as LivingEntity
|
||||
|
||||
// Something to do with Bukkit, leashes must happen after the event.
|
||||
Bukkit.getScheduler().runTask(plugin) { ->
|
||||
// If the entity is already leashed, don't do anything.
|
||||
if (livingEntity.isLeashed) return@runTask
|
||||
|
||||
// Interacted with the entity, don't despawn it.
|
||||
livingEntity.removeWhenFarAway = false
|
||||
|
||||
val leashSuccess = livingEntity.setLeashHolder(event.player)
|
||||
|
||||
if (leashSuccess) {
|
||||
val newStack = if (mainHandItem.amount == 1) {
|
||||
null
|
||||
} else {
|
||||
ItemStack(mainHandItem.type, mainHandItem.amount - 1)
|
||||
}
|
||||
event.player.inventory.setItemInMainHand(newStack)
|
||||
}
|
||||
}
|
||||
|
||||
event.isCancelled = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package gay.pizza.foundation.core.features.persist
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
class PersistenceFeature : Feature() {
|
||||
private val persistence = inject<PluginPersistence>()
|
||||
|
||||
override fun disable() {
|
||||
persistence.value.unload()
|
||||
}
|
||||
|
||||
override fun module(): Module = module {
|
||||
single { PluginPersistence() }
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package gay.pizza.foundation.core.features.persist
|
||||
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import jetbrains.exodus.entitystore.Entity
|
||||
import jetbrains.exodus.entitystore.EntityIterable
|
||||
import jetbrains.exodus.entitystore.PersistentEntityStores
|
||||
import jetbrains.exodus.entitystore.StoreTransaction
|
||||
|
||||
class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) : AutoCloseable {
|
||||
private val fileStorePath = corePlugin.pluginDataPath.resolve("persistence/${fileStoreName}")
|
||||
private val entityStore = PersistentEntityStores.newInstance(fileStorePath.toFile())
|
||||
|
||||
fun <R> transact(block: StoreTransaction.() -> R): R {
|
||||
var result: R? = null
|
||||
entityStore.executeInTransaction { tx ->
|
||||
result = block(tx)
|
||||
}
|
||||
return result!!
|
||||
}
|
||||
|
||||
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact {
|
||||
populate(newEntity(entityTypeName))
|
||||
}
|
||||
|
||||
fun <R> getAll(entityTypeName: String, block: (EntityIterable) -> R): R =
|
||||
transact { block(getAll(entityTypeName)) }
|
||||
|
||||
fun <T, R> find(entityTypeName: String, propertyName: String, value: Comparable<T>, block: (EntityIterable) -> R): R =
|
||||
transact { block(find(entityTypeName, propertyName, value)) }
|
||||
|
||||
fun deleteAllEntities(entityTypeName: String) =
|
||||
transact { entityStore.deleteEntityType(entityTypeName) }
|
||||
|
||||
override fun close() {
|
||||
entityStore.close()
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
package gay.pizza.foundation.core.features.persist
|
||||
|
||||
import gay.pizza.foundation.core.features.stats.StatsFeature
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.command.TabCompleter
|
||||
|
||||
class PersistentStoreCommand(
|
||||
private val statsFeature: StatsFeature
|
||||
) : CommandExecutor, TabCompleter {
|
||||
private val allSubCommands = mutableListOf("stats", "sample", "delete-all-entities")
|
||||
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
if (args.isEmpty()) {
|
||||
sender.sendMessage("Invalid Command Usage.")
|
||||
return true
|
||||
}
|
||||
|
||||
when (args[0]) {
|
||||
"stats" -> {
|
||||
statsFeature.persistence.value.stores.forEach { (name, store) ->
|
||||
val counts = store.transact {
|
||||
entityTypes.associateWith { type -> getAll(type).size() }.toSortedMap()
|
||||
}
|
||||
|
||||
sender.sendMessage(
|
||||
"Store $name ->",
|
||||
*counts.map { " ${it.key} -> ${it.value} entries" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
"sample" -> {
|
||||
if (args.size != 3) {
|
||||
sender.sendMessage("Invalid Subcommand Usage.")
|
||||
return true
|
||||
}
|
||||
|
||||
val storeName = args[1]
|
||||
val entityTypeName = args[2]
|
||||
val store = statsFeature.persistence.value.store(storeName)
|
||||
store.transact {
|
||||
val entities = getAll(entityTypeName).take(3)
|
||||
for (entity in entities) {
|
||||
sender.sendMessage(
|
||||
"Entity ${entity.id.localId} ->",
|
||||
*entity.propertyNames.map { " ${it}: ${entity.getProperty(it)}" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete-all-entities" -> {
|
||||
if (args.size != 3) {
|
||||
sender.sendMessage("Invalid Subcommand Usage.")
|
||||
return true
|
||||
}
|
||||
|
||||
val storeName = args[1]
|
||||
val entityTypeName = args[2]
|
||||
val store = statsFeature.persistence.value.store(storeName)
|
||||
store.transact {
|
||||
store.deleteAllEntities(entityTypeName)
|
||||
}
|
||||
sender.sendMessage("Deleted all entities for $storeName $entityTypeName")
|
||||
}
|
||||
else -> {
|
||||
sender.sendMessage("Unknown Subcommand.")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onTabComplete(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
alias: String,
|
||||
args: Array<out String>
|
||||
): MutableList<String> = when {
|
||||
args.isEmpty() -> {
|
||||
allSubCommands
|
||||
}
|
||||
args.size == 1 -> {
|
||||
allSubCommands.filter { it.startsWith(args[0]) }.toMutableList()
|
||||
}
|
||||
else -> {
|
||||
mutableListOf()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package gay.pizza.foundation.core.features.persist
|
||||
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PluginPersistence : KoinComponent {
|
||||
private val plugin = inject<FoundationCorePlugin>()
|
||||
|
||||
val stores = ConcurrentHashMap<String, PersistentStore>()
|
||||
|
||||
/**
|
||||
* Fetch a persistent store by name. Make sure the name is path-safe, descriptive and consistent across server runs.
|
||||
*/
|
||||
fun store(name: String): PersistentStore =
|
||||
stores.getOrPut(name) { PersistentStore(plugin.value, name) }
|
||||
|
||||
fun unload() {
|
||||
stores.values.forEach { store -> store.close() }
|
||||
stores.clear()
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package gay.pizza.foundation.core.features.persist
|
||||
|
||||
import jetbrains.exodus.entitystore.Entity
|
||||
|
||||
fun <T : Comparable<*>> Entity.setAllProperties(vararg entries: Pair<String, T>) = entries.forEach { entry ->
|
||||
setProperty(entry.first, entry.second)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package gay.pizza.foundation.core.features.player
|
||||
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
class GamemodeCommand(private val gameMode: GameMode) : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
if (sender !is Player) {
|
||||
sender.sendMessage("You are not a player.")
|
||||
return true
|
||||
}
|
||||
|
||||
sender.gameMode = gameMode
|
||||
sender.sendMessage("Switched gamemode to ${gameMode.name.lowercase()}")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package gay.pizza.foundation.core.features.player
|
||||
|
||||
import org.bukkit.WeatherType
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.command.TabCompleter
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
class LocalWeatherCommand : CommandExecutor, TabCompleter {
|
||||
private val weatherTypes = WeatherType.values().associateBy { it.name.lowercase() }
|
||||
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
if (sender !is Player) {
|
||||
sender.sendMessage("You are not a player.")
|
||||
return true
|
||||
}
|
||||
if (args.size != 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
val name = args[0].lowercase()
|
||||
val weatherType = weatherTypes[name]
|
||||
if (weatherType == null) {
|
||||
sender.sendMessage("Not a valid weather type.")
|
||||
return true
|
||||
}
|
||||
|
||||
sender.setPlayerWeather(weatherType)
|
||||
sender.sendMessage("Weather set to \"$name\"")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onTabComplete(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
alias: String,
|
||||
args: Array<out String>
|
||||
): List<String> = when {
|
||||
args.isEmpty() -> weatherTypes.keys.toList()
|
||||
args.size == 1 -> weatherTypes.filterKeys { it.startsWith(args[0]) }.keys.toList()
|
||||
else -> listOf()
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package gay.pizza.foundation.core.features.player
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayerConfig(
|
||||
@SerialName("anti-idle")
|
||||
val antiIdle: AntiIdleConfig = AntiIdleConfig(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AntiIdleConfig(
|
||||
val enabled: Boolean = false,
|
||||
val idleDuration: Int = 3600,
|
||||
val ignore: List<String> = listOf(),
|
||||
)
|
@ -0,0 +1,88 @@
|
||||
package gay.pizza.foundation.core.features.player
|
||||
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.google.common.cache.Cache
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.RemovalCause
|
||||
import gay.pizza.foundation.core.FoundationCorePlugin
|
||||
import gay.pizza.foundation.core.Util
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
import net.kyori.adventure.text.Component
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerKickEvent
|
||||
import org.bukkit.event.player.PlayerMoveEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
import org.koin.core.component.inject
|
||||
import java.time.Duration
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
class PlayerFeature : Feature() {
|
||||
private val config by inject<PlayerConfig>()
|
||||
private lateinit var playerActivity: Cache<String, String>
|
||||
|
||||
override fun enable() {
|
||||
playerActivity = CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(Duration.ofSeconds(config.antiIdle.idleDuration.toLong()))
|
||||
.removalListener<String, String> z@{
|
||||
if (!config.antiIdle.enabled) return@z
|
||||
if (it.cause == RemovalCause.EXPIRED) {
|
||||
if (!config.antiIdle.ignore.contains(it.key!!)) {
|
||||
plugin.server.scheduler.runTask(plugin) { ->
|
||||
plugin.server.getPlayer(it.key!!)
|
||||
?.kick(Component.text("Kicked for idling"), PlayerKickEvent.Cause.IDLING)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
// Expire player activity tokens occasionally.
|
||||
plugin.server.scheduler.scheduleSyncRepeatingTask(plugin, {
|
||||
playerActivity.cleanUp()
|
||||
}, 20, 100)
|
||||
|
||||
registerCommandExecutor(listOf("survival", "s"), GamemodeCommand(GameMode.SURVIVAL))
|
||||
registerCommandExecutor(listOf("creative", "c"), GamemodeCommand(GameMode.CREATIVE))
|
||||
registerCommandExecutor(listOf("adventure", "a"), GamemodeCommand(GameMode.ADVENTURE))
|
||||
registerCommandExecutor(listOf("spectator", "sp"), GamemodeCommand(GameMode.SPECTATOR))
|
||||
registerCommandExecutor(listOf("localweather", "lw"), LocalWeatherCommand())
|
||||
}
|
||||
|
||||
override fun module() = org.koin.dsl.module {
|
||||
single {
|
||||
val configPath = Util.copyDefaultConfig<FoundationCorePlugin>(
|
||||
plugin.slF4JLogger,
|
||||
plugin.pluginDataPath,
|
||||
"player.yaml",
|
||||
)
|
||||
return@single Yaml.default.decodeFromStream(
|
||||
PlayerConfig.serializer(),
|
||||
configPath.inputStream()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerJoin(e: PlayerJoinEvent) {
|
||||
if (!config.antiIdle.enabled) return
|
||||
|
||||
playerActivity.put(e.player.name, e.player.name)
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerQuit(e: PlayerQuitEvent) {
|
||||
if (!config.antiIdle.enabled) return
|
||||
|
||||
playerActivity.invalidate(e.player.name)
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerMove(e: PlayerMoveEvent) {
|
||||
if (!config.antiIdle.enabled) return
|
||||
|
||||
if (e.hasChangedPosition() || e.hasChangedOrientation()) {
|
||||
playerActivity.put(e.player.name, e.player.name)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package gay.pizza.foundation.core.features.scheduler
|
||||
|
||||
import org.quartz.CronScheduleBuilder.cronSchedule
|
||||
import org.quartz.JobBuilder.newJob
|
||||
import org.quartz.JobDataMap
|
||||
import org.quartz.Scheduler
|
||||
import org.quartz.TriggerBuilder.newTrigger
|
||||
import org.quartz.TriggerKey.triggerKey
|
||||
import java.util.UUID
|
||||
|
||||
fun Scheduler.cron(cronExpression: String, f: () -> Unit): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val job = newJob(SchedulerRunner::class.java).apply {
|
||||
setJobData(JobDataMap().apply {
|
||||
set("function", f)
|
||||
})
|
||||
}.build()
|
||||
|
||||
val trigger = newTrigger()
|
||||
.withIdentity(triggerKey(id))
|
||||
.withSchedule(cronSchedule(cronExpression))
|
||||
.build()
|
||||
|
||||
scheduleJob(job, trigger)
|
||||
return id
|
||||
}
|
||||
|
||||
fun Scheduler.cancel(id: String) {
|
||||
unscheduleJob(triggerKey(id))
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package gay.pizza.foundation.core.features.scheduler
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.CoreFeature
|
||||
import org.koin.dsl.module
|
||||
import org.quartz.Scheduler
|
||||
import org.quartz.impl.StdSchedulerFactory
|
||||
|
||||
class SchedulerFeature : CoreFeature {
|
||||
private val scheduler: Scheduler = StdSchedulerFactory.getDefaultScheduler()
|
||||
|
||||
override fun enable() {
|
||||
scheduler.start()
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
scheduler.shutdown(true)
|
||||
}
|
||||
|
||||
override fun module() = module {
|
||||
single { scheduler }
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package gay.pizza.foundation.core.features.scheduler
|
||||
|
||||
import org.quartz.Job
|
||||
import org.quartz.JobExecutionContext
|
||||
|
||||
class SchedulerRunner : Job {
|
||||
override fun execute(context: JobExecutionContext) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val function = context.jobDetail.jobDataMap["function"] as () -> Unit
|
||||
function()
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
package gay.pizza.foundation.core.features.stats
|
||||
|
||||
import gay.pizza.foundation.core.SortOrder
|
||||
import gay.pizza.foundation.core.allPlayerStatisticsOf
|
||||
import org.bukkit.Statistic
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.command.TabCompleter
|
||||
|
||||
class LeaderboardCommand : CommandExecutor, TabCompleter {
|
||||
private val leaderboards = listOf(
|
||||
LeaderboardType("player-kills", Statistic.PLAYER_KILLS, "Player Kills", "kills"),
|
||||
LeaderboardType("mob-kills", Statistic.MOB_KILLS, "Mob Kills", "kills"),
|
||||
LeaderboardType("animals-bred", Statistic.ANIMALS_BRED, "Animals Bred", "animals"),
|
||||
LeaderboardType("chest-opens", Statistic.CHEST_OPENED, "Chest Opens", "opens"),
|
||||
LeaderboardType("raid-wins", Statistic.RAID_WIN, "Raid Wins", "wins"),
|
||||
LeaderboardType("item-enchants", Statistic.ITEM_ENCHANTED, "Item Enchants", "enchants"),
|
||||
LeaderboardType("damage-dealt", Statistic.DAMAGE_DEALT, "Damage Dealt", "damage"),
|
||||
LeaderboardType("fish-caught", Statistic.FISH_CAUGHT, "Fish Caught", "fish")
|
||||
)
|
||||
|
||||
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
|
||||
if (args.size != 1) {
|
||||
sender.sendMessage("Leaderboard type not specified.")
|
||||
return true
|
||||
}
|
||||
|
||||
val leaderboardType = leaderboards.firstOrNull { it.id == args[0] }
|
||||
if (leaderboardType == null) {
|
||||
sender.sendMessage("Leaderboard type is unknown.")
|
||||
return true
|
||||
}
|
||||
val statistics = sender.server.allPlayerStatisticsOf(leaderboardType.statistic, order = SortOrder.Descending)
|
||||
val topFivePlayers = statistics.take(5)
|
||||
sender.sendMessage(
|
||||
"${leaderboardType.friendlyName} Leaderboard:",
|
||||
*topFivePlayers.withIndex()
|
||||
.map { "(#${it.index + 1}) ${it.value.first.name}: ${it.value.second} ${leaderboardType.unit}" }.toTypedArray()
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
class LeaderboardType(val id: String, val statistic: Statistic, val friendlyName: String, val unit: String)
|
||||
|
||||
override fun onTabComplete(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
alias: String,
|
||||
args: Array<out String>
|
||||
): MutableList<String> = when {
|
||||
args.isEmpty() -> {
|
||||
leaderboards.map { it.id }.toMutableList()
|
||||
}
|
||||
args.size == 1 -> {
|
||||
leaderboards.map { it.id }.filter { it.startsWith(args[0]) }.toMutableList()
|
||||
}
|
||||
else -> {
|
||||
mutableListOf()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package gay.pizza.foundation.core.features.stats
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
import gay.pizza.foundation.core.features.persist.PersistentStore
|
||||
import gay.pizza.foundation.core.features.persist.PersistentStoreCommand
|
||||
import gay.pizza.foundation.core.features.persist.PluginPersistence
|
||||
import gay.pizza.foundation.core.features.persist.setAllProperties
|
||||
import io.papermc.paper.event.player.AsyncChatEvent
|
||||
import net.kyori.adventure.text.TextComponent
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.koin.core.component.inject
|
||||
import java.time.Instant
|
||||
|
||||
class StatsFeature : Feature() {
|
||||
internal val persistence = inject<PluginPersistence>()
|
||||
private lateinit var chatLogStore: PersistentStore
|
||||
|
||||
override fun enable() {
|
||||
chatLogStore = persistence.value.store("chat-logs")
|
||||
|
||||
registerCommandExecutor(listOf("leaderboard", "lb"), LeaderboardCommand())
|
||||
registerCommandExecutor("pstore", PersistentStoreCommand(this))
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun logOnChatMessage(e: AsyncChatEvent) {
|
||||
val player = e.player
|
||||
val message = e.message()
|
||||
|
||||
if (message !is TextComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
val content = message.content()
|
||||
chatLogStore.create("ChatMessageEvent") {
|
||||
setAllProperties(
|
||||
"timestamp" to Instant.now().toEpochMilli(),
|
||||
"player.id" to player.identity().uuid().toString(),
|
||||
"player.name" to player.name,
|
||||
"message.content" to content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package gay.pizza.foundation.core.features.update
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ModuleManifest(
|
||||
val version: String,
|
||||
val artifacts: List<String>,
|
||||
)
|
@ -0,0 +1,17 @@
|
||||
package gay.pizza.foundation.core.features.update
|
||||
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
|
||||
class UpdateCommand : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
UpdateService.updatePlugins(sender)
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package gay.pizza.foundation.core.features.update
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
|
||||
class UpdateFeature : Feature() {
|
||||
override fun enable() {
|
||||
registerCommandExecutor("fupdate", UpdateCommand())
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package gay.pizza.foundation.core.features.update
|
||||
|
||||
import org.bukkit.command.CommandSender
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.toPath
|
||||
|
||||
// TODO: Switch to a class and use dependency injection with koin.
|
||||
object UpdateService {
|
||||
fun updatePlugins(sender: CommandSender, onFinish: (() -> Unit)? = null) {
|
||||
val updateDir = sender.server.pluginsFolder.resolve("update")
|
||||
updateDir.mkdir()
|
||||
if (!updateDir.exists()) {
|
||||
sender.sendMessage("Error: Failed to create plugin update directory.")
|
||||
return
|
||||
}
|
||||
val updatePath = updateDir.toPath()
|
||||
|
||||
Thread {
|
||||
val modules = UpdateUtil.fetchManifest()
|
||||
val plugins = sender.server.pluginManager.plugins.associateBy { it.name.lowercase() }
|
||||
|
||||
sender.sendMessage("Updates:")
|
||||
modules.forEach { (name, manifest) ->
|
||||
// Dumb naming problem. Don't want to fix it right now.
|
||||
val plugin = if (name == "foundation-core") {
|
||||
plugins["foundation"]
|
||||
} else {
|
||||
plugins[name.lowercase()]
|
||||
}
|
||||
|
||||
if (plugin == null) {
|
||||
sender.sendMessage("Plugin in manifest, but not installed: $name (${manifest.version})")
|
||||
} else {
|
||||
val fileName = plugin.javaClass.protectionDomain.codeSource.location.toURI().toPath().name
|
||||
val artifactPath = manifest.artifacts.getOrNull(0) ?: return@forEach
|
||||
|
||||
sender.sendMessage("${plugin.name}: Updating ${plugin.description.version} to ${manifest.version}")
|
||||
UpdateUtil.downloadArtifact(artifactPath, updatePath.resolve(fileName))
|
||||
}
|
||||
}
|
||||
sender.sendMessage("Restart to take effect")
|
||||
|
||||
if (onFinish != null) onFinish()
|
||||
}.start()
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package gay.pizza.foundation.core.features.update
|
||||
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.nio.file.Path
|
||||
|
||||
object UpdateUtil {
|
||||
private val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()
|
||||
|
||||
// TODO: Add environment variable override. Document it.
|
||||
private const val basePath =
|
||||
"https://git.mystic.run/minecraft/foundation/-/jobs/artifacts/main/raw"
|
||||
private const val basePathQueryParams = "job=build"
|
||||
private const val manifestPath = "build/manifests/update.json"
|
||||
|
||||
fun fetchManifest() = fetchFile(
|
||||
getUrl(manifestPath), MapSerializer(String.serializer(), ModuleManifest.serializer()),
|
||||
)
|
||||
|
||||
fun getUrl(path: String) = "$basePath/$path?$basePathQueryParams"
|
||||
|
||||
private inline fun <reified T> fetchFile(url: String, strategy: DeserializationStrategy<T>): T {
|
||||
val request = HttpRequest
|
||||
.newBuilder()
|
||||
.GET()
|
||||
.uri(URI.create(url))
|
||||
.build()
|
||||
|
||||
val response = client.send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
)
|
||||
|
||||
return Json.decodeFromString(
|
||||
strategy,
|
||||
response.body()
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadArtifact(path: String, outPath: Path) {
|
||||
val request = HttpRequest
|
||||
.newBuilder()
|
||||
.GET()
|
||||
.uri(URI.create(getUrl(path)))
|
||||
.build()
|
||||
|
||||
val response = client.send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofFile(outPath)
|
||||
)
|
||||
response.body()
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package gay.pizza.foundation.core.features.world
|
||||
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
class SetSpawnCommand : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
if (sender !is Player) {
|
||||
sender.sendMessage("You are not a player.")
|
||||
return true
|
||||
}
|
||||
|
||||
val loc = sender.location
|
||||
sender.world.setSpawnLocation(loc.blockX, loc.blockY, loc.blockZ)
|
||||
|
||||
sender.sendMessage("World spawn point set.")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package gay.pizza.foundation.core.features.world
|
||||
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
class SpawnCommand : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
if (sender !is Player) {
|
||||
sender.sendMessage("You are not a player.")
|
||||
return true
|
||||
}
|
||||
|
||||
sender.teleport(sender.world.spawnLocation)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package gay.pizza.foundation.core.features.world
|
||||
|
||||
import gay.pizza.foundation.core.abstraction.Feature
|
||||
|
||||
class WorldFeature : Feature() {
|
||||
override fun enable() {
|
||||
registerCommandExecutor("setspawn", SetSpawnCommand())
|
||||
registerCommandExecutor("spawn", SpawnCommand())
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package gay.pizza.foundation.core.util
|
||||
|
||||
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
|
||||
import org.bukkit.advancement.Advancement
|
||||
import java.lang.reflect.Field
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private fun Advancement.getInternalHandle(): Any =
|
||||
javaClass.getMethod("getHandle").invoke(this)
|
||||
|
||||
private fun Class<*>.getDeclaredFieldAccessible(name: String): Field {
|
||||
val field = getDeclaredField(name)
|
||||
if (!field.trySetAccessible()) {
|
||||
throw RuntimeException("Failed to set reflection permissions to accessible.")
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
private fun Advancement.getInternalAdvancementDisplay(handle: Any = getInternalHandle()): Any? =
|
||||
handle.javaClass.methods.firstOrNull {
|
||||
it.returnType.simpleName == "AdvancementDisplay" &&
|
||||
it.parameterCount == 0
|
||||
}?.invoke(handle) ?: handle.javaClass.getDeclaredFieldAccessible("c").get(handle)
|
||||
|
||||
private fun Advancement.displayTitleText(): String? {
|
||||
val handle = getInternalHandle()
|
||||
val advancementDisplay = getInternalAdvancementDisplay(handle) ?: return null
|
||||
try {
|
||||
val field = advancementDisplay.javaClass.getDeclaredField("a")
|
||||
field.trySetAccessible()
|
||||
val message = field.get(advancementDisplay)
|
||||
val title = message.javaClass.getMethod("getString").invoke(message)
|
||||
return title.toString()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val titleComponentField = advancementDisplay.javaClass.declaredFields.firstOrNull {
|
||||
it.type.simpleName == "IChatBaseComponent"
|
||||
}
|
||||
|
||||
if (titleComponentField != null) {
|
||||
titleComponentField.trySetAccessible()
|
||||
val titleChatBaseComponent = titleComponentField.get(advancementDisplay)
|
||||
val title = titleChatBaseComponent.javaClass.getMethod("getText").invoke(titleChatBaseComponent).toString()
|
||||
if (title.isNotBlank()) {
|
||||
return title
|
||||
}
|
||||
|
||||
val chatSerializerClass = titleChatBaseComponent.javaClass.declaredClasses.firstOrNull {
|
||||
it.simpleName == "ChatSerializer"
|
||||
}
|
||||
|
||||
if (chatSerializerClass != null) {
|
||||
val componentJson = chatSerializerClass
|
||||
.getMethod("a", titleChatBaseComponent.javaClass)
|
||||
.invoke(null, titleChatBaseComponent).toString()
|
||||
val gson = GsonComponentSerializer.gson().deserialize(componentJson)
|
||||
return LegacyComponentSerializer.legacySection().serialize(gson)
|
||||
}
|
||||
}
|
||||
|
||||
val rawAdvancementName = key.key
|
||||
return rawAdvancementName.substring(rawAdvancementName.lastIndexOf("/") + 1)
|
||||
.lowercase().split("_")
|
||||
.joinToString(" ") { it.substring(0, 1).uppercase() + it.substring(1) }
|
||||
}
|
||||
|
||||
object AdvancementTitleCache {
|
||||
private val cache = ConcurrentHashMap<Advancement, String?>()
|
||||
|
||||
fun of(advancement: Advancement): String? =
|
||||
cache.computeIfAbsent(advancement) { it.displayTitleText() }
|
||||
}
|
Reference in New Issue
Block a user