Initial renaming pass.

This commit is contained in:
Liv Gorence
2023-01-24 21:37:24 -08:00
parent 5d7bf94e5c
commit 83ae7df4a6
139 changed files with 335 additions and 317 deletions

View File

@ -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 }

View File

@ -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)
}

View File

@ -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 }
}
}

View File

@ -0,0 +1,6 @@
package gay.pizza.foundation.core
enum class SortOrder {
Ascending,
Descending
}

View File

@ -0,0 +1,7 @@
package gay.pizza.foundation.core
import net.kyori.adventure.text.format.TextColor
object TextColors {
val AMARANTH_PINK = TextColor.fromHexString("#F7A8B8")!!
}

View File

@ -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")
}
}

View File

@ -0,0 +1,7 @@
package gay.pizza.foundation.core.abstraction
interface CoreFeature {
fun enable()
fun disable()
fun module() = org.koin.dsl.module {}
}

View File

@ -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
}
}
}
}

View File

@ -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>
}

View File

@ -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()
}
}

View File

@ -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 = "",
)

View File

@ -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"
}
}

View File

@ -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()
}
}

View File

@ -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("*")
)

View File

@ -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>
)

View File

@ -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()
}
}

View File

@ -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,
)

View File

@ -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
}
}
}

View File

@ -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() }
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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(),
)

View File

@ -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)
}
}
}

View File

@ -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))
}

View File

@ -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 }
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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>,
)

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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() }
}