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! =
Invalid ID! =
# Multiplayer options menu
Current IP address =
Server's IP address =
Reset to Dropbox =
Check connection to server =
Awaiting response... =
Success! =
Failed! =
# Mods

View File

@ -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")
}
}

View File

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

View File

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

View File

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

View File

@ -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<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 :/

View File

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

View File

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

View File

@ -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<String, String>, ) {
private fun autoUpdateUniques(mod: Ruleset, replaceableUniques: HashMap<String, String>) {
if (mod.name.contains("mod"))
println("mod")

View File

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

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!