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