mirror of
https://github.com/GayPizzaSpecifications/foundation.git
synced 2025-08-05 14:31:32 +00:00
Compare commits
113 Commits
v0.1
...
bifrost-se
Author | SHA1 | Date | |
---|---|---|---|
e05d6355e6 | |||
5d7bf94e5c | |||
89ad6d1ece | |||
8289616762 | |||
c9d4fe1733 | |||
e3d9eb80fc | |||
a184d2e845 | |||
6e1afb5e5c | |||
1879df780b | |||
f0c344ca1f | |||
6bcddb15b5 | |||
1afb1c7148 | |||
86800e59f4 | |||
ac2e99052d | |||
eb5cb1a229 | |||
74fed8c222 | |||
ba18fcddbc | |||
0da3202555 | |||
66ee0ba701 | |||
d4a06ea84a | |||
54cd41e925 | |||
011e3100bf | |||
9395f43e40 | |||
d16b9b1138 | |||
4ca241aa5b | |||
25c72d1ce3 | |||
3115990352 | |||
ea83ce5853 | |||
2bfa39c6a2 | |||
cd518c6928 | |||
9398ada817 | |||
93d1888537 | |||
ef13c2371c | |||
0a08436088 | |||
0d2e454941 | |||
e9548c5a3d | |||
9d156d250b | |||
8f34209aff | |||
01999eadd7 | |||
763b61ba04 | |||
71f0b46728 | |||
4187b0f50c | |||
203ecd1ca9 | |||
3ac24f6912 | |||
dcec7cab54 | |||
c1a07f1001 | |||
2d429ae04d | |||
94d644916b | |||
7a5a27d581 | |||
3f06845ac4 | |||
a81b160675 | |||
0a96435669 | |||
41547f2e14 | |||
8caf3de634 | |||
81a76da809 | |||
3350034060 | |||
8ea1ea1540 | |||
d54f434805 | |||
643567dfb5 | |||
08ba582931 | |||
9f8d417e5d | |||
86f82692b4 | |||
927abe54b6 | |||
a0669f815b | |||
10cf0cadac | |||
bc2d3e28ae | |||
cc6fbaae83 | |||
06eda8932a | |||
e681df1e65 | |||
9386dc7c56 | |||
ff665c27f5 | |||
cbbefc94a2 | |||
d7f094f765 | |||
e10fa42c68 | |||
767faba8d8 | |||
c1f621aa7b | |||
b2851d13b9 | |||
4017c3cb8c | |||
1985b3c507 | |||
139249c1de | |||
e00ef21db1 | |||
fae116a2a5 | |||
9952c4c427 | |||
847f46273b | |||
e0183127b4 | |||
78566d08ad | |||
fca1db8802 | |||
da820b8a0d | |||
2c98cacf96 | |||
c854e7c47c | |||
ec7810a11a | |||
13479b1ae3 | |||
f8178c2307 | |||
7f9bd32cc7 | |||
a7d7c9f818 | |||
4284791804 | |||
ad8c82725b | |||
4e066d8f11 | |||
76019a62fc | |||
552ef608d9 | |||
46ba0a4a44 | |||
e3402505fd | |||
f7e19b1509 | |||
7a30d066ac | |||
139ce551dc | |||
795e99ad4f | |||
b8c8097f58 | |||
4439fe74a6 | |||
ecdb6a2898 | |||
b91602d719 | |||
b32b8efc84 | |||
fb1fd1a6e5 | |||
6b4bd2a987 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -116,3 +116,6 @@ run/
|
||||
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
!gradle-wrapper.jar
|
||||
|
||||
# Foundation Server
|
||||
/server
|
||||
|
22
README.md
22
README.md
@ -1,7 +1,25 @@
|
||||
# Foundation
|
||||
|
||||
Foundation is a set of plugins that implement the core functionality for a small community Minecraft
|
||||
server.
|
||||
|
||||
## Plugins
|
||||
* foundation-core - Core functionality
|
||||
* foundation-bifrost - Discord chat bridge
|
||||
|
||||
* foundation-core: Core functionality
|
||||
* foundation-bifrost: Discord chat bridge
|
||||
* foundation-heimdall: Event tracking
|
||||
|
||||
## Tools
|
||||
|
||||
* tool-gjallarhorn - Heimdall swiss army knife
|
||||
|
||||
## Installation
|
||||
|
||||
The following command downloads and runs a script that will fetch the latest update manifest, and
|
||||
install all plugins available. It can also be used to update plugins to the latest version
|
||||
available.
|
||||
|
||||
```bash
|
||||
# Always validate the contents of a script from the internet!
|
||||
bash -c "$(curl -sL https://git.mystic.run/minecraft/foundation/-/raw/main/install.sh)"
|
||||
```
|
||||
|
@ -1,24 +1,21 @@
|
||||
import cloud.kubelet.foundation.gradle.FoundationProjectPlugin
|
||||
import cloud.kubelet.foundation.gradle.isFoundationPlugin
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
import org.jetbrains.kotlin.com.google.gson.Gson
|
||||
import java.io.FileWriter
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
java
|
||||
id("org.jetbrains.kotlin.jvm") version "1.6.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.6.10" apply false
|
||||
id("com.github.johnrengelman.shadow") version "7.1.1" apply false
|
||||
id("cloud.kubelet.foundation.gradle")
|
||||
}
|
||||
|
||||
// Disable the JAR task for the root project.
|
||||
tasks["jar"].enabled = false
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = "papermc-repo"
|
||||
name = "papermc"
|
||||
url = uri("https://papermc.io/repo/repository/maven-public/")
|
||||
}
|
||||
|
||||
maven {
|
||||
name = "sonatype"
|
||||
url = uri("https://oss.sonatype.org/content/groups/public/")
|
||||
@ -26,61 +23,29 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
val manifestsDir = buildDir.resolve("manifests")
|
||||
manifestsDir.mkdirs()
|
||||
val gson = Gson()
|
||||
|
||||
tasks.create("updateManifests") {
|
||||
// TODO: not using task dependencies, outputs, blah blah blah.
|
||||
doLast {
|
||||
val updateFile = manifestsDir.resolve("update.json")
|
||||
val writer = FileWriter(updateFile)
|
||||
writer.use {
|
||||
val rootPath = rootProject.rootDir.toPath()
|
||||
val updateManifest = subprojects.mapNotNull { project ->
|
||||
val files = project.tasks.getByName("shadowJar").outputs
|
||||
val paths = files.files.map { rootPath.relativize(it.toPath()).toString() }
|
||||
|
||||
if (paths.isNotEmpty()) project.name to mapOf(
|
||||
"version" to project.version,
|
||||
"artifacts" to paths,
|
||||
)
|
||||
else null
|
||||
}.toMap()
|
||||
|
||||
gson.toJson(
|
||||
updateManifest,
|
||||
writer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.assemble {
|
||||
dependsOn("updateManifests")
|
||||
}
|
||||
|
||||
version = "0.2"
|
||||
|
||||
subprojects {
|
||||
plugins.apply("org.jetbrains.kotlin.jvm")
|
||||
plugins.apply("org.jetbrains.kotlin.plugin.serialization")
|
||||
plugins.apply("com.github.johnrengelman.shadow")
|
||||
plugins.apply(FoundationProjectPlugin::class)
|
||||
|
||||
version = "0.1"
|
||||
group = "io.gorence"
|
||||
|
||||
// Add build number if running under CI.
|
||||
val versionWithBuild = if (System.getenv("CI_PIPELINE_IID") != null) {
|
||||
version as String + ".${System.getenv("CI_PIPELINE_IID")}"
|
||||
} else {
|
||||
"DEV"
|
||||
}
|
||||
version = versionWithBuild
|
||||
group = "lgbt.mystic"
|
||||
|
||||
dependencies {
|
||||
// Kotlin dependencies
|
||||
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
|
||||
// Core libraries.
|
||||
implementation("io.insert-koin:koin-core:3.1.4")
|
||||
testImplementation("io.insert-koin:koin-test:3.1.4")
|
||||
|
||||
// Serialization
|
||||
implementation("com.charleskorn.kaml:kaml:0.38.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1")
|
||||
@ -90,7 +55,7 @@ subprojects {
|
||||
implementation("org.jetbrains.xodus:xodus-entity-store:1.3.232")
|
||||
|
||||
// Paper API
|
||||
compileOnly("io.papermc.paper:paper-api:1.18.1-R0.1-SNAPSHOT")
|
||||
compileOnly("io.papermc.paper:paper-api:1.18.2-R0.1-SNAPSHOT")
|
||||
}
|
||||
|
||||
java {
|
||||
@ -99,6 +64,13 @@ subprojects {
|
||||
targetCompatibility = javaVersion
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs =
|
||||
freeCompilerArgs + "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
val props = mapOf("version" to version)
|
||||
inputs.properties(props)
|
||||
@ -108,11 +80,18 @@ subprojects {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<ShadowJar> {
|
||||
archiveClassifier.set("plugin")
|
||||
if (project.isFoundationPlugin()) {
|
||||
tasks.withType<ShadowJar> {
|
||||
archiveClassifier.set("plugin")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.assemble {
|
||||
dependsOn("shadowJar")
|
||||
}
|
||||
}
|
||||
|
||||
foundation {
|
||||
minecraftServerPath.set("server")
|
||||
paperVersionGroup.set("1.18")
|
||||
}
|
||||
|
28
buildSrc/build.gradle.kts
Normal file
28
buildSrc/build.gradle.kts
Normal file
@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
kotlin("plugin.serialization") version "1.6.21"
|
||||
}
|
||||
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
|
||||
implementation("org.jetbrains.kotlin:kotlin-serialization:1.6.21")
|
||||
implementation("gradle.plugin.com.github.johnrengelman:shadow:7.1.2")
|
||||
implementation("com.google.code.gson:gson:2.9.0")
|
||||
implementation("org.bouncycastle:bcprov-jdk15on:1.70")
|
||||
}
|
||||
|
||||
java.sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
java.targetCompatibility = JavaVersion.VERSION_1_8
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("foundation") {
|
||||
id = "cloud.kubelet.foundation.gradle"
|
||||
implementationClass = "cloud.kubelet.foundation.gradle.FoundationGradlePlugin"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.provider.Property
|
||||
|
||||
interface FoundationExtension {
|
||||
val paperVersionGroup: Property<String>
|
||||
val minecraftServerPath: Property<String>
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import com.google.gson.Gson
|
||||
|
||||
object FoundationGlobals {
|
||||
val gson = Gson()
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.create
|
||||
|
||||
class FoundationGradlePlugin : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
project.extensions.create<FoundationExtension>("foundation")
|
||||
val setupPaperServer = project.tasks.create<SetupPaperServer>("setupPaperServer")
|
||||
project.afterEvaluate { ->
|
||||
setupPaperServer.dependsOn(*project.subprojects
|
||||
.filter { it.name.startsWith("foundation-") }
|
||||
.map { it.tasks.getByName("shadowJar") }
|
||||
.toTypedArray()
|
||||
)
|
||||
}
|
||||
val runPaperServer = project.tasks.create<RunPaperServer>("runPaperServer")
|
||||
runPaperServer.dependsOn(setupPaperServer)
|
||||
|
||||
val updateManifests = project.tasks.create<UpdateManifestTask>("updateManifests")
|
||||
project.tasks.getByName("assemble").dependsOn(updateManifests)
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
|
||||
class FoundationProjectPlugin : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
val versionWithBuild = if (System.getenv("CI_PIPELINE_IID") != null) {
|
||||
project.rootProject.version.toString() + ".${System.getenv("CI_PIPELINE_IID")}"
|
||||
} else {
|
||||
"DEV"
|
||||
}
|
||||
|
||||
project.version = versionWithBuild
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import com.google.gson.Gson
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
|
||||
class PaperVersionClient(
|
||||
val client: HttpClient = HttpClient.newHttpClient(),
|
||||
private val gson: Gson = FoundationGlobals.gson
|
||||
) {
|
||||
private val apiBaseUrl = URI.create("https://papermc.io/api/v2/")
|
||||
|
||||
fun getVersionBuilds(group: String): List<PaperBuild> {
|
||||
val response = client.send(
|
||||
HttpRequest.newBuilder()
|
||||
.GET()
|
||||
.uri(apiBaseUrl.resolve("projects/paper/version_group/${group}/builds"))
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
)
|
||||
|
||||
val body = response.body()
|
||||
val root = gson.fromJson(body, PaperVersionRoot::class.java)
|
||||
return root.builds
|
||||
}
|
||||
|
||||
fun resolveDownloadUrl(build: PaperBuild, download: PaperVersionDownload): URI =
|
||||
apiBaseUrl.resolve("projects/paper/versions/${build.version}/builds/${build.build}/downloads/${download.name}")
|
||||
|
||||
data class PaperVersionRoot(
|
||||
val builds: List<PaperBuild>
|
||||
)
|
||||
|
||||
data class PaperBuild(
|
||||
val version: String,
|
||||
val build: Int,
|
||||
val downloads: Map<String, PaperVersionDownload>
|
||||
)
|
||||
|
||||
data class PaperVersionDownload(
|
||||
val name: String,
|
||||
val sha256: String
|
||||
)
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.kotlin.dsl.getByType
|
||||
import java.io.File
|
||||
import java.util.jar.JarFile
|
||||
|
||||
open class RunPaperServer : DefaultTask() {
|
||||
init {
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
@TaskAction
|
||||
fun runPaperServer() {
|
||||
val foundation = project.extensions.getByType<FoundationExtension>()
|
||||
|
||||
val minecraftServerDirectory = project.file(foundation.minecraftServerPath.get())
|
||||
val paperJarFile = minecraftServerDirectory.resolve("paper.jar")
|
||||
val mainClassName = readMainClass(paperJarFile)
|
||||
|
||||
project.javaexec {
|
||||
classpath(paperJarFile.absolutePath)
|
||||
workingDir(minecraftServerDirectory)
|
||||
args("nogui")
|
||||
mainClass.set(mainClassName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readMainClass(file: File): String = JarFile(file).use { jar ->
|
||||
jar.manifest.mainAttributes.getValue("Main-Class")!!
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.api.tasks.options.Option
|
||||
import org.gradle.kotlin.dsl.getByType
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
open class SetupPaperServer : DefaultTask() {
|
||||
init {
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
@get:Input
|
||||
@set:Option(option = "update", description = "Update Paper Server")
|
||||
var shouldUpdatePaperServer = false
|
||||
|
||||
private val paperVersionClient = PaperVersionClient()
|
||||
|
||||
@TaskAction
|
||||
fun downloadPaperTask() {
|
||||
val foundation = project.extensions.getByType<FoundationExtension>()
|
||||
val minecraftServerDirectory = project.file(foundation.minecraftServerPath.get())
|
||||
|
||||
if (!minecraftServerDirectory.exists()) {
|
||||
minecraftServerDirectory.mkdirs()
|
||||
}
|
||||
|
||||
val paperJarFile = project.file("${foundation.minecraftServerPath.get()}/paper.jar")
|
||||
if (!paperJarFile.exists() || shouldUpdatePaperServer) {
|
||||
downloadLatestBuild(foundation.paperVersionGroup.get(), paperJarFile)
|
||||
}
|
||||
|
||||
val paperPluginsDirectory = minecraftServerDirectory.resolve("plugins")
|
||||
|
||||
if (!paperPluginsDirectory.exists()) {
|
||||
paperPluginsDirectory.mkdirs()
|
||||
}
|
||||
|
||||
for (project in project.subprojects) {
|
||||
if (!project.name.startsWith("foundation-")) {
|
||||
continue
|
||||
}
|
||||
|
||||
val pluginJarFile = project.buildDir.resolve("libs/${project.name}-DEV-plugin.jar")
|
||||
val pluginLinkFile = paperPluginsDirectory.resolve("${project.name}.jar")
|
||||
if (pluginLinkFile.exists()) {
|
||||
pluginLinkFile.delete()
|
||||
}
|
||||
|
||||
Files.createSymbolicLink(pluginLinkFile.toPath(), pluginJarFile.toPath())
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadLatestBuild(paperVersionGroup: String, paperJarFile: File) {
|
||||
val builds = paperVersionClient.getVersionBuilds(paperVersionGroup)
|
||||
val build = builds.last()
|
||||
val download = build.downloads["application"]!!
|
||||
val url = paperVersionClient.resolveDownloadUrl(build, download)
|
||||
val downloader = SmartDownloader(paperJarFile.toPath(), url, download.sha256)
|
||||
if (downloader.download()) {
|
||||
logger.lifecycle("Installed Paper Server ${build.version} build ${build.build}")
|
||||
} else {
|
||||
logger.lifecycle("Paper Server ${build.version} build ${build.build} is up-to-date")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.MessageDigest
|
||||
|
||||
class SmartDownloader(
|
||||
private val localFilePath: Path,
|
||||
private val remoteDownloadUrl: URI,
|
||||
private val sha256: String
|
||||
) {
|
||||
fun download(): Boolean {
|
||||
val hashResult = checkLocalFileHash()
|
||||
if (hashResult != HashResult.ValidHash) {
|
||||
downloadRemoteFile()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadRemoteFile() {
|
||||
val url = remoteDownloadUrl.toURL()
|
||||
val remoteFileStream = url.openStream()
|
||||
val localFileStream = Files.newOutputStream(localFilePath)
|
||||
remoteFileStream.transferTo(localFileStream)
|
||||
val hashResult = checkLocalFileHash()
|
||||
if (hashResult != HashResult.ValidHash) {
|
||||
throw RuntimeException("Download of $remoteDownloadUrl did not result in valid hash.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkLocalFileHash(): HashResult {
|
||||
if (!Files.exists(localFilePath)) {
|
||||
return HashResult.DoesNotExist
|
||||
}
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val localFileStream = Files.newInputStream(localFilePath)
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
|
||||
while (true) {
|
||||
val size = localFileStream.read(buffer)
|
||||
if (size <= 0) {
|
||||
break
|
||||
}
|
||||
|
||||
val bytes = buffer.take(size).toByteArray()
|
||||
digest.update(bytes)
|
||||
}
|
||||
|
||||
val sha256Bytes = digest.digest()
|
||||
val localSha256Hash = bytesToHex(sha256Bytes)
|
||||
return if (localSha256Hash.equals(sha256, ignoreCase = true)) {
|
||||
HashResult.ValidHash
|
||||
} else {
|
||||
HashResult.InvalidHash
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytesToHex(hash: ByteArray): String {
|
||||
val hexString = StringBuilder(2 * hash.size)
|
||||
for (i in hash.indices) {
|
||||
val hex = Integer.toHexString(0xff and hash[i].toInt())
|
||||
if (hex.length == 1) {
|
||||
hexString.append('0')
|
||||
}
|
||||
hexString.append(hex)
|
||||
}
|
||||
return hexString.toString()
|
||||
}
|
||||
|
||||
enum class HashResult {
|
||||
DoesNotExist,
|
||||
InvalidHash,
|
||||
ValidHash
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
open class UpdateManifestTask : DefaultTask() {
|
||||
@TaskAction
|
||||
fun update() {
|
||||
val manifestsDir = ensureManifestsDir()
|
||||
val updateFile = manifestsDir.resolve("update.json")
|
||||
val rootPath = project.rootProject.rootDir.toPath()
|
||||
val updateManifest = project.findPluginProjects().mapNotNull { project ->
|
||||
val paths = project.shadowJarOutputs.allFilesRelativeToPath(rootPath)
|
||||
if (paths.isNotEmpty()) {
|
||||
project.name to mapOf(
|
||||
"version" to project.version,
|
||||
"artifacts" to paths.map { it.toUnixString() }
|
||||
)
|
||||
} else null
|
||||
}.toMap()
|
||||
|
||||
Files.writeString(updateFile, FoundationGlobals.gson.toJson(updateManifest))
|
||||
}
|
||||
|
||||
private fun ensureManifestsDir(): Path {
|
||||
val manifestsDir = project.buildDir.resolve("manifests")
|
||||
manifestsDir.mkdirs()
|
||||
return manifestsDir.toPath()
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.foundation.gradle
|
||||
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.tasks.TaskOutputs
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
|
||||
fun Project.isFoundationPlugin() = name.startsWith("foundation-")
|
||||
|
||||
fun Project.findPluginProjects() = rootProject.subprojects.filter { project -> project.isFoundationPlugin() }
|
||||
|
||||
val Project.shadowJarOutputs: TaskOutputs
|
||||
get() = project.tasks.getByName("shadowJar").outputs
|
||||
|
||||
fun TaskOutputs.allFilesRelativeToPath(root: Path): List<Path> = files.map { root.relativize(it.toPath()) }
|
||||
|
||||
fun Path.toUnixString() = toString().replace(FileSystems.getDefault().separator, "/")
|
@ -2,6 +2,7 @@ dependencies {
|
||||
implementation("net.dv8tion:JDA:5.0.0-alpha.2") {
|
||||
exclude(module = "opus-java")
|
||||
}
|
||||
implementation("com.rabbitmq:amqp-client:5.14.2")
|
||||
|
||||
compileOnly(project(":foundation-core"))
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
package cloud.kubelet.foundation.bifrost
|
||||
|
||||
import io.papermc.paper.event.player.AsyncChatEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
|
||||
interface EventHandler {
|
||||
fun onPlayerJoin(e: PlayerJoinEvent)
|
||||
fun onPlayerQuit(e: PlayerQuitEvent)
|
||||
fun onChat(e: AsyncChatEvent)
|
||||
}
|
@ -3,30 +3,35 @@ package cloud.kubelet.foundation.bifrost
|
||||
import cloud.kubelet.foundation.bifrost.model.BifrostConfig
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.core.util.AdvancementTitleCache
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import io.papermc.paper.event.player.AsyncChatEvent
|
||||
import net.dv8tion.jda.api.EmbedBuilder
|
||||
import net.dv8tion.jda.api.JDA
|
||||
import net.dv8tion.jda.api.JDABuilder
|
||||
import net.dv8tion.jda.api.MessageBuilder
|
||||
import net.dv8tion.jda.api.entities.Message
|
||||
import net.dv8tion.jda.api.entities.TextChannel
|
||||
import net.dv8tion.jda.api.events.GenericEvent
|
||||
import net.dv8tion.jda.api.events.ReadyEvent
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent
|
||||
import net.dv8tion.jda.api.hooks.EventListener
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.TextComponent
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.entity.PlayerDeathEvent
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.awt.Color
|
||||
import kotlin.io.path.inputStream
|
||||
import net.dv8tion.jda.api.hooks.EventListener as DiscordEventListener
|
||||
import org.bukkit.event.Listener as BukkitEventListener
|
||||
|
||||
class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
class FoundationBifrostPlugin : JavaPlugin(), DiscordEventListener, BukkitEventListener {
|
||||
private lateinit var config: BifrostConfig
|
||||
private lateinit var jda: JDA
|
||||
private var jda: JDA? = null
|
||||
private var isDev = false
|
||||
|
||||
override fun onEnable() {
|
||||
@ -43,6 +48,10 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
config = Yaml.default.decodeFromStream(BifrostConfig.serializer(), configPath.inputStream())
|
||||
|
||||
server.pluginManager.registerEvents(this, this)
|
||||
if (config.authentication.token.isEmpty()) {
|
||||
slF4JLogger.warn("Token empty, Bifrost will not connect to Discord.")
|
||||
return
|
||||
}
|
||||
|
||||
jda = JDABuilder
|
||||
.createDefault(config.authentication.token)
|
||||
@ -51,11 +60,14 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
// Plugin was not initialized, don't do anything.
|
||||
if (jda == null) return
|
||||
|
||||
onServerStop()
|
||||
|
||||
logger.info("Shutting down JDA")
|
||||
jda.shutdown()
|
||||
while (jda.status != JDA.Status.SHUTDOWN) {
|
||||
jda?.shutdown()
|
||||
while (jda != null && jda!!.status != JDA.Status.SHUTDOWN) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
@ -63,13 +75,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
override fun onEvent(e: GenericEvent) {
|
||||
when (e) {
|
||||
is ReadyEvent -> {
|
||||
val channel = getChannel() ?: return
|
||||
if (isDev) return
|
||||
channel.sendMessage(":white_check_mark: Server is ready!").queue()
|
||||
onDiscordReady()
|
||||
}
|
||||
is MessageReceivedEvent -> {
|
||||
if (!config.channel.bridge) return
|
||||
// Prevent this bot from receiving its own messages and creating a feedback loop.
|
||||
if (e.author.id == jda.selfUser.id) return
|
||||
if (e.author.id == jda?.selfUser?.id) return
|
||||
|
||||
// Only forward messages from the configured channel.
|
||||
if (e.channel.id != config.channel.id) return
|
||||
@ -82,8 +93,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChannel(): TextChannel? {
|
||||
val channel = jda.getTextChannelById(config.channel.id)
|
||||
private fun getTextChannel(): TextChannel? {
|
||||
if (jda == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val channel = jda?.getTextChannelById(config.channel.id)
|
||||
if (channel == null) {
|
||||
slF4JLogger.error("Failed to retrieve channel ${config.channel.id}")
|
||||
}
|
||||
@ -95,45 +110,85 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
||||
setEmbeds(EmbedBuilder().apply(f).build())
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerJoin(e: PlayerJoinEvent) {
|
||||
val channel = getChannel() ?: return
|
||||
private fun sendChannelMessage(message: Message, debug: () -> String) {
|
||||
val channel = getTextChannel()
|
||||
channel?.sendMessage(message)?.queue()
|
||||
|
||||
channel.sendMessage(message {
|
||||
embed {
|
||||
setAuthor("${e.player.name} joined the server")
|
||||
setColor(Color.GREEN)
|
||||
}
|
||||
}).queue()
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerQuit(e: PlayerQuitEvent) {
|
||||
val channel = getChannel() ?: return
|
||||
|
||||
channel.sendMessage(message {
|
||||
embed {
|
||||
setAuthor("${e.player.name} left the server")
|
||||
setColor(Color.RED)
|
||||
}
|
||||
}).queue()
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private fun onPlayerChat(e: AsyncChatEvent) {
|
||||
val channel = getChannel() ?: return
|
||||
val message = e.message()
|
||||
|
||||
if (message is TextComponent) {
|
||||
channel.sendMessage("${e.player.name}: ${message.content()}").queue()
|
||||
} else {
|
||||
slF4JLogger.error("Not sure what to do here, message != TextComponent: ${message.javaClass}")
|
||||
if (config.enableDebugLog) {
|
||||
slF4JLogger.info("Send '${debug()}' to Discord")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServerStop() {
|
||||
val channel = getChannel() ?: return
|
||||
private fun sendChannelMessage(message: String): Unit = sendChannelMessage(message {
|
||||
setContent(message)
|
||||
}) { message }
|
||||
|
||||
private fun sendEmbedMessage(color: Color, message: String): Unit = sendChannelMessage(message {
|
||||
embed {
|
||||
setAuthor(message)
|
||||
setColor(color)
|
||||
}
|
||||
}) { "[rgb:${color.rgb}] $message" }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
private fun onPlayerJoin(e: PlayerJoinEvent) {
|
||||
if (!config.channel.sendPlayerJoin) return
|
||||
|
||||
sendEmbedMessage(Color.GREEN, "${e.player.name} joined the server")
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
private fun onPlayerQuit(e: PlayerQuitEvent) {
|
||||
if (!config.channel.sendPlayerQuit) return
|
||||
|
||||
sendEmbedMessage(Color.RED, "${e.player.name} left the server")
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
private fun onPlayerChat(e: AsyncChatEvent) {
|
||||
if (!config.channel.bridge) return
|
||||
val message = e.message()
|
||||
|
||||
val messageAsText = LegacyComponentSerializer.legacySection().serialize(message)
|
||||
sendChannelMessage("${e.player.name}: $messageAsText")
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
private fun onPlayerDeath(e: PlayerDeathEvent) {
|
||||
if (!config.channel.sendPlayerDeath) return
|
||||
@Suppress("DEPRECATION")
|
||||
var deathMessage = e.deathMessage
|
||||
if (deathMessage == null || deathMessage.isBlank()) {
|
||||
deathMessage = "${e.player.name} died"
|
||||
}
|
||||
sendEmbedMessage(Color.YELLOW, deathMessage)
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
private fun onPlayerAdvancementDone(e: PlayerAdvancementDoneEvent) {
|
||||
if (!config.channel.sendPlayerAdvancement) return
|
||||
if (e.advancement.key.key.contains("recipe/")) {
|
||||
return
|
||||
}
|
||||
|
||||
val advancementDisplay = e.advancement.display ?: return
|
||||
if (!advancementDisplay.doesAnnounceToChat()) {
|
||||
return
|
||||
}
|
||||
|
||||
val display = AdvancementTitleCache.of(e.advancement) ?: return
|
||||
sendEmbedMessage(Color.CYAN, "${e.player.name} completed the advancement '${display}'")
|
||||
}
|
||||
|
||||
private fun onDiscordReady() {
|
||||
if (!config.channel.sendStart) return
|
||||
if (isDev) return
|
||||
channel.sendMessage(":octagonal_sign: Server is stopping!").queue()
|
||||
sendChannelMessage(":white_check_mark: Server is ready!")
|
||||
}
|
||||
|
||||
private fun onServerStop() {
|
||||
if (!config.channel.sendShutdown) return
|
||||
if (isDev) return
|
||||
sendChannelMessage(":octagonal_sign: Server is stopping!")
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
package cloud.kubelet.foundation.bifrost
|
||||
|
||||
import cloud.kubelet.foundation.bifrost.model.BifrostMessageQueueConfig
|
||||
import cloud.kubelet.foundation.bifrost.model.BifrostMultiConfig
|
||||
import com.rabbitmq.client.Connection
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import io.papermc.paper.event.player.AsyncChatEvent
|
||||
|
||||
class MultiServerEventHandler(config: BifrostMultiConfig) : EventHandler {
|
||||
private val bus = buildConnection(config.messageQueue)
|
||||
private val channel = bus.createChannel()
|
||||
|
||||
init {
|
||||
channel.queueDeclare(config.messageQueue.queueName, false, false, false, emptyMap())
|
||||
}
|
||||
|
||||
override fun onChat(e: AsyncChatEvent) {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun buildConnection(config: BifrostMessageQueueConfig): Connection = ConnectionFactory().apply {
|
||||
host = config.host
|
||||
port = config.port
|
||||
}.newConnection()
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
|
||||
data class BifrostConfig(
|
||||
val authentication: BifrostAuthentication,
|
||||
val channel: BifrostChannel,
|
||||
val enableDebugLog: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@ -16,4 +17,11 @@ data class BifrostAuthentication(
|
||||
@Serializable
|
||||
data class BifrostChannel(
|
||||
val id: String,
|
||||
val bridge: Boolean = true,
|
||||
val sendStart: Boolean = true,
|
||||
val sendShutdown: Boolean = true,
|
||||
val sendPlayerJoin: Boolean = true,
|
||||
val sendPlayerQuit: Boolean = true,
|
||||
val sendPlayerDeath: Boolean = true,
|
||||
val sendPlayerAdvancement: Boolean = true
|
||||
)
|
||||
|
@ -0,0 +1,19 @@
|
||||
package cloud.kubelet.foundation.bifrost.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BifrostMultiConfig(
|
||||
val messageQueue: BifrostMessageQueueConfig,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BifrostMessageQueueConfig(
|
||||
val host: String = "localhost",
|
||||
val port: Int = 5672,
|
||||
|
||||
/**
|
||||
* Name of the RabbitMQ queue
|
||||
*/
|
||||
val queueName: String = "bifrost",
|
||||
)
|
5
foundation-bifrost/src/main/resources/bifrost-multi.yaml
Normal file
5
foundation-bifrost/src/main/resources/bifrost-multi.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
# Configuration for the Bifrost multi-server chat bridge.
|
||||
|
||||
messageQueue:
|
||||
host: localhost
|
||||
|
@ -1,10 +1,25 @@
|
||||
# Authentication configuration for the bridge.
|
||||
authentication:
|
||||
# Token from the Discord Bot developer's page.
|
||||
token: abc123
|
||||
# Token from the Discord Bot developer's page. If this is empty, the Bifrost plugin will do
|
||||
# nothing.
|
||||
token: ""
|
||||
|
||||
# Channel configuration for the bridge.
|
||||
channel:
|
||||
# Channel ID, can be copied by turning on Developer Mode in User Settings -> Advanced. The ID can
|
||||
# then be copied by right-clicking the channel and selecting "Copy ID".
|
||||
id: 123456789
|
||||
|
||||
# Toggles the chat message bridge.
|
||||
bridge: true
|
||||
|
||||
# Toggles for common events that generate notifications that are sent to the channel.
|
||||
sendStart: true
|
||||
sendShutdown: true
|
||||
sendPlayerJoin: true
|
||||
sendPlayerQuit: true
|
||||
sendPlayerDeath: true
|
||||
sendPlayerAdvancement: true
|
||||
|
||||
# Enables logging of what is sent to Discord.
|
||||
enableDebugLog: false
|
||||
|
@ -1,3 +1,7 @@
|
||||
dependencies {
|
||||
// TODO: might be able to ship all dependencies in core? are we duplicating classes in JARs?
|
||||
|
||||
implementation("software.amazon.awssdk:s3:2.17.102")
|
||||
implementation("org.quartz-scheduler:quartz:2.3.2")
|
||||
implementation("com.google.guava:guava:31.0.1-jre")
|
||||
}
|
||||
|
@ -1,22 +1,19 @@
|
||||
package cloud.kubelet.foundation.core
|
||||
|
||||
import cloud.kubelet.foundation.core.command.*
|
||||
import cloud.kubelet.foundation.core.persist.PersistentStore
|
||||
import cloud.kubelet.foundation.core.persist.setAllProperties
|
||||
import io.papermc.paper.event.player.AsyncChatEvent
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.TextComponent
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import cloud.kubelet.foundation.core.abstraction.FoundationPlugin
|
||||
import cloud.kubelet.foundation.core.features.backup.BackupFeature
|
||||
import cloud.kubelet.foundation.core.features.dev.DevFeature
|
||||
import cloud.kubelet.foundation.core.features.gameplay.GameplayFeature
|
||||
import cloud.kubelet.foundation.core.features.persist.PersistenceFeature
|
||||
import cloud.kubelet.foundation.core.features.player.PlayerFeature
|
||||
import cloud.kubelet.foundation.core.features.scheduler.SchedulerFeature
|
||||
import cloud.kubelet.foundation.core.features.stats.StatsFeature
|
||||
import cloud.kubelet.foundation.core.features.update.UpdateFeature
|
||||
import cloud.kubelet.foundation.core.features.world.WorldFeature
|
||||
import org.koin.dsl.module
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FoundationCorePlugin : JavaPlugin(), Listener {
|
||||
internal val persistentStores = ConcurrentHashMap<String, PersistentStore>()
|
||||
class FoundationCorePlugin : FoundationPlugin() {
|
||||
private lateinit var _pluginDataPath: Path
|
||||
|
||||
var pluginDataPath: Path
|
||||
@ -34,98 +31,27 @@ class FoundationCorePlugin : JavaPlugin(), Listener {
|
||||
_pluginDataPath = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a persistent store by name. Make sure the name is path-safe, descriptive and consistent across server runs.
|
||||
*/
|
||||
fun getPersistentStore(name: String) = persistentStores.getOrPut(name) { PersistentStore(this, name) }
|
||||
|
||||
private lateinit var chatLogStore: PersistentStore
|
||||
|
||||
override fun onEnable() {
|
||||
// Create core plugin directory.
|
||||
pluginDataPath = dataFolder.toPath()
|
||||
val backupPath = pluginDataPath.resolve(BACKUPS_DIRECTORY)
|
||||
|
||||
// Create Foundation plugin directories.
|
||||
pluginDataPath.toFile().mkdir()
|
||||
backupPath.toFile().mkdir()
|
||||
|
||||
// Register this as an event listener.
|
||||
server.pluginManager.registerEvents(this, this)
|
||||
|
||||
// Register commands.
|
||||
registerCommandExecutor("fbackup", BackupCommand(this, backupPath))
|
||||
registerCommandExecutor("fupdate", UpdateCommand())
|
||||
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("leaderboard", "lb"), LeaderboardCommand())
|
||||
registerCommandExecutor(listOf("pstorestats"), StoreStatsCommand(this))
|
||||
|
||||
val log = slF4JLogger
|
||||
log.info("Features:")
|
||||
Util.printFeatureStatus(log, "Backup", BACKUP_ENABLED)
|
||||
chatLogStore = getPersistentStore("chat-logs")
|
||||
super.onEnable()
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
persistentStores.values.forEach { store -> store.close() }
|
||||
persistentStores.clear()
|
||||
override fun createFeatures() = listOf(
|
||||
SchedulerFeature(),
|
||||
PersistenceFeature(),
|
||||
BackupFeature(),
|
||||
DevFeature(),
|
||||
GameplayFeature(),
|
||||
PlayerFeature(),
|
||||
StatsFeature(),
|
||||
UpdateFeature(),
|
||||
WorldFeature(),
|
||||
)
|
||||
|
||||
override fun createModule() = module {
|
||||
single { this@FoundationCorePlugin }
|
||||
}
|
||||
|
||||
private fun registerCommandExecutor(name: String, executor: CommandExecutor) {
|
||||
registerCommandExecutor(listOf(name), executor)
|
||||
}
|
||||
|
||||
private fun registerCommandExecutor(names: List<String>, executor: CommandExecutor) {
|
||||
for (name in names) {
|
||||
val command = getCommand(name) ?: throw Exception("Failed to get $name command")
|
||||
command.setExecutor(executor)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Disabling chat reformatting until I do something with it and figure out how to make it
|
||||
// be less disruptive.
|
||||
/*@EventHandler
|
||||
private fun onChatMessage(e: ChatEvent) {
|
||||
return
|
||||
e.isCancelled = true
|
||||
val name = e.player.displayName()
|
||||
val component = Component.empty()
|
||||
.append(leftBracket)
|
||||
.append(name)
|
||||
.append(rightBracket)
|
||||
.append(Component.text(' '))
|
||||
.append(e.message())
|
||||
server.sendMessage(component)
|
||||
}*/
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BACKUPS_DIRECTORY = "backups"
|
||||
|
||||
private val leftBracket: Component = Component.text('[')
|
||||
private val rightBracket: Component = Component.text(']')
|
||||
|
||||
const val BACKUP_ENABLED = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,10 +11,6 @@ object Util {
|
||||
private val whitespace: Component = Component.text(' ')
|
||||
private val foundationName: Component = Component.text("Foundation")
|
||||
|
||||
fun printFeatureStatus(logger: Logger, feature: String?, state: Boolean) {
|
||||
logger.info("{}: {}", feature, if (state) "Enabled" else "Disabled")
|
||||
}
|
||||
|
||||
fun formatSystemMessage(message: String): Component {
|
||||
return formatSystemMessage(TextColors.AMARANTH_PINK, message)
|
||||
}
|
||||
@ -61,4 +57,9 @@ object Util {
|
||||
|
||||
return outPath
|
||||
}
|
||||
|
||||
fun isPlatformWindows(): Boolean {
|
||||
val os = System.getProperty("os.name")
|
||||
return os != null && os.lowercase().startsWith("windows")
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package cloud.kubelet.foundation.core.abstraction
|
||||
|
||||
interface CoreFeature {
|
||||
fun enable()
|
||||
fun disable()
|
||||
fun module() = org.koin.dsl.module {}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package cloud.kubelet.foundation.core.abstraction
|
||||
|
||||
import cloud.kubelet.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 cloud.kubelet.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>
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
package cloud.kubelet.foundation.core.command
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
|
||||
class StoreStatsCommand(private val plugin: FoundationCorePlugin) : CommandExecutor {
|
||||
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
|
||||
plugin.persistentStores.forEach { (name, store) ->
|
||||
val counts = store.transact { tx ->
|
||||
tx.entityTypes.associateWith { type -> tx.getAll(type).size() }.toSortedMap()
|
||||
}
|
||||
|
||||
sender.sendMessage(
|
||||
"Store $name ->",
|
||||
*counts.map { " ${it.key} -> ${it.value} entries" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package cloud.kubelet.foundation.core.command
|
||||
|
||||
import cloud.kubelet.foundation.core.update.UpdateUtil
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.toPath
|
||||
|
||||
class UpdateCommand : CommandExecutor {
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean {
|
||||
val updateDir = sender.server.pluginsFolder.resolve("update")
|
||||
if (!updateDir.exists()) {
|
||||
sender.sendMessage("Error: Failed to create plugin update directory.")
|
||||
return true
|
||||
}
|
||||
val updatePath = updateDir.toPath()
|
||||
|
||||
// TODO: Move to separate 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")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.command
|
||||
package cloud.kubelet.foundation.core.features.backup
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
@ -8,33 +8,31 @@ 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 backupPath: Path
|
||||
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 (!FoundationCorePlugin.BACKUP_ENABLED) {
|
||||
sender.sendMessage(
|
||||
Component
|
||||
.text("Backup is not enabled.")
|
||||
.color(TextColor.fromHexString("#FF0000"))
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (RUNNING.get()) {
|
||||
sender.sendMessage(
|
||||
Component
|
||||
@ -44,35 +42,59 @@ class BackupCommand(
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
val server = sender.server
|
||||
server.scheduler.runTaskAsynchronously(plugin, Runnable {
|
||||
runBackup(server)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
sender.sendMessage(String.format("Failed to backup: %s", e.message))
|
||||
val server = sender.server
|
||||
server.scheduler.runTaskAsynchronously(plugin) { ->
|
||||
runBackup(server, sender)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runBackup(server: Server) {
|
||||
// TODO: Pull backup creation code into a separate service.
|
||||
private fun runBackup(server: Server, sender: CommandSender? = null) = try {
|
||||
RUNNING.set(true)
|
||||
|
||||
server.sendMessage(Util.formatSystemMessage("Backup started."))
|
||||
server.scheduler.runTask(plugin) { ->
|
||||
server.sendMessage(Util.formatSystemMessage("Backup started."))
|
||||
}
|
||||
|
||||
val backupFile =
|
||||
backupPath.resolve(String.format("backup-%s.zip", Instant.now().toString())).toFile()
|
||||
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()
|
||||
|
||||
try {
|
||||
FileOutputStream(backupFile).use { zipFileStream ->
|
||||
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
|
||||
backupPlugins(server, zipStream)
|
||||
backupWorlds(server, zipStream)
|
||||
}
|
||||
FileOutputStream(backupFile).use { zipFileStream ->
|
||||
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
|
||||
backupPlugins(server, zipStream)
|
||||
backupWorlds(server, zipStream)
|
||||
}
|
||||
} finally {
|
||||
RUNNING.set(false)
|
||||
}
|
||||
|
||||
// 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."))
|
||||
}
|
||||
}
|
||||
@ -82,7 +104,7 @@ class BackupCommand(
|
||||
addDirectoryToZip(zipStream, server.pluginsFolder.toPath())
|
||||
} catch (e: IOException) {
|
||||
// TODO: Add error handling.
|
||||
e.printStackTrace()
|
||||
plugin.slF4JLogger.warn("Failed to backup plugins.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,11 +133,13 @@ class BackupCommand(
|
||||
}
|
||||
|
||||
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: Path -> Files.isRegularFile(path) }
|
||||
.filter { path -> !matchers.any { it.matches(Paths.get(path.normalize().toString())) } }
|
||||
.toList()
|
||||
val buffer = ByteArray(1024)
|
||||
val backupsPath = backupPath.toRealPath()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
val backupsPath = backupsPath.toRealPath()
|
||||
|
||||
for (path in paths) {
|
||||
val realPath = path.toRealPath()
|
@ -0,0 +1,27 @@
|
||||
package cloud.kubelet.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 cloud.kubelet.foundation.core.features.backup
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
import cloud.kubelet.foundation.core.features.scheduler.cancel
|
||||
import cloud.kubelet.foundation.core.features.scheduler.cron
|
||||
import com.charleskorn.kaml.Yaml
|
||||
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 cloud.kubelet.foundation.core.features.dev
|
||||
|
||||
import cloud.kubelet.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 cloud.kubelet.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 cloud.kubelet.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 cloud.kubelet.foundation.core.features.dev
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.core.features.update.UpdateService
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
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 cloud.kubelet.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 cloud.kubelet.foundation.core.features.gameplay
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
import com.charleskorn.kaml.Yaml
|
||||
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 cloud.kubelet.foundation.core.features.persist
|
||||
|
||||
import cloud.kubelet.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() }
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.persist
|
||||
package cloud.kubelet.foundation.core.features.persist
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import jetbrains.exodus.entitystore.Entity
|
||||
@ -10,7 +10,7 @@ class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) :
|
||||
private val fileStorePath = corePlugin.pluginDataPath.resolve("persistence/${fileStoreName}")
|
||||
private val entityStore = PersistentEntityStores.newInstance(fileStorePath.toFile())
|
||||
|
||||
fun <R> transact(block: (StoreTransaction) -> R): R {
|
||||
fun <R> transact(block: StoreTransaction.() -> R): R {
|
||||
var result: R? = null
|
||||
entityStore.executeInTransaction { tx ->
|
||||
result = block(tx)
|
||||
@ -18,16 +18,18 @@ class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) :
|
||||
return result!!
|
||||
}
|
||||
|
||||
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact { tx ->
|
||||
val entity = tx.newEntity(entityTypeName)
|
||||
populate(entity)
|
||||
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact {
|
||||
populate(newEntity(entityTypeName))
|
||||
}
|
||||
|
||||
fun getAll(entityTypeName: String) =
|
||||
transact { tx -> tx.getAll(entityTypeName) }
|
||||
fun <R> getAll(entityTypeName: String, block: (EntityIterable) -> R): R =
|
||||
transact { block(getAll(entityTypeName)) }
|
||||
|
||||
fun <T> find(entityTypeName: String, propertyName: String, value: Comparable<T>): EntityIterable =
|
||||
transact { tx -> tx.find(entityTypeName, propertyName, value) }
|
||||
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 cloud.kubelet.foundation.core.features.persist
|
||||
|
||||
import cloud.kubelet.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 cloud.kubelet.foundation.core.features.persist
|
||||
|
||||
import cloud.kubelet.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()
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.persist
|
||||
package cloud.kubelet.foundation.core.features.persist
|
||||
|
||||
import jetbrains.exodus.entitystore.Entity
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.command
|
||||
package cloud.kubelet.foundation.core.features.player
|
||||
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.command.Command
|
@ -0,0 +1,50 @@
|
||||
package cloud.kubelet.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 cloud.kubelet.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 cloud.kubelet.foundation.core.features.player
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.google.common.cache.Cache
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.RemovalCause
|
||||
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 cloud.kubelet.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 cloud.kubelet.foundation.core.features.scheduler
|
||||
|
||||
import cloud.kubelet.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 cloud.kubelet.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()
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.command
|
||||
package cloud.kubelet.foundation.core.features.stats
|
||||
|
||||
import cloud.kubelet.foundation.core.SortOrder
|
||||
import cloud.kubelet.foundation.core.allPlayerStatisticsOf
|
||||
@ -6,13 +6,18 @@ 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 {
|
||||
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("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 {
|
||||
@ -30,10 +35,28 @@ class LeaderboardCommand : CommandExecutor {
|
||||
val topFivePlayers = statistics.take(5)
|
||||
sender.sendMessage(
|
||||
"${leaderboardType.friendlyName} Leaderboard:",
|
||||
*topFivePlayers.map { "* ${it.first.name}: ${it.second} ${leaderboardType.unit}" }.toTypedArray()
|
||||
*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,41 @@
|
||||
package cloud.kubelet.foundation.core.features.stats
|
||||
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
import cloud.kubelet.foundation.core.features.persist.*
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.update
|
||||
package cloud.kubelet.foundation.core.features.update
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.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 cloud.kubelet.foundation.core.features.update
|
||||
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
|
||||
class UpdateFeature : Feature() {
|
||||
override fun enable() {
|
||||
registerCommandExecutor("fupdate", UpdateCommand())
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package cloud.kubelet.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()
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package cloud.kubelet.foundation.core.update
|
||||
package cloud.kubelet.foundation.core.features.update
|
||||
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
@ -15,7 +15,7 @@ object UpdateUtil {
|
||||
|
||||
// TODO: Add environment variable override. Document it.
|
||||
private const val basePath =
|
||||
"https://git.gorence.io/lgorence/foundation/-/jobs/artifacts/main/raw"
|
||||
"https://git.mystic.run/minecraft/foundation/-/jobs/artifacts/main/raw"
|
||||
private const val basePathQueryParams = "job=build"
|
||||
private const val manifestPath = "build/manifests/update.json"
|
||||
|
@ -0,0 +1,27 @@
|
||||
package cloud.kubelet.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 cloud.kubelet.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 cloud.kubelet.foundation.core.features.world
|
||||
|
||||
import cloud.kubelet.foundation.core.abstraction.Feature
|
||||
|
||||
class WorldFeature : Feature() {
|
||||
override fun enable() {
|
||||
registerCommandExecutor("setspawn", SetSpawnCommand())
|
||||
registerCommandExecutor("spawn", SpawnCommand())
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package cloud.kubelet.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() }
|
||||
}
|
40
foundation-core/src/main/resources/backup.yaml
Normal file
40
foundation-core/src/main/resources/backup.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
# Configuration of backup scheduling, expressed by cron expressions.
|
||||
schedule:
|
||||
# Cron expression to use for the backup schedule. This is not standard cron, but rather a subset
|
||||
# that is given to us by the Quartz Scheduler. You may use http://www.cronmaker.com/ as a
|
||||
# reference to build out custom cron expressions that are compatible. It should be noted that
|
||||
# the second field is automatically added to the cron expression, as most typical cron expressions
|
||||
# do not include it.
|
||||
# Examples:
|
||||
# "0 3 * * ?" -> every day at 3 AM
|
||||
# "0 3 ? * SUN" -> every Sunday at 3 AM
|
||||
cron: ""
|
||||
|
||||
# List of file patterns to ignore and ignore in the backup.
|
||||
ignore:
|
||||
# Dynmap web output.
|
||||
- "plugins/dynmap/web/**"
|
||||
|
||||
# Configuration of S3 service to upload back-ups to.
|
||||
s3:
|
||||
# The access key ID from your S3-compliant storage provider.
|
||||
# If empty, backups will not be uploaded to S3.
|
||||
accessKeyId: ""
|
||||
|
||||
# The secret access key from your S3-compliant storage provider.
|
||||
secretAccessKey: ""
|
||||
|
||||
# The region the bucket is located in. If using something other than AWS, this field can be set to
|
||||
# any valid region (us-west-1, etc.), or blank which defaults to us-west-1.
|
||||
region: ""
|
||||
|
||||
# An endpoint override, this is typically used for S3-compatible services like Backblaze B2.
|
||||
# If not specified, it will use the AWS region specified.
|
||||
endpointOverride: ""
|
||||
|
||||
# Name of the bucket to upload to.
|
||||
bucket: ""
|
||||
|
||||
# Base directory to store backups in. Value being set to "my-server" will store backups with a
|
||||
# path like bucket-name/my-server/backup-2021-12-21T00:06:41.760568Z.zip
|
||||
baseDirectory: ""
|
12
foundation-core/src/main/resources/devupdate.yaml
Normal file
12
foundation-core/src/main/resources/devupdate.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Server port to listen on.
|
||||
port: 8484
|
||||
|
||||
# An authentication token. Should be random and 8 or more characters.
|
||||
# If empty, the DevUpdate server is not enabled.
|
||||
token: ""
|
||||
|
||||
# IP address allow list.
|
||||
# If * is specified, all addresses are allowed.
|
||||
# Specify IP addresses as a string that should be allowed to update the server.
|
||||
ipAllowList:
|
||||
- "*"
|
19
foundation-core/src/main/resources/gameplay.yaml
Normal file
19
foundation-core/src/main/resources/gameplay.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
# Configuration that allows fine-tuning of various gameplay aspects.
|
||||
|
||||
# Settings which affect mob entities.
|
||||
mobs:
|
||||
# Disable the ability for an Enderman to pick up blocks.
|
||||
# When set to true, an Enderman cannot pick up or place blocks. :(
|
||||
# When set to false, an Enderman is allowed to pick up blocks.
|
||||
disableEndermanGriefing: false
|
||||
|
||||
# Disable freeze damage that occurs to all mobs.
|
||||
# Note: This does not impact players, that is still controlled with the freezeDamage game-rule.
|
||||
# When set to true, mobs will not take damage from freezing.
|
||||
# When set to false, mobs take damage from freezing.
|
||||
disableFreezeDamage: false
|
||||
|
||||
# Allow leads on all mobs.
|
||||
# When set to true, all mobs can have leads attached to them.
|
||||
# When set to false, only specific mobs can have leads attached to them.
|
||||
allowLeads: false
|
10
foundation-core/src/main/resources/player.yaml
Normal file
10
foundation-core/src/main/resources/player.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# Kicks players idle for longer than a set amount of time.
|
||||
anti-idle:
|
||||
# Whether anti-idle kicking is enabled.
|
||||
enabled: false
|
||||
|
||||
# Number of seconds the player is idle before kick.
|
||||
idleDuration: 3600
|
||||
|
||||
# List of usernames to ignore.
|
||||
ignore: []
|
@ -45,7 +45,20 @@ commands:
|
||||
aliases:
|
||||
- lb
|
||||
permission: foundation.command.leaderboard
|
||||
pstorestats:
|
||||
description: Persistent Store Stats
|
||||
usage: /pstorestats
|
||||
permission: foundation.command.pstorestats
|
||||
pstore:
|
||||
description: Persistent Store Manager
|
||||
usage: /pstore
|
||||
permission: foundation.command.pstore
|
||||
setspawn:
|
||||
description: Set the spawn of the current world.
|
||||
usage: /setspawn
|
||||
permission: foundation.command.setspawn
|
||||
spawn:
|
||||
description: Teleport to the spawn of the current world.
|
||||
usage: /spawn
|
||||
permission: foundation.command.spawn
|
||||
localweather:
|
||||
description: Set the player's local weather in the client.
|
||||
usage: /localweather <type>
|
||||
aliases:
|
||||
- lw
|
||||
|
3
foundation-core/src/main/resources/quartz.properties
Normal file
3
foundation-core/src/main/resources/quartz.properties
Normal file
@ -0,0 +1,3 @@
|
||||
org.quartz.scheduler.instanceName = Foundation
|
||||
org.quartz.threadPool.threadCount = 2
|
||||
org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore
|
7
foundation-heimdall/build.gradle.kts
Normal file
7
foundation-heimdall/build.gradle.kts
Normal file
@ -0,0 +1,7 @@
|
||||
dependencies {
|
||||
api("org.postgresql:postgresql:42.3.1")
|
||||
api("org.jetbrains.exposed:exposed-jdbc:0.36.2")
|
||||
api("org.jetbrains.exposed:exposed-java-time:0.36.2")
|
||||
api("com.zaxxer:HikariCP:5.0.0")
|
||||
compileOnly(project(":foundation-core"))
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package cloud.kubelet.foundation.heimdall
|
||||
|
||||
fun String.sqlSplitStatements(): List<String> {
|
||||
val statements = mutableListOf<String>()
|
||||
val buffer = StringBuilder()
|
||||
fun flush() {
|
||||
val trimmed = buffer.toString().trim()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
statements.add(trimmed)
|
||||
}
|
||||
}
|
||||
for (line in lines()) {
|
||||
if (line.trim() == "--") {
|
||||
flush()
|
||||
} else {
|
||||
buffer.append(line).append("\n")
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return statements
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
package cloud.kubelet.foundation.heimdall
|
||||
|
||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||
import cloud.kubelet.foundation.core.Util
|
||||
import cloud.kubelet.foundation.heimdall.buffer.BufferFlushThread
|
||||
import cloud.kubelet.foundation.heimdall.buffer.EventBuffer
|
||||
import cloud.kubelet.foundation.heimdall.event.*
|
||||
import cloud.kubelet.foundation.heimdall.model.HeimdallConfig
|
||||
import cloud.kubelet.foundation.heimdall.export.ExportChunksCommand
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.block.BlockBreakEvent
|
||||
import org.bukkit.event.block.BlockPlaceEvent
|
||||
import org.bukkit.event.entity.EntityDeathEvent
|
||||
import org.bukkit.event.entity.PlayerDeathEvent
|
||||
import org.bukkit.event.player.*
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.postgresql.Driver
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
class FoundationHeimdallPlugin : JavaPlugin(), Listener {
|
||||
private lateinit var config: HeimdallConfig
|
||||
private lateinit var pool: HikariDataSource
|
||||
internal var db: Database? = null
|
||||
|
||||
private val buffer = EventBuffer()
|
||||
private val bufferFlushThread = BufferFlushThread(this, buffer)
|
||||
|
||||
private val playerJoinTimes = ConcurrentHashMap<UUID, Instant>()
|
||||
|
||||
private val legacyComponentSerializer = LegacyComponentSerializer.builder().build()
|
||||
|
||||
override fun onEnable() {
|
||||
val exportChunksCommand = getCommand("export_all_chunks") ?: throw Exception("Failed to get export_all_chunks command")
|
||||
exportChunksCommand.setExecutor(ExportChunksCommand(this))
|
||||
|
||||
val foundation = server.pluginManager.getPlugin("Foundation") as FoundationCorePlugin
|
||||
|
||||
val configPath = Util.copyDefaultConfig<FoundationHeimdallPlugin>(
|
||||
slF4JLogger,
|
||||
foundation.pluginDataPath,
|
||||
"heimdall.yaml"
|
||||
)
|
||||
config = Yaml.default.decodeFromStream(HeimdallConfig.serializer(), configPath.inputStream())
|
||||
if (!config.enabled) {
|
||||
slF4JLogger.info("Heimdall is not enabled.")
|
||||
return
|
||||
}
|
||||
slF4JLogger.info("Heimdall is enabled.")
|
||||
if (!Driver.isRegistered()) {
|
||||
Driver.register()
|
||||
}
|
||||
pool = HikariDataSource(HikariConfig().apply {
|
||||
jdbcUrl = config.db.url
|
||||
username = config.db.username
|
||||
password = config.db.password
|
||||
schema = "heimdall"
|
||||
maximumPoolSize = 10
|
||||
idleTimeout = Duration.ofMinutes(5).toMillis()
|
||||
maxLifetime = Duration.ofMinutes(10).toMillis()
|
||||
})
|
||||
val initMigrationContent = FoundationHeimdallPlugin::class.java.getResourceAsStream(
|
||||
"/init.sql"
|
||||
)?.readAllBytes()?.decodeToString() ?: throw RuntimeException("Unable to find Heimdall init.sql")
|
||||
|
||||
val statements = initMigrationContent.sqlSplitStatements()
|
||||
|
||||
pool.connection.use { conn ->
|
||||
conn.autoCommit = false
|
||||
try {
|
||||
for (statementAsString in statements) {
|
||||
conn.prepareStatement(statementAsString).use {
|
||||
it.execute()
|
||||
}
|
||||
}
|
||||
conn.commit()
|
||||
} catch (e: Exception) {
|
||||
conn.rollback()
|
||||
throw e
|
||||
} finally {
|
||||
conn.autoCommit = true
|
||||
}
|
||||
}
|
||||
|
||||
db = Database.connect(pool)
|
||||
server.pluginManager.registerEvents(this, this)
|
||||
bufferFlushThread.start()
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
fun onPlayerMove(event: PlayerMoveEvent) = buffer.push(PlayerPosition(event))
|
||||
|
||||
@EventHandler
|
||||
fun onBlockBroken(event: BlockPlaceEvent) = buffer.push(BlockPlace(event))
|
||||
|
||||
@EventHandler
|
||||
fun onBlockBroken(event: BlockBreakEvent) = buffer.push(BlockBreak(event))
|
||||
|
||||
@EventHandler
|
||||
fun onPlayerJoin(event: PlayerJoinEvent) {
|
||||
playerJoinTimes[event.player.uniqueId] = Instant.now()
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
fun onPlayerQuit(event: PlayerQuitEvent) {
|
||||
val startTime = playerJoinTimes.remove(event.player.uniqueId) ?: return
|
||||
val endTime = Instant.now()
|
||||
buffer.push(PlayerSession(event.player.uniqueId, event.player.name, startTime, endTime))
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
fun onPlayerDeath(event: PlayerDeathEvent) {
|
||||
val deathMessage = event.deathMessage()
|
||||
val deathMessageString = if (deathMessage != null) {
|
||||
legacyComponentSerializer.serialize(deathMessage)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
buffer.push(PlayerDeath(event, deathMessageString))
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
fun onPlayerAdvancementDone(event: PlayerAdvancementDoneEvent) = buffer.push(PlayerAdvancement(event))
|
||||
|
||||
@EventHandler
|
||||
fun onWorldLoad(event: PlayerChangedWorldEvent) = buffer.push(
|
||||
WorldChange(
|
||||
event.player.uniqueId,
|
||||
event.from.uid,
|
||||
event.from.name,
|
||||
event.player.world.uid,
|
||||
event.player.world.name
|
||||
)
|
||||
)
|
||||
|
||||
@EventHandler
|
||||
fun onEntityDeath(event: EntityDeathEvent) {
|
||||
val killer = event.entity.killer ?: return
|
||||
buffer.push(
|
||||
EntityKill(
|
||||
killer.uniqueId,
|
||||
killer.location,
|
||||
event.entity.uniqueId,
|
||||
event.entityType.key.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
bufferFlushThread.stop()
|
||||
val endTime = Instant.now()
|
||||
for (playerId in playerJoinTimes.keys().toList()) {
|
||||
val startTime = playerJoinTimes.remove(playerId) ?: continue
|
||||
buffer.push(PlayerSession(
|
||||
playerId,
|
||||
server.getPlayer(playerId)?.name ?: "__unknown__",
|
||||
startTime,
|
||||
endTime
|
||||
))
|
||||
}
|
||||
bufferFlushThread.flush()
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package cloud.kubelet.foundation.heimdall.buffer
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.FoundationHeimdallPlugin
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class BufferFlushThread(val plugin: FoundationHeimdallPlugin, val buffer: EventBuffer) {
|
||||
private val running = AtomicBoolean(false)
|
||||
private var thread: Thread? = null
|
||||
|
||||
fun start() {
|
||||
running.set(true)
|
||||
val thread = Thread {
|
||||
plugin.slF4JLogger.info("Buffer Flusher Started")
|
||||
while (running.get()) {
|
||||
flush()
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
plugin.slF4JLogger.info("Buffer Flusher Stopped")
|
||||
}
|
||||
thread.name = "Heimdall Buffer Flush"
|
||||
thread.isDaemon = false
|
||||
thread.start()
|
||||
this.thread = thread
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running.set(false)
|
||||
thread?.join()
|
||||
}
|
||||
|
||||
fun flush() {
|
||||
try {
|
||||
val db = plugin.db
|
||||
if (db == null) {
|
||||
buffer.clear()
|
||||
return
|
||||
}
|
||||
transaction(plugin.db) {
|
||||
val count = buffer.flush(this)
|
||||
if (count > 0) {
|
||||
plugin.slF4JLogger.debug("Flushed $count Events")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
plugin.slF4JLogger.warn("Failed to flush buffer.", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package cloud.kubelet.foundation.heimdall.buffer
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.event.HeimdallEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
|
||||
class EventBuffer {
|
||||
private var events = mutableListOf<HeimdallEvent>()
|
||||
|
||||
fun flush(transaction: Transaction): Long {
|
||||
val referenceOfEvents = events
|
||||
this.events = mutableListOf()
|
||||
var count = 0L
|
||||
while (referenceOfEvents.isNotEmpty()) {
|
||||
val event = referenceOfEvents.removeAt(0)
|
||||
event.store(transaction)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fun push(event: HeimdallEvent) {
|
||||
events.add(event)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
events = mutableListOf()
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.BlockBreakTable
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.event.block.BlockBreakEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class BlockBreak(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val material: Material,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
constructor(event: BlockBreakEvent) : this(event.player.uniqueId, event.block.location, event.block.type)
|
||||
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
BlockBreakTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
it[block] = material.key.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.BlockPlaceTable
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.event.block.BlockPlaceEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class BlockPlace(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val material: Material,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
constructor(event: BlockPlaceEvent) : this(event.player.uniqueId, event.block.location, event.block.type)
|
||||
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
BlockPlaceTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
it[block] = material.key.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.EntityKillTable
|
||||
import org.bukkit.Location
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class EntityKill(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val entityUniqueIdentity: UUID,
|
||||
val entityTypeName: String,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
EntityKillTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
it[entity] = entityUniqueIdentity
|
||||
it[entityType] = entityTypeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
|
||||
abstract class HeimdallEvent {
|
||||
abstract fun store(transaction: Transaction)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.PlayerAdvancementTable
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.advancement.Advancement
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class PlayerAdvancement(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val advancement: Advancement,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
constructor(event: PlayerAdvancementDoneEvent) : this(event.player.uniqueId, event.player.location, event.advancement)
|
||||
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
PlayerAdvancementTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
it[advancement] = this@PlayerAdvancement.advancement.key.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.PlayerDeathTable
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.event.entity.PlayerDeathEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class PlayerDeath(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val experienceLevel: Float,
|
||||
val deathMessage: String?,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
constructor(event: PlayerDeathEvent, deathMessage: String? = null) : this(
|
||||
event.player.uniqueId,
|
||||
event.player.location,
|
||||
event.player.exp,
|
||||
deathMessage
|
||||
)
|
||||
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
PlayerDeathTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
it[experience] = experienceLevel.toDouble()
|
||||
it[message] = deathMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.PlayerPositionTable
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.event.player.PlayerMoveEvent
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class PlayerPosition(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val location: Location,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
constructor(event: PlayerMoveEvent) : this(event.player.uniqueId, event.to)
|
||||
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
PlayerPositionTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[world] = location.world.uid
|
||||
it[x] = location.x
|
||||
it[y] = location.y
|
||||
it[z] = location.z
|
||||
it[pitch] = location.pitch.toDouble()
|
||||
it[yaw] = location.yaw.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.PlayerSessionTable
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class PlayerSession(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val playerName: String,
|
||||
val startTimeInstant: Instant,
|
||||
val endTimeInstant: Instant
|
||||
) : HeimdallEvent() {
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
PlayerSessionTable.insert {
|
||||
it[id] = UUID.randomUUID()
|
||||
it[player] = playerUniqueIdentity
|
||||
it[name] = playerName
|
||||
it[startTime] = startTimeInstant
|
||||
it[endTime] = endTimeInstant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package cloud.kubelet.foundation.heimdall.event
|
||||
|
||||
import cloud.kubelet.foundation.heimdall.table.WorldChangeTable
|
||||
import org.jetbrains.exposed.sql.Transaction
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class WorldChange(
|
||||
val playerUniqueIdentity: UUID,
|
||||
val fromWorldId: UUID,
|
||||
val fromWorldActualName: String,
|
||||
val toWorldId: UUID,
|
||||
val toWorldActualName: String,
|
||||
val timestamp: Instant = Instant.now()
|
||||
) : HeimdallEvent() {
|
||||
override fun store(transaction: Transaction) {
|
||||
transaction.apply {
|
||||
WorldChangeTable.insert {
|
||||
it[time] = timestamp
|
||||
it[player] = playerUniqueIdentity
|
||||
it[fromWorld] = fromWorldId
|
||||
it[fromWorldName] = fromWorldActualName
|
||||
it[toWorld] = toWorldId
|
||||
it[toWorldName] = toWorldActualName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
package cloud.kubelet.foundation.heimdall.export
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import org.bukkit.Chunk
|
||||
import org.bukkit.ChunkSnapshot
|
||||
import org.bukkit.Server
|
||||
import org.bukkit.World
|
||||
import org.bukkit.plugin.Plugin
|
||||
import java.io.File
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
class ChunkExporter(private val plugin: Plugin, private val server: Server, val world: World) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun exportLoadedChunksAsync() {
|
||||
exportChunkListAsync(world.loadedChunks.toList())
|
||||
}
|
||||
|
||||
private fun exportChunkListAsync(chunks: List<Chunk>) {
|
||||
plugin.slF4JLogger.info("Exporting ${chunks.size} Chunks")
|
||||
val snapshots = chunks.map { it.chunkSnapshot }
|
||||
Thread {
|
||||
for (snapshot in snapshots) {
|
||||
exportChunkSnapshot(snapshot)
|
||||
}
|
||||
plugin.slF4JLogger.info("Exported ${chunks.size} Chunks")
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun exportChunkSnapshot(snapshot: ChunkSnapshot) {
|
||||
val sections = mutableListOf<ExportedChunkSection>()
|
||||
val yRange = world.minHeight until world.maxHeight
|
||||
val chunkRange = 0..15
|
||||
for (x in chunkRange) {
|
||||
for (z in chunkRange) {
|
||||
sections.add(exportChunkSection(snapshot, yRange, x, z))
|
||||
}
|
||||
}
|
||||
|
||||
val exported = ExportedChunk(snapshot.x, snapshot.z, sections)
|
||||
saveChunkSnapshot(snapshot, exported)
|
||||
}
|
||||
|
||||
private fun saveChunkSnapshot(snapshot: ChunkSnapshot, chunk: ExportedChunk) {
|
||||
val file = File("exported_chunks/${snapshot.worldName}_chunk_${snapshot.x}_${snapshot.z}.json.gz")
|
||||
if (!file.parentFile.exists()) {
|
||||
file.parentFile.mkdirs()
|
||||
}
|
||||
|
||||
val fileOutputStream = file.outputStream()
|
||||
val gzipOutputStream = GZIPOutputStream(fileOutputStream)
|
||||
json.encodeToStream(ExportedChunk.serializer(), chunk, gzipOutputStream)
|
||||
gzipOutputStream.close()
|
||||
}
|
||||
|
||||
private fun exportChunkSection(snapshot: ChunkSnapshot, yRange: IntRange, x: Int, z: Int): ExportedChunkSection {
|
||||
val blocks = mutableListOf<ExportedBlock>()
|
||||
for (y in yRange) {
|
||||
val blockData = snapshot.getBlockData(x, y, z)
|
||||
val block = ExportedBlock(blockData.material.key.toString())
|
||||
blocks.add(block)
|
||||
}
|
||||
return ExportedChunkSection(x, z, blocks)
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.foundation.heimdall.export
|
||||
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.plugin.Plugin
|
||||
|
||||
class ExportChunksCommand(private val plugin: Plugin) : CommandExecutor {
|
||||
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
|
||||
plugin.slF4JLogger.info("Exporting All Chunks")
|
||||
for (world in sender.server.worlds) {
|
||||
val export = ChunkExporter(plugin, sender.server, world)
|
||||
export.exportLoadedChunksAsync()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package cloud.kubelet.foundation.heimdall.export
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ExportedBlock(
|
||||
val type: String
|
||||
)
|
@ -0,0 +1,10 @@
|
||||
package cloud.kubelet.foundation.heimdall.export
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ExportedChunk(
|
||||
val x: Int,
|
||||
val z: Int,
|
||||
val sections: List<ExportedChunkSection>
|
||||
)
|
@ -0,0 +1,10 @@
|
||||
package cloud.kubelet.foundation.heimdall.export
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ExportedChunkSection(
|
||||
val x: Int,
|
||||
val z: Int,
|
||||
val blocks: List<ExportedBlock>
|
||||
)
|
@ -0,0 +1,16 @@
|
||||
package cloud.kubelet.foundation.heimdall.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HeimdallConfig(
|
||||
val enabled: Boolean = false,
|
||||
val db: DbConfig
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DbConfig(
|
||||
val url: String,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
@ -0,0 +1,16 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object BlockBreakTable : Table("block_breaks") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val block = text("block")
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object BlockPlaceTable : Table("block_places") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val block = text("block")
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object EntityKillTable : Table("entity_kills") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val entity = uuid("entity")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val entityType = text("entity_type")
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object PlayerAdvancementTable : Table("player_advancements") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val advancement = text("advancement")
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object PlayerDeathTable : Table("player_deaths") {
|
||||
val time = timestamp("time")
|
||||
val world = uuid("world")
|
||||
val player = uuid("player")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val experience = double("experience")
|
||||
val message = text("message").nullable()
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object PlayerPositionTable : Table("player_positions") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object PlayerSessionTable : Table("player_sessions") {
|
||||
val id = uuid("id")
|
||||
val player = uuid("player")
|
||||
val name = text("name")
|
||||
val startTime = timestamp("start")
|
||||
val endTime = timestamp("end")
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package cloud.kubelet.foundation.heimdall.table
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object WorldChangeTable : Table("world_changes") {
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val fromWorld = uuid("from_world")
|
||||
val toWorld = uuid("to_world")
|
||||
val fromWorldName = text("from_world_name")
|
||||
val toWorldName = text("to_world_name")
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package cloud.kubelet.foundation.heimdall.view
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
object BlockChangeView : Table("block_changes") {
|
||||
val isBreak = bool("break")
|
||||
val time = timestamp("time")
|
||||
val player = uuid("player")
|
||||
val world = uuid("world")
|
||||
val x = double("x")
|
||||
val y = double("y")
|
||||
val z = double("z")
|
||||
val pitch = double("pitch")
|
||||
val yaw = double("yaw")
|
||||
val block = text("block")
|
||||
}
|
11
foundation-heimdall/src/main/resources/heimdall.yaml
Normal file
11
foundation-heimdall/src/main/resources/heimdall.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
# Whether Heimdall should be enabled for tracking events.
|
||||
enabled: false
|
||||
|
||||
# Database connection information.
|
||||
db:
|
||||
# JDBC URL
|
||||
url: "jdbc:postgresql://localhost/foundation"
|
||||
# JDBC Username
|
||||
username: "foundation"
|
||||
# JDBC Password
|
||||
password: "foundation"
|
147
foundation-heimdall/src/main/resources/init.sql
Normal file
147
foundation-heimdall/src/main/resources/init.sql
Normal file
@ -0,0 +1,147 @@
|
||||
create extension if not exists "uuid-ossp";
|
||||
--
|
||||
create extension if not exists timescaledb;
|
||||
--
|
||||
create schema if not exists heimdall;
|
||||
--
|
||||
create table if not exists heimdall.player_positions (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
PRIMARY KEY (time, player, world)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.player_positions', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
alter table heimdall.player_positions set (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'player,world',
|
||||
timescaledb.compress_orderby = 'time'
|
||||
);
|
||||
--
|
||||
select add_compression_policy('heimdall.player_positions', interval '3 days', if_not_exists => true);
|
||||
--
|
||||
create table if not exists heimdall.block_breaks (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
block text not null,
|
||||
PRIMARY KEY (time, player, world)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.block_breaks', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.block_places (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
block text not null,
|
||||
PRIMARY KEY (time, player, world)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.block_places', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.player_sessions (
|
||||
id uuid not null,
|
||||
player uuid not null,
|
||||
name text not null,
|
||||
"start" timestamp not null,
|
||||
"end" timestamp not null,
|
||||
primary key (id, player, start)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.player_sessions', 'start', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.world_changes (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
from_world uuid not null,
|
||||
from_world_name text not null,
|
||||
to_world uuid not null,
|
||||
to_world_name text not null,
|
||||
primary key (time, player)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.world_changes', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.player_deaths (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
experience double precision not null,
|
||||
message text null,
|
||||
primary key (time, player)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.player_deaths', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.player_advancements (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
advancement text not null,
|
||||
primary key (time, player, advancement)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.player_advancements', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create table if not exists heimdall.entity_kills (
|
||||
time timestamp not null,
|
||||
player uuid not null,
|
||||
entity uuid not null,
|
||||
world uuid not null,
|
||||
x double precision not null,
|
||||
y double precision not null,
|
||||
z double precision not null,
|
||||
pitch double precision not null,
|
||||
yaw double precision not null,
|
||||
entity_type text not null,
|
||||
primary key (time, entity, player)
|
||||
);
|
||||
--
|
||||
select create_hypertable('heimdall.entity_kills', 'time', 'player', 4, if_not_exists => TRUE);
|
||||
--
|
||||
create or replace view heimdall.block_changes as
|
||||
select true as break, *
|
||||
from heimdall.block_breaks
|
||||
union all
|
||||
select false as break, * from heimdall.block_places;
|
||||
--
|
||||
create or replace view heimdall.player_names as
|
||||
with unique_player_ids as (
|
||||
select distinct player
|
||||
from heimdall.player_sessions
|
||||
)
|
||||
select player, (
|
||||
select name
|
||||
from heimdall.player_sessions
|
||||
where player = unique_player_ids.player
|
||||
order by "end" desc
|
||||
limit 1
|
||||
) as name
|
||||
from unique_player_ids;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user