Compare commits

..

3 Commits

Author SHA1 Message Date
f61c95f3ab 1.1 2025-11-27 07:40:32 +09:00
3be9a59370 feat: マイグレーションを作成 2025-11-27 03:48:19 +09:00
f497ef1ee2 feat: NTBAPI非依存シリアライズ・デシリアライズ 2025-11-26 23:02:43 +09:00
17 changed files with 444 additions and 94 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ gradle-app.setting
# JDT-specific (Eclipse Java Development Tools) # JDT-specific (Eclipse Java Development Tools)
.classpath .classpath
.direnv
bin

View File

@ -1,7 +1,7 @@
import net.minecrell.pluginyml.bukkit.BukkitPluginDescription import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
group = "net.hareworks" group = "net.hareworks"
version = "1.0" version = "1.1"
val exposedVersion = "0.54.0" val exposedVersion = "0.54.0"
@ -9,7 +9,7 @@ plugins {
kotlin("jvm") version "2.0.20" kotlin("jvm") version "2.0.20"
kotlin("plugin.serialization") version "2.0.20" kotlin("plugin.serialization") version "2.0.20"
id("net.minecrell.plugin-yml.bukkit") version "0.6.0" id("net.minecrell.plugin-yml.bukkit") version "0.6.0"
id("com.github.johnrengelman.shadow") version "8.1.1" id("com.gradleup.shadow") version "9.2.2"
} }
repositories { repositories {
mavenCentral() mavenCentral()
@ -17,23 +17,28 @@ repositories {
maven("https://repo.codemc.io/repository/maven-public/") maven("https://repo.codemc.io/repository/maven-public/")
} }
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.3-R0.1-SNAPSHOT") compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
implementation("net.kyori:adventure-api:4.17.0") implementation("net.kyori:adventure-api:4.17.0")
implementation("net.kyori:adventure-text-minimessage:4.17.0") implementation("net.kyori:adventure-text-minimessage:4.17.0")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("org.postgresql:postgresql:42.7.1") implementation("org.postgresql:postgresql:42.7.1")
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion")
compileOnly("de.tr7zw:item-nbt-api-plugin:2.14.0") implementation("de.tr7zw:item-nbt-api:2.15.3")
implementation("com.michael-bull.kotlin-result:kotlin-result:2.0.0") implementation("com.michael-bull.kotlin-result:kotlin-result:2.0.0")
} }
tasks { tasks {
shadowJar { shadowJar {
archiveBaseName.set("SimplyMCDB") archiveBaseName.set("SimplyMCDB")
archiveClassifier.set("") archiveClassifier.set("")
relocate("de.tr7zw.changeme.nbtapi", "net.hareworks.simplymcdb.libs.nbtapi")
}
build {
dependsOn(shadowJar)
} }
} }
@ -42,10 +47,9 @@ bukkit {
name = "Simply-Minecraft-DB" name = "Simply-Minecraft-DB"
description = "It provides a simple way to manage player data through a database." description = "It provides a simple way to manage player data through a database."
version = getVersion().toString() version = getVersion().toString()
apiVersion = "1.21.3" apiVersion = "1.21.10"
authors = authors =
listOf("Hare-K02") listOf("Hare-K02")
depend = listOf("NBTAPI")
permissions { permissions {
register("simplydb.*") { register("simplydb.*") {
children = listOf("simplydb.command", "simplydb.admin") children = listOf("simplydb.command", "simplydb.admin")

61
flake.lock Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1764020296,
"narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

47
flake.nix Normal file
View File

@ -0,0 +1,47 @@
{
description = "Minecraft dev environment with JDK 21 and Gradle";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
jdk21
gradle
kotlin
git
unzip
];
# 必要に応じて環境変数を設定
shellHook = ''
export JAVA_HOME=${pkgs.jdk21}/lib/openjdk
export PATH="$JAVA_HOME/bin:$PATH"
export GRADLE_USER_HOME="$PWD/.gradle"
echo "Loaded Minecraft dev env (JDK 21 + Gradle)"
java -version || true
gradle --version || true
'';
};
}
);
}

View File

@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21

Binary file not shown.

View File

@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

34
gradlew vendored
View File

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -83,10 +85,9 @@ done
# This is normally unused # This is normally unused
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. ' "$PWD" ) || exit
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@ -133,10 +134,13 @@ location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
@ -144,7 +148,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045 # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
@ -152,7 +156,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045 # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -197,11 +201,15 @@ if "$cygwin" || "$msys" ; then
done done
fi fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
# shell script including quotes and variable substitutions, so put them in DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded. # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View File

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail

View File

@ -18,10 +18,12 @@ public class App : JavaPlugin() {
field = value field = value
Config.config.set("enabled", !value.equals(State.DISABLED)) Config.config.set("enabled", !value.equals(State.DISABLED))
} }
companion object { companion object {
lateinit var instance: App lateinit var instance: App
private set private set
} }
lateinit var command: KommandLib lateinit var command: KommandLib
private set private set
@ -34,6 +36,7 @@ public class App : JavaPlugin() {
if (Config.check()) enable() if (Config.check()) enable()
} }
override fun onDisable() { override fun onDisable() {
enabled = State.DISABLED enabled = State.DISABLED
Database.disconnect() Database.disconnect()

View File

@ -72,6 +72,91 @@ public val smcdb =
sender.sendMessage("database reset.") sender.sendMessage("database reset.")
}, },
), ),
Route("migrate") { sender, _ ->
if (sender !is Player) {
sender.sendMM("<red>[SMCDB] This command can only be run by players.")
return@Route
}
when (App.instance.enabled) {
State.DISABLED -> {
sender.sendMM("<red>[SMCDB] simplymcdb is disabled.")
return@Route
}
State.DISCONNECTED -> {
sender.sendMM("<yellow>[SMCDB] Database disconnected. Try again later.")
return@Route
}
else -> {}
}
if (!isRegistered(sender.uniqueId)) {
sender.sendMM("<red>[SMCDB] You are not registered in the database.")
return@Route
}
try {
sender.sendMM("<gray>[SMCDB] Applying legacy data...")
fetch(sender)
update(sender)
sender.sendMM("<green>[SMCDB] Migration complete. Data updated to the latest format.")
} catch (e: Exception) {
App.instance.logger.warning("Failed to migrate data for ${sender.uniqueId}: ${e.message}")
sender.sendMM("<red>[SMCDB] Migration failed. Check server logs.")
}
}.addArgs(
Route("all") { sender, _ ->
if (sender !is Player) {
sender.sendMM("<red>[SMCDB] This command can only be run by players.")
return@Route
}
when (App.instance.enabled) {
State.DISABLED -> {
sender.sendMM("<red>[SMCDB] simplymcdb is disabled.")
return@Route
}
State.DISCONNECTED -> {
sender.sendMM(
"<yellow>[SMCDB] Database disconnected. Try again later."
)
return@Route
}
else -> {}
}
val targets = findPlayersNeedingMigration()
if (targets.isEmpty()) {
sender.sendMM("<gray>[SMCDB] No legacy data found.")
return@Route
}
sender.sendMM(
"<gray>[SMCDB] Migrating ${targets.size} legacy profiles... Please wait."
)
val backup = PlayerSerializer.serialize(sender)
var migrated = 0
try {
targets.forEach { entry ->
try {
PlayerSerializer.deserialize(sender, entry.serialized)
val updatedSnapshot = PlayerSerializer.serialize(sender)
overwritePlayerData(entry.uuid, updatedSnapshot)
migrated++
} catch (ex: Exception) {
App.instance.logger.warning(
"Failed to migrate data for ${entry.uuid}: ${ex.message}"
)
}
}
} finally {
try {
PlayerSerializer.deserialize(sender, backup)
} catch (restoreEx: Exception) {
App.instance.logger.warning(
"Failed to restore migration executor state: ${restoreEx.message}"
)
}
}
sender.sendMM(
"<green>[SMCDB] Migration finished ($migrated/${targets.size}). Check logs for failures."
)
}
),
Route("check") { sender, _ -> Route("check") { sender, _ ->
sender.sendMM( sender.sendMM(
"${when (App.instance.enabled) { "${when (App.instance.enabled) {

View File

@ -46,18 +46,15 @@ public object EventListener : Listener {
| not | confirm register | | not | confirm register |
+------------+------------------*/ +------------+------------------*/
if (isRegistered(event.player.uniqueId)) fetch(event.player) if (isRegistered(event.player.uniqueId)) fetch(event.player)
else if (event.player.hasPlayedBefore()) { else if (event.player.hasPlayedBefore()) register(event.player)
register(event.player) else event.player.sendMessage(
} else { MiniMessage.miniMessage()
event.player.sendMessage( .deserialize(
MiniMessage.miniMessage() "<gray>[SMCDB] Welcome, ${event.player.name}.<newline>" +
.deserialize( "SMCDB is active but you have already played before.<newline>" +
"<gray>[SMCDB] Welcome, ${event.player.name}.<newline>" + "Run <green>/smcdb register<gray> to register yourself."
"SMCDB is active but you have already played before.<newline>" +
"Run <green>/smcdb register<gray> to register yourself."
)
) )
} )
} }
@EventHandler @EventHandler

View File

@ -1,68 +1,179 @@
package net.hareworks.simplymcdb package net.hareworks.simplymcdb
import de.tr7zw.nbtapi.NBT import de.tr7zw.changeme.nbtapi.NBT
import java.util.Base64
import java.util.function.Function import java.util.function.Function
import io.papermc.paper.registry.RegistryAccess
import io.papermc.paper.registry.RegistryKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.bukkit.NamespacedKey
import org.bukkit.Registry
import org.bukkit.attribute.Attribute
import org.bukkit.entity.Player as BukkitPlayer import org.bukkit.entity.Player as BukkitPlayer
import org.bukkit.inventory.ItemStack
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
data class PlayerData( const val PLAYER_DATA_CURRENT_VERSION = 1
val health: Float,
val hunger: Int, @Serializable
val exp: Float, data class PlayerSnapshot(
val effects: String, val version: Int = PLAYER_DATA_CURRENT_VERSION,
val inv: String, val health: Double,
val enderchest: String val foodLevel: Int,
val xpProgress: Float,
val selectedItemSlot: Int,
val potionEffects: List<PotionEffectSnapshot>,
val inventory: List<ItemStackSnapshot?>,
val enderChest: List<ItemStackSnapshot?>
) )
@Serializable
data class PotionEffectSnapshot(
val type: String,
val amplifier: Int,
val duration: Int,
val ambient: Boolean,
val particles: Boolean,
val icon: Boolean
)
@Serializable data class ItemStackSnapshot(val payload: String)
private val json =
Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
private val mobEffectRegistry: Registry<PotionEffectType>?
get() = RegistryAccess.registryAccess().getRegistry(RegistryKey.MOB_EFFECT)
object PlayerSerializer { object PlayerSerializer {
fun serialize(player: BukkitPlayer): String { fun serialize(player: BukkitPlayer): String {
val result = val snapshot =
NBT.get( PlayerSnapshot(
player, health = player.health,
Function { nbt -> foodLevel = player.foodLevel,
var output = NBT.createNBTObject() xpProgress = player.exp,
selectedItemSlot = player.inventory.heldItemSlot,
output.setFloat("Health", player.health.toFloat()) potionEffects = player.activePotionEffects.map(::serializePotionEffect),
output.setInteger("foodLevel", player.foodLevel) inventory = player.inventory.contents.map { it?.let(::serializeItemStack) },
output.setFloat("XpP", player.exp) enderChest = player.enderChest.contents.map { it?.let(::serializeItemStack) }
output.setInteger("SelectedItemSlot", player.inventory.heldItemSlot)
var active_effects = output.getCompoundList("active_effects")
nbt.getCompoundList("active_effects").forEach {
active_effects.addCompound(it)
}
var inventory = output.getCompoundList("Inventory")
nbt.getCompoundList("Inventory").forEach { inventory.addCompound(it) }
var enderchest = output.getCompoundList("EnderItems")
nbt.getCompoundList("EnderItems").forEach { enderchest.addCompound(it) }
output.toString()
}
) )
return result return json.encodeToString(PlayerSnapshot.serializer(), snapshot)
} }
fun deserialize(player: BukkitPlayer, data: String) { fun deserialize(player: BukkitPlayer, data: String) {
NBT.modify( val snapshot =
player, try {
Function { nbt -> json.decodeFromString(PlayerSnapshot.serializer(), data)
val input = NBT.parseNBT(data) } catch (ex: SerializationException) {
if (LegacySerializer.deserialize(player, data)) return else throw ex
App.instance.logger.info("Deserializing player data: $data") } catch (ex: IllegalArgumentException) {
App.instance.logger.info("deserialized: ${input.toString()}") if (LegacySerializer.deserialize(player, data)) return else throw ex
nbt.setFloat("Health", input.getFloat("Health"))
nbt.setInteger("foodLevel", input.getInteger("foodLevel"))
nbt.setFloat("XpP", input.getFloat("XpP"))
nbt.setInteger("SelectedItemSlot", input.getInteger("SelectedItemSlot"))
val active_effects = nbt.getCompoundList("active_effects")
active_effects.clear()
input.getCompoundList("active_effects").forEach { active_effects.addCompound(it) }
val inventory = nbt.getCompoundList("Inventory")
inventory.clear()
input.getCompoundList("Inventory").forEach { inventory.addCompound(it) }
val enderchest = nbt.getCompoundList("EnderItems")
enderchest.clear()
input.getCompoundList("EnderItems").forEach { enderchest.addCompound(it) }
} }
) applySnapshot(player, migrateIfNeeded(snapshot))
}
private fun migrateIfNeeded(snapshot: PlayerSnapshot): PlayerSnapshot {
var current = snapshot
var version = snapshot.version
while (version < PLAYER_DATA_CURRENT_VERSION) {
current = migrateOnce(version, current)
version++
}
return current
}
private fun migrateOnce(fromVersion: Int, snapshot: PlayerSnapshot): PlayerSnapshot {
return when (fromVersion) {
1 -> snapshot
else -> snapshot
}
}
private fun applySnapshot(player: BukkitPlayer, snapshot: PlayerSnapshot) {
val maxHealth = player.getAttribute(Attribute.MAX_HEALTH)?.value ?: player.health
player.health = snapshot.health.coerceIn(0.0, maxHealth)
player.foodLevel = snapshot.foodLevel.coerceIn(0, 20)
player.exp = snapshot.xpProgress.coerceIn(0f, 1f)
player.inventory.heldItemSlot =
snapshot.selectedItemSlot.coerceIn(0, player.inventory.contents.size - 1)
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
snapshot.potionEffects.forEach { eff ->
val typeKey = NamespacedKey.fromString(eff.type)
val type = typeKey?.let { key -> mobEffectRegistry?.get(key) }
if (type == null) {
App.instance.logger.warning("Unknown potion effect key during restore: ${eff.type}")
return@forEach
}
val potion = PotionEffect(type, eff.duration, eff.amplifier, eff.ambient, eff.particles, eff.icon)
player.addPotionEffect(potion)
}
player.inventory.clear()
snapshot.inventory.forEachIndexed { index, item ->
player.inventory.setItem(index, item?.let(::deserializeItemStack))
}
player.enderChest.clear()
snapshot.enderChest.forEachIndexed { index, item ->
player.enderChest.setItem(index, item?.let(::deserializeItemStack))
}
}
}
private fun serializePotionEffect(effect: PotionEffect): PotionEffectSnapshot {
val typeKey = effect.type.key().toString()
return PotionEffectSnapshot(
type = typeKey,
amplifier = effect.amplifier,
duration = effect.duration,
ambient = effect.isAmbient,
particles = effect.hasParticles(),
icon = effect.hasIcon()
)
}
private fun serializeItemStack(item: ItemStack): ItemStackSnapshot {
val bytes = item.ensureServerConversions().serializeAsBytes()
return ItemStackSnapshot(Base64.getEncoder().encodeToString(bytes))
}
private fun deserializeItemStack(snapshot: ItemStackSnapshot): ItemStack {
val data = Base64.getDecoder().decode(snapshot.payload)
return ItemStack.deserializeBytes(data)
}
private object LegacySerializer {
fun deserialize(player: BukkitPlayer, data: String): Boolean {
return try {
NBT.modify(
player,
Function { nbt ->
val input = NBT.parseNBT(data)
nbt.setFloat("Health", input.getFloat("Health"))
nbt.setInteger("foodLevel", input.getInteger("foodLevel"))
nbt.setFloat("XpP", input.getFloat("XpP"))
nbt.setInteger("SelectedItemSlot", input.getInteger("SelectedItemSlot"))
val activeEffects = nbt.getCompoundList("active_effects")
activeEffects.clear()
input.getCompoundList("active_effects").forEach { activeEffects.addCompound(it) }
val inventory = nbt.getCompoundList("Inventory")
inventory.clear()
input.getCompoundList("Inventory").forEach { inventory.addCompound(it) }
val enderchest = nbt.getCompoundList("EnderItems")
enderchest.clear()
input.getCompoundList("EnderItems").forEach { enderchest.addCompound(it) }
}
)
App.instance.logger.info("Legacy player data applied; will be migrated on next save.")
true
} catch (_: Exception) {
false
}
} }
} }

View File

@ -17,6 +17,7 @@ public object Players : Table() {
val lastIp = varchar("last_ip", 15) val lastIp = varchar("last_ip", 15)
val data = text("data").default("") val data = text("data").default("")
val dataVersion = integer("data_version").default(0)
override val primaryKey = PrimaryKey(uuid) override val primaryKey = PrimaryKey(uuid)
} }
@ -39,6 +40,7 @@ public fun register(player: BukkitPlayer) {
it[firstLogin] = System.currentTimeMillis() it[firstLogin] = System.currentTimeMillis()
it[lastOnline] = System.currentTimeMillis() it[lastOnline] = System.currentTimeMillis()
it[lastIp] = player.address?.address?.hostAddress ?: "unknown" it[lastIp] = player.address?.address?.hostAddress ?: "unknown"
it[dataVersion] = 0
} }
} }
} }
@ -52,6 +54,7 @@ public fun update(player: BukkitPlayer) {
// player.sendMessage(dat) // player.sendMessage(dat)
it[data] = dat it[data] = dat
it[dataVersion] = PLAYER_DATA_CURRENT_VERSION
} }
} }
} }
@ -68,3 +71,25 @@ public fun fetch(player: BukkitPlayer) {
// player.sendMessage(dat) // player.sendMessage(dat)
PlayerSerializer.deserialize(player, dat) PlayerSerializer.deserialize(player, dat)
} }
data class PlayerDataEntry(val uuid: UUID, val serialized: String, val version: Int)
public fun findPlayersNeedingMigration(): List<PlayerDataEntry> {
return transaction(Database.instance) {
Players
.selectAll()
.where { (Players.dataVersion less PLAYER_DATA_CURRENT_VERSION) and (Players.data neq "") }
.map {
PlayerDataEntry(UUID.fromString(it[Players.uuid]), it[Players.data], it[Players.dataVersion])
}
}
}
public fun overwritePlayerData(uuid: UUID, data: String, version: Int = PLAYER_DATA_CURRENT_VERSION) {
transaction(Database.instance) {
Players.update({ Players.uuid eq uuid.toString() }) {
it[Players.data] = data
it[Players.dataVersion] = version
}
}
}

View File

@ -51,6 +51,7 @@ public object Database {
} }
if (instance == null) return if (instance == null) return
App.instance.logger.info("Database connected: $host:$port/$database") App.instance.logger.info("Database connected: $host:$port/$database")
transaction(instance) { SchemaUtils.createMissingTablesAndColumns(Players) }
} }
public fun disconnect() { public fun disconnect() {
instance?.let { instance?.let {