mirror of
https://github.com/GayPizzaSpecifications/foundation.git
synced 2025-08-06 23:11:30 +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)
|
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||||
!gradle-wrapper.jar
|
!gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Foundation Server
|
||||||
|
/server
|
||||||
|
22
README.md
22
README.md
@ -1,7 +1,25 @@
|
|||||||
# Foundation
|
# Foundation
|
||||||
|
|
||||||
Foundation is a set of plugins that implement the core functionality for a small community Minecraft
|
Foundation is a set of plugins that implement the core functionality for a small community Minecraft
|
||||||
server.
|
server.
|
||||||
|
|
||||||
## Plugins
|
## 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 com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
import org.jetbrains.kotlin.com.google.gson.Gson
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import java.io.FileWriter
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
id("org.jetbrains.kotlin.jvm") version "1.6.10" apply false
|
id("cloud.kubelet.foundation.gradle")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.6.10" apply false
|
|
||||||
id("com.github.johnrengelman.shadow") version "7.1.1" apply false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable the JAR task for the root project.
|
|
||||||
tasks["jar"].enabled = false
|
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven {
|
maven {
|
||||||
name = "papermc-repo"
|
name = "papermc"
|
||||||
url = uri("https://papermc.io/repo/repository/maven-public/")
|
url = uri("https://papermc.io/repo/repository/maven-public/")
|
||||||
}
|
}
|
||||||
|
|
||||||
maven {
|
maven {
|
||||||
name = "sonatype"
|
name = "sonatype"
|
||||||
url = uri("https://oss.sonatype.org/content/groups/public/")
|
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 {
|
tasks.assemble {
|
||||||
dependsOn("updateManifests")
|
dependsOn("updateManifests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
plugins.apply("org.jetbrains.kotlin.jvm")
|
plugins.apply("org.jetbrains.kotlin.jvm")
|
||||||
plugins.apply("org.jetbrains.kotlin.plugin.serialization")
|
plugins.apply("org.jetbrains.kotlin.plugin.serialization")
|
||||||
plugins.apply("com.github.johnrengelman.shadow")
|
plugins.apply("com.github.johnrengelman.shadow")
|
||||||
|
plugins.apply(FoundationProjectPlugin::class)
|
||||||
|
|
||||||
version = "0.1"
|
group = "lgbt.mystic"
|
||||||
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
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Kotlin dependencies
|
// Kotlin dependencies
|
||||||
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
|
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
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
|
// Serialization
|
||||||
implementation("com.charleskorn.kaml:kaml:0.38.0")
|
implementation("com.charleskorn.kaml:kaml:0.38.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1")
|
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")
|
implementation("org.jetbrains.xodus:xodus-entity-store:1.3.232")
|
||||||
|
|
||||||
// Paper API
|
// 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 {
|
java {
|
||||||
@ -99,6 +64,13 @@ subprojects {
|
|||||||
targetCompatibility = javaVersion
|
targetCompatibility = javaVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile> {
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs =
|
||||||
|
freeCompilerArgs + "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.processResources {
|
tasks.processResources {
|
||||||
val props = mapOf("version" to version)
|
val props = mapOf("version" to version)
|
||||||
inputs.properties(props)
|
inputs.properties(props)
|
||||||
@ -108,11 +80,18 @@ subprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<ShadowJar> {
|
if (project.isFoundationPlugin()) {
|
||||||
archiveClassifier.set("plugin")
|
tasks.withType<ShadowJar> {
|
||||||
|
archiveClassifier.set("plugin")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.assemble {
|
tasks.assemble {
|
||||||
dependsOn("shadowJar")
|
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") {
|
implementation("net.dv8tion:JDA:5.0.0-alpha.2") {
|
||||||
exclude(module = "opus-java")
|
exclude(module = "opus-java")
|
||||||
}
|
}
|
||||||
|
implementation("com.rabbitmq:amqp-client:5.14.2")
|
||||||
|
|
||||||
compileOnly(project(":foundation-core"))
|
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.bifrost.model.BifrostConfig
|
||||||
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
import cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||||
import cloud.kubelet.foundation.core.Util
|
import cloud.kubelet.foundation.core.Util
|
||||||
|
import cloud.kubelet.foundation.core.util.AdvancementTitleCache
|
||||||
import com.charleskorn.kaml.Yaml
|
import com.charleskorn.kaml.Yaml
|
||||||
import io.papermc.paper.event.player.AsyncChatEvent
|
import io.papermc.paper.event.player.AsyncChatEvent
|
||||||
import net.dv8tion.jda.api.EmbedBuilder
|
import net.dv8tion.jda.api.EmbedBuilder
|
||||||
import net.dv8tion.jda.api.JDA
|
import net.dv8tion.jda.api.JDA
|
||||||
import net.dv8tion.jda.api.JDABuilder
|
import net.dv8tion.jda.api.JDABuilder
|
||||||
import net.dv8tion.jda.api.MessageBuilder
|
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.entities.TextChannel
|
||||||
import net.dv8tion.jda.api.events.GenericEvent
|
import net.dv8tion.jda.api.events.GenericEvent
|
||||||
import net.dv8tion.jda.api.events.ReadyEvent
|
import net.dv8tion.jda.api.events.ReadyEvent
|
||||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent
|
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.Component
|
||||||
import net.kyori.adventure.text.TextComponent
|
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
|
||||||
import org.bukkit.event.EventHandler
|
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.PlayerJoinEvent
|
||||||
import org.bukkit.event.player.PlayerQuitEvent
|
import org.bukkit.event.player.PlayerQuitEvent
|
||||||
import org.bukkit.plugin.java.JavaPlugin
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
import java.awt.Color
|
import java.awt.Color
|
||||||
import kotlin.io.path.inputStream
|
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 config: BifrostConfig
|
||||||
private lateinit var jda: JDA
|
private var jda: JDA? = null
|
||||||
private var isDev = false
|
private var isDev = false
|
||||||
|
|
||||||
override fun onEnable() {
|
override fun onEnable() {
|
||||||
@ -43,6 +48,10 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
|||||||
config = Yaml.default.decodeFromStream(BifrostConfig.serializer(), configPath.inputStream())
|
config = Yaml.default.decodeFromStream(BifrostConfig.serializer(), configPath.inputStream())
|
||||||
|
|
||||||
server.pluginManager.registerEvents(this, this)
|
server.pluginManager.registerEvents(this, this)
|
||||||
|
if (config.authentication.token.isEmpty()) {
|
||||||
|
slF4JLogger.warn("Token empty, Bifrost will not connect to Discord.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jda = JDABuilder
|
jda = JDABuilder
|
||||||
.createDefault(config.authentication.token)
|
.createDefault(config.authentication.token)
|
||||||
@ -51,11 +60,14 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisable() {
|
override fun onDisable() {
|
||||||
|
// Plugin was not initialized, don't do anything.
|
||||||
|
if (jda == null) return
|
||||||
|
|
||||||
onServerStop()
|
onServerStop()
|
||||||
|
|
||||||
logger.info("Shutting down JDA")
|
logger.info("Shutting down JDA")
|
||||||
jda.shutdown()
|
jda?.shutdown()
|
||||||
while (jda.status != JDA.Status.SHUTDOWN) {
|
while (jda != null && jda!!.status != JDA.Status.SHUTDOWN) {
|
||||||
Thread.sleep(100)
|
Thread.sleep(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,13 +75,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
|||||||
override fun onEvent(e: GenericEvent) {
|
override fun onEvent(e: GenericEvent) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is ReadyEvent -> {
|
is ReadyEvent -> {
|
||||||
val channel = getChannel() ?: return
|
onDiscordReady()
|
||||||
if (isDev) return
|
|
||||||
channel.sendMessage(":white_check_mark: Server is ready!").queue()
|
|
||||||
}
|
}
|
||||||
is MessageReceivedEvent -> {
|
is MessageReceivedEvent -> {
|
||||||
|
if (!config.channel.bridge) return
|
||||||
// Prevent this bot from receiving its own messages and creating a feedback loop.
|
// 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.
|
// Only forward messages from the configured channel.
|
||||||
if (e.channel.id != config.channel.id) return
|
if (e.channel.id != config.channel.id) return
|
||||||
@ -82,8 +93,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getChannel(): TextChannel? {
|
private fun getTextChannel(): TextChannel? {
|
||||||
val channel = jda.getTextChannelById(config.channel.id)
|
if (jda == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val channel = jda?.getTextChannelById(config.channel.id)
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
slF4JLogger.error("Failed to retrieve channel ${config.channel.id}")
|
slF4JLogger.error("Failed to retrieve channel ${config.channel.id}")
|
||||||
}
|
}
|
||||||
@ -95,45 +110,85 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
|
|||||||
setEmbeds(EmbedBuilder().apply(f).build())
|
setEmbeds(EmbedBuilder().apply(f).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler
|
private fun sendChannelMessage(message: Message, debug: () -> String) {
|
||||||
private fun onPlayerJoin(e: PlayerJoinEvent) {
|
val channel = getTextChannel()
|
||||||
val channel = getChannel() ?: return
|
channel?.sendMessage(message)?.queue()
|
||||||
|
|
||||||
channel.sendMessage(message {
|
if (config.enableDebugLog) {
|
||||||
embed {
|
slF4JLogger.info("Send '${debug()}' to Discord")
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onServerStop() {
|
private fun sendChannelMessage(message: String): Unit = sendChannelMessage(message {
|
||||||
val channel = getChannel() ?: return
|
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
|
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(
|
data class BifrostConfig(
|
||||||
val authentication: BifrostAuthentication,
|
val authentication: BifrostAuthentication,
|
||||||
val channel: BifrostChannel,
|
val channel: BifrostChannel,
|
||||||
|
val enableDebugLog: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -16,4 +17,11 @@ data class BifrostAuthentication(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class BifrostChannel(
|
data class BifrostChannel(
|
||||||
val id: String,
|
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 configuration for the bridge.
|
||||||
authentication:
|
authentication:
|
||||||
# Token from the Discord Bot developer's page.
|
# Token from the Discord Bot developer's page. If this is empty, the Bifrost plugin will do
|
||||||
token: abc123
|
# nothing.
|
||||||
|
token: ""
|
||||||
|
|
||||||
# Channel configuration for the bridge.
|
# Channel configuration for the bridge.
|
||||||
channel:
|
channel:
|
||||||
# Channel ID, can be copied by turning on Developer Mode in User Settings -> Advanced. The ID can
|
# 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".
|
# then be copied by right-clicking the channel and selecting "Copy ID".
|
||||||
id: 123456789
|
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 {
|
dependencies {
|
||||||
// TODO: might be able to ship all dependencies in core? are we duplicating classes in JARs?
|
// 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
|
package cloud.kubelet.foundation.core
|
||||||
|
|
||||||
import cloud.kubelet.foundation.core.command.*
|
import cloud.kubelet.foundation.core.abstraction.FoundationPlugin
|
||||||
import cloud.kubelet.foundation.core.persist.PersistentStore
|
import cloud.kubelet.foundation.core.features.backup.BackupFeature
|
||||||
import cloud.kubelet.foundation.core.persist.setAllProperties
|
import cloud.kubelet.foundation.core.features.dev.DevFeature
|
||||||
import io.papermc.paper.event.player.AsyncChatEvent
|
import cloud.kubelet.foundation.core.features.gameplay.GameplayFeature
|
||||||
import net.kyori.adventure.text.Component
|
import cloud.kubelet.foundation.core.features.persist.PersistenceFeature
|
||||||
import net.kyori.adventure.text.TextComponent
|
import cloud.kubelet.foundation.core.features.player.PlayerFeature
|
||||||
import org.bukkit.GameMode
|
import cloud.kubelet.foundation.core.features.scheduler.SchedulerFeature
|
||||||
import org.bukkit.command.CommandExecutor
|
import cloud.kubelet.foundation.core.features.stats.StatsFeature
|
||||||
import org.bukkit.event.EventHandler
|
import cloud.kubelet.foundation.core.features.update.UpdateFeature
|
||||||
import org.bukkit.event.Listener
|
import cloud.kubelet.foundation.core.features.world.WorldFeature
|
||||||
import org.bukkit.plugin.java.JavaPlugin
|
import org.koin.dsl.module
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
class FoundationCorePlugin : JavaPlugin(), Listener {
|
class FoundationCorePlugin : FoundationPlugin() {
|
||||||
internal val persistentStores = ConcurrentHashMap<String, PersistentStore>()
|
|
||||||
private lateinit var _pluginDataPath: Path
|
private lateinit var _pluginDataPath: Path
|
||||||
|
|
||||||
var pluginDataPath: Path
|
var pluginDataPath: Path
|
||||||
@ -34,98 +31,27 @@ class FoundationCorePlugin : JavaPlugin(), Listener {
|
|||||||
_pluginDataPath = value
|
_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() {
|
override fun onEnable() {
|
||||||
|
// Create core plugin directory.
|
||||||
pluginDataPath = dataFolder.toPath()
|
pluginDataPath = dataFolder.toPath()
|
||||||
val backupPath = pluginDataPath.resolve(BACKUPS_DIRECTORY)
|
|
||||||
|
|
||||||
// Create Foundation plugin directories.
|
|
||||||
pluginDataPath.toFile().mkdir()
|
pluginDataPath.toFile().mkdir()
|
||||||
backupPath.toFile().mkdir()
|
|
||||||
|
|
||||||
// Register this as an event listener.
|
super.onEnable()
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisable() {
|
override fun createFeatures() = listOf(
|
||||||
persistentStores.values.forEach { store -> store.close() }
|
SchedulerFeature(),
|
||||||
persistentStores.clear()
|
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 whitespace: Component = Component.text(' ')
|
||||||
private val foundationName: Component = Component.text("Foundation")
|
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 {
|
fun formatSystemMessage(message: String): Component {
|
||||||
return formatSystemMessage(TextColors.AMARANTH_PINK, message)
|
return formatSystemMessage(TextColors.AMARANTH_PINK, message)
|
||||||
}
|
}
|
||||||
@ -61,4 +57,9 @@ object Util {
|
|||||||
|
|
||||||
return outPath
|
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.FoundationCorePlugin
|
||||||
import cloud.kubelet.foundation.core.Util
|
import cloud.kubelet.foundation.core.Util
|
||||||
@ -8,33 +8,31 @@ import org.bukkit.Server
|
|||||||
import org.bukkit.command.Command
|
import org.bukkit.command.Command
|
||||||
import org.bukkit.command.CommandExecutor
|
import org.bukkit.command.CommandExecutor
|
||||||
import org.bukkit.command.CommandSender
|
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.BufferedOutputStream
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.nio.file.FileSystems
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
// TODO: Clean up dependency injection.
|
||||||
class BackupCommand(
|
class BackupCommand(
|
||||||
private val plugin: FoundationCorePlugin,
|
private val plugin: FoundationCorePlugin,
|
||||||
private val backupPath: Path
|
private val backupsPath: Path,
|
||||||
|
private val config: BackupConfig,
|
||||||
|
private val s3Client: S3Client,
|
||||||
) : CommandExecutor {
|
) : CommandExecutor {
|
||||||
override fun onCommand(
|
override fun onCommand(
|
||||||
sender: CommandSender, command: Command, label: String, args: Array<String>
|
sender: CommandSender, command: Command, label: String, args: Array<String>
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!FoundationCorePlugin.BACKUP_ENABLED) {
|
|
||||||
sender.sendMessage(
|
|
||||||
Component
|
|
||||||
.text("Backup is not enabled.")
|
|
||||||
.color(TextColor.fromHexString("#FF0000"))
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RUNNING.get()) {
|
if (RUNNING.get()) {
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
Component
|
Component
|
||||||
@ -44,35 +42,59 @@ class BackupCommand(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val server = sender.server
|
||||||
val server = sender.server
|
server.scheduler.runTaskAsynchronously(plugin) { ->
|
||||||
server.scheduler.runTaskAsynchronously(plugin, Runnable {
|
runBackup(server, sender)
|
||||||
runBackup(server)
|
|
||||||
})
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sender.sendMessage(String.format("Failed to backup: %s", e.message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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)
|
RUNNING.set(true)
|
||||||
|
|
||||||
server.sendMessage(Util.formatSystemMessage("Backup started."))
|
server.scheduler.runTask(plugin) { ->
|
||||||
|
server.sendMessage(Util.formatSystemMessage("Backup started."))
|
||||||
|
}
|
||||||
|
|
||||||
val backupFile =
|
val backupTime = Instant.now()
|
||||||
backupPath.resolve(String.format("backup-%s.zip", Instant.now().toString())).toFile()
|
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 ->
|
||||||
FileOutputStream(backupFile).use { zipFileStream ->
|
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
|
||||||
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
|
backupPlugins(server, zipStream)
|
||||||
backupPlugins(server, zipStream)
|
backupWorlds(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."))
|
server.sendMessage(Util.formatSystemMessage("Backup finished."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -82,7 +104,7 @@ class BackupCommand(
|
|||||||
addDirectoryToZip(zipStream, server.pluginsFolder.toPath())
|
addDirectoryToZip(zipStream, server.pluginsFolder.toPath())
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// TODO: Add error handling.
|
// 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) {
|
private fun addDirectoryToZip(zipStream: ZipOutputStream, directoryPath: Path) {
|
||||||
|
val matchers = config.ignore.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||||
val paths = Files.walk(directoryPath)
|
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()
|
.toList()
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(16 * 1024)
|
||||||
val backupsPath = backupPath.toRealPath()
|
val backupsPath = backupsPath.toRealPath()
|
||||||
|
|
||||||
for (path in paths) {
|
for (path in paths) {
|
||||||
val realPath = path.toRealPath()
|
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 cloud.kubelet.foundation.core.FoundationCorePlugin
|
||||||
import jetbrains.exodus.entitystore.Entity
|
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 fileStorePath = corePlugin.pluginDataPath.resolve("persistence/${fileStoreName}")
|
||||||
private val entityStore = PersistentEntityStores.newInstance(fileStorePath.toFile())
|
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
|
var result: R? = null
|
||||||
entityStore.executeInTransaction { tx ->
|
entityStore.executeInTransaction { tx ->
|
||||||
result = block(tx)
|
result = block(tx)
|
||||||
@ -18,16 +18,18 @@ class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) :
|
|||||||
return result!!
|
return result!!
|
||||||
}
|
}
|
||||||
|
|
||||||
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact { tx ->
|
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact {
|
||||||
val entity = tx.newEntity(entityTypeName)
|
populate(newEntity(entityTypeName))
|
||||||
populate(entity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAll(entityTypeName: String) =
|
fun <R> getAll(entityTypeName: String, block: (EntityIterable) -> R): R =
|
||||||
transact { tx -> tx.getAll(entityTypeName) }
|
transact { block(getAll(entityTypeName)) }
|
||||||
|
|
||||||
fun <T> find(entityTypeName: String, propertyName: String, value: Comparable<T>): EntityIterable =
|
fun <T, R> find(entityTypeName: String, propertyName: String, value: Comparable<T>, block: (EntityIterable) -> R): R =
|
||||||
transact { tx -> tx.find(entityTypeName, propertyName, value) }
|
transact { block(find(entityTypeName, propertyName, value)) }
|
||||||
|
|
||||||
|
fun deleteAllEntities(entityTypeName: String) =
|
||||||
|
transact { entityStore.deleteEntityType(entityTypeName) }
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
entityStore.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
|
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.GameMode
|
||||||
import org.bukkit.command.Command
|
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.SortOrder
|
||||||
import cloud.kubelet.foundation.core.allPlayerStatisticsOf
|
import cloud.kubelet.foundation.core.allPlayerStatisticsOf
|
||||||
@ -6,13 +6,18 @@ import org.bukkit.Statistic
|
|||||||
import org.bukkit.command.Command
|
import org.bukkit.command.Command
|
||||||
import org.bukkit.command.CommandExecutor
|
import org.bukkit.command.CommandExecutor
|
||||||
import org.bukkit.command.CommandSender
|
import org.bukkit.command.CommandSender
|
||||||
|
import org.bukkit.command.TabCompleter
|
||||||
|
|
||||||
class LeaderboardCommand : CommandExecutor {
|
class LeaderboardCommand : CommandExecutor, TabCompleter {
|
||||||
private val leaderboards = listOf(
|
private val leaderboards = listOf(
|
||||||
LeaderboardType("player-kills", Statistic.PLAYER_KILLS, "Player Kills", "kills"),
|
LeaderboardType("player-kills", Statistic.PLAYER_KILLS, "Player Kills", "kills"),
|
||||||
LeaderboardType("mob-kills", Statistic.MOB_KILLS, "Mob Kills", "kills"),
|
LeaderboardType("mob-kills", Statistic.MOB_KILLS, "Mob Kills", "kills"),
|
||||||
LeaderboardType("animals-bred", Statistic.ANIMALS_BRED, "Animals Bred", "animals"),
|
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 {
|
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)
|
val topFivePlayers = statistics.take(5)
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
"${leaderboardType.friendlyName} Leaderboard:",
|
"${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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
class LeaderboardType(val id: String, val statistic: Statistic, val friendlyName: String, val unit: String)
|
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
|
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.DeserializationStrategy
|
||||||
import kotlinx.serialization.builtins.MapSerializer
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
@ -15,7 +15,7 @@ object UpdateUtil {
|
|||||||
|
|
||||||
// TODO: Add environment variable override. Document it.
|
// TODO: Add environment variable override. Document it.
|
||||||
private const val basePath =
|
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 basePathQueryParams = "job=build"
|
||||||
private const val manifestPath = "build/manifests/update.json"
|
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:
|
aliases:
|
||||||
- lb
|
- lb
|
||||||
permission: foundation.command.leaderboard
|
permission: foundation.command.leaderboard
|
||||||
pstorestats:
|
pstore:
|
||||||
description: Persistent Store Stats
|
description: Persistent Store Manager
|
||||||
usage: /pstorestats
|
usage: /pstore
|
||||||
permission: foundation.command.pstorestats
|
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