581 lines
17 KiB
Kotlin
581 lines
17 KiB
Kotlin
package ru.dbotthepony.kstarbound.server
|
|
|
|
import com.google.gson.JsonElement
|
|
import com.google.gson.JsonObject
|
|
import com.google.gson.JsonPrimitive
|
|
import it.unimi.dsi.fastutil.objects.ObjectArrayList
|
|
import it.unimi.dsi.fastutil.objects.ObjectArraySet
|
|
import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.cancel
|
|
import kotlinx.coroutines.future.asCompletableFuture
|
|
import kotlinx.coroutines.future.await
|
|
import org.apache.logging.log4j.LogManager
|
|
import ru.dbotthepony.kommons.util.KOptional
|
|
import ru.dbotthepony.kstarbound.math.vector.Vector3i
|
|
import ru.dbotthepony.kstarbound.Globals
|
|
import ru.dbotthepony.kstarbound.Starbound
|
|
import ru.dbotthepony.kstarbound.defs.WorldID
|
|
import ru.dbotthepony.kstarbound.defs.world.AsteroidsWorldParameters
|
|
import ru.dbotthepony.kstarbound.defs.world.FloatingDungeonWorldParameters
|
|
import ru.dbotthepony.kstarbound.defs.world.TerrestrialWorldParameters
|
|
import ru.dbotthepony.kstarbound.defs.world.WorldTemplate
|
|
import ru.dbotthepony.kstarbound.fromJson
|
|
import ru.dbotthepony.kstarbound.json.readJsonElement
|
|
import ru.dbotthepony.kstarbound.json.readJsonElementInflated
|
|
import ru.dbotthepony.kstarbound.json.readJsonObject
|
|
import ru.dbotthepony.kstarbound.json.writeJsonElement
|
|
import ru.dbotthepony.kstarbound.json.writeJsonElementDeflated
|
|
import ru.dbotthepony.kstarbound.json.writeJsonObject
|
|
import ru.dbotthepony.kstarbound.math.vector.Vector2i
|
|
import ru.dbotthepony.kstarbound.server.world.LegacyWorldStorage
|
|
import ru.dbotthepony.kstarbound.server.world.NativeLocalWorldStorage
|
|
import ru.dbotthepony.kstarbound.server.world.ServerUniverse
|
|
import ru.dbotthepony.kstarbound.server.world.ServerWorld
|
|
import ru.dbotthepony.kstarbound.server.world.ServerSystemWorld
|
|
import ru.dbotthepony.kstarbound.server.world.WorldStorage
|
|
import ru.dbotthepony.kstarbound.util.BlockableEventLoop
|
|
import ru.dbotthepony.kstarbound.util.JVMClock
|
|
import ru.dbotthepony.kstarbound.util.random.random
|
|
import ru.dbotthepony.kstarbound.util.toStarboundString
|
|
import ru.dbotthepony.kstarbound.util.uuidFromStarboundString
|
|
import ru.dbotthepony.kstarbound.world.WorldGeometry
|
|
import java.io.File
|
|
import java.sql.DriverManager
|
|
import java.util.Collections
|
|
import java.util.UUID
|
|
import java.util.concurrent.CompletableFuture
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.concurrent.locks.ReentrantLock
|
|
|
|
sealed class StarboundServer(val root: File) : BlockableEventLoop("Server thread") {
|
|
private fun makedir(file: File) {
|
|
if (!file.exists()) {
|
|
check(file.mkdirs()) { "Unable to create ${file.absolutePath}" }
|
|
} else if (!file.isDirectory) {
|
|
throw IllegalArgumentException("${file.absolutePath} is not a directory")
|
|
}
|
|
}
|
|
|
|
val universeFolder = File(root, "universe")
|
|
|
|
init {
|
|
makedir(root)
|
|
makedir(universeFolder)
|
|
}
|
|
|
|
private val worlds = HashMap<WorldID, CompletableFuture<ServerWorld>>()
|
|
val universe = ServerUniverse(universeFolder)
|
|
val chat = ChatHandler(this)
|
|
val globalScope = CoroutineScope(Starbound.COROUTINES + SupervisorJob())
|
|
|
|
private val database = DriverManager.getConnection("jdbc:sqlite:${File(universeFolder, "universe.db").absolutePath.replace('\\', '/')}")
|
|
private val databaseCleanable = Starbound.CLEANER.register(this, database::close)
|
|
|
|
init {
|
|
database.createStatement().use {
|
|
it.execute("PRAGMA journal_mode=WAL")
|
|
it.execute("PRAGMA synchronous=NORMAL")
|
|
|
|
it.execute("""
|
|
CREATE TABLE IF NOT EXISTS "metadata" (
|
|
"key" VARCHAR NOT NULL PRIMARY KEY,
|
|
"value" BLOB NOT NULL
|
|
)
|
|
""".trimIndent())
|
|
|
|
it.execute("""
|
|
CREATE TABLE IF NOT EXISTS "universe_flags" ("flag" VARCHAR NOT NULL PRIMARY KEY)
|
|
""".trimIndent())
|
|
|
|
it.execute("""
|
|
CREATE TABLE IF NOT EXISTS "client_context" (
|
|
"uuid" VARCHAR NOT NULL PRIMARY KEY,
|
|
"data" BLOB NOT NULL
|
|
)
|
|
""".trimIndent())
|
|
|
|
it.execute("""
|
|
CREATE TABLE IF NOT EXISTS "system_worlds" (
|
|
"x" INTEGER NOT NULL,
|
|
"y" INTEGER NOT NULL,
|
|
"z" INTEGER NOT NULL,
|
|
"data" BLOB NOT NULL,
|
|
PRIMARY KEY ("x", "y", "z")
|
|
)
|
|
""".trimIndent())
|
|
}
|
|
|
|
database.autoCommit = false
|
|
}
|
|
|
|
private val lookupMetadata = database.prepareStatement("""
|
|
SELECT "value" FROM "metadata" WHERE "key" = ?
|
|
""".trimIndent())
|
|
|
|
private val writeMetadata = database.prepareStatement("""
|
|
REPLACE INTO "metadata" ("key", "value") VALUES (?, ?)
|
|
""".trimIndent())
|
|
|
|
private val lookupClientContext = database.prepareStatement("""
|
|
SELECT "data" FROM "client_context" WHERE "uuid" = ?
|
|
""".trimIndent())
|
|
|
|
private val writeClientContext = database.prepareStatement("""
|
|
REPLACE INTO "client_context" ("uuid", "data") VALUES (?, ?)
|
|
""".trimIndent())
|
|
|
|
private val lookupSystemWorld = database.prepareStatement("""
|
|
SELECT "data" FROM "system_worlds" WHERE "x" = ? AND "y" = ? AND "z" = ?
|
|
""".trimIndent())
|
|
|
|
private val writeSystemWorld = database.prepareStatement("""
|
|
REPLACE INTO "system_worlds" ("x", "y", "z", "data") VALUES (?, ?, ?, ?)
|
|
""".trimIndent())
|
|
|
|
private fun getMetadata(key: String): KOptional<JsonElement> {
|
|
lookupMetadata.setString(1, key)
|
|
|
|
return lookupMetadata.executeQuery().use {
|
|
if (it.next()) {
|
|
KOptional(it.getBytes(1).readJsonElement())
|
|
} else {
|
|
KOptional()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setMetadata(key: String, value: JsonElement) {
|
|
writeMetadata.setString(1, key)
|
|
writeMetadata.setBytes(2, value.writeJsonElement())
|
|
writeMetadata.execute()
|
|
}
|
|
|
|
fun loadClientContext(uuid: UUID): CompletableFuture<JsonObject?> {
|
|
return supplyAsync {
|
|
lookupClientContext.setString(1, uuid.toStarboundString())
|
|
|
|
lookupClientContext.executeQuery().use {
|
|
if (it.next()) {
|
|
it.getBytes(1).readJsonObject()
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun writeClientContext(uuid: UUID, context: JsonObject): CompletableFuture<*> {
|
|
return supplyAsync {
|
|
writeClientContext.setString(1, uuid.toStarboundString())
|
|
writeClientContext.setBytes(2, context.writeJsonObject())
|
|
writeClientContext.execute()
|
|
}
|
|
}
|
|
|
|
private val universeFlags = Collections.synchronizedSet(ObjectOpenHashSet<String>())
|
|
|
|
init {
|
|
database.createStatement().use {
|
|
it.executeQuery("""SELECT "flag" FROM "universe_flags"""").use {
|
|
while (it.next()) {
|
|
universeFlags.add(it.getString(1))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val insertUniverseFlag = database.prepareStatement("""
|
|
INSERT INTO "universe_flags" ("flag") VALUES (?)
|
|
""".trimIndent())
|
|
|
|
private val removeUniverseFlag = database.prepareStatement("""
|
|
DELETE FROM "universe_flags" WHERE "flag" = ?
|
|
""".trimIndent())
|
|
|
|
fun hasUniverseFlag(flag: String): Boolean {
|
|
return flag in universeFlags
|
|
}
|
|
|
|
fun getUniverseFlags(): Set<String> {
|
|
return Collections.unmodifiableSet(universeFlags)
|
|
}
|
|
|
|
fun addUniverseFlag(flag: String): Boolean {
|
|
if (universeFlags.add(flag)) {
|
|
LOGGER.info("Added universe flag '$flag'")
|
|
|
|
execute {
|
|
insertUniverseFlag.setString(1, flag)
|
|
insertUniverseFlag.execute()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
fun removeUniverseFlag(flag: String): Boolean {
|
|
if (universeFlags.remove(flag)) {
|
|
LOGGER.info("Removed universe flag '$flag'")
|
|
|
|
execute {
|
|
removeUniverseFlag.setString(1, flag)
|
|
removeUniverseFlag.execute()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
fun loadServerWorldData(pos: Vector3i): CompletableFuture<JsonElement?> {
|
|
return supplyAsync {
|
|
lookupSystemWorld.setInt(1, pos.x)
|
|
lookupSystemWorld.setInt(2, pos.y)
|
|
lookupSystemWorld.setInt(3, pos.z)
|
|
|
|
lookupSystemWorld.executeQuery().use {
|
|
if (it.next()) {
|
|
it.getBytes(1).readJsonElementInflated()
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun writeServerWorldData(pos: Vector3i, data: JsonElement) {
|
|
execute {
|
|
writeSystemWorld.setInt(1, pos.x)
|
|
writeSystemWorld.setInt(2, pos.y)
|
|
writeSystemWorld.setInt(3, pos.z)
|
|
writeSystemWorld.setBytes(4, data.writeJsonElementDeflated())
|
|
|
|
writeSystemWorld.execute()
|
|
}
|
|
}
|
|
|
|
val settings = ServerSettings()
|
|
val channels = ServerChannels(this)
|
|
val lock = ReentrantLock()
|
|
var isClosed = false
|
|
private set
|
|
|
|
val serverUUID: UUID = uuidFromStarboundString(getMetadata("server_uuid").orElse { JsonPrimitive(UUID.randomUUID().toStarboundString()) }.asString)
|
|
val universeClock = JVMClock()
|
|
|
|
init {
|
|
universeClock.set(getMetadata("universe_clock").orElse { JsonPrimitive(0.0) }.asDouble)
|
|
setMetadata("server_uuid", JsonPrimitive(serverUUID.toStarboundString()))
|
|
database.commit()
|
|
}
|
|
|
|
private val systemWorlds = HashMap<Vector3i, CompletableFuture<ServerSystemWorld?>>()
|
|
|
|
fun notifySystemWorldUnloaded(pos: Vector3i) {
|
|
execute { systemWorlds.remove(pos) }
|
|
}
|
|
|
|
private suspend fun loadSystemWorld0(location: Vector3i): ServerSystemWorld? {
|
|
try {
|
|
val load = loadServerWorldData(location).await()
|
|
|
|
if (load == null) {
|
|
return ServerSystemWorld.create(this, location)
|
|
} else {
|
|
return ServerSystemWorld.load(this, location, load)
|
|
}
|
|
} catch (err: Throwable) {
|
|
LOGGER.error("Exception loading system world at $location", err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
fun loadSystemWorld(location: Vector3i): CompletableFuture<ServerSystemWorld?> {
|
|
return supplyAsync {
|
|
systemWorlds.computeIfAbsent(location) {
|
|
globalScope.async { loadSystemWorld0(location) }.asCompletableFuture()
|
|
}
|
|
}.thenCompose { it }
|
|
}
|
|
|
|
private suspend fun loadCelestialWorld(location: WorldID.Celestial): ServerWorld {
|
|
val fileName = location.pos.toString().replace(':', '_') + ".db"
|
|
val file = File(universeFolder, fileName)
|
|
val firstTime = !file.exists()
|
|
val storage = NativeLocalWorldStorage(file)
|
|
|
|
val world = try {
|
|
if (firstTime) {
|
|
LOGGER.info("Creating celestial world $location")
|
|
ServerWorld.create(this, WorldTemplate.create(location.pos, universe), storage, location)
|
|
} else {
|
|
LOGGER.info("Loading celestial world $location")
|
|
ServerWorld.load(this, storage, location).await()
|
|
}
|
|
} catch (err: Throwable) {
|
|
storage.close()
|
|
|
|
if (firstTime) {
|
|
file.delete()
|
|
throw err
|
|
} else {
|
|
LOGGER.fatal("Exception loading celestial world at $location, recreating!")
|
|
|
|
var i = 0
|
|
while (!file.renameTo(File(universeFolder, "$fileName-fail$i")) && ++i < 10000) {}
|
|
|
|
ServerWorld.create(this, WorldTemplate.create(location.pos, universe), NativeLocalWorldStorage(file), location)
|
|
}
|
|
}
|
|
|
|
try {
|
|
world.sky.referenceClock = universeClock
|
|
world.eventLoop.start()
|
|
world.prepare(firstTime).await()
|
|
|
|
if (firstTime) {
|
|
world.saveMetadata()
|
|
}
|
|
} catch (err: Throwable) {
|
|
LOGGER.fatal("Exception while initializing celestial world at $location!", err)
|
|
world.eventLoop.shutdown()
|
|
throw err
|
|
}
|
|
|
|
return world
|
|
}
|
|
|
|
private suspend fun loadInstanceWorld(location: WorldID.Instance): ServerWorld {
|
|
val config = Globals.instanceWorlds[location.name] ?: throw NoSuchElementException("No such instance world ${location.name}")
|
|
LOGGER.info("Creating instance world $location")
|
|
val random = random(config.seed ?: System.nanoTime())
|
|
|
|
val visitable = when (config.type.lowercase()) {
|
|
"terrestrial" -> TerrestrialWorldParameters.generate(config.planetType!!, config.planetSize!!, random)
|
|
"asteroids" -> AsteroidsWorldParameters.generate(random)
|
|
"floatingdungeon" -> FloatingDungeonWorldParameters.generate(config.dungeonWorld!!)
|
|
else -> throw RuntimeException()
|
|
}
|
|
|
|
if (location.threatLevel != null) {
|
|
visitable.threatLevel = location.threatLevel
|
|
}
|
|
|
|
if (config.beamUpRule != null) {
|
|
visitable.beamUpRule = config.beamUpRule
|
|
}
|
|
|
|
visitable.disableDeathDrops = config.disableDeathDrops
|
|
|
|
val template = WorldTemplate(visitable, config.skyParameters, random)
|
|
val world = ServerWorld.create(this, template, NativeLocalWorldStorage(null), location)
|
|
|
|
try {
|
|
world.setProperty("ephemeral", JsonPrimitive(!config.persistent))
|
|
|
|
if (config.useUniverseClock)
|
|
world.sky.referenceClock = universeClock
|
|
|
|
world.spawner.active = config.spawningEnabled
|
|
|
|
world.eventLoop.start()
|
|
world.prepare(true).await()
|
|
} catch (err: Throwable) {
|
|
LOGGER.fatal("Exception while creating instance world at $location!", err)
|
|
world.eventLoop.shutdown()
|
|
throw err
|
|
}
|
|
|
|
return world
|
|
}
|
|
|
|
private suspend fun loadWorld0(location: WorldID): ServerWorld {
|
|
return when (location) {
|
|
is WorldID.ShipWorld -> throw IllegalArgumentException("Can't create ship worlds out of thin air")
|
|
is WorldID.Instance -> loadInstanceWorld(location)
|
|
is WorldID.Celestial -> loadCelestialWorld(location)
|
|
is WorldID.Limbo -> throw IllegalArgumentException("Limbo was supplied as world ID")
|
|
}
|
|
}
|
|
|
|
fun loadWorld(location: WorldID): CompletableFuture<ServerWorld> {
|
|
return supplyAsync {
|
|
var world = worlds[location]
|
|
|
|
if (world != null && world.isCompletedExceptionally) {
|
|
worlds.remove(location)
|
|
world = null
|
|
}
|
|
|
|
if (world != null) {
|
|
world
|
|
} else {
|
|
val future = globalScope.async { loadWorld0(location) }.asCompletableFuture()
|
|
worlds[location] = future
|
|
future
|
|
}
|
|
}.thenCompose { it }
|
|
}
|
|
|
|
fun loadShipWorld(connection: ServerConnection, storage: WorldStorage): CompletableFuture<ServerWorld> {
|
|
return scope.async {
|
|
val id = WorldID.ShipWorld(connection.uuid ?: throw NullPointerException("Connection UUID is null"))
|
|
val existing = worlds[id]
|
|
|
|
if (existing != null)
|
|
throw IllegalStateException("Already has $id!")
|
|
|
|
try {
|
|
val world = ServerWorld.load(this@StarboundServer, storage, id)
|
|
worlds[id] = world
|
|
return@async world.await()
|
|
} catch (err: ServerWorld.WorldMetadataMissingException) {
|
|
LOGGER.info("Creating new client shipworld for $connection")
|
|
val world = ServerWorld.create(this@StarboundServer, WorldGeometry(Vector2i(2048, 2048)), storage, id)
|
|
|
|
try {
|
|
val structure = Globals.universeServer.speciesShips[connection.playerSpecies]?.firstOrNull()?.value?.get() ?: throw NoSuchElementException("No ship structure for species ${connection.playerSpecies}")
|
|
|
|
world.spawner.active = false
|
|
|
|
val currentUpgrades = connection.shipUpgrades
|
|
.apply(Globals.shipUpgrades)
|
|
.apply(Starbound.gson.fromJson(structure.config.get("shipUpgrades") ?: throw NoSuchElementException("No shipUpgrades element in world structure config for species ${connection.playerSpecies}")) ?: throw NullPointerException("World structure config.shipUpgrades is null for species ${connection.playerSpecies}"))
|
|
|
|
connection.shipUpgrades = currentUpgrades
|
|
|
|
world.setProperty("invinciblePlayers", JsonPrimitive(true))
|
|
world.setProperty("ship.level", JsonPrimitive(0))
|
|
world.setProperty("ship.fuel", JsonPrimitive(0))
|
|
world.setProperty("ship.maxFuel", JsonPrimitive(currentUpgrades.maxFuel))
|
|
world.setProperty("ship.crewSize", JsonPrimitive(currentUpgrades.crewSize))
|
|
world.setProperty("ship.fuelEfficiency", JsonPrimitive(currentUpgrades.fuelEfficiency))
|
|
|
|
world.eventLoop.start()
|
|
world.replaceCentralStructure(structure).join()
|
|
|
|
world.saveMetadata()
|
|
} catch (err: Throwable) {
|
|
world.eventLoop.shutdown()
|
|
throw err
|
|
}
|
|
|
|
worlds[id] = CompletableFuture.completedFuture(world)
|
|
return@async world
|
|
}
|
|
}.asCompletableFuture()
|
|
}
|
|
|
|
fun notifyWorldUnloaded(worldID: WorldID) {
|
|
if (!isShutdown) {
|
|
execute {
|
|
worlds.remove(worldID)
|
|
}
|
|
}
|
|
}
|
|
|
|
init {
|
|
val declareInterval = TimeUnit.MILLISECONDS.toNanos(Globals.universeServer.universeStorageInterval)
|
|
|
|
scheduleWithFixedDelay(Runnable {
|
|
setMetadata("universe_clock", JsonPrimitive(universeClock.time))
|
|
database.commit()
|
|
}, declareInterval, declareInterval, TimeUnit.NANOSECONDS)
|
|
|
|
scheduleAtFixedRate(Runnable {
|
|
tick(Starbound.SYSTEM_WORLD_TIMESTEP)
|
|
}, Starbound.SYSTEM_WORLD_TIMESTEP_NANOS, Starbound.SYSTEM_WORLD_TIMESTEP_NANOS, TimeUnit.NANOSECONDS)
|
|
|
|
isDaemon = false
|
|
}
|
|
|
|
private val occupiedNicknames = ObjectOpenHashSet<String>()
|
|
|
|
fun reserveNickname(name: String, alternative: String): String {
|
|
synchronized(occupiedNicknames) {
|
|
var name = name
|
|
|
|
if (name.lowercase() == "server" || name.isBlank() || name.length >= 32) {
|
|
name = alternative
|
|
}
|
|
|
|
while (name in occupiedNicknames) {
|
|
name += "_"
|
|
}
|
|
|
|
occupiedNicknames.add(name)
|
|
return name
|
|
}
|
|
}
|
|
|
|
fun freeNickname(name: String): Boolean {
|
|
return synchronized(occupiedNicknames) {
|
|
occupiedNicknames.remove(name)
|
|
}
|
|
}
|
|
|
|
fun clientByUUID(uuid: UUID): ServerConnection? {
|
|
return channels.connections.firstOrNull { it.uuid == uuid }
|
|
}
|
|
|
|
protected open fun close() {
|
|
channels.close()
|
|
shutdown()
|
|
}
|
|
|
|
protected abstract fun tick0(delta: Double)
|
|
|
|
private fun tick(delta: Double) {
|
|
try {
|
|
tick0(delta)
|
|
} catch (err: Throwable) {
|
|
LOGGER.fatal("Exception in main server event loop", err)
|
|
shutdown()
|
|
}
|
|
}
|
|
|
|
override fun performShutdown() {
|
|
super.performShutdown()
|
|
|
|
systemWorlds.values.forEach {
|
|
if (it.isDone && !it.isCompletedExceptionally) {
|
|
it.get()?.save()
|
|
}
|
|
}
|
|
|
|
scope.cancel("Server shutting down")
|
|
channels.close()
|
|
|
|
val worldSlice = ObjectArrayList(worlds.values)
|
|
|
|
worldSlice.forEach {
|
|
it.thenAccept { it.eventLoop.shutdown() }
|
|
}
|
|
|
|
worldSlice.forEach {
|
|
it.thenAccept {
|
|
it.eventLoop.awaitTermination(60L, TimeUnit.SECONDS)
|
|
|
|
if (!it.eventLoop.isTerminated) {
|
|
LOGGER.warn("World $it did not shutdown in 60 seconds, forcing termination. This might leave world in inconsistent state!")
|
|
it.eventLoop.shutdownNow()
|
|
}
|
|
}
|
|
}
|
|
|
|
database.commit()
|
|
databaseCleanable.clean()
|
|
universe.close()
|
|
}
|
|
|
|
companion object {
|
|
private val LOGGER = LogManager.getLogger()
|
|
}
|
|
}
|