Initial Event Bus implementation & Event-based multiplayer updates (#6826)

* Add EventBus

* Refactor: Extract multiplayer logic into their own classes & make updates event-based instead of throwaway+rebuild
This commit is contained in:
Timo T
2022-05-22 00:05:33 +02:00
committed by GitHub
parent 91ffa2e185
commit 244f9477df
18 changed files with 970 additions and 421 deletions

View File

@ -561,7 +561,7 @@ Add multiplayer game =
Refresh list =
Could not save game! =
Could not delete game! =
Could not refresh! =
Error while refreshing: =
Last refresh: [time] minutes ago =
Current Turn: =
Add Currently Running Game =
@ -580,6 +580,8 @@ Minutes =
Hours =
Days =
Server limit reached! Please wait for [time] seconds =
File could not be found on the multiplayer server =
Unhandled problem, [errorMessage] =
# Save game menu

View File

@ -65,6 +65,8 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
isCoreLibraryDesugaringEnabled = true
}
androidResources {
// Don't add local save files and fonts to release, obviously
@ -128,4 +130,5 @@ dependencies {
// Known Android Lint warning: "GradleDependency"
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.work:work-runtime-ktx:2.6.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
}

View File

@ -19,7 +19,7 @@ import com.unciv.ui.audio.MusicMood
import com.unciv.ui.utils.*
import com.unciv.ui.worldscreen.PlayerReadyScreen
import com.unciv.ui.worldscreen.WorldScreen
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.logic.multiplayer.OnlineMultiplayer
import com.unciv.ui.audio.Sounds
import com.unciv.ui.crashhandling.closeExecutors
import com.unciv.ui.crashhandling.launchCrashHandling
@ -47,6 +47,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
fun isGameInfoInitialized() = this::gameInfo.isInitialized
lateinit var settings: GameSettings
lateinit var musicController: MusicController
lateinit var onlineMultiplayer: OnlineMultiplayer
/**
* This exists so that when debugging we can see the entire map.
@ -107,6 +108,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
musicController.getAudioLoopCallback(),
musicController.getAudioExceptionHandler()
)
onlineMultiplayer = OnlineMultiplayer()
ImageGetter.resetAtlases()
ImageGetter.setNewRuleset(ImageGetter.ruleset) // This needs to come after the settings, since we may have default visual mods
@ -191,10 +193,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
setScreen(LoadDeepLinkScreen())
}
try {
val onlineGame = OnlineMultiplayerGameSaver().tryDownloadGame(deepLinkedMultiplayerGame!!)
postCrashHandlingRunnable {
loadGame(onlineGame)
}
onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!)
} catch (ex: Exception) {
postCrashHandlingRunnable {
val mainMenu = MainMenuScreen()

View File

@ -15,8 +15,14 @@ fun json() = Json().apply {
setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer())
}
/**
* @throws SerializationException
*/
fun <T> Json.fromJsonFile(tClass: Class<T>, filePath: String): T = fromJsonFile(tClass, Gdx.files.internal(filePath))
/**
* @throws SerializationException
*/
fun <T> Json.fromJsonFile(tClass: Class<T>, file: FileHandle): T {
try {
val jsonText = file.readString(Charsets.UTF_8.name())

View File

@ -64,6 +64,13 @@ object GameSaver {
getSave(GameName, multiplayer).delete()
}
/**
* Only use this with a [FileHandle] returned by [getSaves]!
*/
fun deleteSave(file: FileHandle) {
file.delete()
}
//endregion
//region Saving
@ -150,7 +157,10 @@ object GameSaver {
}
}
/** Parses [gameData] as gzipped serialization of a [GameInfoPreview] - only called from [OnlineMultiplayerGameSaver] */
/**
* Parses [gameData] as gzipped serialization of a [GameInfoPreview] - only called from [OnlineMultiplayerGameSaver]
* @throws SerializationException
*/
fun gameInfoPreviewFromString(gameData: String): GameInfoPreview {
return json().fromJson(GameInfoPreview::class.java, Gzip.unzip(gameData))
}
@ -159,6 +169,8 @@ object GameSaver {
* WARNING! transitive GameInfo data not initialized
* The returned GameInfo can not be used for most circumstances because its not initialized!
* It is therefore stateless and save to call for Multiplayer Turn Notifier, unlike gameInfoFromString().
*
* @throws SerializationException
*/
private fun gameInfoFromStringWithoutTransients(gameData: String): GameInfo {
val unzippedJson = try {

View File

@ -0,0 +1,6 @@
package com.unciv.logic.event
/**
* Base interface for all events. Use your IDE to list implementing subtypes to list all events available.
*/
interface Event

View File

@ -0,0 +1,100 @@
package com.unciv.logic.event
import java.lang.ref.WeakReference
import kotlin.reflect.KClass
/**
* The heart of the event system. Significant game events are sent/received here.
*
* Use [send] to send events and [EventReceiver.receive] to receive events.
*
* **Do not use this for every communication between modules**. Only use it for events that might be relevant for a wide variety of modules or
* significantly affect the game state, i.e. buildings being created, units dying, new multiplayer data available, etc.
*/
@Suppress("UNCHECKED_CAST") // Through using the "map by KClass", we ensure all methods get called with correct argument type
object EventBus {
// This is one of the simplest implementations possible. If it is ever useful, this could be changed to
private val receivers = mutableMapOf<KClass<*>, MutableList<EventListener<*>>>()
/**
* Only use this from the render thread. For example, in coroutines launched by [com.unciv.ui.crashhandling.launchCrashHandling]
* always wrap the call in [com.unciv.ui.crashhandling.postCrashHandlingRunnable].
*
* We could use a generic method like `sendOnRenderThread` or make the whole event system asynchronous in general,
* but doing it like this makes debugging slightly easier.
*/
fun <T : Event> send(event: T) {
val listeners = receivers[event::class]
if (listeners == null) return
val iterator = listeners.listIterator()
while (iterator.hasNext()) {
val listener = iterator.next() as EventListener<T>
val eventHandler = listener.eventHandler.get()
if (eventHandler == null) {
// eventHandler got garbage collected, prevent WeakListener memory leak
iterator.remove()
continue
}
val filter = listener.filter.get()
if (filter == null || filter(event)) {
eventHandler(event)
}
}
}
private fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
if (receivers[eventClass] == null) {
receivers[eventClass] = mutableListOf()
}
receivers[eventClass]!!.add(EventListener(eventHandler, filter))
}
/**
* Used to receive events by the [EventBus].
*
* Usage:
*
* ```
* class SomeClass {
* private val events = EventReceiver()
*
* init {
* events.receive(SomeEvent::class) {
* // do something when the event is received.
* }
* }
* }
* ```
*
* To have event listeners automatically garbage collected, we need to use [WeakReference]s in the event bus. For that to work, though, the class
* that wants to receive events needs to hold references to its own event listeners. [EventReceiver] allows to do that while also providing the
* interface to start receiving events.
*/
class EventReceiver {
val eventHandlers: MutableList<Any> = mutableListOf()
val filters: MutableList<Any> = mutableListOf()
/**
* The listeners will always be called on the main GDX render thread.
*
* @param T The event class holding the data of the event, or simply [Event].
*/
fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
if (filter != null) {
filters.add(filter)
}
eventHandlers.add(eventHandler)
EventBus.receive(eventClass, filter, eventHandler)
}
}
}
private class EventListener<T>(
eventHandler: (T) -> Unit,
filter: ((T) -> Boolean)? = null
) {
val eventHandler = WeakReference(eventHandler)
val filter = WeakReference(filter)
}

View File

@ -0,0 +1,281 @@
package com.unciv.logic.multiplayer
import com.badlogic.gdx.files.FileHandle
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.logic.civilization.PlayerType
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.utils.isLargerThan
import java.util.*
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import java.io.FileNotFoundException
import java.time.Duration
import java.time.Instant
/** @see getRefreshInterval */
private const val CUSTOM_SERVER_REFRESH_INTERVAL = 30L
/**
* How often files can be checked for new multiplayer games (could be that the user modified their file system directly). More checks within this time period
* will do nothing.
*/
private val FILE_UPDATE_THROTTLE_INTERVAL = Duration.ofSeconds(60)
/**
* Provides multiplayer functionality to the rest of the game.
*
* See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events.
*/
class OnlineMultiplayer {
private val savedGames: MutableMap<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf())
private var lastFileUpdate: AtomicReference<Instant?> = AtomicReference()
val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet()
init {
flow<Unit> {
while (true) {
delay(getRefreshInterval().toMillis())
// TODO will be used later
// requestUpdate()
}
}.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
}
/**
* Requests an update of all multiplayer game state. Does automatic throttling to try to prevent hitting rate limits.
*
* Use [forceUpdate] = true to circumvent this throttling.
*
* Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed]
*/
fun requestUpdate(forceUpdate: Boolean = false) = launchCrashHandling("Update all multiplayer games") {
fun alwaysUpdate(instant: Instant?): Boolean = true
safeUpdateIf(lastFileUpdate, if (forceUpdate) ::alwaysUpdate else ::fileUpdateNeeded, ::updateSavesFromFiles, {}) {
// only happens if the files can't be listed, should basically never happen
throw it
}
for (game in savedGames.values) {
launch {
game.requestUpdate(forceUpdate)
}
}
}
private fun fileUpdateNeeded(it: Instant?) = it == null || Duration.between(it, Instant.now()).isLargerThan(FILE_UPDATE_THROTTLE_INTERVAL)
private fun updateSavesFromFiles() {
val saves = GameSaver.getSaves(true)
val removedSaves = savedGames.keys - saves
removedSaves.forEach(savedGames::remove)
val newSaves = saves - savedGames.keys
for (saveFile in newSaves) {
val game = OnlineMultiplayerGame(saveFile)
savedGames[saveFile] = game
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) }
}
}
/**
* Fires [MultiplayerGameAdded]
*
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
*/
suspend fun createGame(newGame: GameInfo) {
OnlineMultiplayerGameSaver().tryUploadGame(newGame, withPreview = true)
val newGamePreview = newGame.asPreview()
val file = GameSaver.saveGame(newGamePreview, newGamePreview.gameId)
val onlineMultiplayerGame = OnlineMultiplayerGame(file, newGamePreview, Instant.now())
savedGames[file] = onlineMultiplayerGame
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(onlineMultiplayerGame.name)) }
}
/**
* Fires [MultiplayerGameAdded]
*
* @param gameName if this is null or blank, will use the gameId as the game name
* @return the final name the game was added under
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun addGame(gameId: String, gameName: String? = null): String {
val saveFileName = if (gameName.isNullOrBlank()) gameId else gameName
var gamePreview: GameInfoPreview
var fileHandle: FileHandle
try {
gamePreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameId)
fileHandle = GameSaver.saveGame(gamePreview, saveFileName)
} catch (ex: FileNotFoundException) {
// Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead
gamePreview = OnlineMultiplayerGameSaver().tryDownloadGame(gameId).asPreview()
fileHandle = GameSaver.saveGame(gamePreview, saveFileName)
}
val game = OnlineMultiplayerGame(fileHandle, gamePreview, Instant.now())
savedGames[fileHandle] = game
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) }
return saveFileName
}
fun getGameByName(name: String): OnlineMultiplayerGame? {
return savedGames.values.firstOrNull { it.name == name }
}
/**
* Resigns from the given multiplayer [gameId]. Can only resign if it's currently the user's turn,
* to ensure that no one else can upload the game in the meantime.
*
* Fires [MultiplayerGameUpdated]
*
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
* @return false if it's not the user's turn and thus resigning did not happen
*/
suspend fun resign(game: OnlineMultiplayerGame): Boolean {
val preview = game.preview
if (preview == null) {
throw game.error!!
}
// download to work with the latest game state
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(preview.gameId)
val playerCiv = gameInfo.currentPlayerCiv
if (!gameInfo.isUsersTurn()) {
return false
}
//Set own civ info to AI
playerCiv.playerType = PlayerType.AI
playerCiv.playerId = ""
//call next turn so turn gets simulated by AI
gameInfo.nextTurn()
//Add notification so everyone knows what happened
//call for every civ cause AI players are skipped anyway
for (civ in gameInfo.civilizations) {
civ.addNotification("[${playerCiv.civName}] resigned and is now controlled by AI", playerCiv.civName)
}
val newPreview = gameInfo.asPreview()
GameSaver.saveGame(newPreview, game.fileHandle)
OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true)
game.doManualUpdate(newPreview)
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(game.name, newPreview)) }
return true
}
/**
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun loadGame(game: OnlineMultiplayerGame) {
val preview = game.preview
if (preview == null) {
throw game.error!!
}
loadGame(preview.gameId)
}
/**
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun loadGame(gameId: String) {
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId)
gameInfo.isUpToDate = true
postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) }
}
/**
* Deletes the game from disk, does not delete it remotely.
*
* Fires [MultiplayerGameDeleted]
*/
fun deleteGame(multiplayerGame: OnlineMultiplayerGame) {
val name = multiplayerGame.name
GameSaver.deleteSave(multiplayerGame.fileHandle)
EventBus.send(MultiplayerGameDeleted(name))
}
/**
* Fires [MultiplayerGameNameChanged]
*/
fun changeGameName(game: OnlineMultiplayerGame, newName: String) {
val oldPreview = game.preview
if (oldPreview == null) {
throw game.error!!
}
val oldLastUpdate = game.lastUpdate
val oldName = game.name
savedGames.remove(game.fileHandle)
GameSaver.deleteSave(game.fileHandle)
val newFileHandle = GameSaver.saveGame(oldPreview, newName)
val newGame = OnlineMultiplayerGame(newFileHandle, oldPreview, oldLastUpdate)
savedGames[newFileHandle] = newGame
EventBus.send(MultiplayerGameNameChanged(newName, oldName))
}
}
/**
* Calls the given [updateFun] only when [shouldUpdate] called with the current value of [lastUpdate] returns true.
*
* Also updates [lastUpdate] to [Instant.now], but only when [updateFun] did not result in an exception.
*
* Any exception thrown by [updateFun] is propagated.
*
* @return true if the update happened
*/
suspend fun <T> safeUpdateIf(
lastUpdate: AtomicReference<Instant?>,
shouldUpdate: (Instant?) -> Boolean,
updateFun: suspend () -> T,
onUnchanged: () -> T,
onFailed: (Exception) -> T
): T {
val lastUpdateTime = lastUpdate.get()
val now = Instant.now()
if (shouldUpdate(lastUpdateTime) && lastUpdate.compareAndSet(lastUpdateTime, now)) {
try {
return updateFun()
} catch (e: Exception) {
lastUpdate.compareAndSet(now, lastUpdateTime)
return onFailed(e)
}
} else {
return onUnchanged()
}
}
fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId
fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId
/**
* How often all multiplayer games are refreshed in the background
*/
private fun getRefreshInterval(): Duration {
val settings = UncivGame.Current.settings
val isDropbox = settings.multiplayerServer == Constants.dropboxMultiplayerServer
return if (isDropbox) {
Duration.ofMinutes(settings.multiplayerTurnCheckerDelayInMinutes.toLong())
} else {
Duration.ofSeconds(CUSTOM_SERVER_REFRESH_INTERVAL)
}
}

View File

@ -0,0 +1,54 @@
package com.unciv.logic.multiplayer
import com.unciv.logic.GameInfoPreview
import com.unciv.logic.event.Event
/**
* Gets sent when a game was added.
*/
class MultiplayerGameAdded(
val name: String
) : Event
/**
* Gets sent when a game successfully updated
*/
class MultiplayerGameUpdated(
val name: String,
val preview: GameInfoPreview,
) : Event
/**
* Gets sent when a game errored while updating
*/
class MultiplayerGameUpdateFailed(
val name: String,
val error: Exception
) : Event
/**
* Gets sent when a game updated successfully, but nothing changed
*/
class MultiplayerGameUpdateUnchanged(
val name: String
) : Event
/**
* Gets sent when a game starts updating
*/
class MultiplayerGameUpdateStarted(
val name: String
) : Event
/**
* Gets sent when a game's name got changed
*/
class MultiplayerGameNameChanged(
val name: String,
val oldName: String
) : Event
/**
* Gets sent when a game is deleted
*/
class MultiplayerGameDeleted(
val name: String
) : Event

View File

@ -0,0 +1,119 @@
package com.unciv.logic.multiplayer
import com.badlogic.gdx.files.FileHandle
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.GameInfoPreview
import com.unciv.logic.GameSaver
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.utils.isLargerThan
import java.io.FileNotFoundException
import java.time.Duration
import java.time.Instant
import java.util.concurrent.atomic.AtomicReference
/** @see getUpdateThrottleInterval */
private const val DROPBOX_THROTTLE_INTERVAL = 8L
/** @see getUpdateThrottleInterval */
private const val CUSTOM_SERVER_THROTTLE_INTERVAL = 1L
class OnlineMultiplayerGame(
val fileHandle: FileHandle,
var preview: GameInfoPreview? = null,
lastOnlineUpdate: Instant? = null
) {
private val lastOnlineUpdate: AtomicReference<Instant?> = AtomicReference(lastOnlineUpdate)
val lastUpdate: Instant
get() {
val lastFileUpdateTime = Instant.ofEpochMilli(fileHandle.lastModified())
val lastOnlineUpdateTime = lastOnlineUpdate.get()
return if (lastOnlineUpdateTime == null || lastFileUpdateTime.isLargerThan(lastOnlineUpdateTime)) {
lastFileUpdateTime
} else {
lastOnlineUpdateTime
}
}
val name get() = fileHandle.name()
var error: Exception? = null
init {
if (preview == null) {
try {
loadPreviewFromFile()
} catch (e: Exception) {
error = e
}
}
}
private fun loadPreviewFromFile(): GameInfoPreview {
val previewFromFile = GameSaver.loadGamePreviewFromFile(fileHandle)
preview = previewFromFile
return previewFromFile
}
private fun shouldUpdate(lastUpdateTime: Instant?): Boolean =
preview == null || error != null || lastUpdateTime == null || Duration.between(lastUpdateTime, Instant.now()).isLargerThan(getUpdateThrottleInterval())
/**
* Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed]
*
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun requestUpdate(forceUpdate: Boolean = false) {
fun alwaysUpdate(instant: Instant?): Boolean = true
val shouldUpdateFun = if (forceUpdate) ::alwaysUpdate else ::shouldUpdate
val onUnchanged = { GameUpdateResult.UNCHANGED }
val onError = { e: Exception ->
error = e
GameUpdateResult.FAILURE
}
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdateStarted(name)) }
val updateResult = safeUpdateIf(lastOnlineUpdate, shouldUpdateFun, ::update, onUnchanged, onError)
when (updateResult) {
GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null
else -> {}
}
val updateEvent = when (updateResult) {
GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!)
GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!)
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name)
}
postCrashHandlingRunnable { EventBus.send(updateEvent) }
}
private suspend fun update(): GameUpdateResult {
val curPreview = if (preview != null) preview!! else loadPreviewFromFile()
val newPreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(curPreview.gameId)
if (newPreview.turns == curPreview.turns && newPreview.currentPlayer == curPreview.currentPlayer) return GameUpdateResult.UNCHANGED
GameSaver.saveGame(newPreview, fileHandle)
preview = newPreview
return GameUpdateResult.CHANGED
}
fun doManualUpdate(gameInfo: GameInfoPreview) {
lastOnlineUpdate.set(Instant.now())
error = null
preview = gameInfo
}
override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle
override fun hashCode(): Int = fileHandle.hashCode()
}
private enum class GameUpdateResult {
CHANGED, UNCHANGED, FAILURE
}
/**
* How often games can be checked for remote updates. More attempted checks within this time period will do nothing.
*/
private fun getUpdateThrottleInterval(): Duration {
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_INTERVAL else CUSTOM_SERVER_THROTTLE_INTERVAL)
}

View File

@ -1,6 +1,5 @@
package com.unciv.logic.multiplayer.storage
import java.io.FileNotFoundException
import java.util.*
class FileStorageConflictException : Exception()

View File

@ -10,6 +10,8 @@ import com.unciv.logic.GameSaver
* Allows access to games stored on a server for multiplayer purposes.
* Defaults to using UncivGame.Current.settings.multiplayerServer if fileStorageIdentifier is not given.
*
* For low-level access only, use [UncivGame.onlineMultiplayer] on [UncivGame.Current] if you're looking to load/save a game.
*
* @param fileStorageIdentifier must be given if UncivGame.Current might not be initialized
* @see FileStorage
* @see UncivGame.Current.settings.multiplayerServer
@ -24,6 +26,7 @@ class OnlineMultiplayerGameSaver(
return if (identifier == Constants.dropboxMultiplayerServer) DropBox else UncivServerFileStorage(identifier!!)
}
/** @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */
suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) {
// We upload the gamePreview before we upload the game as this
// seems to be necessary for the kick functionality
@ -39,6 +42,9 @@ class OnlineMultiplayerGameSaver(
/**
* Used to upload only the preview of a game. If the preview is uploaded together with (before/after)
* the gameInfo, it is recommended to use tryUploadGame(gameInfo, withPreview = true)
*
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
*
* @see tryUploadGame
* @see GameInfo.asPreview
*/
@ -47,11 +53,19 @@ class OnlineMultiplayerGameSaver(
fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo, true)
}
/**
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun tryDownloadGame(gameId: String): GameInfo {
val zippedGameInfo = fileStorage().loadFileData(gameId)
return GameSaver.gameInfoFromString(zippedGameInfo)
}
/**
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found
*/
suspend fun tryDownloadGamePreview(gameId: String): GameInfoPreview {
val zippedGameInfo = fileStorage().loadFileData("${gameId}_Preview")
return GameSaver.gameInfoPreviewFromString(zippedGameInfo)

View File

@ -5,12 +5,15 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.logic.IdChecker
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.*
import java.util.*
class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen(){
class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() {
init {
val gameNameTextField = TextField("", skin)
val gameIDTextField = TextField("", skin)
@ -21,12 +24,12 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen(){
topTable.add("GameID".toLabel()).row()
val gameIDTable = Table()
gameIDTable.add(gameIDTextField).pad(10f).width(2*stage.width/3 - pasteGameIDButton.width)
gameIDTable.add(gameIDTextField).pad(10f).width(2 * stage.width / 3 - pasteGameIDButton.width)
gameIDTable.add(pasteGameIDButton)
topTable.add(gameIDTable).padBottom(30f).row()
topTable.add("Game name".toLabel()).row()
topTable.add(gameNameTextField).pad(10f).padBottom(30f).width(stage.width/2).row()
topTable.add(gameNameTextField).pad(10f).padBottom(30f).width(stage.width / 2).row()
//CloseButton Setup
closeButton.setText("Back".tr())
@ -40,13 +43,29 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen(){
rightSideButton.onClick {
try {
UUID.fromString(IdChecker.checkAndReturnGameUuid(gameIDTextField.text))
}catch (ex: Exception){
} catch (ex: Exception) {
ToastPopup("Invalid game ID!", this)
return@onClick
}
backScreen.addMultiplayerGame(gameIDTextField.text.trim(), gameNameTextField.text.trim())
backScreen.game.setScreen(backScreen)
val popup = Popup(this)
popup.addGoodSizedLabel("Working...")
popup.open()
launchCrashHandling("AddMultiplayerGame") {
try {
game.onlineMultiplayer.addGame(gameIDTextField.text.trim(), gameNameTextField.text.trim())
postCrashHandlingRunnable {
popup.close()
game.setScreen(backScreen)
}
} catch (ex: Exception) {
val message = backScreen.getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
popup.reuseWith(message, true)
}
}
}
}
}
}

View File

@ -2,34 +2,34 @@ package com.unciv.ui.multiplayer
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.logic.GameInfoPreview
import com.unciv.logic.GameSaver
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.utils.*
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.YesNoPopup
/** Subscreen of MultiplayerScreen to edit and delete saves
* backScreen is used for getting back to the MultiplayerScreen so it doesn't have to be created over and over again */
class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: String, backScreen: MultiplayerScreen): PickerScreen(){
* backScreen is used for getting back to the MultiplayerScreen so it doesn't have to be created over and over again */
class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame, backScreen: MultiplayerScreen) : PickerScreen() {
init {
val textField = TextField(gameName, skin)
val textField = TextField(multiplayerGame.name, skin)
topTable.add("Rename".toLabel()).row()
topTable.add(textField).pad(10f).padBottom(30f).width(stage.width/2).row()
topTable.add(textField).pad(10f).padBottom(30f).width(stage.width / 2).row()
val deleteButton = "Delete save".toTextButton()
deleteButton.onClick {
val askPopup = YesNoPopup("Are you sure you want to delete this map?", {
backScreen.removeMultiplayerGame(gameInfo, gameName)
backScreen.game.setScreen(backScreen)
backScreen.reloadGameListUI()
try {
game.onlineMultiplayer.deleteGame(multiplayerGame)
game.setScreen(backScreen)
} catch (ex: Exception) {
ToastPopup("Could not delete game!", this)
}
}, this)
askPopup.open()
}.apply { color = Color.RED }
@ -37,7 +37,7 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St
val giveUpButton = "Resign".toTextButton()
giveUpButton.onClick {
val askPopup = YesNoPopup("Are you sure you want to resign?", {
resign(gameInfo!!.gameId, gameName, backScreen)
resign(multiplayerGame, backScreen)
}, this)
askPopup.open()
}
@ -57,15 +57,13 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St
rightSideButton.enable()
rightSideButton.onClick {
rightSideButton.setText("Saving...".tr())
//remove the old game file
backScreen.removeMultiplayerGame(gameInfo, gameName)
//using addMultiplayerGame will download the game from Dropbox so the descriptionLabel displays the right things
backScreen.addMultiplayerGame(gameInfo!!.gameId, textField.text)
val newName = textField.text.trim()
game.onlineMultiplayer.changeGameName(multiplayerGame, newName)
backScreen.selectGame(newName)
backScreen.game.setScreen(backScreen)
backScreen.reloadGameListUI()
}
if (gameInfo == null){
if (multiplayerGame.preview == null) {
textField.isDisabled = true
textField.color = Color.GRAY
rightSideButton.disable()
@ -77,7 +75,7 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St
* Helper function to decrease indentation
* Turns the current playerCiv into an AI civ and uploads the game afterwards.
*/
private fun resign(gameId: String, gameName: String, backScreen: MultiplayerScreen){
private fun resign(multiplayerGame: OnlineMultiplayerGame, backScreen: MultiplayerScreen) {
//Create a popup
val popup = Popup(this)
popup.addGoodSizedLabel("Working...").row()
@ -85,49 +83,22 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St
launchCrashHandling("Resign", runAsDaemon = false) {
try {
//download to work with newest game state
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId)
val playerCiv = gameInfo.currentPlayerCiv
//only give up if it's the users turn
//this ensures that no one can upload a newer game state while we try to give up
if (playerCiv.playerId == game.settings.userId) {
//Set own civ info to AI
playerCiv.playerType = PlayerType.AI
playerCiv.playerId = ""
//call next turn so turn gets simulated by AI
gameInfo.nextTurn()
//Add notification so everyone knows what happened
//call for every civ cause AI players are skipped anyway
for (civ in gameInfo.civilizations) {
civ.addNotification("[${playerCiv.civName}] resigned and is now controlled by AI", playerCiv.civName)
}
//save game so multiplayer list stays up to date but do not override multiplayer settings
val updatedSave = this@EditMultiplayerGameInfoScreen.gameInfo!!.updateCurrentTurn(gameInfo)
GameSaver.saveGame(updatedSave, gameName)
OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true)
val resignSuccess = game.onlineMultiplayer.resign(multiplayerGame)
if (resignSuccess) {
postCrashHandlingRunnable {
popup.close()
//go back to the MultiplayerScreen
backScreen.game.setScreen(backScreen)
backScreen.reloadGameListUI()
game.setScreen(backScreen)
}
} else {
postCrashHandlingRunnable {
popup.reuseWith("You can only resign if it's your turn", true)
}
}
} catch (ex: FileStorageRateLimitReached) {
postCrashHandlingRunnable {
popup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true)
}
} catch (ex: Exception) {
val message = backScreen.getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
popup.reuseWith("Could not upload game!", true)
popup.reuseWith(message, true)
}
}
}

View File

@ -1,11 +1,13 @@
package com.unciv.ui.multiplayer
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.*
import com.unciv.UncivGame
import com.unciv.logic.*
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.*
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.utils.*
@ -15,45 +17,134 @@ import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import java.io.FileNotFoundException
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.time.Duration
import java.time.Instant
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
private lateinit var selectedGameFile: FileHandle
// Concurrent because we can get concurrent modification errors if we change things around while running redownloadAllGames() in another thread
private var multiplayerGames = ConcurrentHashMap<FileHandle, GameInfoPreview>()
private val rightSideTable = Table()
private val leftSideTable = Table()
private var selectedGame: OnlineMultiplayerGame? = null
private val editButtonText = "Game settings"
private val addGameText = "Add multiplayer game"
private val copyGameIdText = "Copy game ID"
private val copyUserIdText = "Copy user ID"
private val refreshText = "Refresh list"
private val editButton = createEditButton()
private val editButton = editButtonText.toTextButton().apply { disable() }
private val addGameButton = addGameText.toTextButton()
private val copyGameIdButton = copyGameIdText.toTextButton().apply { disable() }
private val copyUserIdButton = copyUserIdText.toTextButton()
private val refreshButton = refreshText.toTextButton()
private val addGameText = "Add multiplayer game"
private val addGameButton = createAddGameButton()
private val copyGameIdText = "Copy game ID"
private val copyGameIdButton = createCopyGameIdButton()
private val copyUserIdText = "Copy user ID"
private val copyUserIdButton = createCopyUserIdButton()
private val refreshText = "Refresh list"
private val refreshButton = createRefreshButton()
private val rightSideTable = createRightSideTable()
private val leftSideTable = GameList(::selectGame)
private val events = EventBus.EventReceiver()
init {
setDefaultCloseAction(previousScreen)
//Help Button Setup
scrollPane.setScrollingDisabled(false, true)
topTable.add(createMainContent()).row()
setupHelpButton()
setupRightSideButton()
events.receive(MultiplayerGameDeleted::class, {it.name == selectedGame?.name}) {
unselectGame()
}
game.onlineMultiplayer.requestUpdate()
}
private fun setupRightSideButton() {
rightSideButton.setText("Join game".tr())
rightSideButton.onClick { joinMultiplayerGame(selectedGame!!) }
}
private fun createRightSideTable(): Table {
val table = Table()
table.defaults().uniformX()
table.defaults().fillX()
table.defaults().pad(10.0f)
table.add(copyUserIdButton).padBottom(30f).row()
table.add(copyGameIdButton).row()
table.add(editButton).row()
table.add(addGameButton).padBottom(30f).row()
table.add(refreshButton).row()
return table
}
fun createRefreshButton(): TextButton {
val btn = refreshText.toTextButton()
btn.onClick { game.onlineMultiplayer.requestUpdate() }
return btn
}
fun createAddGameButton(): TextButton {
val btn = addGameText.toTextButton()
btn.onClick {
game.setScreen(AddMultiplayerGameScreen(this))
}
return btn
}
fun createEditButton(): TextButton {
val btn = editButtonText.toTextButton().apply { disable() }
btn.onClick {
game.setScreen(EditMultiplayerGameInfoScreen(selectedGame!!, this))
}
return btn
}
fun createCopyGameIdButton(): TextButton {
val btn = copyGameIdText.toTextButton().apply { disable() }
btn.onClick {
val gameInfo = selectedGame?.preview
if (gameInfo != null) {
Gdx.app.clipboard.contents = gameInfo.gameId
ToastPopup("Game ID copied to clipboard!", this)
}
}
return btn
}
private fun createCopyUserIdButton(): TextButton {
val btn = copyUserIdText.toTextButton()
btn.onClick {
Gdx.app.clipboard.contents = game.settings.userId
ToastPopup("UserID copied to clipboard", this)
}
return btn
}
private fun createMainContent(): Table {
val mainTable = Table()
mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).center()
mainTable.add(rightSideTable)
return mainTable
}
private fun setupHelpButton() {
val tab = Table()
val helpButton = "Help".toTextButton()
helpButton.onClick {
val helpPopup = Popup(this)
helpPopup.addGoodSizedLabel("To create a multiplayer game, check the 'multiplayer' toggle in the New Game screen, and for each human player insert that player's user ID.").row()
helpPopup.addGoodSizedLabel("You can assign your own user ID there easily, and other players can copy their user IDs here and send them to you for you to include them in the game.").row()
helpPopup.addGoodSizedLabel("To create a multiplayer game, check the 'multiplayer' toggle in the New Game screen, and for each human player insert that player's user ID.")
.row()
helpPopup.addGoodSizedLabel("You can assign your own user ID there easily, and other players can copy their user IDs here and send them to you for you to include them in the game.")
.row()
helpPopup.addGoodSizedLabel("").row()
helpPopup.addGoodSizedLabel("Once you've created your game, the Game ID gets automatically copied to your clipboard so you can send it to the other players.").row()
helpPopup.addGoodSizedLabel("Players can enter your game by copying the game ID to the clipboard, and clicking on the 'Add multiplayer game' button").row()
helpPopup.addGoodSizedLabel("Once you've created your game, the Game ID gets automatically copied to your clipboard so you can send it to the other players.")
.row()
helpPopup.addGoodSizedLabel("Players can enter your game by copying the game ID to the clipboard, and clicking on the 'Add multiplayer game' button")
.row()
helpPopup.addGoodSizedLabel("").row()
helpPopup.addGoodSizedLabel("The symbol of your nation will appear next to the game when it's your turn").row()
@ -64,145 +155,20 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
tab.add(helpButton)
tab.x = (stage.width - helpButton.width)
tab.y = (stage.height - helpButton.height)
stage.addActor(tab)
//TopTable Setup
//Have to put it into a separate Table to be able to add another copyGameID button
val mainTable = Table()
mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).height(stage.height * 2 / 3)
mainTable.add(rightSideTable)
topTable.add(mainTable).row()
scrollPane.setScrollingDisabled(false, true)
rightSideTable.defaults().uniformX()
rightSideTable.defaults().fillX()
rightSideTable.defaults().pad(10.0f)
// leftTable Setup
reloadGameListUI()
// A Button to add the currently running game as multiplayer game
//addCurrentGameButton()
//rightTable Setup
copyUserIdButton.onClick {
Gdx.app.clipboard.contents = game.settings.userId
ToastPopup("UserID copied to clipboard", this)
}
rightSideTable.add(copyUserIdButton).padBottom(30f).row()
copyGameIdButton.onClick {
val gameInfo = multiplayerGames[selectedGameFile]
if (gameInfo != null) {
Gdx.app.clipboard.contents = gameInfo.gameId
ToastPopup("Game ID copied to clipboard!", this)
}
}
rightSideTable.add(copyGameIdButton).row()
editButton.onClick {
game.setScreen(EditMultiplayerGameInfoScreen(multiplayerGames[selectedGameFile], selectedGameFile.name(), this))
//game must be unselected in case the game gets deleted inside the EditScreen
unselectGame()
}
rightSideTable.add(editButton).row()
addGameButton.onClick {
game.setScreen(AddMultiplayerGameScreen(this))
}
rightSideTable.add(addGameButton).padBottom(30f).row()
refreshButton.onClick {
redownloadAllGames()
}
rightSideTable.add(refreshButton).row()
//RightSideButton Setup
rightSideButton.setText("Join game".tr())
rightSideButton.onClick {
joinMultiplayerGame()
}
}
//Adds a new Multiplayer game to the List
//gameId must be nullable because clipboard content could be null
fun addMultiplayerGame(gameId: String?, gameName: String = "") {
val popup = Popup(this)
popup.addGoodSizedLabel("Working...")
popup.open()
try {
//since the gameId is a String it can contain anything and has to be checked
UUID.fromString(IdChecker.checkAndReturnGameUuid(gameId!!))
} catch (ex: Exception) {
popup.reuseWith("Invalid game ID!", true)
return
}
if (gameIsAlreadySavedAsMultiplayer(gameId)) {
popup.reuseWith("Game is already added", true)
return
}
addGameButton.setText("Working...".tr())
addGameButton.disable()
launchCrashHandling("MultiplayerDownload", runAsDaemon = false) {
try {
val gamePreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameId.trim())
if (gameName == "")
GameSaver.saveGame(gamePreview, gamePreview.gameId)
else
GameSaver.saveGame(gamePreview, gameName)
postCrashHandlingRunnable { reloadGameListUI() }
} catch (ex: FileNotFoundException) {
// Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead
try {
val gamePreview = OnlineMultiplayerGameSaver().tryDownloadGame(gameId.trim()).asPreview()
if (gameName == "")
GameSaver.saveGame(gamePreview, gamePreview.gameId)
else
GameSaver.saveGame(gamePreview, gameName)
postCrashHandlingRunnable { reloadGameListUI() }
} catch (ex: Exception) {
postCrashHandlingRunnable {
popup.reuseWith("Could not download game!", true)
}
}
} catch (ex: Exception) {
postCrashHandlingRunnable {
val message = when (ex) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
else -> "Could not download game!"
}
popup.reuseWith(message, true)
}
}
postCrashHandlingRunnable {
addGameButton.setText(addGameText.tr())
addGameButton.enable()
}
}
}
//Download game and use the popup to cover ANRs
private fun joinMultiplayerGame() {
fun joinMultiplayerGame(selectedGame: OnlineMultiplayerGame) {
val loadingGamePopup = Popup(this)
loadingGamePopup.add("Loading latest game state...".tr())
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
loadingGamePopup.open()
launchCrashHandling("JoinMultiplayerGame") {
try {
val gameId = multiplayerGames[selectedGameFile]!!.gameId
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId)
postCrashHandlingRunnable { game.loadGame(gameInfo) }
game.onlineMultiplayer.loadGame(selectedGame)
} catch (ex: Exception) {
val message = when (ex) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
else -> "Could not download game!"
}
val message = getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
loadingGamePopup.reuseWith(message, true)
}
@ -210,191 +176,188 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
}
}
private fun gameIsAlreadySavedAsMultiplayer(gameId: String): Boolean {
val games = multiplayerGames.filterValues { it.gameId == gameId }
return games.isNotEmpty()
}
//reloads all gameFiles to refresh UI
fun reloadGameListUI() {
val leftSubTable = Table()
val gameSaver = GameSaver
val savedGames: Sequence<FileHandle>
try {
savedGames = gameSaver.getSaves(true)
} catch (ex: Exception) {
val errorPopup = Popup(this)
errorPopup.addGoodSizedLabel("Could not refresh!")
errorPopup.row()
errorPopup.addCloseButton()
errorPopup.open()
return
}
for (gameSaveFile in savedGames) {
val gameTable = Table()
val turnIndicator = Table()
var currentTurnUser = ""
var lastTurnMillis = 0L
try {
turnIndicator.add(ImageGetter.getImage("EmojiIcons/Turn"))
gameTable.add(turnIndicator)
val lastModifiedMillis = gameSaveFile.lastModified()
val gameButton = gameSaveFile.name().toTextButton()
//TODO: replace this with nice formatting using kotlin.time.DurationUnit (once it is no longer experimental)
fun formattedElapsedTime(lastMillis: Long): String {
val elapsedMinutes = (System.currentTimeMillis() - lastMillis) / 60000
return when {
elapsedMinutes < 120 -> "[$elapsedMinutes] [Minutes]"
elapsedMinutes < 2880 -> "[${elapsedMinutes / 60}] [Hours]"
else -> "[${elapsedMinutes / 1440}] [Days]"
}
}
gameButton.onClick {
selectedGameFile = gameSaveFile
if (multiplayerGames[gameSaveFile] != null) {
copyGameIdButton.enable()
} else {
copyGameIdButton.disable()
}
editButton.enable()
rightSideButton.enable()
var descriptionText = "Last refresh: ${formattedElapsedTime(lastModifiedMillis)} ago".tr() + "\n"
descriptionText += "Current Turn: [$currentTurnUser] since ${formattedElapsedTime(lastTurnMillis)} ago".tr() + "\n"
descriptionLabel.setText(descriptionText)
}
gameTable.add(gameButton).pad(5f).row()
leftSubTable.add(gameTable).row()
} catch (ex: Exception) {
//skipping one save is not fatal
ToastPopup("Could not refresh!", this)
continue
}
launchCrashHandling("loadGameFile") {
try {
val game = gameSaver.loadGamePreviewFromFile(gameSaveFile)
//Add games to list so saves don't have to be loaded as Files so often
if (!gameIsAlreadySavedAsMultiplayer(game.gameId)) {
multiplayerGames[gameSaveFile] = game
}
postCrashHandlingRunnable {
turnIndicator.clear()
if (isUsersTurn(game)) {
turnIndicator.add(ImageGetter.getImage("OtherIcons/ExclamationMark")).size(50f)
}
//set variable so it can be displayed when gameButton.onClick gets called
currentTurnUser = game.currentPlayer
lastTurnMillis = game.currentTurnStartTime
}
} catch (usx: UncivShowableException) {
//Gets thrown when mods are not installed
postCrashHandlingRunnable {
val popup = Popup(this@MultiplayerScreen)
popup.addGoodSizedLabel(usx.message!! + " in ${gameSaveFile.name()}").row()
popup.addCloseButton()
popup.open(true)
turnIndicator.clear()
turnIndicator.add(ImageGetter.getImage("StatIcons/Malcontent")).size(50f)
}
} catch (ex: Exception) {
postCrashHandlingRunnable {
ToastPopup("Could not refresh!", this@MultiplayerScreen)
turnIndicator.clear()
turnIndicator.add(ImageGetter.getImage("StatIcons/Malcontent")).size(50f)
}
}
}
}
leftSideTable.clear()
leftSideTable.add(leftSubTable)
}
//redownload all games to update the list
//can maybe replaced when notification support gets introduced
private fun redownloadAllGames() {
addGameButton.disable()
refreshButton.setText("Working...".tr())
refreshButton.disable()
launchCrashHandling("multiplayerGameDownload") {
for ((fileHandle, gameInfo) in multiplayerGames) {
try {
// Update game without overriding multiplayer settings
val game = gameInfo.updateCurrentTurn(OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameInfo.gameId))
GameSaver.saveGame(game, fileHandle.name())
multiplayerGames[fileHandle] = game
} catch (ex: FileNotFoundException) {
// Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead
try {
// Update game without overriding multiplayer settings
val game = gameInfo.updateCurrentTurn(OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId))
GameSaver.saveGame(game, fileHandle.name())
multiplayerGames[fileHandle] = game
} catch (ex: Exception) {
postCrashHandlingRunnable {
ToastPopup("Could not download game!" + " ${fileHandle.name()}", this@MultiplayerScreen)
}
}
} catch (ex: FileStorageRateLimitReached) {
postCrashHandlingRunnable {
ToastPopup("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", this@MultiplayerScreen)
}
break // No need to keep trying if rate limit is reached
} catch (ex: Exception) {
//skipping one is not fatal
//Trying to use as many prev. used strings as possible
postCrashHandlingRunnable {
ToastPopup("Could not download game!" + " ${fileHandle.name()}", this@MultiplayerScreen)
}
}
}
//Reset UI
postCrashHandlingRunnable {
addGameButton.enable()
refreshButton.setText(refreshText.tr())
refreshButton.enable()
unselectGame()
reloadGameListUI()
}
}
}
//It doesn't really unselect the game because selectedGame cant be null
//It just disables everything a selected game has set
private fun unselectGame() {
selectedGame = null
editButton.disable()
copyGameIdButton.disable()
rightSideButton.disable()
descriptionLabel.setText("")
}
//check if its the users turn
private fun isUsersTurn(gameInfo: GameInfoPreview) = gameInfo.getCivilization(gameInfo.currentPlayer).playerId == game.settings.userId
fun removeMultiplayerGame(gameInfo: GameInfoPreview?, gameName: String) {
val games = multiplayerGames.filterValues { it == gameInfo }.keys
try {
GameSaver.deleteSave(gameName, true)
if (games.isNotEmpty()) multiplayerGames.remove(games.first())
} catch (ex: Exception) {
ToastPopup("Could not delete game!", this)
fun selectGame(name: String) {
val multiplayerGame = game.onlineMultiplayer.getGameByName(name)
if (multiplayerGame == null) {
// Should never happen
unselectGame()
return
}
selectedGame = multiplayerGame
if (multiplayerGame.preview != null) {
copyGameIdButton.enable()
} else {
copyGameIdButton.disable()
}
editButton.enable()
rightSideButton.enable()
descriptionLabel.setText(buildDescriptionText(multiplayerGame))
}
private fun buildDescriptionText(multiplayerGame: OnlineMultiplayerGame): StringBuilder {
val descriptionText = StringBuilder()
val ex = multiplayerGame.error
if (ex != null) {
descriptionText.append("Error while refreshing:".tr()).append(' ')
val message = getLoadExceptionMessage(ex)
descriptionText.appendLine(message.tr())
}
val lastUpdate = multiplayerGame.lastUpdate
descriptionText.appendLine("Last refresh: ${formattedElapsedTime(lastUpdate)} ago".tr())
val preview = multiplayerGame.preview
if (preview?.currentPlayer != null) {
val currentTurnStartTime = Instant.ofEpochMilli(preview.currentTurnStartTime)
descriptionText.appendLine("Current Turn: [${preview.currentPlayer}] since ${formattedElapsedTime(currentTurnStartTime)} ago".tr())
}
return descriptionText
}
private fun formattedElapsedTime(lastUpdate: Instant): String {
val durationToNow = Duration.between(lastUpdate, Instant.now())
val elapsedMinutes = durationToNow.toMinutes()
if (elapsedMinutes < 120) return "[$elapsedMinutes] [Minutes]"
val elapsedHours = durationToNow.toHours()
if (elapsedHours < 48) {
return "[${elapsedHours}] [Hours]"
} else {
return "[${durationToNow.toDays()}] [Days]"
}
}
fun getLoadExceptionMessage(ex: Exception) = when (ex) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
is FileNotFoundException -> "File could not be found on the multiplayer server"
is UncivShowableException -> ex.message!! // some of these seem to be translated already, but not all
else -> "Unhandled problem, [${ex::class.simpleName}] ${ex.message}"
}
}
private class GameList(
onSelected: (String) -> Unit
) : VerticalGroup() {
private val gameDisplays = mutableMapOf<String, GameDisplay>()
private val events = EventBus.EventReceiver()
init {
padTop(10f)
padBottom(10f)
events.receive(MultiplayerGameAdded::class) {
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name)
if (multiplayerGame == null) return@receive
addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected)
}
events.receive(MultiplayerGameNameChanged::class) {
val gameDisplay = gameDisplays.remove(it.oldName)
if (gameDisplay == null) return@receive
gameDisplay.changeName(it.name)
gameDisplays[it.name] = gameDisplay
children.sort()
}
events.receive(MultiplayerGameDeleted::class) {
val gameDisplay = gameDisplays.remove(it.name)
if (gameDisplay == null) return@receive
gameDisplay.remove()
}
for (game in UncivGame.Current.onlineMultiplayer.games) {
addGame(game.name, game.preview, game.error, onSelected)
}
}
private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) {
val gameDisplay = GameDisplay(name, preview, error, onSelected)
gameDisplays[name] = gameDisplay
addActor(gameDisplay)
children.sort()
}
}
private class GameDisplay(
multiplayerGameName: String,
preview: GameInfoPreview?,
error: Exception?,
private val onSelected: (String) -> Unit
) : Table(), Comparable<GameDisplay> {
var gameName: String = multiplayerGameName
private set
val gameButton = TextButton(gameName, BaseScreen.skin)
val turnIndicator = createIndicator("OtherIcons/ExclamationMark")
val errorIndicator = createIndicator("StatIcons/Malcontent")
val refreshIndicator = createIndicator("EmojiIcons/Turn")
val statusIndicators = HorizontalGroup()
val events = EventBus.EventReceiver()
init {
padBottom(5f)
updateTurnIndicator(preview)
updateErrorIndicator(error != null)
add(statusIndicators)
add(gameButton)
onClick { onSelected(gameName) }
events.receive(MultiplayerGameUpdateStarted::class, { it.name == gameName }, {
statusIndicators.addActor(refreshIndicator)
})
events.receive(MultiplayerGameUpdateUnchanged::class, { it.name == gameName }, {
refreshIndicator.remove()
})
events.receive(MultiplayerGameUpdated::class, { it.name == gameName }) {
updateTurnIndicator(it.preview)
updateErrorIndicator(false)
refreshIndicator.remove()
}
events.receive(MultiplayerGameUpdateFailed::class, { it.name == gameName }) {
updateErrorIndicator(true)
refreshIndicator.remove()
}
}
fun changeName(newName: String) {
gameName = newName
gameButton.setText(newName)
}
private fun updateTurnIndicator(preview: GameInfoPreview?) {
if (preview?.isUsersTurn() == true) {
statusIndicators.addActor(turnIndicator)
} else {
turnIndicator.remove()
}
}
private fun updateErrorIndicator(hasError: Boolean) {
if (hasError) {
statusIndicators.addActor(errorIndicator)
} else {
errorIndicator.remove()
}
}
private fun createIndicator(imagePath: String): Actor {
val image = ImageGetter.getImage(imagePath)
image.setSize(50f)
val container = Container(image)
container.padRight(5f)
return container
}
override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName)
override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName)
override fun hashCode(): Int = gameName.hashCode()
}

View File

@ -11,8 +11,8 @@ import com.unciv.UncivGame
import com.unciv.logic.*
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.map.MapType
import com.unciv.logic.multiplayer.OnlineMultiplayer
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
@ -255,13 +255,8 @@ class NewGameScreen(
if (gameSetupInfo.gameParameters.isOnlineMultiplayer) {
newGame.isUpToDate = true // So we don't try to download it from dropbox the second after we upload it - the file is not yet ready for loading!
try {
OnlineMultiplayerGameSaver().tryUploadGame(newGame, withPreview = true)
game.onlineMultiplayer.createGame(newGame)
GameSaver.autoSave(newGame)
// Saved as Multiplayer game to show up in the session browser
val newGamePreview = newGame.asPreview()
GameSaver.saveGame(newGamePreview, newGamePreview.gameId)
} catch (ex: FileStorageRateLimitReached) {
postCrashHandlingRunnable {
popup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true)
@ -329,11 +324,7 @@ class TranslatedSelectBox(values : Collection<String>, default:String, skin: Ski
val translation = value.tr()
override fun toString() = translation
// Equality contract needs to be implemented else TranslatedSelectBox.setSelected won't work properly
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return value == (other as TranslatedString).value
}
override fun equals(other: Any?): Boolean = other is TranslatedString && value == other.value
override fun hashCode() = value.hashCode()
}

View File

@ -17,6 +17,8 @@ import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import java.text.SimpleDateFormat
import java.time.Duration
import java.time.Instant
import java.util.*
import kotlin.random.Random
@ -159,6 +161,11 @@ fun <T : Actor> Cell<T>.pad(vertical: Float, horizontal: Float): Cell<T> {
return pad(vertical, horizontal, vertical, horizontal)
}
/** Sets both the width and height to [size] */
fun Image.setSize(size: Float) {
setSize(size, size)
}
/** Gets a clone of an [ArrayList] with an additional item
*
* Solves concurrent modification problems - everyone who had a reference to the previous arrayList can keep using it because it hasn't changed
@ -312,6 +319,12 @@ object UncivDateFormat {
fun String.parseDate(): Date = utcFormat.parse(this)
}
fun Duration.isLargerThan(other: Duration): Boolean {
return compareTo(other) > 0
}
fun Instant.isLargerThan(other: Instant): Boolean {
return compareTo(other) > 0
}
/**
* Returns a wrapped version of a function that safely crashes the game to [CrashScreen] if an exception or error is thrown.

View File

@ -672,17 +672,14 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
if (originalGameInfo.gameParameters.isOnlineMultiplayer) {
try {
OnlineMultiplayerGameSaver().tryUploadGame(gameInfoClone, withPreview = true)
} catch (ex: FileStorageRateLimitReached) {
postCrashHandlingRunnable {
val cantUploadNewGamePopup = Popup(this@WorldScreen)
cantUploadNewGamePopup.addGoodSizedLabel("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds").row()
cantUploadNewGamePopup.addCloseButton()
cantUploadNewGamePopup.open()
}
} catch (ex: Exception) {
val message = when (ex) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
else -> "Could not upload game!"
}
postCrashHandlingRunnable { // Since we're changing the UI, that should be done on the main thread
val cantUploadNewGamePopup = Popup(this@WorldScreen)
cantUploadNewGamePopup.addGoodSizedLabel("Could not upload game!").row()
cantUploadNewGamePopup.addGoodSizedLabel(message).row()
cantUploadNewGamePopup.addCloseButton()
cantUploadNewGamePopup.open()
}