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
This commit is contained in:
Yair Morgenstern 2022-03-21 21:05:02 +02:00 committed by GitHub
parent 77839b4b9d
commit 2b1251258c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 286 additions and 22 deletions

View File

@ -1340,6 +1340,16 @@ Doing this will reset your current user ID to the clipboard contents - are you s
ID successfully set! = ID successfully set! =
Invalid ID! = Invalid ID! =
# Multiplayer options menu
Current IP address =
Server's IP address =
Reset to Dropbox =
Check connection to server =
Awaiting response... =
Success! =
Failed! =
# Mods # Mods

View File

@ -70,6 +70,12 @@ project(":desktop") {
} }
"implementation"("com.github.MinnDevelopment:java-discord-rpc:v2.0.1") "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")
} }
} }

View File

@ -69,6 +69,8 @@ object Constants {
const val uniqueOrDelimiter = "\" OR \"" const val uniqueOrDelimiter = "\" OR \""
const val dropboxMultiplayerServer = "Dropbox"
/** /**
* Use this to determine whether a [MapUnit][com.unciv.logic.map.MapUnit]'s movement is exhausted * Use this to determine whether a [MapUnit][com.unciv.logic.map.MapUnit]'s movement is exhausted
* (currentMovement <= this) if and only if a fuzzy comparison is needed to account for Float rounding errors. * (currentMovement <= this) if and only if a fuzzy comparison is needed to account for Float rounding errors.

View File

@ -138,7 +138,6 @@ object DropBox {
var name = "" var name = ""
private var server_modified = "" private var server_modified = ""
override fun getFileName() = name
override fun getLastModified(): Date { override fun getLastModified(): Date {
return server_modified.parseDate() return server_modified.parseDate()
} }

View File

@ -1,9 +1,14 @@
package com.unciv.logic.multiplayer 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.GameInfo
import com.unciv.logic.GameInfoPreview import com.unciv.logic.GameInfoPreview
import com.unciv.logic.GameSaver import com.unciv.logic.GameSaver
import com.unciv.ui.saves.Gzip import com.unciv.ui.saves.Gzip
import com.unciv.ui.worldscreen.mainmenu.OptionsPopup
import java.util.* import java.util.*
interface IFileStorage { interface IFileStorage {
@ -14,14 +19,59 @@ interface IFileStorage {
} }
interface IFileMetaData { interface IFileMetaData {
fun getFileName(): String?
fun getLastModified(): Date? 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 FileStorageConflictException: Exception()
class OnlineMultiplayer { 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) { fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) {
// We upload the gamePreview before we upload the game as this // We upload the gamePreview before we upload the game as this

View File

@ -2,6 +2,7 @@ package com.unciv.models.metadata
import com.badlogic.gdx.Application import com.badlogic.gdx.Application
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.unciv.Constants
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.GameSaver import com.unciv.logic.GameSaver
import java.text.Collator import java.text.Collator
@ -51,6 +52,10 @@ class GameSettings {
var isFreshlyCreated = false var isFreshlyCreated = false
var visualMods = HashSet<String>() var visualMods = HashSet<String>()
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 :/ 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 :/
var lastOverviewPage: String = "Cities" var lastOverviewPage: String = "Cities"

View File

@ -214,7 +214,8 @@ class NewGameScreen(
} catch (ex: Exception) { } catch (ex: Exception) {
postCrashHandlingRunnable { postCrashHandlingRunnable {
Popup(this).apply { Popup(this).apply {
addGoodSizedLabel("Could not upload game!") addGoodSizedLabel("Could not upload game!").row()
Gdx.input.inputProcessor = stage
addCloseButton() addCloseButton()
open() open()
} }

View File

@ -263,9 +263,10 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
if (!mapHolder.setCenterPosition(capital.location)) if (!mapHolder.setCenterPosition(capital.location))
game.setScreen(CityScreen(capital)) game.setScreen(CityScreen(capital))
} }
keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { this.openOptionsPopup() } // Game Options // These cause crashes so disabling for now
keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save // keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { this.openOptionsPopup() } // Game Options
keyPressDispatcher[KeyCharAndCode.ctrl('L')] = { game.setScreen(LoadGameScreen(this)) } // Load // 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_ADD] = { this.mapHolder.zoomIn() } // '+' Zoom
keyPressDispatcher[Input.Keys.NUMPAD_SUBTRACT] = { this.mapHolder.zoomOut() } // '-' Zoom keyPressDispatcher[Input.Keys.NUMPAD_SUBTRACT] = { this.mapHolder.zoomOut() } // '-' Zoom
keyPressDispatcher.setCheckpoint() keyPressDispatcher.setCheckpoint()

View File

@ -3,15 +3,20 @@ package com.unciv.ui.worldscreen.mainmenu
import com.badlogic.gdx.Application import com.badlogic.gdx.Application
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input 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.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.MainMenuScreen import com.unciv.MainMenuScreen
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.MapSaver import com.unciv.logic.MapSaver
import com.unciv.logic.civilization.PlayerType import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.multiplayer.FileStorageConflictException
import com.unciv.models.UncivSound import com.unciv.models.UncivSound
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.Ruleset.RulesetError 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.LanguageTable.Companion.addLanguageTables
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.worldscreen.WorldScreen 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 java.util.*
import kotlin.math.floor import kotlin.math.floor
import com.badlogic.gdx.utils.Array as GdxArray import com.badlogic.gdx.utils.Array as GdxArray
@ -77,8 +91,6 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
tabs.addPage("Gameplay", getGamePlayTab(), ImageGetter.getImage("OtherIcons/Options"), 24f) tabs.addPage("Gameplay", getGamePlayTab(), ImageGetter.getImage("OtherIcons/Options"), 24f)
tabs.addPage("Language", getLanguageTab(), ImageGetter.getImage("FlagIcons/${settings.language}"), 24f) tabs.addPage("Language", getLanguageTab(), ImageGetter.getImage("FlagIcons/${settings.language}"), 24f)
tabs.addPage("Sound", getSoundTab(), ImageGetter.getImage("OtherIcons/Speaker"), 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) tabs.addPage("Advanced", getAdvancedTab(), ImageGetter.getImage("OtherIcons/Settings"), 24f)
if (RulesetCache.size > 1) { if (RulesetCache.size > 1) {
@ -229,7 +241,10 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
pad(10f) pad(10f)
defaults().pad(5f) defaults().pad(5f)
addCheckbox("Enable out-of-game turn notifications", settings.multiplayerTurnCheckerEnabled) { // 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.multiplayerTurnCheckerEnabled = it
settings.save() settings.save()
tabs.replacePage("Multiplayer", getMultiplayerTab()) tabs.replacePage("Multiplayer", getMultiplayerTab())
@ -238,11 +253,102 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
if (settings.multiplayerTurnCheckerEnabled) { if (settings.multiplayerTurnCheckerEnabled) {
addMultiplayerTurnCheckerDelayBox() addMultiplayerTurnCheckerDelayBox()
addCheckbox("Show persistent notification for turn notifier service", settings.multiplayerTurnCheckerPersistentNotificationEnabled) addCheckbox("Show persistent notification for turn notifier service",
settings.multiplayerTurnCheckerPersistentNotificationEnabled)
{ settings.multiplayerTurnCheckerPersistentNotificationEnabled = it } { 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 { private fun getAdvancedTab() = Table(BaseScreen.skin).apply {
pad(10f) pad(10f)
defaults().pad(5f) defaults().pad(5f)
@ -442,7 +548,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) {
return deprecatedUniquesToReplacementText return deprecatedUniquesToReplacementText
} }
private fun autoUpdateUniques(mod: Ruleset, replaceableUniques: HashMap<String, String>, ) { private fun autoUpdateUniques(mod: Ruleset, replaceableUniques: HashMap<String, String>) {
if (mod.name.contains("mod")) if (mod.name.contains("mod"))
println("mod") println("mod")

View File

@ -13,7 +13,6 @@ import com.unciv.UncivGame
import com.unciv.UncivGameParameters import com.unciv.UncivGameParameters
import com.unciv.logic.GameSaver import com.unciv.logic.GameSaver
import com.unciv.models.metadata.GameSettings import com.unciv.models.metadata.GameSettings
import com.unciv.models.translations.tr
import com.unciv.ui.utils.Fonts import com.unciv.ui.utils.Fonts
import java.util.* import java.util.*
import kotlin.concurrent.timer import kotlin.concurrent.timer
@ -58,7 +57,6 @@ internal object DesktopLauncher {
val game = UncivGame(desktopParameters) val game = UncivGame(desktopParameters)
tryActivateDiscord(game) tryActivateDiscord(game)
Lwjgl3Application(game, config) Lwjgl3Application(game, config)
} }

View File

@ -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<String>) {
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)
}
}

View File

@ -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!