KStarbound/src/main/kotlin/ru/dbotthepony/kstarbound/server/StarboundServer.kt

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