113 Commits

Author SHA1 Message Date
Liv
e05d6355e6 Initial work on multi-server bridge from a while back. 2022-07-09 15:56:46 -07:00
5d7bf94e5c Update to new domains. 2022-05-30 17:07:10 -07:00
89ad6d1ece Update to paper-api 1.18.2. 2022-03-06 03:21:00 +00:00
8289616762 Gjallarhorn: Graphical render session fix for frame not closing. 2022-02-25 00:17:14 -05:00
c9d4fe1733 Heimdall/Gjallarhorn: Minor changes to idle timeout. 2022-02-21 20:38:15 -05:00
e3d9eb80fc Heimdall: Actually set max lifetime and pool size. 2022-02-21 19:45:26 -05:00
a184d2e845 Heimdall: Set DB pool max lifetime lower to prevent stale connections. 2022-02-21 19:42:54 -05:00
6e1afb5e5c Heimdall: Fix bug where DB being disabled might cause errors. 2022-02-21 19:30:27 -05:00
1879df780b Refactor Manifest Generation into Gradle Plugin 2022-02-21 18:09:52 -05:00
f0c344ca1f Gjallarhorn: Implement combined chunk format for storing many chunks in one map. 2022-02-20 06:11:14 -05:00
6bcddb15b5 Gjallarhorn: First attempt at a graphical render system. 2022-02-20 05:35:47 -05:00
1afb1c7148 Gjallarhorn Improvements 2022-02-20 04:03:21 -05:00
86800e59f4 Heimdall/Gjallarhorn: Chunk Export Improvements and Chunk Export Renderer 2022-02-17 21:37:38 -05:00
ac2e99052d Implement Chunk Export 2022-02-16 23:48:51 -05:00
eb5cb1a229 Gjallarhorn: Attempt to clarify the mess that is ChangelogSlice. 2022-02-16 00:56:48 -05:00
74fed8c222 Gjallarhorn: Implement render loop. 2022-01-29 23:15:05 -05:00
ba18fcddbc Add anti-idle feature (Closes #21). 2022-01-29 06:02:15 +00:00
0da3202555 Add config defaults to prevent deserialize errors. 2022-01-29 04:57:01 +00:00
66ee0ba701 Persist all entities that have been leashed. 2022-01-24 17:22:46 -08:00
d4a06ea84a Gjallarhorn: Initial Player Position Code 2022-01-17 22:24:47 -05:00
54cd41e925 Gjallarhorn: Render pool rework and cleanup. 2022-01-17 21:07:40 -05:00
011e3100bf Bifrost: Don't announce uninteresting advancements. 2022-01-17 17:36:58 -05:00
9395f43e40 Bifrost: Implement player advancement notifications. Oh my god this was hard and it still is ugly. 2022-01-17 17:19:12 -05:00
d16b9b1138 Bifrost: Fix player death messages. 2022-01-16 20:37:24 -05:00
4ca241aa5b Add allowLeads gameplay feature to allow leads on all mobs. 2022-01-16 14:04:21 -08:00
25c72d1ce3 Bifrost: Fix removal of sendPlayerDeath in default config. 2022-01-16 16:38:53 -05:00
3115990352 Bifrost: Debug mode, air-gap development mode, and fix chat message encoding. 2022-01-16 16:37:57 -05:00
ea83ce5853 Add missing sendPlayerDeath to Bifrost config template. 2022-01-16 13:15:26 -08:00
2bfa39c6a2 Fix download for setupPaperServer. 2022-01-16 13:11:36 -08:00
cd518c6928 Bifrost: Add simple player death notifications. 2022-01-15 23:24:35 -05:00
9398ada817 Small amount of inspection cleanup. 2022-01-15 16:21:38 -05:00
93d1888537 Gradle: runPaperServer should read Main-Class from manifest. 2022-01-15 16:15:11 -05:00
ef13c2371c Core: Backup should use a 16KB buffer. 2022-01-15 16:10:23 -05:00
0a08436088 Core: Backup cleanup and fixes for Windows. 2022-01-15 15:08:22 -05:00
0d2e454941 Gradle: Utilize Gradle plugin creation DSL. 2022-01-15 14:43:24 -05:00
e9548c5a3d Heimdall: Implement Player Position Compression 2022-01-14 23:57:16 -05:00
9d156d250b Gradle: Implement smart downloads which avoid download if the file exists and is valid. 2022-01-14 20:36:15 -05:00
8f34209aff Gradle: Implement --update option for setupPaperServer, and add runPaperServer 2022-01-13 23:19:07 -05:00
01999eadd7 Gradle: Implement setupPaperServer action which downloads Paper and links plugin JARs. 2022-01-13 18:25:46 -05:00
763b61ba04 Add gameplay feature. 2022-01-13 06:02:21 +00:00
71f0b46728 Core: Add Enderman Griefing Disabler 2022-01-12 23:00:03 -05:00
4187b0f50c Add missing alias and try to improve tab completion. 2022-01-10 00:04:08 -08:00
203ecd1ca9 Blindly wrote a command. 2022-01-09 23:46:43 -08:00
3ac24f6912 Gjallarhorn: Implement trimming at the changelog level, resulting in really fast renderings. 2022-01-10 02:13:13 -05:00
dcec7cab54 Gjallarhorn: Use 3-byte RGB for images, and improve timelapse text. 2022-01-10 01:56:56 -05:00
c1a07f1001 Update GitLab path. 2022-01-09 16:23:18 -08:00
2d429ae04d Gjallarhorn: Block Color Key and Render Pool Enhancements 2022-01-09 04:03:07 -05:00
94d644916b Gjallarhorn: Dynamic Timelapse Slices 2022-01-08 23:17:59 -05:00
7a5a27d581 Gjallarhorn: Various fixes to new pipeline for production usage. 2022-01-08 22:00:59 -05:00
3f06845ac4 Gjallarhorn: Use Player Position Changelog in Player Position Export 2022-01-08 16:34:13 -05:00
a81b160675 Gjallarhorn: Reuse 2D graphics when building individual images. 2022-01-08 15:34:58 -05:00
0a96435669 Gjallarhorn: Render pool should ignore playback segments which are empty. 2022-01-08 15:28:41 -05:00
41547f2e14 Gjallarhorn: Rename block-changes command to block-change-timelapse 2022-01-08 15:25:10 -05:00
8caf3de634 Gjallarhorn: Implement block state global cache.
This reduces memory usage by reusing block state objects.
2022-01-08 15:04:16 -05:00
81a76da809 Gjallarhorn: Create render pool concept. 2022-01-08 14:57:56 -05:00
3350034060 Gjallarhorn: Refactor Color Gradient 2022-01-08 14:10:30 -05:00
8ea1ea1540 Gjallarhorn: Start support for player position changelogs and rename replay-block-log to block-changes 2022-01-08 02:40:09 -05:00
d54f434805 Gjallarhorn: Changelog state tracker should use global air block. 2022-01-08 02:23:14 -05:00
643567dfb5 Gjallarhorn: Introduce concept of block changelogs, which makes timelapse rendering more efficient. 2022-01-08 02:21:42 -05:00
08ba582931 Gjallarhorn: Refactor Rendering Code 2022-01-08 01:58:31 -05:00
9f8d417e5d Gjallarhorn: Fifteen Minute Timelapse Support 2022-01-08 01:32:47 -05:00
86f82692b4 Fix issue that prevented plugins from being published as artifacts. 2022-01-08 01:08:42 +00:00
927abe54b6 Update to Gradle v7.3.3. 2022-01-07 23:27:47 +00:00
a0669f815b Gjallarhorn: Timelapse Limiting and Coordinate Cropping 2022-01-07 08:29:41 -05:00
10cf0cadac Gjallarhorn: Parallel Rendering and Quad Image Improvements 2022-01-07 07:29:04 -05:00
bc2d3e28ae Gjallarhorn: Timelapse Mode 2022-01-07 06:15:26 -05:00
cc6fbaae83 Gjallarhorn: Heat Map Support 2022-01-04 02:19:24 -05:00
06eda8932a Add backup file ignore list. 2021-12-28 09:41:28 +00:00
e681df1e65 Heimdall: Player Names Table, Gjallarhorn: Block State Image Rendering 2021-12-27 23:56:20 -05:00
9386dc7c56 Adjust cron expression and config comments. 2021-12-27 21:08:43 +00:00
ff665c27f5 Initial Commit of Gjallarhorn: A Heimdall Analytics Tool 2021-12-26 03:33:23 -05:00
cbbefc94a2 Heimdall: Log event count at debug log level. 2021-12-24 19:41:09 -05:00
d7f094f765 Heimdall: Implement Entity Kill Tracking 2021-12-24 19:04:03 -05:00
e10fa42c68 Adjust cron expression and config comments. 2021-12-24 22:10:48 +00:00
767faba8d8 Heimdall: Implement Player Death and Player Advancement Tracking 2021-12-24 04:10:33 -05:00
c1f621aa7b Initial work on scheduled backups. 2021-12-24 08:38:57 +00:00
b2851d13b9 Heimdall: Implement world change events. 2021-12-24 03:32:07 -05:00
4017c3cb8c Heimdall: Add id column to player session tracking. 2021-12-24 02:49:53 -05:00
1985b3c507 Heimdall: Player Session Tracking 2021-12-24 02:42:13 -05:00
139249c1de Core: Properly register leaderboard under lb as well. 2021-12-24 02:02:04 -05:00
e00ef21db1 Core: Add back leaderboard command. 2021-12-24 02:00:16 -05:00
fae116a2a5 Heimdall: Correct timestamp resolution of all events. 2021-12-24 01:41:14 -05:00
9952c4c427 Heimdall: Block Place and Break Tracking 2021-12-24 01:32:40 -05:00
847f46273b Update README. 2021-12-24 06:12:24 +00:00
e0183127b4 Initial Rough Cut of Heimdall Tracking System 2021-12-24 00:08:38 -05:00
78566d08ad Refactor persistence into it's own feature. 2021-12-23 21:26:10 -05:00
fca1db8802 Add S3 support to backups, fixes #7. 2021-12-24 00:43:44 +00:00
da820b8a0d Disable Bifrost onDisable if plugin was not initialized. 2021-12-23 23:08:02 +00:00
2c98cacf96 Reorganize feature + module init to fix bug. 2021-12-23 23:05:29 +00:00
c854e7c47c Finalize package organization. 2021-12-23 22:51:42 +00:00
ec7810a11a Not sure how this happened. 2021-12-23 22:47:10 +00:00
13479b1ae3 Major refactoring to use Koin. 2021-12-23 22:44:17 +00:00
f8178c2307 DevUpdateServer: Change HTTP server stop delay to one second. 2021-12-23 02:55:51 -05:00
7f9bd32cc7 DevUpdateServer: Minor logging changes. 2021-12-23 02:51:06 -05:00
a7d7c9f818 DevUpdateServer: Properly handle update in callback. 2021-12-23 02:47:32 -05:00
4284791804 DevUpdateServer: Simple code cleanup change. 2021-12-23 02:45:53 -05:00
ad8c82725b leaderboard: improved format and more leaderboards 2021-12-23 02:38:59 -05:00
4e066d8f11 leaderboard: add tab completion 2021-12-23 02:32:14 -05:00
76019a62fc DevUpdateServer: Improve update code. 2021-12-23 02:22:12 -05:00
552ef608d9 Break off update plugins code into separate object. 2021-12-23 07:17:48 +00:00
46ba0a4a44 Opt-into ExperimentalSerializationApi. 2021-12-23 07:02:31 +00:00
e3402505fd DevServer: Parse pipeline payload for filtering. 2021-12-23 01:51:27 -05:00
f7e19b1509 Add /setspawn and /spawn. 2021-12-23 06:32:12 +00:00
7a30d066ac If token is empty (the default now), Bifrost won't do anything.
- Fixes #6.
2021-12-23 06:31:46 +00:00
139ce551dc DevUpdate Server for Test Server Updates 2021-12-22 23:45:41 -05:00
795e99ad4f Add toggles for chat bridge. 2021-12-23 04:10:40 +00:00
b8c8097f58 Add toggles for start, shutdown, player join and quit events. 2021-12-23 03:44:16 +00:00
4439fe74a6 Delete println that I stupidly left in tab completion for persistent store.
I' a println debugger and I'm PROUD https://youtu.be/-N0yXGVWS1Y
2021-12-22 22:22:02 -05:00
ecdb6a2898 Persistent Store Command Tweaks
- delete-all-entities command.
- Basic tab completion.
2021-12-22 22:06:34 -05:00
b91602d719 Add installation instructions to README. 2021-12-23 03:02:40 +00:00
b32b8efc84 Persistent store fixes and /pstore command
- Fixes find() and getAll() to run in a transaction.
- Add /pstore command which has debug utilities.
2021-12-22 21:37:01 -05:00
fb1fd1a6e5 Forgot to add mkdir after reworking logic. 2021-12-23 02:25:06 +00:00
6b4bd2a987 Bump version to v0.2. 2021-12-23 02:23:26 +00:00
154 changed files with 4474 additions and 343 deletions

3
.gitignore vendored
View File

@ -116,3 +116,6 @@ run/
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Foundation Server
/server

View File

@ -1,7 +1,25 @@
# Foundation
Foundation is a set of plugins that implement the core functionality for a small community Minecraft
server.
## Plugins
* foundation-core - Core functionality
* foundation-bifrost - Discord chat bridge
* foundation-core: Core functionality
* foundation-bifrost: Discord chat bridge
* foundation-heimdall: Event tracking
## Tools
* tool-gjallarhorn - Heimdall swiss army knife
## Installation
The following command downloads and runs a script that will fetch the latest update manifest, and
install all plugins available. It can also be used to update plugins to the latest version
available.
```bash
# Always validate the contents of a script from the internet!
bash -c "$(curl -sL https://git.mystic.run/minecraft/foundation/-/raw/main/install.sh)"
```

View File

@ -1,24 +1,21 @@
import cloud.kubelet.foundation.gradle.FoundationProjectPlugin
import cloud.kubelet.foundation.gradle.isFoundationPlugin
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.jetbrains.kotlin.com.google.gson.Gson
import java.io.FileWriter
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
java
id("org.jetbrains.kotlin.jvm") version "1.6.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.6.10" apply false
id("com.github.johnrengelman.shadow") version "7.1.1" apply false
id("cloud.kubelet.foundation.gradle")
}
// Disable the JAR task for the root project.
tasks["jar"].enabled = false
allprojects {
repositories {
mavenCentral()
maven {
name = "papermc-repo"
name = "papermc"
url = uri("https://papermc.io/repo/repository/maven-public/")
}
maven {
name = "sonatype"
url = uri("https://oss.sonatype.org/content/groups/public/")
@ -26,61 +23,29 @@ allprojects {
}
}
val manifestsDir = buildDir.resolve("manifests")
manifestsDir.mkdirs()
val gson = Gson()
tasks.create("updateManifests") {
// TODO: not using task dependencies, outputs, blah blah blah.
doLast {
val updateFile = manifestsDir.resolve("update.json")
val writer = FileWriter(updateFile)
writer.use {
val rootPath = rootProject.rootDir.toPath()
val updateManifest = subprojects.mapNotNull { project ->
val files = project.tasks.getByName("shadowJar").outputs
val paths = files.files.map { rootPath.relativize(it.toPath()).toString() }
if (paths.isNotEmpty()) project.name to mapOf(
"version" to project.version,
"artifacts" to paths,
)
else null
}.toMap()
gson.toJson(
updateManifest,
writer
)
}
}
}
tasks.assemble {
dependsOn("updateManifests")
}
version = "0.2"
subprojects {
plugins.apply("org.jetbrains.kotlin.jvm")
plugins.apply("org.jetbrains.kotlin.plugin.serialization")
plugins.apply("com.github.johnrengelman.shadow")
plugins.apply(FoundationProjectPlugin::class)
version = "0.1"
group = "io.gorence"
// Add build number if running under CI.
val versionWithBuild = if (System.getenv("CI_PIPELINE_IID") != null) {
version as String + ".${System.getenv("CI_PIPELINE_IID")}"
} else {
"DEV"
}
version = versionWithBuild
group = "lgbt.mystic"
dependencies {
// Kotlin dependencies
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
// Core libraries.
implementation("io.insert-koin:koin-core:3.1.4")
testImplementation("io.insert-koin:koin-test:3.1.4")
// Serialization
implementation("com.charleskorn.kaml:kaml:0.38.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1")
@ -90,7 +55,7 @@ subprojects {
implementation("org.jetbrains.xodus:xodus-entity-store:1.3.232")
// Paper API
compileOnly("io.papermc.paper:paper-api:1.18.1-R0.1-SNAPSHOT")
compileOnly("io.papermc.paper:paper-api:1.18.2-R0.1-SNAPSHOT")
}
java {
@ -99,6 +64,13 @@ subprojects {
targetCompatibility = javaVersion
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs =
freeCompilerArgs + "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi"
}
}
tasks.processResources {
val props = mapOf("version" to version)
inputs.properties(props)
@ -108,11 +80,18 @@ subprojects {
}
}
tasks.withType<ShadowJar> {
archiveClassifier.set("plugin")
if (project.isFoundationPlugin()) {
tasks.withType<ShadowJar> {
archiveClassifier.set("plugin")
}
}
tasks.assemble {
dependsOn("shadowJar")
}
}
foundation {
minecraftServerPath.set("server")
paperVersionGroup.set("1.18")
}

28
buildSrc/build.gradle.kts Normal file
View 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"
}
}
}

View File

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

View File

@ -0,0 +1,7 @@
package cloud.kubelet.foundation.gradle
import com.google.gson.Gson
object FoundationGlobals {
val gson = Gson()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ dependencies {
implementation("net.dv8tion:JDA:5.0.0-alpha.2") {
exclude(module = "opus-java")
}
implementation("com.rabbitmq:amqp-client:5.14.2")
compileOnly(project(":foundation-core"))
}

View File

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

View File

@ -3,30 +3,35 @@ package cloud.kubelet.foundation.bifrost
import cloud.kubelet.foundation.bifrost.model.BifrostConfig
import cloud.kubelet.foundation.core.FoundationCorePlugin
import cloud.kubelet.foundation.core.Util
import cloud.kubelet.foundation.core.util.AdvancementTitleCache
import com.charleskorn.kaml.Yaml
import io.papermc.paper.event.player.AsyncChatEvent
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.MessageBuilder
import net.dv8tion.jda.api.entities.Message
import net.dv8tion.jda.api.entities.TextChannel
import net.dv8tion.jda.api.events.GenericEvent
import net.dv8tion.jda.api.events.ReadyEvent
import net.dv8tion.jda.api.events.message.MessageReceivedEvent
import net.dv8tion.jda.api.hooks.EventListener
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.TextComponent
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.EventPriority
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.player.PlayerAdvancementDoneEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
import java.awt.Color
import kotlin.io.path.inputStream
import net.dv8tion.jda.api.hooks.EventListener as DiscordEventListener
import org.bukkit.event.Listener as BukkitEventListener
class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
class FoundationBifrostPlugin : JavaPlugin(), DiscordEventListener, BukkitEventListener {
private lateinit var config: BifrostConfig
private lateinit var jda: JDA
private var jda: JDA? = null
private var isDev = false
override fun onEnable() {
@ -43,6 +48,10 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
config = Yaml.default.decodeFromStream(BifrostConfig.serializer(), configPath.inputStream())
server.pluginManager.registerEvents(this, this)
if (config.authentication.token.isEmpty()) {
slF4JLogger.warn("Token empty, Bifrost will not connect to Discord.")
return
}
jda = JDABuilder
.createDefault(config.authentication.token)
@ -51,11 +60,14 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
}
override fun onDisable() {
// Plugin was not initialized, don't do anything.
if (jda == null) return
onServerStop()
logger.info("Shutting down JDA")
jda.shutdown()
while (jda.status != JDA.Status.SHUTDOWN) {
jda?.shutdown()
while (jda != null && jda!!.status != JDA.Status.SHUTDOWN) {
Thread.sleep(100)
}
}
@ -63,13 +75,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
override fun onEvent(e: GenericEvent) {
when (e) {
is ReadyEvent -> {
val channel = getChannel() ?: return
if (isDev) return
channel.sendMessage(":white_check_mark: Server is ready!").queue()
onDiscordReady()
}
is MessageReceivedEvent -> {
if (!config.channel.bridge) return
// Prevent this bot from receiving its own messages and creating a feedback loop.
if (e.author.id == jda.selfUser.id) return
if (e.author.id == jda?.selfUser?.id) return
// Only forward messages from the configured channel.
if (e.channel.id != config.channel.id) return
@ -82,8 +93,12 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
}
}
private fun getChannel(): TextChannel? {
val channel = jda.getTextChannelById(config.channel.id)
private fun getTextChannel(): TextChannel? {
if (jda == null) {
return null
}
val channel = jda?.getTextChannelById(config.channel.id)
if (channel == null) {
slF4JLogger.error("Failed to retrieve channel ${config.channel.id}")
}
@ -95,45 +110,85 @@ class FoundationBifrostPlugin : JavaPlugin(), EventListener, Listener {
setEmbeds(EmbedBuilder().apply(f).build())
}
@EventHandler
private fun onPlayerJoin(e: PlayerJoinEvent) {
val channel = getChannel() ?: return
private fun sendChannelMessage(message: Message, debug: () -> String) {
val channel = getTextChannel()
channel?.sendMessage(message)?.queue()
channel.sendMessage(message {
embed {
setAuthor("${e.player.name} joined the server")
setColor(Color.GREEN)
}
}).queue()
}
@EventHandler
private fun onPlayerQuit(e: PlayerQuitEvent) {
val channel = getChannel() ?: return
channel.sendMessage(message {
embed {
setAuthor("${e.player.name} left the server")
setColor(Color.RED)
}
}).queue()
}
@EventHandler
private fun onPlayerChat(e: AsyncChatEvent) {
val channel = getChannel() ?: return
val message = e.message()
if (message is TextComponent) {
channel.sendMessage("${e.player.name}: ${message.content()}").queue()
} else {
slF4JLogger.error("Not sure what to do here, message != TextComponent: ${message.javaClass}")
if (config.enableDebugLog) {
slF4JLogger.info("Send '${debug()}' to Discord")
}
}
private fun onServerStop() {
val channel = getChannel() ?: return
private fun sendChannelMessage(message: String): Unit = sendChannelMessage(message {
setContent(message)
}) { message }
private fun sendEmbedMessage(color: Color, message: String): Unit = sendChannelMessage(message {
embed {
setAuthor(message)
setColor(color)
}
}) { "[rgb:${color.rgb}] $message" }
@EventHandler(priority = EventPriority.MONITOR)
private fun onPlayerJoin(e: PlayerJoinEvent) {
if (!config.channel.sendPlayerJoin) return
sendEmbedMessage(Color.GREEN, "${e.player.name} joined the server")
}
@EventHandler(priority = EventPriority.MONITOR)
private fun onPlayerQuit(e: PlayerQuitEvent) {
if (!config.channel.sendPlayerQuit) return
sendEmbedMessage(Color.RED, "${e.player.name} left the server")
}
@EventHandler(priority = EventPriority.MONITOR)
private fun onPlayerChat(e: AsyncChatEvent) {
if (!config.channel.bridge) return
val message = e.message()
val messageAsText = LegacyComponentSerializer.legacySection().serialize(message)
sendChannelMessage("${e.player.name}: $messageAsText")
}
@EventHandler(priority = EventPriority.MONITOR)
private fun onPlayerDeath(e: PlayerDeathEvent) {
if (!config.channel.sendPlayerDeath) return
@Suppress("DEPRECATION")
var deathMessage = e.deathMessage
if (deathMessage == null || deathMessage.isBlank()) {
deathMessage = "${e.player.name} died"
}
sendEmbedMessage(Color.YELLOW, deathMessage)
}
@EventHandler(priority = EventPriority.MONITOR)
private fun onPlayerAdvancementDone(e: PlayerAdvancementDoneEvent) {
if (!config.channel.sendPlayerAdvancement) return
if (e.advancement.key.key.contains("recipe/")) {
return
}
val advancementDisplay = e.advancement.display ?: return
if (!advancementDisplay.doesAnnounceToChat()) {
return
}
val display = AdvancementTitleCache.of(e.advancement) ?: return
sendEmbedMessage(Color.CYAN, "${e.player.name} completed the advancement '${display}'")
}
private fun onDiscordReady() {
if (!config.channel.sendStart) return
if (isDev) return
channel.sendMessage(":octagonal_sign: Server is stopping!").queue()
sendChannelMessage(":white_check_mark: Server is ready!")
}
private fun onServerStop() {
if (!config.channel.sendShutdown) return
if (isDev) return
sendChannelMessage(":octagonal_sign: Server is stopping!")
}
}

View File

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

View File

@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
data class BifrostConfig(
val authentication: BifrostAuthentication,
val channel: BifrostChannel,
val enableDebugLog: Boolean = false
)
@Serializable
@ -16,4 +17,11 @@ data class BifrostAuthentication(
@Serializable
data class BifrostChannel(
val id: String,
val bridge: Boolean = true,
val sendStart: Boolean = true,
val sendShutdown: Boolean = true,
val sendPlayerJoin: Boolean = true,
val sendPlayerQuit: Boolean = true,
val sendPlayerDeath: Boolean = true,
val sendPlayerAdvancement: Boolean = true
)

View File

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

View File

@ -0,0 +1,5 @@
# Configuration for the Bifrost multi-server chat bridge.
messageQueue:
host: localhost

View File

@ -1,10 +1,25 @@
# Authentication configuration for the bridge.
authentication:
# Token from the Discord Bot developer's page.
token: abc123
# Token from the Discord Bot developer's page. If this is empty, the Bifrost plugin will do
# nothing.
token: ""
# Channel configuration for the bridge.
channel:
# Channel ID, can be copied by turning on Developer Mode in User Settings -> Advanced. The ID can
# then be copied by right-clicking the channel and selecting "Copy ID".
id: 123456789
# Toggles the chat message bridge.
bridge: true
# Toggles for common events that generate notifications that are sent to the channel.
sendStart: true
sendShutdown: true
sendPlayerJoin: true
sendPlayerQuit: true
sendPlayerDeath: true
sendPlayerAdvancement: true
# Enables logging of what is sent to Discord.
enableDebugLog: false

View File

@ -1,3 +1,7 @@
dependencies {
// TODO: might be able to ship all dependencies in core? are we duplicating classes in JARs?
implementation("software.amazon.awssdk:s3:2.17.102")
implementation("org.quartz-scheduler:quartz:2.3.2")
implementation("com.google.guava:guava:31.0.1-jre")
}

View File

@ -1,22 +1,19 @@
package cloud.kubelet.foundation.core
import cloud.kubelet.foundation.core.command.*
import cloud.kubelet.foundation.core.persist.PersistentStore
import cloud.kubelet.foundation.core.persist.setAllProperties
import io.papermc.paper.event.player.AsyncChatEvent
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.TextComponent
import org.bukkit.GameMode
import org.bukkit.command.CommandExecutor
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.plugin.java.JavaPlugin
import cloud.kubelet.foundation.core.abstraction.FoundationPlugin
import cloud.kubelet.foundation.core.features.backup.BackupFeature
import cloud.kubelet.foundation.core.features.dev.DevFeature
import cloud.kubelet.foundation.core.features.gameplay.GameplayFeature
import cloud.kubelet.foundation.core.features.persist.PersistenceFeature
import cloud.kubelet.foundation.core.features.player.PlayerFeature
import cloud.kubelet.foundation.core.features.scheduler.SchedulerFeature
import cloud.kubelet.foundation.core.features.stats.StatsFeature
import cloud.kubelet.foundation.core.features.update.UpdateFeature
import cloud.kubelet.foundation.core.features.world.WorldFeature
import org.koin.dsl.module
import java.nio.file.Path
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
class FoundationCorePlugin : JavaPlugin(), Listener {
internal val persistentStores = ConcurrentHashMap<String, PersistentStore>()
class FoundationCorePlugin : FoundationPlugin() {
private lateinit var _pluginDataPath: Path
var pluginDataPath: Path
@ -34,98 +31,27 @@ class FoundationCorePlugin : JavaPlugin(), Listener {
_pluginDataPath = value
}
/**
* Fetch a persistent store by name. Make sure the name is path-safe, descriptive and consistent across server runs.
*/
fun getPersistentStore(name: String) = persistentStores.getOrPut(name) { PersistentStore(this, name) }
private lateinit var chatLogStore: PersistentStore
override fun onEnable() {
// Create core plugin directory.
pluginDataPath = dataFolder.toPath()
val backupPath = pluginDataPath.resolve(BACKUPS_DIRECTORY)
// Create Foundation plugin directories.
pluginDataPath.toFile().mkdir()
backupPath.toFile().mkdir()
// Register this as an event listener.
server.pluginManager.registerEvents(this, this)
// Register commands.
registerCommandExecutor("fbackup", BackupCommand(this, backupPath))
registerCommandExecutor("fupdate", UpdateCommand())
registerCommandExecutor(listOf("survival", "s"), GamemodeCommand(GameMode.SURVIVAL))
registerCommandExecutor(listOf("creative", "c"), GamemodeCommand(GameMode.CREATIVE))
registerCommandExecutor(listOf("adventure", "a"), GamemodeCommand(GameMode.ADVENTURE))
registerCommandExecutor(listOf("spectator", "sp"), GamemodeCommand(GameMode.SPECTATOR))
registerCommandExecutor(listOf("leaderboard", "lb"), LeaderboardCommand())
registerCommandExecutor(listOf("pstorestats"), StoreStatsCommand(this))
val log = slF4JLogger
log.info("Features:")
Util.printFeatureStatus(log, "Backup", BACKUP_ENABLED)
chatLogStore = getPersistentStore("chat-logs")
super.onEnable()
}
override fun onDisable() {
persistentStores.values.forEach { store -> store.close() }
persistentStores.clear()
override fun createFeatures() = listOf(
SchedulerFeature(),
PersistenceFeature(),
BackupFeature(),
DevFeature(),
GameplayFeature(),
PlayerFeature(),
StatsFeature(),
UpdateFeature(),
WorldFeature(),
)
override fun createModule() = module {
single { this@FoundationCorePlugin }
}
private fun registerCommandExecutor(name: String, executor: CommandExecutor) {
registerCommandExecutor(listOf(name), executor)
}
private fun registerCommandExecutor(names: List<String>, executor: CommandExecutor) {
for (name in names) {
val command = getCommand(name) ?: throw Exception("Failed to get $name command")
command.setExecutor(executor)
}
}
// TODO: Disabling chat reformatting until I do something with it and figure out how to make it
// be less disruptive.
/*@EventHandler
private fun onChatMessage(e: ChatEvent) {
return
e.isCancelled = true
val name = e.player.displayName()
val component = Component.empty()
.append(leftBracket)
.append(name)
.append(rightBracket)
.append(Component.text(' '))
.append(e.message())
server.sendMessage(component)
}*/
@EventHandler
private fun logOnChatMessage(e: AsyncChatEvent) {
val player = e.player
val message = e.message()
if (message !is TextComponent) {
return
}
val content = message.content()
chatLogStore.create("ChatMessageEvent") {
setAllProperties(
"timestamp" to Instant.now().toEpochMilli(),
"player.id" to player.identity().uuid().toString(),
"player.name" to player.name,
"message.content" to content
)
}
}
companion object {
private const val BACKUPS_DIRECTORY = "backups"
private val leftBracket: Component = Component.text('[')
private val rightBracket: Component = Component.text(']')
const val BACKUP_ENABLED = true
}
}
}

View File

@ -11,10 +11,6 @@ object Util {
private val whitespace: Component = Component.text(' ')
private val foundationName: Component = Component.text("Foundation")
fun printFeatureStatus(logger: Logger, feature: String?, state: Boolean) {
logger.info("{}: {}", feature, if (state) "Enabled" else "Disabled")
}
fun formatSystemMessage(message: String): Component {
return formatSystemMessage(TextColors.AMARANTH_PINK, message)
}
@ -61,4 +57,9 @@ object Util {
return outPath
}
fun isPlatformWindows(): Boolean {
val os = System.getProperty("os.name")
return os != null && os.lowercase().startsWith("windows")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.command
package cloud.kubelet.foundation.core.features.backup
import cloud.kubelet.foundation.core.FoundationCorePlugin
import cloud.kubelet.foundation.core.Util
@ -8,33 +8,31 @@ import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
// TODO: Clean up dependency injection.
class BackupCommand(
private val plugin: FoundationCorePlugin,
private val backupPath: Path
private val backupsPath: Path,
private val config: BackupConfig,
private val s3Client: S3Client,
) : CommandExecutor {
override fun onCommand(
sender: CommandSender, command: Command, label: String, args: Array<String>
): Boolean {
if (!FoundationCorePlugin.BACKUP_ENABLED) {
sender.sendMessage(
Component
.text("Backup is not enabled.")
.color(TextColor.fromHexString("#FF0000"))
)
return true
}
if (RUNNING.get()) {
sender.sendMessage(
Component
@ -44,35 +42,59 @@ class BackupCommand(
return true
}
try {
val server = sender.server
server.scheduler.runTaskAsynchronously(plugin, Runnable {
runBackup(server)
})
} catch (e: Exception) {
sender.sendMessage(String.format("Failed to backup: %s", e.message))
val server = sender.server
server.scheduler.runTaskAsynchronously(plugin) { ->
runBackup(server, sender)
}
return true
}
private fun runBackup(server: Server) {
// TODO: Pull backup creation code into a separate service.
private fun runBackup(server: Server, sender: CommandSender? = null) = try {
RUNNING.set(true)
server.sendMessage(Util.formatSystemMessage("Backup started."))
server.scheduler.runTask(plugin) { ->
server.sendMessage(Util.formatSystemMessage("Backup started."))
}
val backupFile =
backupPath.resolve(String.format("backup-%s.zip", Instant.now().toString())).toFile()
val backupTime = Instant.now()
val backupIdentifier = if (Util.isPlatformWindows()) {
backupTime.toEpochMilli().toString()
} else {
backupTime.toString()
}
val backupFileName = String.format("backup-%s.zip", backupIdentifier)
val backupPath = backupsPath.resolve(backupFileName)
val backupFile = backupPath.toFile()
try {
FileOutputStream(backupFile).use { zipFileStream ->
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
backupPlugins(server, zipStream)
backupWorlds(server, zipStream)
}
FileOutputStream(backupFile).use { zipFileStream ->
ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream ->
backupPlugins(server, zipStream)
backupWorlds(server, zipStream)
}
} finally {
RUNNING.set(false)
}
// TODO: Pull upload code out into a separate service.
if (config.s3.accessKeyId.isNotEmpty()) {
s3Client.putObject(
PutObjectRequest.builder().apply {
bucket(config.s3.bucket)
key("${config.s3.baseDirectory}/$backupFileName")
}.build(),
backupPath
)
}
Unit
} catch (e: Exception) {
if (sender != null) {
server.scheduler.runTask(plugin) { ->
sender.sendMessage(String.format("Failed to backup: %s", e.message))
}
}
plugin.slF4JLogger.warn("Failed to backup.", e)
} finally {
RUNNING.set(false)
server.scheduler.runTask(plugin) { ->
server.sendMessage(Util.formatSystemMessage("Backup finished."))
}
}
@ -82,7 +104,7 @@ class BackupCommand(
addDirectoryToZip(zipStream, server.pluginsFolder.toPath())
} catch (e: IOException) {
// TODO: Add error handling.
e.printStackTrace()
plugin.slF4JLogger.warn("Failed to backup plugins.", e)
}
}
@ -111,11 +133,13 @@ class BackupCommand(
}
private fun addDirectoryToZip(zipStream: ZipOutputStream, directoryPath: Path) {
val matchers = config.ignore.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
val paths = Files.walk(directoryPath)
.filter { path: Path? -> Files.isRegularFile(path) }
.filter { path: Path -> Files.isRegularFile(path) }
.filter { path -> !matchers.any { it.matches(Paths.get(path.normalize().toString())) } }
.toList()
val buffer = ByteArray(1024)
val backupsPath = backupPath.toRealPath()
val buffer = ByteArray(16 * 1024)
val backupsPath = backupsPath.toRealPath()
for (path in paths) {
val realPath = path.toRealPath()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.persist
package cloud.kubelet.foundation.core.features.persist
import cloud.kubelet.foundation.core.FoundationCorePlugin
import jetbrains.exodus.entitystore.Entity
@ -10,7 +10,7 @@ class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) :
private val fileStorePath = corePlugin.pluginDataPath.resolve("persistence/${fileStoreName}")
private val entityStore = PersistentEntityStores.newInstance(fileStorePath.toFile())
fun <R> transact(block: (StoreTransaction) -> R): R {
fun <R> transact(block: StoreTransaction.() -> R): R {
var result: R? = null
entityStore.executeInTransaction { tx ->
result = block(tx)
@ -18,16 +18,18 @@ class PersistentStore(corePlugin: FoundationCorePlugin, fileStoreName: String) :
return result!!
}
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact { tx ->
val entity = tx.newEntity(entityTypeName)
populate(entity)
fun create(entityTypeName: String, populate: Entity.() -> Unit) = transact {
populate(newEntity(entityTypeName))
}
fun getAll(entityTypeName: String) =
transact { tx -> tx.getAll(entityTypeName) }
fun <R> getAll(entityTypeName: String, block: (EntityIterable) -> R): R =
transact { block(getAll(entityTypeName)) }
fun <T> find(entityTypeName: String, propertyName: String, value: Comparable<T>): EntityIterable =
transact { tx -> tx.find(entityTypeName, propertyName, value) }
fun <T, R> find(entityTypeName: String, propertyName: String, value: Comparable<T>, block: (EntityIterable) -> R): R =
transact { block(find(entityTypeName, propertyName, value)) }
fun deleteAllEntities(entityTypeName: String) =
transact { entityStore.deleteEntityType(entityTypeName) }
override fun close() {
entityStore.close()

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.persist
package cloud.kubelet.foundation.core.features.persist
import jetbrains.exodus.entitystore.Entity

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.command
package cloud.kubelet.foundation.core.features.player
import org.bukkit.GameMode
import org.bukkit.command.Command

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.command
package cloud.kubelet.foundation.core.features.stats
import cloud.kubelet.foundation.core.SortOrder
import cloud.kubelet.foundation.core.allPlayerStatisticsOf
@ -6,13 +6,18 @@ import org.bukkit.Statistic
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.command.TabCompleter
class LeaderboardCommand : CommandExecutor {
class LeaderboardCommand : CommandExecutor, TabCompleter {
private val leaderboards = listOf(
LeaderboardType("player-kills", Statistic.PLAYER_KILLS, "Player Kills", "kills"),
LeaderboardType("mob-kills", Statistic.MOB_KILLS, "Mob Kills", "kills"),
LeaderboardType("animals-bred", Statistic.ANIMALS_BRED, "Animals Bred", "animals"),
LeaderboardType("chest-opens", Statistic.CHEST_OPENED, "Chest Opens", "opens")
LeaderboardType("chest-opens", Statistic.CHEST_OPENED, "Chest Opens", "opens"),
LeaderboardType("raid-wins", Statistic.RAID_WIN, "Raid Wins", "wins"),
LeaderboardType("item-enchants", Statistic.ITEM_ENCHANTED, "Item Enchants", "enchants"),
LeaderboardType("damage-dealt", Statistic.DAMAGE_DEALT, "Damage Dealt", "damage"),
LeaderboardType("fish-caught", Statistic.FISH_CAUGHT, "Fish Caught", "fish")
)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
@ -30,10 +35,28 @@ class LeaderboardCommand : CommandExecutor {
val topFivePlayers = statistics.take(5)
sender.sendMessage(
"${leaderboardType.friendlyName} Leaderboard:",
*topFivePlayers.map { "* ${it.first.name}: ${it.second} ${leaderboardType.unit}" }.toTypedArray()
*topFivePlayers.withIndex()
.map { "(#${it.index + 1}) ${it.value.first.name}: ${it.value.second} ${leaderboardType.unit}" }.toTypedArray()
)
return true
}
class LeaderboardType(val id: String, val statistic: Statistic, val friendlyName: String, val unit: String)
override fun onTabComplete(
sender: CommandSender,
command: Command,
alias: String,
args: Array<out String>
): MutableList<String> = when {
args.isEmpty() -> {
leaderboards.map { it.id }.toMutableList()
}
args.size == 1 -> {
leaderboards.map { it.id }.filter { it.startsWith(args[0]) }.toMutableList()
}
else -> {
mutableListOf()
}
}
}

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.update
package cloud.kubelet.foundation.core.features.update
import kotlinx.serialization.Serializable

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.core.update
package cloud.kubelet.foundation.core.features.update
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.builtins.MapSerializer
@ -15,7 +15,7 @@ object UpdateUtil {
// TODO: Add environment variable override. Document it.
private const val basePath =
"https://git.gorence.io/lgorence/foundation/-/jobs/artifacts/main/raw"
"https://git.mystic.run/minecraft/foundation/-/jobs/artifacts/main/raw"
private const val basePathQueryParams = "job=build"
private const val manifestPath = "build/manifests/update.json"

View File

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

View File

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

View File

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

View File

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

View 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: ""

View 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:
- "*"

View 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

View 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: []

View File

@ -45,7 +45,20 @@ commands:
aliases:
- lb
permission: foundation.command.leaderboard
pstorestats:
description: Persistent Store Stats
usage: /pstorestats
permission: foundation.command.pstorestats
pstore:
description: Persistent Store Manager
usage: /pstore
permission: foundation.command.pstore
setspawn:
description: Set the spawn of the current world.
usage: /setspawn
permission: foundation.command.setspawn
spawn:
description: Teleport to the spawn of the current world.
usage: /spawn
permission: foundation.command.spawn
localweather:
description: Set the player's local weather in the client.
usage: /localweather <type>
aliases:
- lw

View File

@ -0,0 +1,3 @@
org.quartz.scheduler.instanceName = Foundation
org.quartz.threadPool.threadCount = 2
org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package cloud.kubelet.foundation.heimdall.event
import org.jetbrains.exposed.sql.Transaction
abstract class HeimdallEvent {
abstract fun store(transaction: Transaction)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package cloud.kubelet.foundation.heimdall.export
import kotlinx.serialization.Serializable
@Serializable
data class ExportedBlock(
val type: String
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"

View 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