#6914 Multiplayer Status Display (#6916)

* Handle subclassing of Events properly

Previously, you could only listen to the exact class

* Add relevant parent classes for the multiplayer events

* Refactor: use the old name as the main name in MultiplayerGameNameChanged event

* Add being able to stop listening to events in the EventBus

* Add tests for EventBus

* Refactor: Extract GameList into standalone file

* Refactor: safeUpdateIf to more generic throttle function

* Refactor: Extract multiplayer UI helper functions into separate file

* Refactor: Extract load/download multiplayer game into logic class from UI

* Make loading a multiplayer game automatically update the in-memory game in OnlineMultiplayer

* Refactor: Extract multiplayer settings into separate object

* Add multiplayer status display

* Fix error with multiplayer games not correctly being cleaned up after successful update

* Prevent loadLatestMultiplayerState() while next turn update is running

* Show "Working..." while waiting for next turn calculations instead of "Waiting for [civ]..."

* Fix race condition while updating online game state
This commit is contained in:
Timo T
2022-05-25 22:22:58 +02:00
committed by GitHub
parent 8dadab872c
commit ea03b97639
35 changed files with 1883 additions and 1147 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1009 KiB

After

Width:  |  Height:  |  Size: 1008 KiB

View File

@ -542,6 +542,7 @@ Username =
Multiplayer =
Could not download game! =
Could not upload game! =
Retry =
Join game =
Invalid game ID! =
Copy user ID =
@ -577,6 +578,7 @@ You can only resign if it's your turn =
[civName] resigned and is now controlled by AI =
Last refresh: [time] [timeUnit] ago =
Current Turn: [civName] since [time] [timeUnit] ago =
Seconds =
Minutes =
Hours =
Days =
@ -1346,7 +1348,7 @@ Choose name for [unitName] =
# Multiplayer Turn Checker Service
Enable out-of-game turn notifications =
Time between turn checks out-of-game (in minutes) =
Out-of-game, update status of all games every: =
Show persistent notification for turn notifier service =
Take user ID from clipboard =
Doing this will reset your current user ID to the clipboard contents - are you sure? =
@ -1355,6 +1357,9 @@ Invalid ID! =
# Multiplayer options menu
Enable multiplayer status button in singleplayer games =
Update status of currently played game every: =
In-game, update status of all games every: =
Server address =
Reset to Dropbox =
Check connection to server =

View File

@ -69,9 +69,10 @@ open class AndroidLauncher : AndroidApplication() {
override fun onPause() {
if (UncivGame.isCurrentInitialized()
&& UncivGame.Current.isGameInfoInitialized()
&& UncivGame.Current.settings.multiplayerTurnCheckerEnabled
&& UncivGame.Current.settings.multiplayer.turnCheckerEnabled
&& UncivGame.Current.gameSaver.getMultiplayerSaves().any()) {
MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver, UncivGame.Current.gameInfo, UncivGame.Current.settings)
MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver,
UncivGame.Current.gameInfo, UncivGame.Current.settings.multiplayer)
}
super.onPause()
}

View File

@ -24,11 +24,13 @@ import com.unciv.logic.GameSaver
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.models.metadata.GameSettings
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.models.metadata.GameSettingsMultiplayer
import kotlinx.coroutines.runBlocking
import java.io.FileNotFoundException
import java.io.PrintWriter
import java.io.StringWriter
import java.io.Writer
import java.time.Duration
import java.util.*
import java.util.concurrent.TimeUnit
@ -59,8 +61,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
private const val PERSISTENT_NOTIFICATION_ENABLED = "PERSISTENT_NOTIFICATION_ENABLED"
private const val FILE_STORAGE = "FILE_STORAGE"
fun enqueue(appContext: Context,
delayInMinutes: Int, inputData: Data) {
fun enqueue(appContext: Context, delay: Duration, inputData: Data) {
val constraints = Constraints.Builder()
// If no internet is available, worker waits before becoming active.
@ -69,7 +70,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
val checkTurnWork = OneTimeWorkRequestBuilder<MultiplayerTurnCheckWorker>()
.setConstraints(constraints)
.setInitialDelay(delayInMinutes.toLong(), TimeUnit.MINUTES)
.setInitialDelay(delay.seconds, TimeUnit.SECONDS)
.addTag(WORK_TAG)
.setInputData(inputData)
.build()
@ -123,7 +124,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
* The persistent notification is purely for informational reasons.
* It is not technically necessary for the Worker, since it is not a Service.
*/
fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: String) {
fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: Duration) {
val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) FLAG_IMMUTABLE else 0) or
FLAG_UPDATE_CURRENT
val pendingIntent: PendingIntent =
@ -136,7 +137,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
.setContentTitle(appContext.resources.getString(R.string.Notify_Persist_Short) + " " + lastTimeChecked)
.setStyle(NotificationCompat.BigTextStyle()
.bigText(appContext.resources.getString(R.string.Notify_Persist_Long_P1) + " " +
appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod + " "
appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod.seconds / 60f + " "
+ appContext.resources.getString(R.string.Notify_Persist_Long_P3)
+ " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4)))
.setSmallIcon(R.drawable.uncivnotification)
@ -180,7 +181,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
}
}
fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettings) {
fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettingsMultiplayer) {
Log.i(LOG_TAG, "startTurnChecker")
val gameFiles = gameSaver.getMultiplayerSaves()
val gameIds = Array(gameFiles.count()) {""}
@ -211,17 +212,16 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
}
} else {
val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair(GAME_NAME, gameNames),
Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.multiplayerTurnCheckerDelayInMinutes),
Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.multiplayerTurnCheckerPersistentNotificationEnabled),
Pair(FILE_STORAGE, settings.multiplayerServer))
Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.turnCheckerDelay.seconds),
Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.turnCheckerPersistentNotificationEnabled),
Pair(FILE_STORAGE, settings.server))
if (settings.multiplayerTurnCheckerPersistentNotificationEnabled) {
showPersistentNotification(applicationContext,
"", settings.multiplayerTurnCheckerDelayInMinutes.toString())
if (settings.turnCheckerPersistentNotificationEnabled) {
showPersistentNotification(applicationContext, "", settings.turnCheckerDelay)
}
Log.d(LOG_TAG, "startTurnChecker enqueue")
// Initial check always happens after a minute, ignoring delay config. Better user experience this way.
enqueue(applicationContext, 1, inputData)
enqueue(applicationContext, Duration.ofMinutes(1), inputData)
}
}
@ -247,6 +247,11 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
}
}
}
private fun getConfiguredDelay(inputData: Data): Duration {
val delay = inputData.getLong(CONFIGURED_DELAY, Duration.ofMinutes(5).seconds)
return Duration.ofSeconds(delay)
}
}
/**
@ -270,7 +275,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
override fun doWork(): Result = runBlocking {
Log.i(LOG_TAG, "doWork")
val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true)
val configuredDelay = inputData.getInt(CONFIGURED_DELAY, 5)
val configuredDelay = getConfiguredDelay(inputData)
val fileStorage = inputData.getString(FILE_STORAGE)
try {
@ -350,13 +355,12 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
}
return@runBlocking Result.failure()
} else {
if (showPersistNotific) { showPersistentNotification(applicationContext,
applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay.toString()) }
if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay) }
// If check fails, retry in one minute.
// Makes sense, since checks only happen if Internet is available in principle.
// Therefore a failure means either a problem with the GameInfo or with Dropbox.
val inputDataFailIncrease = Data.Builder().putAll(inputData).putInt(FAIL_COUNT, failCount + 1).build()
enqueue(applicationContext, 1, inputDataFailIncrease)
enqueue(applicationContext, Duration.ofMinutes(1), inputDataFailIncrease)
}
} catch (outOfMemory: OutOfMemoryError){ // no point in trying multiple times if this was an oom error
return@runBlocking Result.failure()
@ -379,8 +383,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
}
val displayTime = "$hour:$minute"
showPersistentNotification(applicationContext, displayTime,
inputData.getInt(CONFIGURED_DELAY, 5).toString())
showPersistentNotification(applicationContext, displayTime, getConfiguredDelay(inputData))
}
private fun showErrorNotification(stackTraceString: String) {

View File

@ -121,6 +121,7 @@ project(":core") {
dependencies {
"implementation"("com.badlogicgames.gdx:gdx:$gdxVersion")
"implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1")
"implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}")
}

View File

@ -201,16 +201,23 @@ class MainMenuScreen: BaseScreen() {
return@launchCrashHandling
}
if (savedGame.gameParameters.isOnlineMultiplayer) {
try {
game.onlineMultiplayer.loadGame(savedGame)
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
} else {
postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context
try {
game.loadGame(savedGame)
dispose()
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
}
}
}
}
private fun quickstartNewGame() {
ToastPopup("Working...", this)

View File

@ -13,19 +13,20 @@ import com.unciv.models.metadata.GameSettings
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.tilesets.TileSetCache
import com.unciv.models.translations.Translations
import com.unciv.ui.LanguagePickerScreen
import com.unciv.ui.audio.MusicController
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.OnlineMultiplayer
import com.unciv.ui.LanguagePickerScreen
import com.unciv.ui.audio.Sounds
import com.unciv.ui.crashhandling.closeExecutors
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.multiplayer.LoadDeepLinkScreen
import com.unciv.ui.multiplayer.MultiplayerHelpers
import com.unciv.ui.popup.Popup
import kotlinx.coroutines.runBlocking
import java.util.*
@ -128,8 +129,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
translations.loadPercentageCompleteOfLanguages()
TileSetCache.loadTileSetConfigs(printOutput = true)
if (settings.userId.isEmpty()) { // assign permanent user id
settings.userId = UUID.randomUUID().toString()
if (settings.multiplayer.userId.isEmpty()) { // assign permanent user id
settings.multiplayer.userId = UUID.randomUUID().toString()
settings.save()
}
@ -201,7 +202,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
val mainMenu = MainMenuScreen()
setScreen(mainMenu)
val popup = Popup(mainMenu)
popup.addGoodSizedLabel("Failed to load multiplayer game: ${ex.message ?: ex::class.simpleName}")
popup.addGoodSizedLabel(MultiplayerHelpers.getLoadExceptionMessage(ex))
popup.row()
popup.addCloseButton()
popup.open()

View File

@ -0,0 +1,16 @@
package com.unciv.json
import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.Json.Serializer
import com.badlogic.gdx.utils.JsonValue
import java.time.Duration
class DurationSerializer : Serializer<Duration> {
override fun write(json: Json, duration: Duration, knownType: Class<*>?) {
json.writeValue(duration.toString())
}
override fun read(json: Json, jsonData: JsonValue, type: Class<*>?): Duration {
return Duration.parse(jsonData.asString())
}
}

View File

@ -3,6 +3,7 @@ package com.unciv.json
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.utils.Json
import java.time.Duration
/**
@ -13,6 +14,7 @@ fun json() = Json().apply {
ignoreUnknownFields = true
setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer())
setSerializer(Duration::class.java, DurationSerializer())
}
/**

View File

@ -103,7 +103,7 @@ class GameInfo {
fun getPlayerToViewAs(): CivilizationInfo {
if (!gameParameters.isOnlineMultiplayer) return currentPlayerCiv // non-online, play as human player
val userId = UncivGame.Current.settings.userId
val userId = UncivGame.Current.settings.multiplayer.userId
// Iterating on all civs, starting from the the current player, gives us the one that will have the next turn
// This allows multiple civs from the same UserID
@ -227,7 +227,7 @@ class GameInfo {
|| turns < simulateMaxTurns && simulateUntilWin
// For multiplayer, if there are 3+ players and one is defeated or spectator,
// we'll want to skip over their turn
|| gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.userId)
|| gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.multiplayer.userId)
) {
if (!thisPlayer.isDefeated() || thisPlayer.isBarbarian()) {
NextTurnAutomation.automateCivMoves(thisPlayer)

View File

@ -3,10 +3,13 @@ package com.unciv.logic
import com.badlogic.gdx.Files
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.utils.JsonReader
import com.unciv.UncivGame
import com.unciv.json.fromJsonFile
import com.unciv.json.json
import com.unciv.models.metadata.GameSettings
import com.unciv.models.metadata.doMigrations
import com.unciv.models.metadata.isMigrationNecessary
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.saves.Gzip
@ -164,22 +167,23 @@ class GameSaver(
fun getGeneralSettings(): GameSettings {
val settingsFile = getGeneralSettingsFile()
val settings: GameSettings =
if (!settingsFile.exists())
GameSettings().apply { isFreshlyCreated = true }
else try {
json().fromJson(GameSettings::class.java, settingsFile)
var settings: GameSettings? = null
if (settingsFile.exists()) {
try {
settings = json().fromJson(GameSettings::class.java, settingsFile)
if (settings.isMigrationNecessary()) {
settings.doMigrations(JsonReader().parse(settingsFile))
}
} catch (ex: Exception) {
// I'm not sure of the circumstances,
// but some people were getting null settings, even though the file existed??? Very odd.
// ...Json broken or otherwise unreadable is the only possible reason.
println("Error reading settings file: ${ex.localizedMessage}")
println(" cause: ${ex.cause}")
GameSettings().apply { isFreshlyCreated = true }
}
}
return settings
return settings ?: GameSettings().apply { isFreshlyCreated = true }
}
fun setGeneralSettings(gameSettings: GameSettings) {

View File

@ -11,10 +11,10 @@ import kotlin.reflect.KClass
* **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
@Suppress("UNCHECKED_CAST") // Through using the "map by KClass" pattern, 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<*>>>()
private val listeners = mutableMapOf<KClass<*>, MutableList<EventListenerWeakReference<*>>>()
/**
* Only use this from the render thread. For example, in coroutines launched by [com.unciv.ui.crashhandling.launchCrashHandling]
@ -24,29 +24,65 @@ object EventBus {
* 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()
val eventListeners = getListeners(event::class) as Set<EventListener<T>>
for (listener in eventListeners) {
val filter = listener.filter
if (filter == null || filter(event)) {
eventHandler(event)
listener.eventHandler(event)
}
}
}
private fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
if (receivers[eventClass] == null) {
receivers[eventClass] = mutableListOf()
private fun <T : Event> getListeners(eventClass: KClass<T>): Set<EventListener<*>> {
val classesToListenTo = getClassesToListenTo(eventClass) // This is always a KClass
// Set because we don't want to notify the same listener multiple times
return buildSet {
for (classToListenTo in classesToListenTo) {
addAll(updateActiveListeners(classToListenTo))
}
}
}
/** To be able to listen to an event class and get notified even when child classes are sent as an event */
private fun <T : Event> getClassesToListenTo(eventClass: KClass<T>): List<KClass<*>> {
val superClasses = eventClass.supertypes.map { it.classifier as KClass<*> }.filter { it != Any::class }
return superClasses + eventClass
}
/** Removes all listeners whose WeakReference got collected and returns the ones that are still active */
private fun updateActiveListeners(eventClass: KClass<*>): List<EventListener<*>> {
return buildList {
val listenersWeak = listeners[eventClass] ?: return listOf()
val iterator = listenersWeak.listIterator()
while (iterator.hasNext()) {
val listener = iterator.next()
val eventHandler = listener.eventHandler.get()
if (eventHandler == null) {
// eventHandler got garbage collected, prevent WeakListener memory leak
iterator.remove()
} else {
add(EventListener(eventHandler, listener.filter.get()))
}
}
}
}
private fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
if (listeners[eventClass] == null) {
listeners[eventClass] = mutableListOf()
}
listeners[eventClass]!!.add(EventListenerWeakReference(eventHandler, filter))
}
private fun cleanUp(eventHandlers: Map<KClass<*>, MutableList<Any>>) {
for ((kClass, toRemove) in eventHandlers) {
val registeredListeners = listeners.get(kClass)
registeredListeners?.removeIf {
val eventHandler = it.eventHandler.get()
eventHandler == null || (eventHandler as Any) in toRemove
}
}
receivers[eventClass]!!.add(EventListener(eventHandler, filter))
}
/**
@ -63,19 +99,29 @@ object EventBus {
* // do something when the event is received.
* }
* }
*
* // Optional
* cleanup() {
* events.stopReceiving()
* }
* }
* ```
*
* The [stopReceiving] call is optional. Event listeners will be automatically garbage collected. However, garbage collection is non-deterministic, so it's
* possible that the events keep being received for quite a while even after a class is unused. [stopReceiving] immediately cleans up all listeners.
*
* 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 eventHandlers = mutableMapOf<KClass<*>, MutableList<Any>>()
val filters: MutableList<Any> = mutableListOf()
/**
* Listen to the event with the given [eventClass] and all events that subclass it. Use [stopReceiving] to stop listening to all events.
*
* 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].
@ -84,14 +130,34 @@ object EventBus {
if (filter != null) {
filters.add(filter)
}
eventHandlers.add(eventHandler)
if (eventHandlers[eventClass] == null) {
eventHandlers[eventClass] = mutableListOf()
}
eventHandlers[eventClass]!!.add(eventHandler)
EventBus.receive(eventClass, filter, eventHandler)
}
/**
* Stops receiving all events, cleaning up all event listeners.
*/
fun stopReceiving() {
cleanUp(eventHandlers)
eventHandlers.clear()
filters.clear()
}
}
}
/** Exists so that eventHandlers and filters do not get garbage-collected *while* we are passing them around in here,
* otherwise we would only need [EventListenerWeakReference] */
private class EventListener<T>(
val eventHandler: (T) -> Unit,
val filter: ((T) -> Boolean)? = null
)
private class EventListenerWeakReference<T>(
eventHandler: (T) -> Unit,
filter: ((T) -> Boolean)? = null
) {

View File

@ -1,11 +1,9 @@
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
@ -14,48 +12,65 @@ 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.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.concurrent.atomic.AtomicReference
/** @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)
private val FILE_UPDATE_THROTTLE_PERIOD = 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() {
class OnlineMultiplayer {
private val gameSaver = UncivGame.Current.gameSaver
private val onlineGameSaver = OnlineMultiplayerGameSaver()
private val savedGames: MutableMap<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf())
private var lastFileUpdate: AtomicReference<Instant?> = AtomicReference()
private val lastFileUpdate: AtomicReference<Instant?> = AtomicReference()
private val lastAllGamesRefresh: AtomicReference<Instant?> = AtomicReference()
private val lastCurGameRefresh: AtomicReference<Instant?> = AtomicReference()
val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet()
init {
flow<Unit> {
while (true) {
delay(getRefreshInterval().toMillis())
delay(500)
// TODO will be used later
// requestUpdate()
val currentGame = getCurrentGame()
val multiplayerSettings = UncivGame.Current.settings.multiplayer
if (currentGame != null) {
throttle(lastCurGameRefresh, multiplayerSettings.currentGameRefreshDelay, {}) { currentGame.requestUpdate() }
}
val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame)
throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) }
}
}.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
}
private fun getCurrentGame(): OnlineMultiplayerGame? {
if (UncivGame.isCurrentInitialized() && UncivGame.Current.isGameInfoInitialized()) {
return getGameByGameId(UncivGame.Current.gameInfo.gameId)
} else {
return null
}
}
/**
* Requests an update of all multiplayer game state. Does automatic throttling to try to prevent hitting rate limits.
*
@ -63,26 +78,24 @@ class OnlineMultiplayer() {
*
* 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
}
fun requestUpdate(forceUpdate: Boolean = false, doNotUpdate: List<OnlineMultiplayerGame> = listOf()) {
launchCrashHandling("Update all multiplayer games") {
val fileThrottleInterval = if (forceUpdate) Duration.ZERO else FILE_UPDATE_THROTTLE_PERIOD
// An exception only happens here if the files can't be listed, should basically never happen
throttle(lastFileUpdate, fileThrottleInterval, {}, action = ::updateSavesFromFiles)
for (game in savedGames.values) {
if (game in doNotUpdate) continue
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.getMultiplayerSaves()
val removedSaves = savedGames.keys - saves
val removedSaves = savedGames.keys - saves.toSet()
removedSaves.forEach(savedGames::remove)
val newSaves = saves - savedGames.keys
for (saveFile in newSaves) {
@ -98,12 +111,8 @@ class OnlineMultiplayer() {
* @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)) }
onlineGameSaver.tryUploadGame(newGame, withPreview = true)
addGame(newGame)
}
/**
@ -117,16 +126,23 @@ class OnlineMultiplayer() {
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)
gamePreview = onlineGameSaver.tryDownloadGamePreview(gameId)
} 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)
gamePreview = onlineGameSaver.tryDownloadGame(gameId).asPreview()
}
val game = OnlineMultiplayerGame(fileHandle, gamePreview, Instant.now())
return addGame(gamePreview, saveFileName)
}
private fun addGame(newGame: GameInfo) {
val newGamePreview = newGame.asPreview()
addGame(newGamePreview, newGamePreview.gameId)
}
private fun addGame(preview: GameInfoPreview, saveFileName: String): String {
val fileHandle = gameSaver.saveGame(preview, saveFileName)
val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now())
savedGames[fileHandle] = game
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) }
return saveFileName
@ -136,8 +152,12 @@ class OnlineMultiplayer() {
return savedGames.values.firstOrNull { it.name == name }
}
fun getGameByGameId(gameId: String): OnlineMultiplayerGame? {
return savedGames.values.firstOrNull { it.preview?.gameId == gameId }
}
/**
* Resigns from the given multiplayer [gameId]. Can only resign if it's currently the user's turn,
* Resigns from the given multiplayer [game]. 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]
@ -147,12 +167,9 @@ class OnlineMultiplayer() {
* @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!!
}
val preview = game.preview ?: throw game.error!!
// download to work with the latest game state
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(preview.gameId)
val gameInfo = onlineGameSaver.tryDownloadGame(preview.gameId)
val playerCiv = gameInfo.currentPlayerCiv
if (!gameInfo.isUsersTurn()) {
@ -174,9 +191,8 @@ class OnlineMultiplayer() {
val newPreview = gameInfo.asPreview()
gameSaver.saveGame(newPreview, game.fileHandle)
OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true)
onlineGameSaver.tryUploadGame(gameInfo, withPreview = true)
game.doManualUpdate(newPreview)
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(game.name, newPreview)) }
return true
}
@ -185,10 +201,7 @@ class OnlineMultiplayer() {
* @throws FileNotFoundException if the file can't be found
*/
suspend fun loadGame(game: OnlineMultiplayerGame) {
val preview = game.preview
if (preview == null) {
throw game.error!!
}
val preview = game.preview ?: throw game.error!!
loadGame(preview.gameId)
}
@ -197,9 +210,40 @@ class OnlineMultiplayer() {
* @throws FileNotFoundException if the file can't be found
*/
suspend fun loadGame(gameId: String) {
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId)
val gameInfo = downloadGame(gameId)
val preview = gameInfo.asPreview()
val onlineGame = getGameByGameId(gameId)
val onlinePreview = onlineGame?.preview
if (onlineGame == null) {
createGame(gameInfo)
} else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){
onlineGame.doManualUpdate(preview)
}
postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) }
}
/**
* Checks if the given game is current and loads it, otherwise loads the game from the server
*/
suspend fun loadGame(gameInfo: GameInfo) {
val gameId = gameInfo.gameId
val preview = onlineGameSaver.tryDownloadGamePreview(gameId)
if (hasLatestGameState(gameInfo, preview)) {
gameInfo.isUpToDate = true
postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) }
} else {
loadGame(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 downloadGame(gameId: String): GameInfo {
val latestGame = onlineGameSaver.tryDownloadGame(gameId)
latestGame.isUpToDate = true
return latestGame
}
/**
@ -217,10 +261,7 @@ class OnlineMultiplayer() {
* Fires [MultiplayerGameNameChanged]
*/
fun changeGameName(game: OnlineMultiplayerGame, newName: String) {
val oldPreview = game.preview
if (oldPreview == null) {
throw game.error!!
}
val oldPreview = game.preview ?: throw game.error!!
val oldLastUpdate = game.lastUpdate
val oldName = game.name
@ -230,53 +271,92 @@ class OnlineMultiplayer() {
val newGame = OnlineMultiplayerGame(newFileHandle, oldPreview, oldLastUpdate)
savedGames[newFileHandle] = newGame
EventBus.send(MultiplayerGameNameChanged(newName, oldName))
EventBus.send(MultiplayerGameNameChanged(oldName, newName))
}
/**
* @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 updateGame(gameInfo: GameInfo) {
onlineGameSaver.tryUploadGame(gameInfo, withPreview = true)
val game = getGameByGameId(gameInfo.gameId)
if (game == null) {
addGame(gameInfo)
} else {
game.doManualUpdate(gameInfo.asPreview())
}
}
/**
* Checks if [gameInfo] and [preview] are up-to-date with each other.
*/
fun hasLatestGameState(gameInfo: GameInfo, preview: GameInfoPreview): Boolean {
// TODO look into how to maybe extract interfaces to not make this take two different methods
return gameInfo.currentPlayer == preview.currentPlayer
&& gameInfo.turns == preview.turns
}
/**
* Checks if [preview1] has a more recent game state than [preview2]
*/
private fun hasNewerGameState(preview1: GameInfoPreview, preview2: GameInfoPreview): Boolean {
return preview1.turns > preview2.turns
}
}
/**
* Calls the given [updateFun] only when [shouldUpdate] called with the current value of [lastUpdate] returns true.
* Calls the given [action] when [lastSuccessfulExecution] lies further in the past than [throttleInterval].
*
* Also updates [lastUpdate] to [Instant.now], but only when [updateFun] did not result in an exception.
* Also updates [lastSuccessfulExecution] to [Instant.now], but only when [action] did not result in an exception.
*
* Any exception thrown by [updateFun] is propagated.
* Any exception thrown by [action] 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
suspend fun <T> throttle(
lastSuccessfulExecution: AtomicReference<Instant?>,
throttleInterval: Duration,
onNoExecution: () -> T,
onFailed: (Exception) -> T = { throw it },
action: suspend () -> T
): T {
val lastUpdateTime = lastUpdate.get()
val lastExecution = lastSuccessfulExecution.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)
}
val shouldRunAction = lastExecution == null || Duration.between(lastExecution, now).isLargerThan(throttleInterval)
return if (shouldRunAction) {
attemptAction(lastSuccessfulExecution, onNoExecution, onFailed, action)
} else {
return onUnchanged()
onNoExecution()
}
}
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
* Attempts to run the [action], changing [lastSuccessfulExecution], but only if no other thread changed [lastSuccessfulExecution] in the meantime
* and [action] did not throw an exception.
*/
private fun getRefreshInterval(): Duration {
val settings = UncivGame.Current.settings
val isDropbox = settings.multiplayerServer == Constants.dropboxMultiplayerServer
return if (isDropbox) {
Duration.ofMinutes(settings.multiplayerTurnCheckerDelayInMinutes.toLong())
suspend fun <T> attemptAction(
lastSuccessfulExecution: AtomicReference<Instant?>,
onNoExecution: () -> T,
onFailed: (Exception) -> T = { throw it },
action: suspend () -> T
): T {
val lastExecution = lastSuccessfulExecution.get()
val now = Instant.now()
return if (lastSuccessfulExecution.compareAndSet(lastExecution, now)) {
try {
action()
} catch (e: Exception) {
lastSuccessfulExecution.compareAndSet(now, lastExecution)
onFailed(e)
}
} else {
Duration.ofSeconds(CUSTOM_SERVER_REFRESH_INTERVAL)
onNoExecution()
}
}
fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId
fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId

View File

@ -3,52 +3,62 @@ package com.unciv.logic.multiplayer
import com.unciv.logic.GameInfoPreview
import com.unciv.logic.event.Event
interface HasMultiplayerGameName {
val name: String
}
interface MultiplayerGameUpdateEnded : Event, HasMultiplayerGameName
interface MultiplayerGameUpdateSucceeded : Event, HasMultiplayerGameName {
val preview: GameInfoPreview
}
/**
* Gets sent when a game was added.
*/
class MultiplayerGameAdded(
val name: String
) : Event
override val name: String
) : Event, HasMultiplayerGameName
/**
* Gets sent when a game successfully updated
*/
class MultiplayerGameUpdated(
val name: String,
val preview: GameInfoPreview,
) : Event
override val name: String,
override val preview: GameInfoPreview,
) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded
/**
* Gets sent when a game errored while updating
*/
class MultiplayerGameUpdateFailed(
val name: String,
override val name: String,
val error: Exception
) : Event
) : MultiplayerGameUpdateEnded
/**
* Gets sent when a game updated successfully, but nothing changed
*/
class MultiplayerGameUpdateUnchanged(
val name: String
) : Event
override val name: String,
override val preview: GameInfoPreview
) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded
/**
* Gets sent when a game starts updating
*/
class MultiplayerGameUpdateStarted(
val name: String
) : Event
override val name: String
) : Event, HasMultiplayerGameName
/**
* Gets sent when a game's name got changed
*/
class MultiplayerGameNameChanged(
val name: String,
val oldName: String
) : Event
override val name: String,
val newName: String
) : Event, HasMultiplayerGameName
/**
* Gets sent when a game is deleted
*/
class MultiplayerGameDeleted(
val name: String
) : Event
override val name: String
) : Event, HasMultiplayerGameName

View File

@ -16,9 +16,9 @@ import java.util.concurrent.atomic.AtomicReference
/** @see getUpdateThrottleInterval */
private const val DROPBOX_THROTTLE_INTERVAL = 8L
private const val DROPBOX_THROTTLE_PERIOD = 8L
/** @see getUpdateThrottleInterval */
private const val CUSTOM_SERVER_THROTTLE_INTERVAL = 1L
private const val CUSTOM_SERVER_THROTTLE_PERIOD = 1L
class OnlineMultiplayerGame(
val fileHandle: FileHandle,
@ -55,8 +55,7 @@ class OnlineMultiplayerGame(
return previewFromFile
}
private fun shouldUpdate(lastUpdateTime: Instant?): Boolean =
preview == null || error != null || lastUpdateTime == null || Duration.between(lastUpdateTime, Instant.now()).isLargerThan(getUpdateThrottleInterval())
private fun needsUpdate(): Boolean = preview == null || error != null
/**
* Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed]
@ -65,15 +64,18 @@ class OnlineMultiplayerGame(
* @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)
val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval()
val updateResult = if (forceUpdate || needsUpdate()) {
attemptAction(lastOnlineUpdate, onUnchanged, onError, ::update)
} else {
throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update)
}
when (updateResult) {
GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null
else -> {}
@ -81,7 +83,7 @@ class OnlineMultiplayerGame(
val updateEvent = when (updateResult) {
GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!)
GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!)
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name)
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!)
}
postCrashHandlingRunnable { EventBus.send(updateEvent) }
}
@ -99,6 +101,7 @@ class OnlineMultiplayerGame(
lastOnlineUpdate.set(Instant.now())
error = null
preview = gameInfo
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) }
}
override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle
@ -113,6 +116,6 @@ private enum class GameUpdateResult {
* 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)
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_PERIOD else CUSTOM_SERVER_THROTTLE_PERIOD)
}

View File

@ -21,21 +21,25 @@ class OnlineMultiplayerGameSaver(
private var fileStorageIdentifier: String? = null
) {
fun fileStorage(): FileStorage {
val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayerServer else fileStorageIdentifier
val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayer.server else fileStorageIdentifier
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
val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true)
fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true)
// We upload the preview after the game because otherwise the following race condition will happen:
// Current player ends turn -> Uploads Game Preview
// Other player checks for updates -> Downloads Game Preview
// Current player starts game upload
// Other player sees update in preview -> Downloads game, gets old state
// Current player finishes uploading game
if (withPreview) {
tryUploadGamePreview(gameInfo.asPreview())
}
val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true)
fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true)
}
@Suppress("MemberVisibilityCanBePrivate")

View File

@ -6,6 +6,7 @@ import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.ui.utils.Fonts
import java.text.Collator
import java.time.Duration
import java.util.*
import kotlin.collections.HashSet
@ -43,10 +44,6 @@ class GameSettings {
var showPixelUnits: Boolean = true
var showPixelImprovements: Boolean = true
var continuousRendering = false
var userId = ""
var multiplayerTurnCheckerEnabled = true
var multiplayerTurnCheckerPersistentNotificationEnabled = true
var multiplayerTurnCheckerDelayInMinutes = 5
var orderTradeOffersByAmount = true
var confirmNextTurn = false
var windowState = WindowState()
@ -54,9 +51,7 @@ class GameSettings {
var visualMods = HashSet<String>()
var useDemographics: Boolean = false
var multiplayerServer = Constants.dropboxMultiplayerServer
var multiplayer = GameSettingsMultiplayer()
var showExperimentalWorldWrap = false // We're keeping this as a config due to ANR problems on Android phones for people who don't know what they're doing :/
@ -72,10 +67,13 @@ class GameSettings {
/** Maximum zoom-out of the map - performance heavy */
var maxWorldZoomOut = 2f
/** used to migrate from older versions of the settings */
var version: Int? = null
init {
// 26 = Android Oreo. Versions below may display permanent icon in notification bar.
if (Gdx.app?.type == Application.ApplicationType.Android && Gdx.app.version < 26) {
multiplayerTurnCheckerPersistentNotificationEnabled = false
multiplayer.turnCheckerPersistentNotificationEnabled = false
}
}
@ -156,3 +154,14 @@ enum class LocaleCode(var language: String, var country: String) {
Ukrainian("uk", "UA"),
Vietnamese("vi", "VN"),
}
class GameSettingsMultiplayer {
var userId = ""
var server = Constants.dropboxMultiplayerServer
var turnCheckerEnabled = true
var turnCheckerPersistentNotificationEnabled = true
var turnCheckerDelay = Duration.ofMinutes(5)
var statusButtonInSinglePlayer = false
var currentGameRefreshDelay = Duration.ofSeconds(10)
var allGameRefreshDelay = Duration.ofMinutes(5)
}

View File

@ -0,0 +1,40 @@
package com.unciv.models.metadata
import com.badlogic.gdx.utils.JsonValue
import java.time.Duration
private const val CURRENT_VERSION = 1
fun GameSettings.doMigrations(json: JsonValue) {
if (version == null) {
migrateMultiplayerSettings(json)
version = 1
}
}
fun GameSettings.isMigrationNecessary(): Boolean {
return version != CURRENT_VERSION
}
private fun GameSettings.migrateMultiplayerSettings(json: JsonValue) {
val userId = json.get("userId")
if (userId != null && userId.isString) {
multiplayer.userId = userId.asString()
}
val server = json.get("multiplayerServer")
if (server != null && server.isString) {
multiplayer.server = server.asString()
}
val enabled = json.get("multiplayerTurnCheckerEnabled")
if (enabled != null && enabled.isBoolean) {
multiplayer.turnCheckerEnabled = enabled.asBoolean()
}
val notification = json.get("multiplayerTurnCheckerPersistentNotificationEnabled")
if (notification != null && notification.isBoolean) {
multiplayer.turnCheckerPersistentNotificationEnabled = notification.asBoolean()
}
val delayInMinutes = json.get("multiplayerTurnCheckerDelayInMinutes")
if (delayInMinutes != null && delayInMinutes.isNumber) {
multiplayer.turnCheckerDelay = Duration.ofMinutes(delayInMinutes.asLong())
}
}

View File

@ -7,6 +7,7 @@ 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.multiplayer.MultiplayerHelpers
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
@ -60,7 +61,7 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() {
game.setScreen(backScreen)
}
} catch (ex: Exception) {
val message = backScreen.getLoadExceptionMessage(ex)
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
popup.reuseWith(message, true)
}

View File

@ -8,6 +8,7 @@ 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.multiplayer.MultiplayerHelpers
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.YesNoPopup
@ -96,7 +97,7 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame,
}
}
} catch (ex: Exception) {
val message = backScreen.getLoadExceptionMessage(ex)
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
popup.reuseWith(message, true)
}

View File

@ -0,0 +1,146 @@
package com.unciv.ui.multiplayer
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.ui.VerticalGroup
import com.unciv.UncivGame
import com.unciv.logic.GameInfoPreview
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.HasMultiplayerGameName
import com.unciv.logic.multiplayer.MultiplayerGameAdded
import com.unciv.logic.multiplayer.MultiplayerGameDeleted
import com.unciv.logic.multiplayer.MultiplayerGameNameChanged
import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded
import com.unciv.logic.multiplayer.MultiplayerGameUpdateFailed
import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted
import com.unciv.logic.multiplayer.MultiplayerGameUpdateSucceeded
import com.unciv.logic.multiplayer.MultiplayerGameUpdateUnchanged
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.isUsersTurn
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.setSize
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.name)
if (gameDisplay == null) return@receive
gameDisplay.changeName(it.newName)
gameDisplays[it.newName] = 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) }
val isOurGame: (HasMultiplayerGameName) -> Boolean = { it.name == gameName }
events.receive(MultiplayerGameUpdateStarted::class, isOurGame, {
statusIndicators.addActor(refreshIndicator)
})
events.receive(MultiplayerGameUpdateEnded::class, isOurGame) {
refreshIndicator.remove()
}
events.receive(MultiplayerGameUpdated::class, isOurGame) {
updateTurnIndicator(it.preview)
}
events.receive(MultiplayerGameUpdateSucceeded::class, isOurGame) {
updateErrorIndicator(false)
}
events.receive(MultiplayerGameUpdateFailed::class, isOurGame) {
updateErrorIndicator(true)
}
}
fun changeName(newName: String) {
gameName = newName
gameButton.setText(newName)
}
private fun updateTurnIndicator(preview: GameInfoPreview?) {
if (preview?.isUsersTurn() == true) {
statusIndicators.addActor(turnIndicator)
} else {
turnIndicator.remove()
}
}
private fun updateErrorIndicator(hasError: Boolean) {
if (hasError) {
statusIndicators.addActor(errorIndicator)
} else {
errorIndicator.remove()
}
}
private fun createIndicator(imagePath: String): Actor {
val image = ImageGetter.getImage(imagePath)
image.setSize(50f)
val container = Container(image)
container.padRight(5f)
return container
}
override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName)
override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName)
override fun hashCode(): Int = gameName.hashCode()
}

View File

@ -0,0 +1,70 @@
package com.unciv.ui.multiplayer
import com.unciv.UncivGame
import com.unciv.logic.UncivShowableException
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.BaseScreen
import java.io.FileNotFoundException
import java.time.Duration
import java.time.Instant
object MultiplayerHelpers {
fun getLoadExceptionMessage(ex: Throwable) = 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}"
}
fun loadMultiplayerGame(screen: BaseScreen, selectedGame: OnlineMultiplayerGame) {
val loadingGamePopup = Popup(screen)
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
loadingGamePopup.open()
launchCrashHandling("JoinMultiplayerGame") {
try {
UncivGame.Current.onlineMultiplayer.loadGame(selectedGame)
} catch (ex: Exception) {
val message = getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
loadingGamePopup.reuseWith(message, true)
}
}
}
}
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]"
}
}
}

View File

@ -1,24 +1,22 @@
package com.unciv.ui.multiplayer
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.*
import com.unciv.UncivGame
import com.unciv.logic.*
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.*
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.MultiplayerGameDeleted
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.models.translations.tr
import com.unciv.ui.multiplayer.GameList
import com.unciv.ui.multiplayer.MultiplayerHelpers
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.images.ImageGetter
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import java.io.FileNotFoundException
import java.time.Duration
import java.time.Instant
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.disable
import com.unciv.ui.utils.enable
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.toTextButton
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
@ -55,7 +53,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
setupRightSideButton()
events.receive(MultiplayerGameDeleted::class, {it.name == selectedGame?.name}) {
events.receive(MultiplayerGameDeleted::class, { it.name == selectedGame?.name }) {
unselectGame()
}
@ -64,7 +62,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
private fun setupRightSideButton() {
rightSideButton.setText("Join game".tr())
rightSideButton.onClick { joinMultiplayerGame(selectedGame!!) }
rightSideButton.onClick { MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!) }
}
private fun createRightSideTable(): Table {
@ -117,7 +115,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
private fun createCopyUserIdButton(): TextButton {
val btn = copyUserIdText.toTextButton()
btn.onClick {
Gdx.app.clipboard.contents = game.settings.userId
Gdx.app.clipboard.contents = game.settings.multiplayer.userId
ToastPopup("UserID copied to clipboard", this)
}
return btn
@ -159,23 +157,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
stage.addActor(tab)
}
fun joinMultiplayerGame(selectedGame: OnlineMultiplayerGame) {
val loadingGamePopup = Popup(this)
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
loadingGamePopup.open()
launchCrashHandling("JoinMultiplayerGame") {
try {
game.onlineMultiplayer.loadGame(selectedGame)
} catch (ex: Exception) {
val message = getLoadExceptionMessage(ex)
postCrashHandlingRunnable {
loadingGamePopup.reuseWith(message, true)
}
}
}
}
private fun unselectGame() {
selectedGame = null
@ -203,161 +184,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
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}"
descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame))
}
}
private class GameList(
onSelected: (String) -> Unit
) : VerticalGroup() {
private val gameDisplays = mutableMapOf<String, GameDisplay>()
private val events = EventBus.EventReceiver()
init {
padTop(10f)
padBottom(10f)
events.receive(MultiplayerGameAdded::class) {
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name)
if (multiplayerGame == null) return@receive
addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected)
}
events.receive(MultiplayerGameNameChanged::class) {
val gameDisplay = gameDisplays.remove(it.oldName)
if (gameDisplay == null) return@receive
gameDisplay.changeName(it.name)
gameDisplays[it.name] = gameDisplay
children.sort()
}
events.receive(MultiplayerGameDeleted::class) {
val gameDisplay = gameDisplays.remove(it.name)
if (gameDisplay == null) return@receive
gameDisplay.remove()
}
for (game in UncivGame.Current.onlineMultiplayer.games) {
addGame(game.name, game.preview, game.error, onSelected)
}
}
private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) {
val gameDisplay = GameDisplay(name, preview, error, onSelected)
gameDisplays[name] = gameDisplay
addActor(gameDisplay)
children.sort()
}
}
private class GameDisplay(
multiplayerGameName: String,
preview: GameInfoPreview?,
error: Exception?,
private val onSelected: (String) -> Unit
) : Table(), Comparable<GameDisplay> {
var gameName: String = multiplayerGameName
private set
val gameButton = TextButton(gameName, BaseScreen.skin)
val turnIndicator = createIndicator("OtherIcons/ExclamationMark")
val errorIndicator = createIndicator("StatIcons/Malcontent")
val refreshIndicator = createIndicator("EmojiIcons/Turn")
val statusIndicators = HorizontalGroup()
val events = EventBus.EventReceiver()
init {
padBottom(5f)
updateTurnIndicator(preview)
updateErrorIndicator(error != null)
add(statusIndicators)
add(gameButton)
onClick { onSelected(gameName) }
events.receive(MultiplayerGameUpdateStarted::class, { it.name == gameName }, {
statusIndicators.addActor(refreshIndicator)
})
events.receive(MultiplayerGameUpdateUnchanged::class, { it.name == gameName }, {
refreshIndicator.remove()
})
events.receive(MultiplayerGameUpdated::class, { it.name == gameName }) {
updateTurnIndicator(it.preview)
updateErrorIndicator(false)
refreshIndicator.remove()
}
events.receive(MultiplayerGameUpdateFailed::class, { it.name == gameName }) {
updateErrorIndicator(true)
refreshIndicator.remove()
}
}
fun changeName(newName: String) {
gameName = newName
gameButton.setText(newName)
}
private fun updateTurnIndicator(preview: GameInfoPreview?) {
if (preview?.isUsersTurn() == true) {
statusIndicators.addActor(turnIndicator)
} else {
turnIndicator.remove()
}
}
private fun updateErrorIndicator(hasError: Boolean) {
if (hasError) {
statusIndicators.addActor(errorIndicator)
} else {
errorIndicator.remove()
}
}
private fun createIndicator(imagePath: String): Actor {
val image = ImageGetter.getImage(imagePath)
image.setSize(50f)
val container = Container(image)
container.padRight(5f)
return container
}
override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName)
override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName)
override fun hashCode(): Int = gameName.hashCode()
}

View File

@ -73,7 +73,7 @@ class NewGameScreen(
rightSideButton.setText("Start game!".tr())
rightSideButton.onClick {
if (gameSetupInfo.gameParameters.isOnlineMultiplayer) {
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
if (!checkConnectionToMultiplayerServer()) {
val noInternetConnectionPopup = Popup(this)
val label = if (isDropbox) "Couldn't connect to Dropbox!" else "Couldn't connect to Multiplayer Server!"
@ -100,7 +100,7 @@ class NewGameScreen(
it.playerType == PlayerType.Human &&
// do not allow multiplayer with only remote spectator(s) and AI(s) - non-MP that works
!(it.chosenCiv == Constants.spectator && gameSetupInfo.gameParameters.isOnlineMultiplayer &&
it.playerId != UncivGame.Current.settings.userId)
it.playerId != UncivGame.Current.settings.multiplayer.userId)
}) {
val noHumanPlayersPopup = Popup(this)
noHumanPlayersPopup.addGoodSizedLabel("No human players selected!".tr()).row()
@ -212,9 +212,9 @@ class NewGameScreen(
}
private fun checkConnectionToMultiplayerServer(): Boolean {
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
return try {
val multiplayerServer = UncivGame.Current.settings.multiplayerServer
val multiplayerServer = UncivGame.Current.settings.multiplayer.server
val u = URL(if (isDropbox) "https://content.dropboxapi.com" else multiplayerServer)
val con = u.openConnection()
con.connectTimeout = 3000

View File

@ -176,7 +176,7 @@ class PlayerPickerTable(
}
playerIdTextField.addListener { onPlayerIdTextUpdated(); true }
val currentUserId = UncivGame.Current.settings.userId
val currentUserId = UncivGame.Current.settings.multiplayer.userId
val setCurrentUserButton = "Set current user".toTextButton()
setCurrentUserButton.onClick {
playerIdTextField.text = currentUserId

View File

@ -162,7 +162,7 @@ private fun addSetUserId(table: Table, settings: GameSettings, screen: BaseScree
YesNoPopup(
"Doing this will reset your current user ID to the clipboard contents - are you sure?",
{
settings.userId = clipboardContents
settings.multiplayer.userId = clipboardContents
settings.save()
idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr())
},

View File

@ -2,6 +2,7 @@ package com.unciv.ui.options
import com.badlogic.gdx.Application
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
@ -9,10 +10,13 @@ import com.badlogic.gdx.utils.Array
import com.unciv.Constants
import com.unciv.logic.multiplayer.storage.SimpleHttp
import com.unciv.models.metadata.GameSettings
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.*
import java.time.Duration
import kotlin.reflect.KMutableProperty0
fun multiplayerTab(
optionsPopup: OptionsPopup
@ -22,31 +26,45 @@ fun multiplayerTab(
val settings = optionsPopup.settings
optionsPopup.addCheckbox(this, "Enable multiplayer status button in singleplayer games",
settings.multiplayer.statusButtonInSinglePlayer, updateWorld = true
) {
settings.multiplayer.statusButtonInSinglePlayer = it
settings.save()
}
val curRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::currentGameRefreshDelay,
"Update status of currently played game every:".toLabel(), curRefreshDropboxOptions, curRefreshCustomServerOptions)
val allRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::allGameRefreshDelay,
"In-game, update status of all games every:".toLabel(), allRefreshDropboxOptions, allRefreshCustomServerOptions)
var turnCheckerSelect: SelectBox<RefreshOptions>? = null
// at the moment the notification service only exists on Android
if (Gdx.app.type == Application.ApplicationType.Android) {
optionsPopup.addCheckbox(
this, "Enable out-of-game turn notifications",
settings.multiplayerTurnCheckerEnabled
settings.multiplayer.turnCheckerEnabled
) {
settings.multiplayerTurnCheckerEnabled = it
settings.multiplayer.turnCheckerEnabled = it
settings.save()
}
if (settings.multiplayerTurnCheckerEnabled) {
addMultiplayerTurnCheckerDelayBox(this, settings)
if (settings.multiplayer.turnCheckerEnabled) {
turnCheckerSelect = addRefreshSelect(this, settings, settings.multiplayer::turnCheckerDelay,
"Out-of-game, update status of all games every:".toLabel(), turnCheckerDropboxOptions, turnCheckerCustomServerOptions)
optionsPopup.addCheckbox(
this, "Show persistent notification for turn notifier service",
settings.multiplayerTurnCheckerPersistentNotificationEnabled
settings.multiplayer.turnCheckerPersistentNotificationEnabled
)
{ settings.multiplayerTurnCheckerPersistentNotificationEnabled = it }
{ settings.multiplayer.turnCheckerPersistentNotificationEnabled = it }
}
}
val connectionToServerButton = "Check connection to server".toTextButton()
val textToShowForMultiplayerAddress =
if (settings.multiplayerServer != Constants.dropboxMultiplayerServer) settings.multiplayerServer
if (!usesDropbox(settings)) settings.multiplayer.server
else "https://..."
val multiplayerServerTextField = TextField(textToShowForMultiplayerAddress, BaseScreen.skin)
multiplayerServerTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" }
@ -57,13 +75,21 @@ fun multiplayerTab(
multiplayerServerTextField.text = Gdx.app.clipboard.contents
}).row()
multiplayerServerTextField.onChange {
connectionToServerButton.isEnabled = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer
if (connectionToServerButton.isEnabled) {
val isCustomServer = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer
connectionToServerButton.isEnabled = isCustomServer
updateRefreshSelectOptions(curRefreshSelect, isCustomServer, curRefreshDropboxOptions, curRefreshCustomServerOptions)
updateRefreshSelectOptions(allRefreshSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions)
if (turnCheckerSelect != null) {
updateRefreshSelectOptions(turnCheckerSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions)
}
if (isCustomServer) {
fixTextFieldUrlOnType(multiplayerServerTextField)
// we can't trim on 'fixTextFieldUrlOnType' for reasons
settings.multiplayerServer = multiplayerServerTextField.text.trimEnd('/')
settings.multiplayer.server = multiplayerServerTextField.text.trimEnd('/')
} else {
settings.multiplayerServer = multiplayerServerTextField.text
settings.multiplayer.server = multiplayerServerTextField.text
}
settings.save()
}
@ -74,6 +100,16 @@ fun multiplayerTab(
add("Reset to Dropbox".toTextButton().onClick {
multiplayerServerTextField.text = Constants.dropboxMultiplayerServer
if (allRefreshDropboxOptions.size != allRefreshSelect.items.size) {
allRefreshSelect.items = allRefreshDropboxOptions
}
if (curRefreshDropboxOptions.size != curRefreshSelect.items.size) {
curRefreshSelect.items = curRefreshDropboxOptions
}
if (turnCheckerSelect != null && turnCheckerDropboxOptions.size != turnCheckerSelect.items.size) {
turnCheckerSelect.items = turnCheckerDropboxOptions
}
settings.save()
}).row()
add(connectionToServerButton.onClick {
@ -91,7 +127,7 @@ fun multiplayerTab(
private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) {
launchCrashHandling("TestIsAlive") {
SimpleHttp.sendGetRequest("${settings.multiplayerServer}/isalive") {
SimpleHttp.sendGetRequest("${settings.multiplayer.server}/isalive") {
success, result, code ->
postCrashHandlingRunnable {
action(success, result, code)
@ -134,19 +170,86 @@ private fun fixTextFieldUrlOnType(TextField: TextField) {
}
}
private fun addMultiplayerTurnCheckerDelayBox(table: Table, settings: GameSettings) {
table.add("Time between turn checks out-of-game (in minutes)".toLabel()).left().fillX()
private class RefreshOptions(val delay: Duration, val label: String) {
override fun toString(): String = label
override fun equals(other: Any?): Boolean = other is RefreshOptions && delay == other.delay
override fun hashCode(): Int = delay.hashCode()
}
val checkDelaySelectBox = SelectBox<Int>(table.skin)
val possibleDelaysArray = Array<Int>()
possibleDelaysArray.addAll(1, 2, 5, 15)
checkDelaySelectBox.items = possibleDelaysArray
checkDelaySelectBox.selected = settings.multiplayerTurnCheckerDelayInMinutes
table.add(checkDelaySelectBox).pad(10f).row()
private val curRefreshDropboxOptions =
(listOf<Long>(10, 20, 30, 60).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) }).toGdxArray()
checkDelaySelectBox.onChange {
settings.multiplayerTurnCheckerDelayInMinutes = checkDelaySelectBox.selected
private val curRefreshCustomServerOptions =
(listOf<Long>(3, 5).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + curRefreshDropboxOptions).toGdxArray()
private val allRefreshDropboxOptions =
(listOf<Long>(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray()
private val allRefreshCustomServerOptions =
(listOf<Long>(15, 30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray()
private val turnCheckerDropboxOptions =
(listOf<Long>(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray()
private val turnCheckerCustomServerOptions =
(listOf<Long>(30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray()
private fun <T> List<T>.toGdxArray(): Array<T> {
val arr = Array<T>(size)
for (it in this) {
arr.add(it)
}
return arr
}
private fun usesDropbox(settings: GameSettings) = settings.multiplayer.server == Constants.dropboxMultiplayerServer
private fun addRefreshSelect(
table: Table,
settings: GameSettings,
settingsProperty: KMutableProperty0<Duration>,
label: Label,
dropboxOptions: Array<RefreshOptions>,
customServerOptions: Array<RefreshOptions>
): SelectBox<RefreshOptions> {
table.add(label).left()
val refreshSelectBox = SelectBox<RefreshOptions>(table.skin)
val options = if (usesDropbox(settings)) {
dropboxOptions
} else {
customServerOptions
}
refreshSelectBox.items = options
refreshSelectBox.selected = options.firstOrNull() { it.delay == settingsProperty.get() } ?: options.first()
table.add(refreshSelectBox).pad(10f).row()
refreshSelectBox.onChange {
settingsProperty.set(refreshSelectBox.selected.delay)
settings.save()
}
return refreshSelectBox
}
private fun updateRefreshSelectOptions(
selectBox: SelectBox<RefreshOptions>,
isCustomServer: Boolean,
dropboxOptions: Array<RefreshOptions>,
customServerOptions: Array<RefreshOptions>
) {
fun replaceItems(selectBox: SelectBox<RefreshOptions>, options: Array<RefreshOptions>) {
val prev = selectBox.selected
selectBox.items = options
selectBox.selected = prev
}
if (isCustomServer && selectBox.items.size != customServerOptions.size) {
replaceItems(selectBox, customServerOptions)
} else if (!isCustomServer && selectBox.items.size != dropboxOptions.size) {
replaceItems(selectBox, dropboxOptions)
}
}

View File

@ -14,14 +14,16 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.MainMenuScreen
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.civilization.ReligionState
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
import com.unciv.logic.event.EventBus
import com.unciv.logic.map.MapVisualization
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.logic.trade.TradeEvaluation
import com.unciv.models.Tutorial
import com.unciv.models.UncivSound
@ -30,12 +32,19 @@ import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.tr
import com.unciv.ui.cityscreen.CityScreen
import com.unciv.ui.civilopedia.CivilopediaScreen
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.images.ImageGetter
import com.unciv.ui.multiplayer.MultiplayerHelpers
import com.unciv.ui.overviewscreen.EmpireOverviewScreen
import com.unciv.ui.pickerscreens.*
import com.unciv.ui.pickerscreens.DiplomaticVotePickerScreen
import com.unciv.ui.pickerscreens.DiplomaticVoteResultScreen
import com.unciv.ui.pickerscreens.GreatPersonPickerScreen
import com.unciv.ui.pickerscreens.PantheonPickerScreen
import com.unciv.ui.pickerscreens.PolicyPickerScreen
import com.unciv.ui.pickerscreens.ReligiousBeliefsPickerScreen
import com.unciv.ui.pickerscreens.TechButton
import com.unciv.ui.pickerscreens.TechPickerScreen
import com.unciv.ui.popup.ExitGamePopup
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
@ -44,23 +53,32 @@ import com.unciv.ui.popup.hasOpenPopups
import com.unciv.ui.saves.LoadGameScreen
import com.unciv.ui.saves.SaveGameScreen
import com.unciv.ui.trade.DiplomacyScreen
import com.unciv.ui.utils.*
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.Fonts
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.UncivDateFormat.formatDate
import com.unciv.ui.utils.centerX
import com.unciv.ui.utils.colorFromRGB
import com.unciv.ui.utils.darken
import com.unciv.ui.utils.disable
import com.unciv.ui.utils.enable
import com.unciv.ui.utils.isEnabled
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.setFontSize
import com.unciv.ui.utils.toLabel
import com.unciv.ui.utils.toTextButton
import com.unciv.ui.victoryscreen.VictoryScreen
import com.unciv.ui.worldscreen.bottombar.BattleTable
import com.unciv.ui.worldscreen.bottombar.TileInfoTable
import com.unciv.ui.worldscreen.minimap.MinimapHolder
import com.unciv.ui.worldscreen.status.MultiplayerStatusButton
import com.unciv.ui.worldscreen.status.NextTurnAction
import com.unciv.ui.worldscreen.status.NextTurnButton
import com.unciv.ui.worldscreen.status.StatusButtons
import com.unciv.ui.worldscreen.unit.UnitActionsTable
import com.unciv.ui.worldscreen.unit.UnitTable
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import java.util.*
import kotlin.concurrent.timerTask
/**
* Unciv's world screen
@ -99,19 +117,20 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
private val diplomacyButtonHolder = Table()
private val fogOfWarButton = createFogOfWarButton()
private val nextTurnButton = NextTurnButton(keyPressDispatcher)
private val statusButtons = StatusButtons(nextTurnButton)
private val tutorialTaskTable = Table().apply { background = ImageGetter.getBackground(
ImageGetter.getBlue().darken(0.5f)) }
private val notificationsScroll: NotificationsScroll
var shouldUpdate = false
private var nextTurnUpdateJob: Job? = null
private val events = EventBus.EventReceiver()
companion object {
/** Switch for console logging of next turn duration */
private const val consoleLog = false
private lateinit var multiPlayerRefresher: Flow<Unit>
// this object must not be created multiple times
private var multiPlayerRefresherJob: Job? = null
}
init {
@ -158,7 +177,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
stage.addActor(notificationsScroll) // very low in z-order, so we're free to let it extend _below_ tile info and minimap if we want
stage.addActor(minimapWrapper)
stage.addActor(topBar)
stage.addActor(nextTurnButton)
stage.addActor(statusButtons)
stage.addActor(techPolicyAndVictoryHolder)
stage.addActor(tutorialTaskTable)
@ -198,17 +217,16 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
if (gameInfo.gameParameters.isOnlineMultiplayer && !gameInfo.isUpToDate)
isPlayersTurn = false // until we're up to date, don't let the player do anything
if (gameInfo.gameParameters.isOnlineMultiplayer && !isPlayersTurn) {
// restart the timer
stopMultiPlayerRefresher()
multiPlayerRefresher = flow {
while (true) {
if (gameInfo.gameParameters.isOnlineMultiplayer) {
val gameId = gameInfo.gameId
events.receive(MultiplayerGameUpdated::class, { it.preview.gameId == gameId }) {
if (isNextTurnUpdateRunning() || game.onlineMultiplayer.hasLatestGameState(gameInfo, it.preview)) {
return@receive
}
launchCrashHandling("Load latest multiplayer state") {
loadLatestMultiplayerState()
delay(10000)
}
}
multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
}
// don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups
@ -216,10 +234,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
shouldUpdate = true
}
private fun stopMultiPlayerRefresher() {
if (multiPlayerRefresherJob != null) {
multiPlayerRefresherJob?.cancel()
}
override fun dispose() {
super.dispose()
events.stopReceiving()
}
private fun addKeyboardPresses() {
@ -287,7 +304,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { // Game Options
this.openOptionsPopup(onClose = {
mapHolder.reloadMaxZoom()
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave)
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning())
})
}
keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save
@ -353,53 +370,37 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
}
private suspend fun loadLatestMultiplayerState() {
// Since we're on a background thread, all the UI calls in this func need to run from the
// main thread which has a GL context
val loadingGamePopup = Popup(this)
postCrashHandlingRunnable {
loadingGamePopup.add("Loading latest game state...".tr())
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
loadingGamePopup.open()
}
try {
val latestGame = OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId)
// if we find the current player didn't change, don't update
// Additionally, check if we are the current player, and in that case always stop
// This fixes a bug where for some reason players were waiting for themselves.
if (gameInfo.currentPlayer == latestGame.currentPlayer
&& gameInfo.turns == latestGame.turns
&& latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName
) {
postCrashHandlingRunnable { loadingGamePopup.close() }
shouldUpdate = true
return
} else { // if the game updated, even if it's not our turn, reload the world -
// stuff has changed and the "waiting for X" will now show the correct civ
stopMultiPlayerRefresher()
latestGame.isUpToDate = true
val latestGame = game.onlineMultiplayer.downloadGame(gameInfo.gameId)
if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) {
game.platformSpecificHelper?.notifyTurnStarted()
}
postCrashHandlingRunnable { createNewWorldScreen(latestGame) }
}
} catch (ex: FileStorageRateLimitReached) {
postCrashHandlingRunnable {
loadingGamePopup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true)
loadingGamePopup.close()
if (game.gameInfo.gameId == gameInfo.gameId) { // game could've been changed during download
createNewWorldScreen(latestGame)
}
}
// stop refresher to not spam user with "Server limit reached!"
// popups and restart after limit timer is over
stopMultiPlayerRefresher()
val restartAfter : Long = ex.limitRemainingSeconds.toLong() * 1000
Timer("RestartTimerTimer", true).schedule(timerTask {
multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
}, restartAfter)
} catch (ex: Throwable) {
postCrashHandlingRunnable {
loadingGamePopup.reuseWith("Couldn't download the latest game state!", true)
loadingGamePopup.addAction(Actions.delay(5f, Actions.run { loadingGamePopup.close() }))
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
loadingGamePopup.innerTable.clear()
loadingGamePopup.addGoodSizedLabel("Couldn't download the latest game state!").colspan(2).row()
loadingGamePopup.addGoodSizedLabel(message).colspan(2).row()
loadingGamePopup.addButtonInRow("Retry") {
launchCrashHandling("Load latest multiplayer state after error") {
loadLatestMultiplayerState()
}
}.right()
loadingGamePopup.addButtonInRow("Main menu") {
game.setScreen(MainMenuScreen())
}.left()
}
}
}
@ -494,9 +495,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
}
}
}
updateNextTurnButton(hasOpenPopups()) // This must be before the notifications update, since its position is based on it
updateGameplayButtons()
notificationsScroll.update(viewingCiv.notifications, bottomTileInfoTable.height)
notificationsScroll.setTopRight(stage.width - 10f, nextTurnButton.y - 5f)
notificationsScroll.setTopRight(stage.width - 10f, statusButtons.y - 5f)
}
private fun getCurrentTutorialTask(): String {
@ -658,7 +659,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
shouldUpdate = true
// on a separate thread so the user can explore their world while we're passing the turn
launchCrashHandling("NextTurn", runAsDaemon = false) {
nextTurnUpdateJob = launchCrashHandling("NextTurn", runAsDaemon = false) {
if (consoleLog)
println("\nNext turn starting " + Date().formatDate())
val startTime = System.currentTimeMillis()
@ -670,7 +671,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
if (originalGameInfo.gameParameters.isOnlineMultiplayer) {
try {
OnlineMultiplayerGameSaver().tryUploadGame(gameInfoClone, withPreview = true)
game.onlineMultiplayer.updateGame(gameInfoClone)
} catch (ex: Exception) {
val message = when (ex) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
@ -738,14 +739,34 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
}
}
private fun updateNextTurnButton(isSomethingOpen: Boolean) {
nextTurnButton.update(isSomethingOpen, isPlayersTurn, waitingForAutosave, getNextTurnAction())
nextTurnButton.setPosition(stage.width - nextTurnButton.width - 10f, topBar.y - nextTurnButton.height - 10f)
private fun isNextTurnUpdateRunning(): Boolean {
val job = nextTurnUpdateJob
return job != null && job.isActive
}
private fun updateGameplayButtons() {
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning(), getNextTurnAction())
updateMultiplayerStatusButton()
statusButtons.pack()
statusButtons.setPosition(stage.width - statusButtons.width - 10f, topBar.y - statusButtons.height - 10f)
}
private fun updateMultiplayerStatusButton() {
if (gameInfo.gameParameters.isOnlineMultiplayer || game.settings.multiplayer.statusButtonInSinglePlayer) {
if (statusButtons.multiplayerStatusButton != null) return
statusButtons.multiplayerStatusButton = MultiplayerStatusButton(this, game.onlineMultiplayer.getGameByGameId(gameInfo.gameId))
} else {
if (statusButtons.multiplayerStatusButton == null) return
statusButtons.multiplayerStatusButton = null
}
}
private fun getNextTurnAction(): NextTurnAction {
return when {
isNextTurnUpdateRunning() ->
NextTurnAction("Working...", Color.GRAY) {}
!isPlayersTurn && gameInfo.gameParameters.isOnlineMultiplayer ->
NextTurnAction("Waiting for [${gameInfo.currentPlayerCiv}]...", Color.GRAY) {}
!isPlayersTurn && !gameInfo.gameParameters.isOnlineMultiplayer ->

View File

@ -0,0 +1,185 @@
package com.unciv.ui.worldscreen.status
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.actions.RepeatAction
import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Stack
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.HasMultiplayerGameName
import com.unciv.logic.multiplayer.MultiplayerGameNameChanged
import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded
import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.logic.multiplayer.isUsersTurn
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.setSize
import kotlinx.coroutines.delay
import java.time.Duration
import java.time.Instant
class MultiplayerStatusButton(
/*val*/ screen: BaseScreen,
curGame: OnlineMultiplayerGame?
) : Button(BaseScreen.skin) {
private var curGameName = curGame?.name
private val multiplayerImage = createMultiplayerImage()
private val loadingImage = createLoadingImage()
private val turnIndicator = TurnIndicator()
private val turnIndicatorCell: Cell<Actor>
private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn()
private var loadingStarted: Instant? = null
private val events = EventBus.EventReceiver()
init {
turnIndicatorCell = add().padTop(10f).padBottom(10f)
add(Stack(multiplayerImage, loadingImage)).pad(5f)
updateTurnIndicator(flash = false) // no flash since this is just the initial construction
events.receive(MultiplayerGameUpdated::class) {
val shouldUpdate = if (it.preview.isUsersTurn()) {
gameNamesWithCurrentTurn.add(it.name)
} else {
gameNamesWithCurrentTurn.remove(it.name)
}
if (shouldUpdate) postCrashHandlingRunnable {
updateTurnIndicator()
}
}
val curGameFilter: (HasMultiplayerGameName) -> Boolean = { it.name == curGameName }
events.receive(MultiplayerGameNameChanged::class, curGameFilter) {
curGameName = it.newName
}
events.receive(MultiplayerGameUpdateStarted::class, curGameFilter) { startLoading() }
events.receive(MultiplayerGameUpdateEnded::class, curGameFilter) { stopLoading() }
onClick {
MultiplayerStatusPopup(screen).open()
}
}
private fun startLoading() {
loadingStarted = Instant.now()
if (UncivGame.Current.settings.continuousRendering) {
loadingImage.clearActions()
loadingImage.addAction(Actions.repeat(RepeatAction.FOREVER,Actions.rotateBy(-90f, 1f)))
}
loadingImage.isVisible = true
multiplayerImage.color.a = 0.4f
}
private fun stopLoading() {
val loadingTime = Duration.between(loadingStarted ?: Instant.now(), Instant.now())
val waitFor = if (loadingTime.toMillis() < 500) {
// Some servers might reply almost instantly. That's nice and all, but the user will just see a blinking icon in that case
// and won't be able to make out what it was. So we just show the loading indicator a little longer even though it's already done.
Duration.ofMillis(500 - loadingTime.toMillis())
} else {
Duration.ZERO
}
launchCrashHandling("Hide loading indicator") {
delay(waitFor.toMillis())
postCrashHandlingRunnable {
loadingImage.clearActions()
loadingImage.isVisible = false
multiplayerImage.color.a = 1f
}
}
}
private fun getInitialGamesWithCurrentTurn(): MutableSet<String> {
return findGamesToBeNotifiedAbout(UncivGame.Current.onlineMultiplayer.games)
}
/** @return set of gameIds */
private fun findGamesToBeNotifiedAbout(games: Iterable<OnlineMultiplayerGame>): MutableSet<String> {
return games
.filter { it.name != curGameName }
.filter { it.preview?.isUsersTurn() == true }
.map { it.name }
.toMutableSet()
}
private fun createMultiplayerImage(): Image {
val img = ImageGetter.getImage("OtherIcons/Multiplayer")
img.setSize(40f)
return img
}
private fun createLoadingImage(): Image {
val img = ImageGetter.getImage("OtherIcons/Loading")
img.setSize(40f)
img.isVisible = false
img.setOrigin(Align.center)
return img
}
private fun updateTurnIndicator(flash: Boolean = true) {
if (gameNamesWithCurrentTurn.size == 0) {
turnIndicatorCell.clearActor()
} else {
turnIndicatorCell.setActor(turnIndicator)
turnIndicator.update(gameNamesWithCurrentTurn.size)
}
// flash so the user sees an better update
if (flash) {
turnIndicator.flash()
}
}
}
private class TurnIndicator : HorizontalGroup() {
val gameAmount = Label("2", BaseScreen.skin)
val image: Image
init {
image = ImageGetter.getImage("OtherIcons/ExclamationMark")
image.setSize(30f)
addActor(image)
}
fun update(gamesWithUpdates: Int) {
if (gamesWithUpdates < 2) {
gameAmount.remove()
} else {
gameAmount.setText(gamesWithUpdates)
addActorAt(0, gameAmount)
}
}
fun flash() {
// using a gdx Action would be nicer, but we don't necessarily have continuousRendering on and we still want to flash
flash(6, Color.WHITE, Color.ORANGE)
}
private fun flash(alternations: Int, curColor: Color, nextColor: Color) {
if (alternations == 0) return
gameAmount.color = nextColor
image.color = nextColor
launchCrashHandling("StatusButton color flash") {
delay(500)
postCrashHandlingRunnable {
flash(alternations - 1, nextColor, curColor)
}
}
}
}

View File

@ -0,0 +1,48 @@
package com.unciv.ui.worldscreen.status
import com.unciv.UncivGame
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.models.translations.tr
import com.unciv.ui.multiplayer.GameList
import com.unciv.ui.multiplayer.MultiplayerHelpers
import com.unciv.ui.pickerscreens.PickerPane
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.onClick
class MultiplayerStatusPopup(
screen: BaseScreen,
) : Popup(screen) {
val pickerPane = PickerPane()
var selectedGame: OnlineMultiplayerGame? = null
init {
val pickerCell = add()
.width(700f).fillX().expandX()
.minHeight(screen.stage.height * 0.5f)
.maxHeight(screen.stage.height * 0.8f)
val gameList = GameList(::gameSelected)
pickerPane.topTable.add(gameList)
pickerPane.rightSideButton.setText("Load game".tr())
pickerPane.closeButton.onClick(::close)
pickerCell.setActor<PickerPane>(pickerPane)
pickerPane.rightSideButton.onClick {
close()
val game = selectedGame
if (game != null) {
MultiplayerHelpers.loadMultiplayerGame(screen, game)
}
}
}
private fun gameSelected(gameName: String) {
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(gameName)!!
selectedGame = multiplayerGame
pickerPane.setRightSideButtonEnabled(true)
pickerPane.rightSideButton.setText("Load [$gameName]".tr())
pickerPane.descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame))
}
}

View File

@ -19,7 +19,11 @@ class NextTurnButton(
keyPressDispatcher['n'] = action
}
fun update(isSomethingOpen: Boolean, isPlayersTurn: Boolean, waitingForAutosave: Boolean, nextTurnAction: NextTurnAction? = null) {
fun update(isSomethingOpen: Boolean,
isPlayersTurn: Boolean,
waitingForAutosave: Boolean,
isNextTurnUpdateRunning: Boolean,
nextTurnAction: NextTurnAction? = null) {
if (nextTurnAction != null) {
this.nextTurnAction = nextTurnAction
setText(nextTurnAction.text.tr())
@ -27,7 +31,7 @@ class NextTurnButton(
pack()
}
isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave
isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave && !isNextTurnUpdateRunning
}
}

View File

@ -0,0 +1,26 @@
package com.unciv.ui.worldscreen.status
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
class StatusButtons(
nextTurnButton: NextTurnButton,
multiplayerStatusButton: MultiplayerStatusButton? = null
) : HorizontalGroup() {
var multiplayerStatusButton: MultiplayerStatusButton? = multiplayerStatusButton
set(button) {
multiplayerStatusButton?.remove()
field = button
if (button != null) {
addActorAt(0, button)
}
}
init {
space(10f)
right()
if (multiplayerStatusButton != null) {
addActor(multiplayerStatusButton)
}
addActor(nextTurnButton)
}
}

View File

@ -0,0 +1,46 @@
package com.unciv.logic.event
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import java.lang.ref.WeakReference
class EventBusTest {
open class Parent : Event
class Child : Parent()
@Test
fun `should receive parent event once when receiving child event`() {
val events = EventBus.EventReceiver()
var callCount = 0
events.receive(Parent::class) { ++callCount }
EventBus.send(Child())
assertThat(callCount, `is`(1))
}
@Test
fun `should not receive parent event when listening to child event`() {
val events = EventBus.EventReceiver()
var callCount = 0
events.receive(Child::class) { callCount++ }
EventBus.send(Parent())
assertThat(callCount, `is`(0))
}
@Test
fun `should stop listening to events when requested`() {
val events = EventBus.EventReceiver()
var callCount = 0
events.receive(Child::class) { callCount++ }
EventBus.send(Child())
events.stopReceiving()
EventBus.send(Child())
assertThat(callCount, `is`(1))
}
}