diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 1766a1a587..ffc8fac630 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -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 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dc6f604e23..604ce18e34 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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") } diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 693c58e474..b20cfae92a 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -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() diff --git a/core/src/com/unciv/json/UncivJson.kt b/core/src/com/unciv/json/UncivJson.kt index 3658841286..69da6bcb9d 100644 --- a/core/src/com/unciv/json/UncivJson.kt +++ b/core/src/com/unciv/json/UncivJson.kt @@ -15,8 +15,14 @@ fun json() = Json().apply { setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer()) } +/** + * @throws SerializationException + */ fun Json.fromJsonFile(tClass: Class, filePath: String): T = fromJsonFile(tClass, Gdx.files.internal(filePath)) +/** + * @throws SerializationException + */ fun Json.fromJsonFile(tClass: Class, file: FileHandle): T { try { val jsonText = file.readString(Charsets.UTF_8.name()) diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index fe336d869b..f716640bc8 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -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 { diff --git a/core/src/com/unciv/logic/event/Event.kt b/core/src/com/unciv/logic/event/Event.kt new file mode 100644 index 0000000000..93d6fdf383 --- /dev/null +++ b/core/src/com/unciv/logic/event/Event.kt @@ -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 diff --git a/core/src/com/unciv/logic/event/EventBus.kt b/core/src/com/unciv/logic/event/EventBus.kt new file mode 100644 index 0000000000..2353683f66 --- /dev/null +++ b/core/src/com/unciv/logic/event/EventBus.kt @@ -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, MutableList>>() + + /** + * 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 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 + 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 receive(eventClass: KClass, 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 = mutableListOf() + val filters: MutableList = 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 receive(eventClass: KClass, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) { + if (filter != null) { + filters.add(filter) + } + eventHandlers.add(eventHandler) + EventBus.receive(eventClass, filter, eventHandler) + } + } + +} + +private class EventListener( + eventHandler: (T) -> Unit, + filter: ((T) -> Boolean)? = null +) { + val eventHandler = WeakReference(eventHandler) + val filter = WeakReference(filter) +} diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt new file mode 100644 index 0000000000..7858f4c781 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -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 = Collections.synchronizedMap(mutableMapOf()) + private var lastFileUpdate: AtomicReference = AtomicReference() + + val games: Set get() = savedGames.values.toSet() + + init { + flow { + 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 safeUpdateIf( + lastUpdate: AtomicReference, + 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) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt new file mode 100644 index 0000000000..1f62f00864 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt @@ -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 diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt new file mode 100644 index 0000000000..321c201cff --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt @@ -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 = 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) +} diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt index c6e8c13515..e95ed7ecfe 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt @@ -1,6 +1,5 @@ package com.unciv.logic.multiplayer.storage -import java.io.FileNotFoundException import java.util.* class FileStorageConflictException : Exception() diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt index 13eb7790f4..5f8d726008 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt @@ -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) diff --git a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt index e88236995f..3e7bdc7d2c 100644 --- a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt @@ -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) + } + } + } } } } diff --git a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt index 03bd14d5c0..01699d0c05 100644 --- a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt @@ -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) } } } diff --git a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt index cc48a07f32..315f50c658 100644 --- a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt @@ -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() - 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 - - 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() + + 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 { + 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() +} diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index c688045c42..f659fd512c 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -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, 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() } diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 0488d7f55f..046886f16e 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -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 @@ -141,7 +143,7 @@ fun Table.addSeparator(color: Color = Color.WHITE, colSpan: Int = 0, height: Flo /** * Create a vertical separator as an empty Container with a colored background. - * + * * Note: Unlike the horizontal [addSeparator] this cannot automatically span several rows. Repeat the separator if needed. */ fun Table.addSeparatorVertical(color: Color = Color.WHITE, width: Float = 2f): Cell { @@ -159,6 +161,11 @@ fun Cell.pad(vertical: Float, horizontal: Float): Cell { 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 @@ -236,11 +243,11 @@ fun String.toLabel(fontColor: Color = Color.WHITE, fontSize: Int = Constants.def fun String.toCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) = CheckBox(this.tr(), BaseScreen.skin).apply { isChecked = startsOutChecked - if (changeAction != null) onChange { + if (changeAction != null) onChange { changeAction(isChecked) } // Add a little distance between the icon and the text. 0 looks glued together, - // 5 is about half an uppercase letter, and 1 about the width of the vertical line in "P". + // 5 is about half an uppercase letter, and 1 about the width of the vertical line in "P". imageCell.padRight(1f) } @@ -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. diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index dfeed7df25..b8814eb58f 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -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() }