From 2b1251258c5f9f90e93fe797114778fddc6efa52 Mon Sep 17 00:00:00 2001 From: Yair Morgenstern Date: Mon, 21 Mar 2022 21:05:02 +0200 Subject: [PATCH] Unciv server (#6384) * Background implementation for Unciv server with ktor. Server ip in settings, able to copy your own ip and copy ip from clipboard for easy sharing, created stub for the client-server data transfer. No actual data storage or server implementation. * Unciv server round 2 - implementing crud for files and it works! metadata seems to only be in use for mutex, which is currently unused That's all for today * When starting a new multiplayer game the files are correctly saved in the server, and the server can return the files, but the function in the game to retrieve the game info is non-blocking so it doesn't work. Still, progress! * Changed the Gdx http to basic Java http, as used for Dropbox, and now everything works!!!! * Documentation for running and using the server * Better texts, translations, etc * Trog is right this should be a PUT not POST --- .../jsons/translations/template.properties | 10 ++ build.gradle.kts | 6 + core/src/com/unciv/Constants.kt | 2 + .../com/unciv/logic/multiplayer/DropBox.kt | 1 - .../unciv/logic/multiplayer/Multiplayer.kt | 54 ++++++- .../com/unciv/models/metadata/GameSettings.kt | 5 + .../unciv/ui/newgamescreen/NewGameScreen.kt | 3 +- .../com/unciv/ui/worldscreen/WorldScreen.kt | 7 +- .../ui/worldscreen/mainmenu/OptionsPopup.kt | 132 ++++++++++++++++-- .../com/unciv/app/desktop/DesktopLauncher.kt | 2 - .../src/com/unciv/app/desktop/UncivServer.kt | 53 +++++++ docs/Other/Hosting_a_Multiplayer_server.md | 33 +++++ 12 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 desktop/src/com/unciv/app/desktop/UncivServer.kt create mode 100644 docs/Other/Hosting_a_Multiplayer_server.md diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 19190cec6c..16e3b89fe4 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -1340,6 +1340,16 @@ Doing this will reset your current user ID to the clipboard contents - are you s ID successfully set! = Invalid ID! = +# Multiplayer options menu + +Current IP address = +Server's IP address = +Reset to Dropbox = +Check connection to server = +Awaiting response... = +Success! = +Failed! = + # Mods diff --git a/build.gradle.kts b/build.gradle.kts index 47b06ad7be..0fd76680d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,6 +70,12 @@ project(":desktop") { } "implementation"("com.github.MinnDevelopment:java-discord-rpc:v2.0.1") + + // For server-side + + "implementation"("io.ktor:ktor-server-core:1.6.8") + "implementation"("io.ktor:ktor-server-netty:1.6.8") + "implementation"("ch.qos.logback:logback-classic:1.2.5") } } diff --git a/core/src/com/unciv/Constants.kt b/core/src/com/unciv/Constants.kt index b8b4ef21b2..45890fca93 100644 --- a/core/src/com/unciv/Constants.kt +++ b/core/src/com/unciv/Constants.kt @@ -68,6 +68,8 @@ object Constants { const val remove = "Remove " const val uniqueOrDelimiter = "\" OR \"" + + const val dropboxMultiplayerServer = "Dropbox" /** * Use this to determine whether a [MapUnit][com.unciv.logic.map.MapUnit]'s movement is exhausted diff --git a/core/src/com/unciv/logic/multiplayer/DropBox.kt b/core/src/com/unciv/logic/multiplayer/DropBox.kt index 8ff2b5d156..78487485f8 100644 --- a/core/src/com/unciv/logic/multiplayer/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/DropBox.kt @@ -138,7 +138,6 @@ object DropBox { var name = "" private var server_modified = "" - override fun getFileName() = name override fun getLastModified(): Date { return server_modified.parseDate() } diff --git a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt b/core/src/com/unciv/logic/multiplayer/Multiplayer.kt index 1359d83317..15f9f30be1 100644 --- a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/Multiplayer.kt @@ -1,9 +1,14 @@ package com.unciv.logic.multiplayer +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Net +import com.unciv.Constants +import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.GameSaver import com.unciv.ui.saves.Gzip +import com.unciv.ui.worldscreen.mainmenu.OptionsPopup import java.util.* interface IFileStorage { @@ -14,14 +19,59 @@ interface IFileStorage { } interface IFileMetaData { - fun getFileName(): String? fun getLastModified(): Date? } + + +class UncivServerFileStorage(val serverIp:String):IFileStorage { + val serverUrl = "http://$serverIp:8080" + override fun saveFileData(fileName: String, data: String) { + OptionsPopup.SimpleHttp.sendRequest(Net.HttpMethods.PUT, "$serverUrl/files/$fileName", data){ + success: Boolean, result: String -> + if (!success) { + println(result) + throw java.lang.Exception(result) + } + } + } + + override fun loadFileData(fileName: String): String { + var fileData = "" + OptionsPopup.SimpleHttp.sendGetRequest("$serverUrl/files/$fileName"){ + success: Boolean, result: String -> + if (!success) { + println(result) + throw java.lang.Exception(result) + } + else fileData = result + } + return fileData + } + + override fun getFileMetaData(fileName: String): IFileMetaData { + TODO("Not yet implemented") + } + + override fun deleteFile(fileName: String) { + OptionsPopup.SimpleHttp.sendRequest(Net.HttpMethods.DELETE, "$serverUrl/files/$fileName", ""){ + success: Boolean, result: String -> + if (!success) throw java.lang.Exception(result) + } + } + +} + class FileStorageConflictException: Exception() class OnlineMultiplayer { - val fileStorage: IFileStorage = DropboxFileStorage() + val fileStorage: IFileStorage + init { + val settings = UncivGame.Current.settings + if (settings.multiplayerServer == Constants.dropboxMultiplayerServer) + fileStorage = DropboxFileStorage() + else fileStorage = UncivServerFileStorage(settings.multiplayerServer) + } fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { // We upload the gamePreview before we upload the game as this diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index f2c36c6725..5376c61fa2 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -2,6 +2,7 @@ package com.unciv.models.metadata import com.badlogic.gdx.Application import com.badlogic.gdx.Gdx +import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.GameSaver import java.text.Collator @@ -50,6 +51,10 @@ class GameSettings { var windowState = WindowState() var isFreshlyCreated = false var visualMods = HashSet() + + + var multiplayerServer = Constants.dropboxMultiplayerServer + var showExperimentalWorldWrap = false // We're keeping this as a config due to ANR problems on Android phones for people who don't know what they're doing :/ diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 00fd266650..d2bc000ce6 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -214,7 +214,8 @@ class NewGameScreen( } catch (ex: Exception) { postCrashHandlingRunnable { Popup(this).apply { - addGoodSizedLabel("Could not upload game!") + addGoodSizedLabel("Could not upload game!").row() + Gdx.input.inputProcessor = stage addCloseButton() open() } diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index 565702c68e..64781e0bec 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -263,9 +263,10 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (!mapHolder.setCenterPosition(capital.location)) game.setScreen(CityScreen(capital)) } - keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { this.openOptionsPopup() } // Game Options - keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save - keyPressDispatcher[KeyCharAndCode.ctrl('L')] = { game.setScreen(LoadGameScreen(this)) } // Load + // These cause crashes so disabling for now +// keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { this.openOptionsPopup() } // Game Options +// keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save +// keyPressDispatcher[KeyCharAndCode.ctrl('L')] = { game.setScreen(LoadGameScreen(this)) } // Load keyPressDispatcher[Input.Keys.NUMPAD_ADD] = { this.mapHolder.zoomIn() } // '+' Zoom keyPressDispatcher[Input.Keys.NUMPAD_SUBTRACT] = { this.mapHolder.zoomOut() } // '-' Zoom keyPressDispatcher.setCheckpoint() diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index 6642ad8280..7ff2ca0b4e 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -3,15 +3,20 @@ package com.unciv.ui.worldscreen.mainmenu import com.badlogic.gdx.Application import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input +import com.badlogic.gdx.Net +import com.badlogic.gdx.Net.HttpResponseListener import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.SelectBox import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.badlogic.gdx.utils.Align +import com.unciv.Constants import com.unciv.MainMenuScreen import com.unciv.UncivGame import com.unciv.logic.MapSaver import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.multiplayer.FileStorageConflictException import com.unciv.models.UncivSound import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset.RulesetError @@ -31,6 +36,15 @@ import com.unciv.ui.utils.* import com.unciv.ui.utils.LanguageTable.Companion.addLanguageTables import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip import com.unciv.ui.worldscreen.WorldScreen +import java.io.BufferedReader +import java.io.DataOutputStream +import java.io.FileNotFoundException +import java.io.InputStreamReader +import java.net.DatagramSocket +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.URL +import java.nio.charset.Charset import java.util.* import kotlin.math.floor import com.badlogic.gdx.utils.Array as GdxArray @@ -77,9 +91,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { tabs.addPage("Gameplay", getGamePlayTab(), ImageGetter.getImage("OtherIcons/Options"), 24f) tabs.addPage("Language", getLanguageTab(), ImageGetter.getImage("FlagIcons/${settings.language}"), 24f) tabs.addPage("Sound", getSoundTab(), ImageGetter.getImage("OtherIcons/Speaker"), 24f) - // at the moment the notification service only exists on Android - if (Gdx.app.type == Application.ApplicationType.Android) - tabs.addPage("Multiplayer", getMultiplayerTab(), ImageGetter.getImage("OtherIcons/Multiplayer"), 24f) + tabs.addPage("Multiplayer", getMultiplayerTab(), ImageGetter.getImage("OtherIcons/Multiplayer"), 24f) tabs.addPage("Advanced", getAdvancedTab(), ImageGetter.getImage("OtherIcons/Settings"), 24f) if (RulesetCache.size > 1) { tabs.addPage("Locate mod errors", getModCheckTab(), ImageGetter.getImage("OtherIcons/Mods"), 24f) { _, _ -> @@ -228,19 +240,113 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { private fun getMultiplayerTab(): Table = Table(BaseScreen.skin).apply { pad(10f) defaults().pad(5f) + + // at the moment the notification service only exists on Android + if (Gdx.app.type == Application.ApplicationType.Android) { + addCheckbox("Enable out-of-game turn notifications", + settings.multiplayerTurnCheckerEnabled) { + settings.multiplayerTurnCheckerEnabled = it + settings.save() + tabs.replacePage("Multiplayer", getMultiplayerTab()) + } - addCheckbox("Enable out-of-game turn notifications", settings.multiplayerTurnCheckerEnabled) { - settings.multiplayerTurnCheckerEnabled = it - settings.save() - tabs.replacePage("Multiplayer", getMultiplayerTab()) - } + if (settings.multiplayerTurnCheckerEnabled) { + addMultiplayerTurnCheckerDelayBox() - if (settings.multiplayerTurnCheckerEnabled) { - addMultiplayerTurnCheckerDelayBox() - - addCheckbox("Show persistent notification for turn notifier service", settings.multiplayerTurnCheckerPersistentNotificationEnabled) + addCheckbox("Show persistent notification for turn notifier service", + settings.multiplayerTurnCheckerPersistentNotificationEnabled) { settings.multiplayerTurnCheckerPersistentNotificationEnabled = it } + } } + + val connectionToServerButton = "Check connection to server".toTextButton() + + val ipAddress = getIpAddress() + add("{Current IP address}: $ipAddress".toTextButton().onClick { + Gdx.app.clipboard.contents = ipAddress.toString() + }).row() + + val multiplayerServerTextField = TextField(settings.multiplayerServer, BaseScreen.skin) + multiplayerServerTextField.programmaticChangeEvents = true + val serverIpTable = Table() + + serverIpTable.add("Server's IP address".toLabel().onClick { + multiplayerServerTextField.text = Gdx.app.clipboard.contents + }).padRight(10f) + multiplayerServerTextField.onChange { + settings.multiplayerServer = multiplayerServerTextField.text + settings.save() + connectionToServerButton.isEnabled = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer + } + serverIpTable.add(multiplayerServerTextField) + add(serverIpTable).row() + + add("Reset to Dropbox".toTextButton().onClick { + multiplayerServerTextField.text = Constants.dropboxMultiplayerServer + }).row() + + add(connectionToServerButton.onClick { + val popup = Popup(screen).apply { + addGoodSizedLabel("Awaiting response...").row() + } + popup.open(true) + + successfullyConnectedToServer { success: Boolean, result: String -> + if (success) { + popup.addGoodSizedLabel("Success!").row() + popup.addCloseButton() + } else { + popup.addGoodSizedLabel("Failed!").row() + popup.addCloseButton() + } + } + }).row() + } + + fun getIpAddress(): String? { + DatagramSocket().use { socket -> + socket.connect(InetAddress.getByName("8.8.8.8"), 10002) + return socket.getLocalAddress().getHostAddress() + } + } + + object SimpleHttp{ + fun sendGetRequest(url:String, action: (success: Boolean, result:String)->Unit){ + sendRequest(Net.HttpMethods.GET, url, "", action) + } + + fun sendRequest(method:String, url:String, content:String, action: (success:Boolean, result:String)->Unit){ + with(URL(url).openConnection() as HttpURLConnection) { + requestMethod = method // default is GET + + doOutput = true + + try { + if (content != "") { + // StandardCharsets.UTF_8 requires API 19 + val postData: ByteArray = content.toByteArray(Charset.forName("UTF-8")) + val outputStream = DataOutputStream(outputStream) + outputStream.write(postData) + outputStream.flush() + } + + val text = BufferedReader(InputStreamReader(inputStream)).readText() + action(true, text) + } catch (t: Throwable) { + println(t.message) + val errorMessageToReturn = + if (errorStream != null) BufferedReader(InputStreamReader(errorStream)).readText() + else t.message!! + println(errorMessageToReturn) + action(false, errorMessageToReturn) + } + } + } + + } + + fun successfullyConnectedToServer(action: (Boolean, String)->Unit){ + SimpleHttp.sendGetRequest( "http://"+ settings.multiplayerServer+":8080/isalive", action) } private fun getAdvancedTab() = Table(BaseScreen.skin).apply { @@ -442,7 +548,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { return deprecatedUniquesToReplacementText } - private fun autoUpdateUniques(mod: Ruleset, replaceableUniques: HashMap, ) { + private fun autoUpdateUniques(mod: Ruleset, replaceableUniques: HashMap) { if (mod.name.contains("mod")) println("mod") diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index 962da03db3..301af932fa 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -13,7 +13,6 @@ import com.unciv.UncivGame import com.unciv.UncivGameParameters import com.unciv.logic.GameSaver import com.unciv.models.metadata.GameSettings -import com.unciv.models.translations.tr import com.unciv.ui.utils.Fonts import java.util.* import kotlin.concurrent.timer @@ -58,7 +57,6 @@ internal object DesktopLauncher { val game = UncivGame(desktopParameters) tryActivateDiscord(game) - Lwjgl3Application(game, config) } diff --git a/desktop/src/com/unciv/app/desktop/UncivServer.kt b/desktop/src/com/unciv/app/desktop/UncivServer.kt new file mode 100644 index 0000000000..36dffcc64d --- /dev/null +++ b/desktop/src/com/unciv/app/desktop/UncivServer.kt @@ -0,0 +1,53 @@ +package com.unciv.app.desktop + +import io.ktor.application.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + + +internal object UncivServer { + @JvmStatic + fun main(arg: Array) { + val fileFolderName = "MultiplayerFiles" + File(fileFolderName).mkdir() + println(File(fileFolderName).absolutePath) + embeddedServer(Netty, port = 8080) { + routing { + get("/isalive") { + call.respondText("true") + } + put("/files/{fileName}") { + val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") + withContext(Dispatchers.IO) { + val recievedBytes = + call.request.receiveChannel().toInputStream().readAllBytes() + val textString = String(recievedBytes) + println("Recieved text: $textString") + File(fileFolderName, fileName).writeText(textString) + } + } + get("/files/{fileName}") { + val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") + println("Get file: $fileName") + val file = File(fileFolderName, fileName) + if (!file.exists()) throw Exception("File does not exist!") + val fileText = file.readText() + println("Text read: $fileText") + call.respondText(fileText) + } + delete("/files/{fileName}") { + val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") + val file = File(fileFolderName, fileName) + if (!file.exists()) throw Exception("File does not exist!") + file.delete() + } + } + }.start(wait = true) + } +} \ No newline at end of file diff --git a/docs/Other/Hosting_a_Multiplayer_server.md b/docs/Other/Hosting_a_Multiplayer_server.md new file mode 100644 index 0000000000..ca575db6a0 --- /dev/null +++ b/docs/Other/Hosting_a_Multiplayer_server.md @@ -0,0 +1,33 @@ + +# Hosting a Multiplayer server + +Due to certain limitations on Dropbox's API, with the current influx of players, we've many times reached the point that Dropbox has become unavailable. + +Therefore, you can now host your own Unciv server, when not on Android. + +To do so, you must have a JDK installed. + +From the directory where the Unciv.jar file is located, open a terminal and run the following line: +`java -cp Unciv.jar com.unciv.app.desktop.UncivServer` + +Your server has now started! + + +In Unciv itself, from the same computer, enter Options > Multiplayer. + +Click the first text (Current IP address) to copy the IP to the clipboard. +Then, click the second the second (Server IP address) to put your computer's IP in the "Server IP" slot. + +If you click "check connection to server" you should now get "Return result: true", which means it's working! + + +So far you ran the server and connected yourself to it, but now for the interesting part - connecting other people! + +The IP should still be in your clipboard - if not, just click the 'copy to clipboard' button again. +Send the IP to the other device, there - copy it, and click 'copy from clipboard'. +You can of course enter the IP manually if that's easier for you. + +Click 'check connection' from the new device, and if you got the same result - congratulations, you're both connected to the same server and can start a multiplayer game on the server! + + +Please note that devices NOT connected to the same server will NOT be able to participate in multiplayer games together! \ No newline at end of file