mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-09 15:29:32 +07:00
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:
@ -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
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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())
|
||||
|
@ -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 {
|
||||
|
6
core/src/com/unciv/logic/event/Event.kt
Normal file
6
core/src/com/unciv/logic/event/Event.kt
Normal 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
|
100
core/src/com/unciv/logic/event/EventBus.kt
Normal file
100
core/src/com/unciv/logic/event/EventBus.kt
Normal 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)
|
||||
}
|
281
core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt
Normal file
281
core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
119
core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt
Normal file
119
core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt
Normal 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)
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package com.unciv.logic.multiplayer.storage
|
||||
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class FileStorageConflictException : Exception()
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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<Image> {
|
||||
@ -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
|
||||
@ -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.
|
||||
|
@ -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()
|
||||
}
|
||||
|
Reference in New Issue
Block a user