diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 1a91087a3cfb5..592b7f62c943b 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -6,7 +6,11 @@ import androidx.core.app.NotificationManagerCompat import androidx.work.WorkManager import com.badlogic.gdx.backends.android.AndroidApplication import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration +import com.unciv.app.turncheck.Common +import com.unciv.app.turncheck.WorkerV1 +import com.unciv.app.turncheck.WorkerV2 import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.ui.components.Fonts import com.unciv.utils.Display import com.unciv.utils.Log @@ -33,7 +37,7 @@ open class AndroidLauncher : AndroidApplication() { UncivFiles.preferExternalStorage = true // Create notification channels for Multiplayer notificator - MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext) + Common.createNotificationChannels(applicationContext) copyMods() @@ -71,23 +75,32 @@ open class AndroidLauncher : AndroidApplication() { override fun onPause() { val game = this.game!! - if (game.isInitializedProxy() - && game.gameInfo != null - && game.settings.multiplayer.turnCheckerEnabled - && game.files.getMultiplayerSaves().any() - ) { - MultiplayerTurnCheckWorker.startTurnChecker( - applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) + if (game.isInitializedProxy()) { + if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + try { + WorkerV2.start(applicationContext, game.files, game.gameInfo, game.onlineMultiplayer, game.settings.multiplayer) + } catch (e: Exception) { + android.util.Log.e(Common.LOG_TAG, "Error during WorkverV2.start of $this: $e\nMessage: ${e.localizedMessage}\n${e.stackTraceToString()}") + } + } else if (game.gameInfo != null && game.settings.multiplayer.turnCheckerEnabled && game.files.getMultiplayerSaves().any()) { + WorkerV1.startTurnChecker(applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) + } + } + if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + game.onlineMultiplayer.api.disableReconnecting() } super.onPause() } override fun onResume() { + if (game != null && game?.isInitializedProxy() == true && game?.onlineMultiplayer?.isInitialized() == true && game?.onlineMultiplayer?.apiVersion == ApiVersion.APIv2) { + game?.onlineMultiplayer?.api?.enableReconnecting() + } try { - WorkManager.getInstance(applicationContext).cancelAllWorkByTag(MultiplayerTurnCheckWorker.WORK_TAG) + WorkManager.getInstance(applicationContext).cancelAllWorkByTag(Common.WORK_TAG) with(NotificationManagerCompat.from(this)) { - cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_INFO) - cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_SERVICE) + cancel(Common.NOTIFICATION_ID_INFO) + cancel(Common.NOTIFICATION_ID_SERVICE) } } catch (ignore: Exception) { /* Sometimes this fails for no apparent reason - the multiplayer checker failing to diff --git a/android/src/com/unciv/app/CopyToClipboardReceiver.kt b/android/src/com/unciv/app/CopyToClipboardReceiver.kt index 8d7ce4d3755d9..fde6ac38d9a3a 100644 --- a/android/src/com/unciv/app/CopyToClipboardReceiver.kt +++ b/android/src/com/unciv/app/CopyToClipboardReceiver.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.widget.Toast import com.badlogic.gdx.backends.android.AndroidApplication +import com.unciv.app.turncheck.Common /** * This Receiver can be called from an Action on the error Notification shown by MultiplayerTurnCheckWorker. @@ -16,7 +17,7 @@ import com.badlogic.gdx.backends.android.AndroidApplication class CopyToClipboardReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val clipboard: ClipboardManager = context.getSystemService(AndroidApplication.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("exception", intent.getStringExtra(MultiplayerTurnCheckWorker.CLIPBOARD_EXTRA)) + val clip = ClipData.newPlainText("exception", intent.getStringExtra(Common.CLIPBOARD_EXTRA)) clipboard.setPrimaryClip(clip) Toast.makeText(context, context.resources.getString(R.string.Notify_Error_StackTrace_Toast), Toast.LENGTH_SHORT).show() } diff --git a/android/src/com/unciv/app/turncheck/Common.kt b/android/src/com/unciv/app/turncheck/Common.kt new file mode 100644 index 0000000000000..050dda11ec5a2 --- /dev/null +++ b/android/src/com/unciv/app/turncheck/Common.kt @@ -0,0 +1,169 @@ +package com.unciv.app.turncheck + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.badlogic.gdx.backends.android.AndroidApplication +import com.unciv.app.AndroidLauncher +import com.unciv.app.R +import java.time.Duration +import java.util.UUID + +/** + * Collection of common utilities for [WorkerV1] and [WorkerV2] + */ +object Common { + const val WORK_TAG = "UNCIV_MULTIPLAYER_TURN_CHECKER_WORKER" + const val LOG_TAG = "Unciv turn checker" + const val CLIPBOARD_EXTRA = "CLIPBOARD_STRING" + const val NOTIFICATION_ID_SERVICE = 1 + const val NOTIFICATION_ID_INFO = 2 + + // Notification Channels can't be modified after creation. + // Therefore Unciv needs to create new ones and delete previously used ones. + // Add old channel names here when replacing them with new ones below. + private val HISTORIC_NOTIFICATION_CHANNELS = arrayOf("UNCIV_NOTIFICATION_CHANNEL_SERVICE") + + internal const val NOTIFICATION_CHANNEL_ID_INFO = "UNCIV_NOTIFICATION_CHANNEL_INFO" + private const val NOTIFICATION_CHANNEL_ID_SERVICE = "UNCIV_NOTIFICATION_CHANNEL_SERVICE_02" + + /** + * Notification Channel for 'It's your turn' and error notifications. + * + * This code is necessary for API level >= 26 + * API level < 26 does not support Notification Channels + * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel + */ + private fun createNotificationChannelInfo(appContext: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val name = appContext.resources.getString(R.string.Notify_ChannelInfo_Short) + val descriptionText = appContext.resources.getString(R.string.Notify_ChannelInfo_Long) + val importance = NotificationManager.IMPORTANCE_HIGH + val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_INFO, name, importance) + mChannel.description = descriptionText + mChannel.setShowBadge(true) + mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + + val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(mChannel) + } + + /** + * Notification Channel for persistent service notification. + * + * This code is necessary for API level >= 26 + * API level < 26 does not support Notification Channels + * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel + */ + private fun createNotificationChannelService(appContext: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val name = appContext.resources.getString(R.string.Notify_ChannelService_Short) + val descriptionText = appContext.resources.getString(R.string.Notify_ChannelService_Long) + val importance = NotificationManager.IMPORTANCE_MIN + val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_SERVICE, name, importance) + mChannel.setShowBadge(false) + mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + mChannel.description = descriptionText + + val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(mChannel) + } + + /** + * 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: Duration) { + val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) or + PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent: PendingIntent = + Intent(appContext, AndroidLauncher::class.java).let { notificationIntent -> + PendingIntent.getActivity(appContext, 0, notificationIntent, flags) + } + + val notification: NotificationCompat.Builder = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID_SERVICE) + .setPriority(NotificationManagerCompat.IMPORTANCE_MIN) // it's only a status + .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.seconds / 60f + " " + + appContext.resources.getString(R.string.Notify_Persist_Long_P3) + + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4))) + .setSmallIcon(R.drawable.uncivnotification) + .setContentIntent(pendingIntent) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setShowWhen(false) + + with(NotificationManagerCompat.from(appContext)) { + notify(NOTIFICATION_ID_INFO, notification.build()) + } + } + + /** + * Create a new notification to inform a user that its his turn in a specfic game + * + * The [game] is a pair of game name and game ID (which is a [UUID]). + */ + fun notifyUserAboutTurn(applicationContext: Context, game: Pair) { + Log.i(LOG_TAG, "notifyUserAboutTurn ${game.first} (${game.second})") + val intent = Intent(applicationContext, AndroidLauncher::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse("https://unciv.app/multiplayer?id=${game.second}") + } + val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) or + PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, flags) + + val contentTitle = applicationContext.resources.getString(R.string.Notify_YourTurn_Short) + val notification: NotificationCompat.Builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID_INFO) + .setPriority(NotificationManagerCompat.IMPORTANCE_HIGH) // people are waiting! + .setContentTitle(contentTitle) + .setContentText(applicationContext.resources.getString(R.string.Notify_YourTurn_Long).replace("[gameName]", game.first)) + .setTicker(contentTitle) + // without at least vibrate, some Android versions don't show a heads-up notification + .setDefaults(NotificationCompat.DEFAULT_VIBRATE) + .setLights(Color.YELLOW, 300, 100) + .setSmallIcon(R.drawable.uncivnotification) + .setContentIntent(pendingIntent) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setOngoing(false) + + with(NotificationManagerCompat.from(applicationContext)) { + notify(NOTIFICATION_ID_INFO, notification.build()) + } + } + + /** + * Necessary for Multiplayer Turner Checker, starting with Android Oreo + */ + fun createNotificationChannels(appContext: Context) { + createNotificationChannelInfo(appContext) + createNotificationChannelService(appContext) + destroyOldChannels(appContext) + } + + /** + * Notification Channels can't be modified after creation. + * Therefore Unciv needs to create new ones and delete legacy ones. + */ + private fun destroyOldChannels(appContext: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager + HISTORIC_NOTIFICATION_CHANNELS.forEach { + if (null != notificationManager.getNotificationChannel(it)) { + notificationManager.deleteNotificationChannel(it) + } + } + } +} diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/turncheck/WorkerV1.kt similarity index 61% rename from android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt rename to android/src/com/unciv/app/turncheck/WorkerV1.kt index d9499b45bd607..8d534c08971b5 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV1.kt @@ -1,7 +1,5 @@ -package com.unciv.app +package com.unciv.app.turncheck -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT @@ -9,7 +7,6 @@ import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.graphics.Color -import android.net.Uri import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat @@ -24,12 +21,22 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.badlogic.gdx.Gdx -import com.badlogic.gdx.backends.android.AndroidApplication import com.badlogic.gdx.backends.android.DefaultAndroidFiles +import com.unciv.UncivGame +import com.unciv.app.AndroidLauncher +import com.unciv.app.CopyToClipboardReceiver +import com.unciv.app.R +import com.unciv.app.turncheck.Common.CLIPBOARD_EXTRA +import com.unciv.app.turncheck.Common.LOG_TAG +import com.unciv.app.turncheck.Common.NOTIFICATION_CHANNEL_ID_INFO +import com.unciv.app.turncheck.Common.NOTIFICATION_ID_INFO +import com.unciv.app.turncheck.Common.NOTIFICATION_ID_SERVICE +import com.unciv.app.turncheck.Common.WORK_TAG +import com.unciv.app.turncheck.Common.notifyUserAboutTurn +import com.unciv.app.turncheck.Common.showPersistentNotification import com.unciv.logic.GameInfo import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached -import com.unciv.logic.multiplayer.storage.OnlineMultiplayerServer import com.unciv.models.metadata.GameSettingsMultiplayer import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException @@ -40,25 +47,12 @@ import java.time.Duration import java.util.GregorianCalendar import java.util.concurrent.TimeUnit - -class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParameters) - : Worker(appContext, workerParams) { +/** + * Active poll-based multiplayer turn checker for APIv0 and APIv1 + */ +class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { companion object { - const val WORK_TAG = "UNCIV_MULTIPLAYER_TURN_CHECKER_WORKER" - const val LOG_TAG = "Unciv turn checker" - const val CLIPBOARD_EXTRA = "CLIPBOARD_STRING" - const val NOTIFICATION_ID_SERVICE = 1 - const val NOTIFICATION_ID_INFO = 2 - - // Notification Channels can't be modified after creation. - // Therefore Unciv needs to create new ones and delete previously used ones. - // Add old channel names here when replacing them with new ones below. - private val HISTORIC_NOTIFICATION_CHANNELS = arrayOf("UNCIV_NOTIFICATION_CHANNEL_SERVICE") - - private const val NOTIFICATION_CHANNEL_ID_INFO = "UNCIV_NOTIFICATION_CHANNEL_INFO" - private const val NOTIFICATION_CHANNEL_ID_SERVICE = "UNCIV_NOTIFICATION_CHANNEL_SERVICE_02" - private const val FAIL_COUNT = "FAIL_COUNT" private const val GAME_ID = "GAME_ID" private const val GAME_NAME = "GAME_NAME" @@ -74,7 +68,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame .build() fun enqueue(appContext: Context, delay: Duration, inputData: Data) { - val checkTurnWork = OneTimeWorkRequestBuilder() + val checkTurnWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInitialDelay(delay.seconds, TimeUnit.SECONDS) .addTag(WORK_TAG) @@ -84,109 +78,6 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame WorkManager.getInstance(appContext).enqueue(checkTurnWork) } - /** - * Notification Channel for 'It's your turn' and error notifications. - * - * This code is necessary for API level >= 26 - * API level < 26 does not support Notification Channels - * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel - */ - fun createNotificationChannelInfo(appContext: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val name = appContext.resources.getString(R.string.Notify_ChannelInfo_Short) - val descriptionText = appContext.resources.getString(R.string.Notify_ChannelInfo_Long) - val importance = NotificationManager.IMPORTANCE_HIGH - val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_INFO, name, importance) - mChannel.description = descriptionText - mChannel.setShowBadge(true) - mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - - val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(mChannel) - } - - /** - * Notification Channel for persistent service notification. - * - * This code is necessary for API level >= 26 - * API level < 26 does not support Notification Channels - * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel - */ - fun createNotificationChannelService(appContext: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val name = appContext.resources.getString(R.string.Notify_ChannelService_Short) - val descriptionText = appContext.resources.getString(R.string.Notify_ChannelService_Long) - val importance = NotificationManager.IMPORTANCE_MIN - val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_SERVICE, name, importance) - mChannel.setShowBadge(false) - mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - mChannel.description = descriptionText - - val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(mChannel) - } - - /** - * 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: Duration) { - val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) FLAG_IMMUTABLE else 0) or - FLAG_UPDATE_CURRENT - val pendingIntent: PendingIntent = - Intent(appContext, AndroidLauncher::class.java).let { notificationIntent -> - PendingIntent.getActivity(appContext, 0, notificationIntent, flags) - } - - val notification: NotificationCompat.Builder = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID_SERVICE) - .setPriority(NotificationManagerCompat.IMPORTANCE_MIN) // it's only a status - .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.seconds / 60f + " " - + appContext.resources.getString(R.string.Notify_Persist_Long_P3) - + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4))) - .setSmallIcon(R.drawable.uncivnotification) - .setContentIntent(pendingIntent) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setOnlyAlertOnce(true) - .setOngoing(true) - .setShowWhen(false) - - with(NotificationManagerCompat.from(appContext)) { - notify(NOTIFICATION_ID_INFO, notification.build()) - } - } - - fun notifyUserAboutTurn(applicationContext: Context, game: Pair) { - Log.i(LOG_TAG, "notifyUserAboutTurn ${game.first}") - val intent = Intent(applicationContext, AndroidLauncher::class.java).apply { - action = Intent.ACTION_VIEW - data = Uri.parse("https://unciv.app/multiplayer?id=${game.second}") - } - val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) FLAG_IMMUTABLE else 0) or - FLAG_UPDATE_CURRENT - val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, flags) - - val contentTitle = applicationContext.resources.getString(R.string.Notify_YourTurn_Short) - val notification: NotificationCompat.Builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID_INFO) - .setPriority(NotificationManagerCompat.IMPORTANCE_HIGH) // people are waiting! - .setContentTitle(contentTitle) - .setContentText(applicationContext.resources.getString(R.string.Notify_YourTurn_Long).replace("[gameName]", game.first)) - .setTicker(contentTitle) - // without at least vibrate, some Android versions don't show a heads-up notification - .setDefaults(DEFAULT_VIBRATE) - .setLights(Color.YELLOW, 300, 100) - .setSmallIcon(R.drawable.uncivnotification) - .setContentIntent(pendingIntent) - .setCategory(NotificationCompat.CATEGORY_SOCIAL) - .setOngoing(false) - - with(NotificationManagerCompat.from(applicationContext)) { - notify(NOTIFICATION_ID_INFO, notification.build()) - } - } - fun startTurnChecker(applicationContext: Context, files: UncivFiles, currentGameInfo: GameInfo, settings: GameSettingsMultiplayer) { Log.i(LOG_TAG, "startTurnChecker") @@ -222,7 +113,8 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame notifyUserAboutTurn(applicationContext, Pair(gameNames[gameIndex], gameIds[gameIndex])) } } else { - val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair(GAME_NAME, gameNames), + val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair( + GAME_NAME, gameNames), Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.turnCheckerDelay.seconds), Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.turnCheckerPersistentNotificationEnabled), Pair(FILE_STORAGE, settings.server), @@ -237,29 +129,6 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } } - /** - * Necessary for Multiplayer Turner Checker, starting with Android Oreo - */ - fun createNotificationChannels(appContext: Context) { - createNotificationChannelInfo(appContext) - createNotificationChannelService(appContext) - destroyOldChannels(appContext) - } - - /** - * Notification Channels can't be modified after creation. - * Therefore Unciv needs to create new ones and delete legacy ones. - */ - private fun destroyOldChannels(appContext: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager - HISTORIC_NOTIFICATION_CHANNELS.forEach { - if (null != notificationManager.getNotificationChannel(it)) { - notificationManager.deleteNotificationChannel(it) - } - } - } - private fun getConfiguredDelay(inputData: Data): Duration { val delay = inputData.getLong(CONFIGURED_DELAY, Duration.ofMinutes(5).seconds) return Duration.ofSeconds(delay) @@ -310,7 +179,8 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame try { Log.d(LOG_TAG, "doWork download $gameId") - val gamePreview = OnlineMultiplayerServer(fileStorage, mapOf("Authorization" to authHeader)).tryDownloadGamePreview(gameId) + val gamePreview = UncivGame.Current.onlineMultiplayer.multiplayerServer.tryDownloadGamePreview(gameId) + //val gamePreview = OnlineMultiplayerServer(fileStorage, mapOf("Authorization" to authHeader)).tryDownloadGamePreview(gameId) Log.d(LOG_TAG, "doWork download $gameId done") val currentTurnPlayer = gamePreview.getCivilization(gamePreview.currentPlayer) @@ -365,7 +235,9 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } return@runBlocking Result.failure() } else { - if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay) } + 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. @@ -406,7 +278,8 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } val pendingCopyClipboardIntent: PendingIntent = - Intent(applicationContext, CopyToClipboardReceiver::class.java).putExtra(CLIPBOARD_EXTRA, stackTraceString) + Intent(applicationContext, CopyToClipboardReceiver::class.java).putExtra( + CLIPBOARD_EXTRA, stackTraceString) .let { notificationIntent -> PendingIntent.getBroadcast(applicationContext,0, notificationIntent, flags) } diff --git a/android/src/com/unciv/app/turncheck/WorkerV2.kt b/android/src/com/unciv/app/turncheck/WorkerV2.kt new file mode 100644 index 0000000000000..9b356db370e07 --- /dev/null +++ b/android/src/com/unciv/app/turncheck/WorkerV2.kt @@ -0,0 +1,160 @@ +package com.unciv.app.turncheck + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.unciv.UncivGame +import com.unciv.app.turncheck.Common.LOG_TAG +import com.unciv.app.turncheck.Common.WORK_TAG +import com.unciv.logic.GameInfo +import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage +import com.unciv.logic.multiplayer.apiv2.UpdateGameData +import com.unciv.logic.multiplayer.isUsersTurn +import com.unciv.models.metadata.GameSettingsMultiplayer +import com.unciv.utils.Concurrency +import com.unciv.utils.Dispatcher +import kotlinx.coroutines.Job +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Push-based multiplayer turn checker for APIv2 + */ +class WorkerV2(appContext: Context, private val params: WorkerParameters) : CoroutineWorker(appContext, params) { + @Deprecated("use withContext(...) inside doWork() instead.") + override val coroutineContext = Dispatcher.DAEMON + + companion object { + private const val USER_ID = "USER_ID" + private const val CONFIGURED_DELAY = "CONFIGURED_DELAY" + private const val MULTIPLAYER_SERVER = "MULTIPLAYER_SERVER" + private const val PERSISTENT_NOTIFICATION_ENABLED = "PERSISTENT_NOTIFICATION_ENABLED" + private const val UNIQUE_WORKER_V2_JOB_NAME = "UNIQUE_WORKER_V2_JOB_NAME" + + private var gameUUID: UUID? = null + private var onlineMultiplayer: OnlineMultiplayer? = null + + /** Job for listening to parsed WebSocket events (created here) */ + private var eventJob: Job? = null + /** Job for listening for raw incoming WebSocket packets (not created here, but in the [ApiV2]) */ + private var websocketJob: Job? = null + + fun start(applicationContext: Context, files: UncivFiles, currentGameInfo: GameInfo?, onlineMultiplayer: OnlineMultiplayer, settings: GameSettingsMultiplayer) { + Log.d(LOG_TAG, "Starting V2 worker to listen for push notifications") + if (currentGameInfo != null) { + this.gameUUID = UUID.fromString(currentGameInfo.gameId) + } + this.onlineMultiplayer = onlineMultiplayer + + // May be useful to remind a player that he forgot to complete his turn + if (currentGameInfo?.isUsersTurn() == true) { + val name = currentGameInfo.gameId // TODO: Lookup the name of the game + Common.notifyUserAboutTurn(applicationContext, Pair(name, currentGameInfo.gameId)) + } else if (settings.turnCheckerPersistentNotificationEnabled) { + Common.showPersistentNotification( + applicationContext, + "—", + settings.turnCheckerDelay + ) + } + + val data = workDataOf( + Pair(USER_ID, settings.userId), + Pair(CONFIGURED_DELAY, settings.turnCheckerDelay.seconds), + Pair(MULTIPLAYER_SERVER, settings.server), + Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.turnCheckerPersistentNotificationEnabled) + ) + enqueue(applicationContext, data, 0) + } + + private fun enqueue(applicationContext: Context, data: Data, delaySeconds: Long) { + val worker = OneTimeWorkRequest.Builder(WorkerV2::class.java) + .addTag(WORK_TAG) + .setInputData(data) + .setInitialDelay(delaySeconds, TimeUnit.SECONDS) + if (delaySeconds > 0) { + // If no internet is available, worker waits before becoming active + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + worker.setConstraints(constraints) + } + WorkManager.getInstance(applicationContext).enqueueUniqueWork(UNIQUE_WORKER_V2_JOB_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, worker.build()) + Log.d(LOG_TAG, "Enqueued APIv2-comptabile oneshot worker with delay of $delaySeconds seconds") + } + } + + private suspend fun checkTurns() { + val channel = onlineMultiplayer?.api?.getWebSocketEventChannel() + if (channel == null) { + Log.w(LOG_TAG, "Failed to get an event channel for parsed WebSocket events!") + return + } + try { + while (true) { + val event = channel.receive() + Log.d(LOG_TAG, "Incoming channel event: $event") + when (event) { + is IncomingChatMessage -> { + Log.i(LOG_TAG, "Incoming chat message! ${event.message}") + } + is UpdateGameData -> { + // TODO: The user here always receives a notification, even if somebody *else* completed their turn. Fix this! + Log.i(LOG_TAG, "Incoming game update! ${event.gameUUID} / ${event.gameDataID}") + // TODO: Resolve the name of the game by cached lookup instead of API query + val name = UncivGame.Current.onlineMultiplayer.api.game.head(event.gameUUID, suppress = true)?.name + Common.notifyUserAboutTurn( + applicationContext, + Pair(name ?: event.gameUUID.toString(), event.gameUUID.toString()) + ) + with(NotificationManagerCompat.from(applicationContext)) { + cancel(Common.NOTIFICATION_ID_SERVICE) + } + } + } + } + } catch (t: Throwable) { + Log.e(LOG_TAG, "CheckTurns APIv2 failure: $t / ${t.localizedMessage}\n${t.stackTraceToString()}") + channel.cancel() + throw t + } + } + + override suspend fun doWork(): Result { + try { + Log.d(LOG_TAG, "Starting doWork for WorkerV2: $this") + enqueue(applicationContext, params.inputData, params.inputData.getLong(CONFIGURED_DELAY, 600L)) + + val ping = onlineMultiplayer?.api?.ensureConnectedWebSocket { + Log.d(LOG_TAG, "WebSocket job $websocketJob, completed ${websocketJob?.isCompleted}, cancelled ${websocketJob?.isCancelled}, active ${websocketJob?.isActive}\nNew Job: $it") + websocketJob = it + } + if (ping != null) { + Log.d(LOG_TAG, "WebSocket ping took $ping ms") + } + + if (eventJob == null || eventJob?.isActive == false || eventJob?.isCancelled == true) { + val job = Concurrency.runOnNonDaemonThreadPool { checkTurns() } + Log.d(LOG_TAG, "Added event job $job from $this (overwrite previous $eventJob)") + eventJob = job + } else { + Log.d(LOG_TAG, "Event job $eventJob seems to be running, so everything is fine") + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Error in $this: $e\nMessage: ${e.localizedMessage}\n${e.stackTraceToString()}\nWebSocket job: $websocketJob\nEvent job: $eventJob") + } + return Result.success() + } +} diff --git a/core/src/com/unciv/Constants.kt b/core/src/com/unciv/Constants.kt index 5d38615f114a4..489cc013b108a 100644 --- a/core/src/com/unciv/Constants.kt +++ b/core/src/com/unciv/Constants.kt @@ -104,6 +104,7 @@ object Constants { const val minimumMovementEpsilon = 0.05f // 0.1f was used previously, too - here for global searches const val aiPreferInquisitorOverMissionaryPressureDifference = 3000f + const val smallFontSize = 14 const val defaultFontSize = 18 const val headingFontSize = 24 } diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 432fbf76ce324..add6f3ba079b9 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -181,10 +181,12 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci onlineMultiplayer = OnlineMultiplayer() - Concurrency.run { + Concurrency.runOnNonDaemonThreadPool { + onlineMultiplayer.initialize() // actually produces first network traffic + // Check if the server is available in case the feature set has changed try { - onlineMultiplayer.multiplayerServer.checkServerStatus() + onlineMultiplayer.checkServerStatus() } catch (ex: Exception) { debug("Couldn't connect to server: " + ex.message) } @@ -459,11 +461,14 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci override fun render() = wrappedCrashHandlingRender() override fun dispose() { + Log.debug("Disposing application") Gdx.input.inputProcessor = null // don't allow ANRs when shutting down, that's silly SoundPlayer.clearCache() if (::musicController.isInitialized) musicController.gracefulShutdown() // Do allow fade-out // We stop the *in-game* multiplayer update, so that it doesn't keep working and A. we'll have errors and B. we'll have multiple updaters active - if (::onlineMultiplayer.isInitialized) onlineMultiplayer.multiplayerGameUpdater.cancel() + if (::onlineMultiplayer.isInitialized) { + onlineMultiplayer.dispose() + } val curGameInfo = gameInfo if (curGameInfo != null) { @@ -543,6 +548,24 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci fun isCurrentInitialized() = this::Current.isInitialized fun isCurrentGame(gameId: String): Boolean = isCurrentInitialized() && Current.gameInfo != null && Current.gameInfo!!.gameId == gameId fun isDeepLinkedGameLoading() = isCurrentInitialized() && Current.deepLinkedMultiplayerGame != null + + /** + * Replace the [onlineMultiplayer] instance in-place + * + * This might be useful if the server URL or other core values got changed. + * It will setup the new instance, replace the reference of [onlineMultiplayer] + * and dispose the old instance if everything went smoothly. Do not call + * this function from the GL thread, since it performs network operations. + */ + suspend fun refreshOnlineMultiplayer() { + val oldMultiplayer = Current.onlineMultiplayer + val newMultiplayer = OnlineMultiplayer() + newMultiplayer.initialize() + oldMultiplayer.dispose() + Concurrency.runOnGLThread { + Current.onlineMultiplayer = newMultiplayer + }.join() + } } data class Version( diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index 53bd03493c525..eb0771583d790 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -87,7 +87,7 @@ data class VictoryData(val winningCiv: String, val victoryType: String, val vict constructor(): this("","",0) } -class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion { +class GameInfo (private val overwriteGameId: UUID? = null) : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion { companion object { /** The current compatibility version of [GameInfo]. This number is incremented whenever changes are made to the save file structure that guarantee that * previous versions of the game will not be able to load or play a game normally. */ @@ -112,7 +112,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion var oneMoreTurnMode = false var currentPlayer = "" var currentTurnStartTime = 0L - var gameId = UUID.randomUUID().toString() // random string + var gameId = if (overwriteGameId != null) overwriteGameId.toString() else UUID.randomUUID().toString() // otherwise random UUID string var checksum = "" var victoryData:VictoryData? = null diff --git a/core/src/com/unciv/logic/GameStarter.kt b/core/src/com/unciv/logic/GameStarter.kt index 8e08394cb7354..06c17fbcaba88 100644 --- a/core/src/com/unciv/logic/GameStarter.kt +++ b/core/src/com/unciv/logic/GameStarter.kt @@ -31,12 +31,15 @@ object GameStarter { private const val consoleTimings = false private lateinit var gameSetupInfo: GameSetupInfo - fun startNewGame(gameSetupInfo: GameSetupInfo): GameInfo { + fun startNewGame(gameSetupInfo: GameSetupInfo, customGameId: String? = null): GameInfo { this.gameSetupInfo = gameSetupInfo if (consoleTimings) debug("\nGameStarter run with parameters %s, map %s", gameSetupInfo.gameParameters, gameSetupInfo.mapParameters) val gameInfo = GameInfo() + if (customGameId != null) { + gameInfo.gameId = customGameId + } lateinit var tileMap: TileMap // In the case where we used to have an extension mod, and now we don't, we cannot "unselect" it in the UI. diff --git a/core/src/com/unciv/logic/files/UncivFiles.kt b/core/src/com/unciv/logic/files/UncivFiles.kt index 74398e68c014e..4d0237ab0b6f9 100644 --- a/core/src/com/unciv/logic/files/UncivFiles.kt +++ b/core/src/com/unciv/logic/files/UncivFiles.kt @@ -5,6 +5,7 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.utils.GdxRuntimeException import com.badlogic.gdx.utils.JsonReader +import com.badlogic.gdx.utils.JsonWriter import com.badlogic.gdx.utils.SerializationException import com.unciv.UncivGame import com.unciv.json.fromJsonFile @@ -371,6 +372,14 @@ class UncivFiles( return json().fromJson(GameInfoPreview::class.java, Gzip.unzip(gameData)) } + /** + * Returns pretty-printed (= manually readable) serialization of [game], optionally gzipped + */ + fun gameInfoToPrettyString(game: GameInfo, useZip: Boolean = false): String { + val prettyJson = json().apply { setOutputType(JsonWriter.OutputType.json) }.prettyPrint(game) + return if (useZip) Gzip.zip(prettyJson) else prettyJson + } + /** Returns gzipped serialization of [game], optionally gzipped ([forceZip] overrides [saveZipped]) */ fun gameInfoToString(game: GameInfo, forceZip: Boolean? = null, updateChecksum:Boolean=false): String { game.version = GameInfo.CURRENT_COMPATIBILITY_VERSION diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt index 76bac2e388b0a..06960b77c6634 100644 --- a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -17,6 +17,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText +import io.ktor.http.URLParserException import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -64,8 +65,12 @@ enum class ApiVersion { * If set, throwing *any* errors is forbidden, so it returns null, otherwise the * detected [ApiVersion] is returned or the exception is thrown. * + * Note that the [baseUrl] must include the protocol (either `http://` or `https://`). + * * @throws UncivNetworkException: thrown for any kind of network error - * or de-serialization problems (ony when [suppress] is false) + * or de-serialization problems (only when [suppress] is false) + * @throws URLParserException: thrown for invalid [baseUrl] which is + * not [Constants.dropboxMultiplayerServer] */ suspend fun detect(baseUrl: String, suppress: Boolean = true, timeout: Long? = null): ApiVersion? { if (baseUrl == Constants.dropboxMultiplayerServer) { @@ -109,7 +114,6 @@ enum class ApiVersion { } try { val serverFeatureSet: ServerFeatureSet = json().fromJson(ServerFeatureSet::class.java, response1.bodyAsText()) - // val serverFeatureSet: ServerFeatureSet = response1.body() Log.debug("Detected APIv1 at %s: %s", fixedBaseUrl, serverFeatureSet) client.close() return APIv1 @@ -140,6 +144,7 @@ enum class ApiVersion { } } + Log.debug("Unable to detect the API version at %s", fixedBaseUrl) client.close() return null } diff --git a/core/src/com/unciv/logic/multiplayer/FriendList.kt b/core/src/com/unciv/logic/multiplayer/FriendList.kt index 07a05b3f72f2e..c5a26786e69ac 100644 --- a/core/src/com/unciv/logic/multiplayer/FriendList.kt +++ b/core/src/com/unciv/logic/multiplayer/FriendList.kt @@ -1,6 +1,7 @@ package com.unciv.logic.multiplayer import com.unciv.UncivGame +import java.util.UUID class FriendList { private val settings = UncivGame.Current.settings @@ -18,6 +19,7 @@ class FriendList { data class Friend(val name: String, val playerID: String) { constructor() : this("", "") + constructor(name: String, playerUUID: UUID) : this(name, playerUUID.toString()) } fun add(friendName: String, playerID: String): ErrorType { diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index caecb9e91488d..a0d318ee59a52 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -1,20 +1,33 @@ package com.unciv.logic.multiplayer import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.Disposable import com.unciv.Constants import com.unciv.UncivGame +import com.unciv.json.json import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview +import com.unciv.logic.UncivShowableException import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus +import com.unciv.logic.files.IncompatibleGameInfoVersionException +import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.logic.multiplayer.apiv2.DEFAULT_REQUEST_TIMEOUT +import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage +import com.unciv.logic.multiplayer.apiv2.UncivNetworkException +import com.unciv.logic.multiplayer.apiv2.UpdateGameData +import com.unciv.logic.multiplayer.storage.ApiV2FileStorage import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.logic.multiplayer.storage.OnlineMultiplayerServer +import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.ui.components.extensions.isLargerThan import com.unciv.utils.Concurrency import com.unciv.utils.Dispatcher +import com.unciv.utils.Log import com.unciv.utils.debug import com.unciv.utils.launchOnThreadPool import com.unciv.utils.withGLContext @@ -31,21 +44,65 @@ import java.time.Instant import java.util.Collections import java.util.concurrent.atomic.AtomicReference - /** - * 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. + * 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_PERIOD = Duration.ofSeconds(60) /** - * Provides multiplayer functionality to the rest of the game. + * Provides multiplayer functionality to the rest of the game + * + * You need to call [initialize] as soon as possible, to bootstrap API detection + * and first network connectivity. A later version may enforce that no network + * traffic is generated before [initialize] gets called, but this is not yet the case. + * You should wait for the completion of this initialization process, otherwise + * certain more complex functionality may be unavailable, especially event handling + * features of the [ApiVersion.APIv2]. You may use [awaitInitialized] for that. + * + * After initialization, this class can be used to access multiplayer features via + * methods such as [downloadGame] or [updateGame]. Use the [api] instance to access + * functionality of the new APIv2 implementations (e.g. lobbies, friends, in-game + * chats and more). You must ensure that the access to that [api] property is properly + * guarded: the API must be V2 and the initialization must be completed. Otherwise, + * accessing that property may yield [UncivShowableException] or trigger race conditions. + * The recommended way to do this is checking the [apiVersion] of this instance, which + * is also set after initialization. After usage, you should [dispose] this instance + * properly to close network connections gracefully. Changing the server URL is only + * possible by [disposing][dispose] this instance and creating a new one. * - * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. + * Certain features (for example the poll checker, see [startPollChecker], or the WebSocket + * handlers, see [ApiV2.handleWebSocket]) send certain events on the [EventBus]. See the + * source file of [MultiplayerGameAdded] and [IncomingChatMessage] for an overview of them. */ -class OnlineMultiplayer { +class OnlineMultiplayer: Disposable { + private val settings + get() = UncivGame.Current.settings + + // Updating the multiplayer server URL in the Api is out of scope, just drop this class and create a new one + val baseUrl = UncivGame.Current.settings.multiplayer.server + + /** + * Access the [ApiV2] instance only after [initialize] has been completed, otherwise + * it will block until [initialize] has finished (which may take very long for high + * latency networks). Accessing this property when the [apiVersion] is **not** + * [ApiVersion.APIv2] will yield an [UncivShowableException] that the it's not supported. + */ + val api: ApiV2 + get() { + if (Concurrency.runBlocking { awaitInitialized(); apiImpl.isCompatible() } == true) { + return apiImpl + } + throw UncivShowableException("Unsupported server API: [$baseUrl]") + } + private val apiImpl = ApiV2(baseUrl) + private val files = UncivGame.Current.files val multiplayerServer = OnlineMultiplayerServer() + private lateinit var featureSet: ServerFeatureSet + + private var pollChecker: Job? = null + private val events = EventBus.EventReceiver() private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -53,30 +110,117 @@ class OnlineMultiplayer { private val lastAllGamesRefresh: AtomicReference = AtomicReference() private val lastCurGameRefresh: AtomicReference = AtomicReference() - val games: Set get() = savedGames.values.toSet() - val multiplayerGameUpdater: Job + val games: Set + get() = savedGames.values.toSet() + + /** Inspect this property to check the API version of the multiplayer server (the server + * API auto-detection happens in the coroutine [initialize], so you need to wait until + * this is finished, otherwise this property access will block or ultimatively throw a + * [UncivNetworkException] to avoid hanging forever when the network is down/very slow) */ + val apiVersion: ApiVersion + get() { + if (apiVersionImpl != null) { + return apiVersionImpl!! + } + + // Using directly blocking code below is fine, since it's enforced to await [initialize] + // anyways -- even though it will be cancelled by the second "timeout fallback" job to + // avoid blocking the calling function forever, so that it will "earlier" crash the game instead + val waitJob = Concurrency.run { + // Using an active sleep loop here is not the best way, but the simplest; it could be improved later + while (apiVersionImpl == null) { + delay(20) + } + } + val cancelJob = Concurrency.run { + delay(2 * DEFAULT_REQUEST_TIMEOUT) + Log.debug("Cancelling the access to apiVersion, since $baseUrl seems to be too slow to respond") + waitJob.cancel() + } + Concurrency.runBlocking { waitJob.join() } + if (apiVersionImpl != null) { + cancelJob.cancel() + return apiVersionImpl!! + } + throw UncivNetworkException("Unable to detect the API version of [$baseUrl]: please check your network connectivity", null) + } + private var apiVersionImpl: ApiVersion? = null init { - /** We have 2 'async processes' that update the multiplayer games: - * A. This one, which as part of *this process* runs refreshes for all OS's - * B. MultiplayerTurnCheckWorker, which *as an Android worker* runs refreshes *even when the game is closed*. - * Only for Android, obviously - */ - multiplayerGameUpdater = flow { - while (true) { - delay(500) - if (!currentCoroutineContext().isActive) return@flow - val currentGame = getCurrentGame() - val multiplayerSettings = UncivGame.Current.settings.multiplayer - val preview = currentGame?.preview - if (currentGame != null && (usesCustomServer() || preview == null || !preview.isUsersTurn())) { - throttle(lastCurGameRefresh, multiplayerSettings.currentGameRefreshDelay, {}) { currentGame.requestUpdate() } + // The purpose of handling this event here is to avoid de-serializing the GameInfo + // more often than really necessary. Other actors may just listen for MultiplayerGameCanBeLoaded + // instead of the "raw" UpdateGameData. Note that the Android turn checker ignores this rule. + events.receive(UpdateGameData::class, null) { + Concurrency.runOnNonDaemonThreadPool { + try { + val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + gameInfo.setTransients() + addGame(gameInfo) + val gameDetails = api.game.head(it.gameUUID, suppress = true) + Concurrency.runOnGLThread { + EventBus.send(MultiplayerGameCanBeLoaded(gameInfo, gameDetails?.name, it.gameDataID)) + } + } catch (e: IncompatibleGameInfoVersionException) { + Log.debug("Failed to load GameInfo from incoming event: %s", e.localizedMessage) } + } + } + } - val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) - throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } + /** + * Initialize this instance and detect the API version of the server automatically + * + * This should be called as early as possible to configure other depending attributes. + * You must await its completion before using the [api] and its related functionality. + */ + suspend fun initialize() { + apiVersionImpl = ApiVersion.detect(baseUrl) + Log.debug("Server at '$baseUrl' detected API version: $apiVersionImpl") + startPollChecker() + featureSet = ServerFeatureSet() // setting this here to fix problems for non-network games + if (apiVersionImpl == ApiVersion.APIv1) { + isAliveAPIv1() + } + if (apiVersionImpl == ApiVersion.APIv2) { + if (hasAuthentication()) { + apiImpl.initialize(Pair(settings.multiplayer.userName, settings.multiplayer.passwords[baseUrl] ?: "")) + } else { + apiImpl.initialize() } - }.launchIn(CoroutineScope(Dispatcher.DAEMON)) + ApiV2FileStorage.setApi(apiImpl) + } + } + + /** + * Determine whether the instance has been initialized + */ + fun isInitialized(): Boolean { + return (this::featureSet.isInitialized) && (apiVersionImpl != null) && (apiVersionImpl != ApiVersion.APIv2 || apiImpl.isInitialized()) + } + + /** + * Actively sleeping loop that awaits [isInitialized] + */ + suspend fun awaitInitialized() { + while (!isInitialized()) { + // Using an active sleep loop here is not the best way, but the simplest; it could be improved later + delay(20) + } + } + + /** + * Determine the server API version of the remote server + * + * Check precedence: [ApiVersion.APIv0] > [ApiVersion.APIv2] > [ApiVersion.APIv1] + */ + private suspend fun determineServerAPI(): ApiVersion { + return if (usesDropbox()) { + ApiVersion.APIv0 + } else if (apiImpl.isCompatible()) { + ApiVersion.APIv2 + } else { + ApiVersion.APIv1 + } } private fun getCurrentGame(): OnlineMultiplayerGame? { @@ -122,7 +266,6 @@ class OnlineMultiplayer { } } - /** * Fires [MultiplayerGameAdded] * @@ -334,6 +477,81 @@ class OnlineMultiplayer { && gameInfo.turns == preview.turns } + /** + * Checks if the server is alive and sets the [featureSet] accordingly. + * @return true if the server is alive, false otherwise + */ + suspend fun checkServerStatus(): Boolean { + if (apiImpl.isCompatible()) { + try { + apiImpl.version() + } catch (e: Throwable) { + Log.error("Unexpected error during server status query: ${e.localizedMessage}") + return false + } + return true + } + + return isAliveAPIv1() + } + + /** + * Check if the server is reachable by getting the /isalive endpoint + * + * This will also update/set the [featureSet] implicitly. + * + * Only use this method for APIv1 servers. This method doesn't check the API version, though. + * + * This is a blocking method doing network operations. + */ + private fun isAliveAPIv1(): Boolean { + var statusOk = false + try { + SimpleHttp.sendGetRequest("${UncivGame.Current.settings.multiplayer.server}/isalive") { success, result, _ -> + statusOk = success + if (result.isNotEmpty()) { + featureSet = try { + json().fromJson(ServerFeatureSet::class.java, result) + } catch (ex: Exception) { + Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") + ServerFeatureSet() + } + } + } + } catch (e: Throwable) { + Log.error("Error while checking server '$baseUrl' isAlive for $apiVersion: $e") + statusOk = false + } + return statusOk + } + + /** + * @return true if the authentication was successful or the server does not support authentication. + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + suspend fun authenticate(password: String?): Boolean { + if (featureSet.authVersion == 0) { + return true + } + + val success = multiplayerServer.fileStorage().authenticate( + userId=settings.multiplayer.userId, + password=password ?: settings.multiplayer.passwords[settings.multiplayer.server] ?: "" + ) + if (password != null && success) { + settings.multiplayer.passwords[settings.multiplayer.server] = password + } + return success + } + + /** + * Determine if there are any known credentials for the current server (the credentials might be invalid!) + */ + fun hasAuthentication(): Boolean { + val settings = UncivGame.Current.settings.multiplayer + return settings.passwords.containsKey(settings.server) + } /** * Checks if [preview1] has a more recent game state than [preview2] @@ -342,6 +560,48 @@ class OnlineMultiplayer { return preview1.turns > preview2.turns } + /** + * Start a background runner that periodically checks for new game updates + * ([ApiVersion.APIv0] and [ApiVersion.APIv1] only) + * + * We have 2 'async processes' that update the multiplayer games: + * A. This one, which runs as part of *this process* and refreshes for all OS's + * B. MultiplayerTurnCheckWorker, which runs *as an Android worker* and refreshes + * *even when the game is paused*. Only for Android, obviously. + */ + private fun startPollChecker() { + if (apiVersion in listOf(ApiVersion.APIv0, ApiVersion.APIv1)) { + Log.debug("Starting poll service for remote games ...") + pollChecker = flow { + while (true) { + delay(500) + + val currentGame = getCurrentGame() + val multiplayerSettings = UncivGame.Current.settings.multiplayer + val preview = currentGame?.preview + if (currentGame != null && (usesCustomServer() || preview == null || !preview.isUsersTurn())) { + throttle(lastCurGameRefresh, multiplayerSettings.currentGameRefreshDelay, {}) { currentGame.requestUpdate() } + } + + val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) + throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } + } + }.launchIn(CoroutineScope(Dispatcher.DAEMON)) + } + } + + /** + * Dispose this [OnlineMultiplayer] instance by closing its background jobs and connections + */ + override fun dispose() { + ApiV2FileStorage.unsetApi() + pollChecker?.cancel() + events.stopReceiving() + if (isInitialized() && apiVersion == ApiVersion.APIv2) { + api.dispose() + } + } + companion object { fun usesCustomServer() = UncivGame.Current.settings.multiplayer.server != Constants.dropboxMultiplayerServer fun usesDropbox() = !usesCustomServer() diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt index cbfae1afe353f..e50b1e12e6051 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt @@ -1,7 +1,9 @@ package com.unciv.logic.multiplayer +import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.event.Event +import com.unciv.logic.multiplayer.apiv2.UpdateGameData interface HasMultiplayerGameName { val name: String @@ -62,3 +64,12 @@ class MultiplayerGameNameChanged( class MultiplayerGameDeleted( override val name: String ) : Event, HasMultiplayerGameName + +/** + * Gets sent when [UpdateGameData] has been processed by [OnlineMultiplayer], used to auto-load a game state + */ +class MultiplayerGameCanBeLoaded( + val gameInfo: GameInfo, + val gameName: String?, // optionally, the name of the game + val gameDataID: Long // server data ID for the game state +): Event diff --git a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt index b053be218ad23..37da361820a33 100644 --- a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt +++ b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt @@ -1,6 +1,5 @@ package com.unciv.logic.multiplayer - /** * This class is used to store the features of the server. * @@ -9,7 +8,9 @@ package com.unciv.logic.multiplayer * * Everything is optional, so if a feature is not present, it is assumed to be 0. * Dropbox does not support anything of this, so it will always be 0. + * + * @see [ApiVersion] */ data class ServerFeatureSet( - val authVersion: Int = 0, + val authVersion: Int = 0 ) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index cd796bb5d9746..700b366f27433 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -6,8 +6,7 @@ import com.unciv.logic.GameInfo import com.unciv.logic.event.Event import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.ApiVersion -import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator -import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper +import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.utils.Concurrency import com.unciv.utils.Log @@ -29,7 +28,6 @@ import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import java.time.Instant import java.util.Random @@ -38,6 +36,11 @@ import java.util.concurrent.atomic.AtomicReference /** * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] + * + * Do not directly initialize this class, but use [OnlineMultiplayer] instead, + * which will provide access via [OnlineMultiplayer.api] if everything has been set up. + * + * */ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { @@ -111,8 +114,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } } } - ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) - ApiV2FileStorageWrapper.api = this initialized = true } @@ -147,7 +148,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { job.cancel() } for (job in websocketJobs) { - runBlocking { + Concurrency.runBlocking { job.join() } } @@ -159,7 +160,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** * Determine if the remote server is compatible with this API implementation * - * This currently only checks the endpoints /api/version and /api/v2/ws. + * This currently only checks the endpoints `/api/version` and `/api/v2/ws`. * If the first returns a valid [VersionResponse] and the second a valid * [ApiErrorResponse] for being not authenticated, then the server API * is most likely compatible. Otherwise, if 404 errors or other unexpected @@ -336,7 +337,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * of milliseconds was reached or the sending of the ping failed. Note that ensuring * this limit is on a best effort basis and may not be reliable, since it uses * [delay] internally to quit waiting for the result of the operation. - * This function may also throw arbitrary exceptions for network failures. + * + * This function may also throw arbitrary exceptions for network failures, + * cancelled channels or other unexpected interruptions. */ suspend fun awaitPing(size: Int = 2, timeout: Long? = null): Double? { require(size < 2) { "Size too small to identify ping responses uniquely" } @@ -351,7 +354,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { var job: Job? = null if (timeout != null) { - job = Concurrency.run { + job = Concurrency.runOnNonDaemonThreadPool { delay(timeout) channel.close() } @@ -362,7 +365,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { if (!sendPing(body)) { return null } - val exception = runBlocking { channel.receive() } + // Using kotlinx.coroutines.runBlocking is fine here, since the caller should check for any + // exceptions, as written in the docs -- i.e., no suppressing of exceptions is expected here + val exception = kotlinx.coroutines.runBlocking { channel.receive() } job?.cancel() channel.close() if (exception != null) { diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt new file mode 100644 index 0000000000000..c59a015851d24 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt @@ -0,0 +1,102 @@ +package com.unciv.logic.multiplayer.storage + +import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.logic.multiplayer.storage.ApiV2FileStorage.api +import com.unciv.logic.multiplayer.storage.ApiV2FileStorage.setApi +import com.unciv.logic.multiplayer.storage.ApiV2FileStorage.unsetApi +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +private const val PREVIEW_SUFFIX = "_Preview" + +/** + * Transition helper that emulates file storage behavior using the [ApiVersion.APIv2] + * + * This storage implementation requires the initialization of its [api] instance, + * which must be provided by [setApi] before its first usage. If the [OnlineMultiplayer] + * is disposed, this object will be cleaned up as well via [unsetApi]. Afterwards, using + * this object to access only storage will **not** be possible anymore (NullPointerException). + * You need to call [setApi] again, which is automatically done when [OnlineMultiplayer] is + * initialized and [ApiVersion.APIv2] was detected. Take counter-measures to avoid + * race conditions of releasing [OnlineMultiplayer] and using this object concurrently. + */ +object ApiV2FileStorage : FileStorage { + private var api: ApiV2? = null + + internal fun setApi(api: ApiV2) { + this.api = api + } + + internal fun unsetApi() { + api = null + } + + override suspend fun saveGameData(gameId: String, data: String) { + val uuid = UUID.fromString(gameId.lowercase()) + api!!.game.upload(uuid, data) + } + + override suspend fun savePreviewData(gameId: String, data: String) { + // Not implemented for this API + Log.debug("Call to deprecated API 'savePreviewData'") + } + + override suspend fun loadGameData(gameId: String): String { + val uuid = UUID.fromString(gameId.lowercase()) + return api!!.game.get(uuid, cache = false)!!.gameData + } + + override suspend fun loadPreviewData(gameId: String): String { + // Not implemented for this API + Log.debug("Call to deprecated API 'loadPreviewData'") + // TODO: This could be improved, since this consumes more resources than necessary + return UncivFiles.gameInfoToString(UncivFiles.gameInfoFromString(loadGameData(gameId)).asPreview()) + } + + fun loadFileData(fileName: String): String { + return Concurrency.runBlocking { + if (fileName.endsWith(PREVIEW_SUFFIX)) { + loadPreviewData(fileName.dropLast(8)) + } else { + loadGameData(fileName) + } + }!! + } + + override suspend fun getFileMetaData(fileName: String): FileMetaData { + TODO("Not yet implemented") + } + + fun deleteFile(fileName: String) { + return Concurrency.runBlocking { + if (fileName.endsWith(PREVIEW_SUFFIX)) { + deletePreviewData(fileName.dropLast(8)) + } else { + deleteGameData(fileName) + } + }!! + } + + override suspend fun deleteGameData(gameId: String) { + TODO("Not yet implemented") + } + + override suspend fun deletePreviewData(gameId: String) { + // Not implemented for this API + Log.debug("Call to deprecated API 'deletedPreviewData'") + deleteGameData(gameId) + } + + override fun authenticate(userId: String, password: String): Boolean { + return Concurrency.runBlocking { api!!.auth.loginOnly(userId, password) }!! + } + + override fun setPassword(newPassword: String): Boolean { + return Concurrency.runBlocking { api!!.account.setPassword(newPassword, suppress = true) }!! + } + +} diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt deleted file mode 100644 index 9fb57d10f11b6..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.unciv.logic.multiplayer.storage - -import com.unciv.logic.files.UncivFiles -import com.unciv.logic.multiplayer.apiv2.ApiV2 -import com.unciv.utils.Log -import kotlinx.coroutines.runBlocking -import java.util.UUID - -private const val PREVIEW_SUFFIX = "_Preview" - -/** - * Transition helper that emulates file storage behavior using the API v2 - */ -class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { - - private suspend fun saveGameData(gameId: String, data: String) { - val uuid = UUID.fromString(gameId.lowercase()) - api.game.upload(uuid, data) - } - - @Suppress("UNUSED_PARAMETER") - private suspend fun savePreviewData(gameId: String, data: String) { - // Not implemented for this API - Log.debug("Call to deprecated API 'savePreviewData'") - } - - override fun saveFileData(fileName: String, data: String) { - return runBlocking { - if (fileName.endsWith(PREVIEW_SUFFIX)) { - savePreviewData(fileName.dropLast(8), data) - } else { - saveGameData(fileName, data) - } - } - } - - private suspend fun loadGameData(gameId: String): String { - val uuid = UUID.fromString(gameId.lowercase()) - return api.game.get(uuid, cache = false)!!.gameData - } - - private suspend fun loadPreviewData(gameId: String): String { - // Not implemented for this API - Log.debug("Call to deprecated API 'loadPreviewData'") - // TODO: This could be improved, since this consumes more resources than necessary - return UncivFiles.gameInfoToString(UncivFiles.gameInfoFromString(loadGameData(gameId)).asPreview()) - } - - override fun loadFileData(fileName: String): String { - return runBlocking { - if (fileName.endsWith(PREVIEW_SUFFIX)) { - loadPreviewData(fileName.dropLast(8)) - } else { - loadGameData(fileName) - } - } - } - - override fun getFileMetaData(fileName: String): FileMetaData { - TODO("Not yet implemented") - } - - override fun deleteFile(fileName: String) { - return runBlocking { - if (fileName.endsWith(PREVIEW_SUFFIX)) { - deletePreviewData(fileName.dropLast(8)) - } else { - deleteGameData(fileName) - } - } - } - - @Suppress("UNUSED_PARAMETER") - private suspend fun deleteGameData(gameId: String) { - TODO("Not yet implemented") - } - - private suspend fun deletePreviewData(gameId: String) { - // Not implemented for this API - Log.debug("Call to deprecated API 'deletedPreviewData'") - deleteGameData(gameId) - } - - override fun authenticate(userId: String, password: String): Boolean { - return runBlocking { api.auth.loginOnly(userId, password) } - } - - override fun setPassword(newPassword: String): Boolean { - return runBlocking { api.account.setPassword(newPassword, suppress = true) } - } - -} - -/** - * Workaround to "just get" the file storage handler and the API, but without initializing - * - * TODO: This wrapper should be replaced by better file storage initialization handling. - * - * This object keeps references which are populated during program startup at runtime. - */ -object ApiV2FileStorageWrapper { - var api: ApiV2? = null - var storage: ApiV2FileStorageEmulator? = null -} diff --git a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt index fccbeeb8eb70f..46f634960d1be 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt @@ -71,15 +71,23 @@ object DropBox: FileStorage { // This is the location in Dropbox only private fun getLocalGameLocation(fileName: String) = "/MultiplayerGames/$fileName" - override fun deleteFile(fileName: String){ + override suspend fun deleteGameData(gameId: String){ dropboxApi( url="https://api.dropboxapi.com/2/files/delete_v2", - data="{\"path\":\"${getLocalGameLocation(fileName)}\"}", + data="{\"path\":\"${getLocalGameLocation(gameId)}\"}", + contentType="application/json" + ) + } + + override suspend fun deletePreviewData(gameId: String){ + dropboxApi( + url="https://api.dropboxapi.com/2/files/delete_v2", + data="{\"path\":\"${getLocalGameLocation(gameId + PREVIEW_FILE_SUFFIX)}\"}", contentType="application/json" ) } - override fun getFileMetaData(fileName: String): FileMetaData { + override suspend fun getFileMetaData(fileName: String): FileMetaData { val stream = dropboxApi( url="https://api.dropboxapi.com/2/files/get_metadata", data="{\"path\":\"${getLocalGameLocation(fileName)}\"}", @@ -89,17 +97,31 @@ object DropBox: FileStorage { return json().fromJson(MetaData::class.java, reader.readText()) } - override fun saveFileData(fileName: String, data: String) { + override suspend fun saveGameData(gameId: String, data: String) { dropboxApi( url="https://content.dropboxapi.com/2/files/upload", data=data, contentType="application/octet-stream", - dropboxApiArg = """{"path":"${getLocalGameLocation(fileName)}","mode":{".tag":"overwrite"}}""" + dropboxApiArg = """{"path":"${getLocalGameLocation(gameId)}","mode":{".tag":"overwrite"}}""" )!! } - override fun loadFileData(fileName: String): String { - val inputStream = downloadFile(getLocalGameLocation(fileName)) + override suspend fun savePreviewData(gameId: String, data: String) { + dropboxApi( + url="https://content.dropboxapi.com/2/files/upload", + data=data, + contentType="application/octet-stream", + dropboxApiArg = """{"path":"${getLocalGameLocation(gameId + PREVIEW_FILE_SUFFIX)}","mode":{".tag":"overwrite"}}""" + ) + } + + override suspend fun loadGameData(gameId: String): String { + val inputStream = downloadFile(getLocalGameLocation(gameId)) + return BufferedReader(InputStreamReader(inputStream)).readText() + } + + override suspend fun loadPreviewData(gameId: String): String { + val inputStream = downloadFile(getLocalGameLocation(gameId + PREVIEW_FILE_SUFFIX)) return BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).readText() } diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt index 4ee74f0586135..0c8d8d29a4735 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt @@ -9,40 +9,65 @@ class FileStorageRateLimitReached(val limitRemainingSeconds: Int) : UncivShowabl class MultiplayerFileNotFoundException(cause: Throwable?) : UncivShowableException("File could not be found on the multiplayer server", cause) class MultiplayerAuthException(cause: Throwable?) : UncivShowableException("Authentication failed", cause) +const val PREVIEW_FILE_SUFFIX = "_Preview" + interface FileMetaData { fun getLastModified(): Date? } interface FileStorage { + /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerAuthException if the authentication failed */ - fun saveFileData(fileName: String, data: String) + suspend fun saveGameData(gameId: String, data: String) + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + suspend fun savePreviewData(gameId: String, data: String) + /** * @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 */ - fun loadFileData(fileName: String): String + suspend fun loadGameData(gameId: String): String /** * @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 */ - fun getFileMetaData(fileName: String): FileMetaData + suspend fun loadPreviewData(gameId: String): String + + /** + * @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 getFileMetaData(fileName: String): FileMetaData + + /** + * @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 + * @throws MultiplayerAuthException if the authentication failed + */ + suspend fun deleteGameData(gameId: String) /** * @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 * @throws MultiplayerAuthException if the authentication failed */ - fun deleteFile(fileName: String) + suspend fun deletePreviewData(gameId: String) + /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerAuthException if the authentication failed */ fun authenticate(userId: String, password: String): Boolean + /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerAuthException if the authentication failed */ fun setPassword(newPassword: String): Boolean + } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt index 98e9975b94c2d..2f6c6216ae5be 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt @@ -6,15 +6,18 @@ import com.unciv.json.json import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.ServerFeatureSet +import com.unciv.utils.Log +import java.io.FileNotFoundException /** * Allows access to games stored on a server for multiplayer purposes. - * Defaults to using UncivGame.Current.settings.multiplayerServer if fileStorageIdentifier is not given. + * Defaults to using [UncivGame.Current.settings.multiplayerServer] if `fileStorageIdentifier` is not given. * * For low-level access only, use [UncivGame.onlineMultiplayer] on [UncivGame.Current] if you're looking to load/save a game. * - * @param fileStorageIdentifier must be given if UncivGame.Current might not be initialized + * @param fileStorageIdentifier is a server base URL and must be given if UncivGame.Current might not be initialized * @see FileStorage * @see UncivGame.Current.settings.multiplayerServer */ @@ -32,10 +35,22 @@ class OnlineMultiplayerServer( mapOf("Authorization" to settings.getAuthHeader()) } else authenticationHeader - return if (serverUrl == Constants.dropboxMultiplayerServer) DropBox - else UncivServerFileStorage.apply { - serverUrl = this@OnlineMultiplayerServer.serverUrl - this.authHeader = authHeader + return if (serverUrl == Constants.dropboxMultiplayerServer) { + DropBox + } else { + if (!UncivGame.Current.onlineMultiplayer.isInitialized()) { + Log.debug("Uninitialized online multiplayer instance might result in errors later") + } + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + if (!UncivGame.Current.onlineMultiplayer.hasAuthentication() && !UncivGame.Current.onlineMultiplayer.api.isAuthenticated()) { + Log.error("User credentials not available, further execution may result in errors!") + } + return ApiV2FileStorage + } + UncivServerFileStorage.apply { + serverUrl = this@OnlineMultiplayerServer.serverUrl + this.authHeader = authHeader + } } } @@ -59,13 +74,12 @@ class OnlineMultiplayerServer( return statusOk } - /** * @return true if the authentication was successful or the server does not support authentication. * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerAuthException if the authentication failed */ - fun authenticate(password: String?): Boolean { + suspend fun authenticate(password: String?): Boolean { if (featureSet.authVersion == 0) return true val settings = UncivGame.Current.settings.multiplayer @@ -85,7 +99,7 @@ class OnlineMultiplayerServer( * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerAuthException if the authentication failed */ - fun setPassword(password: String): Boolean { + suspend fun setPassword(password: String): Boolean { if (featureSet.authVersion > 0 && fileStorage().setPassword(newPassword = password)) { val settings = UncivGame.Current.settings.multiplayer settings.passwords[settings.server] = password @@ -101,8 +115,13 @@ class OnlineMultiplayerServer( * @throws MultiplayerAuthException if the authentication failed */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { - val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo, forceZip = true, updateChecksum = true) - fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo) + // For APIv2 games, the JSON data needs to be valid JSON instead of minimal + val zippedGameInfo = if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + UncivFiles.gameInfoToPrettyString(gameInfo, useZip = true) + } else { + UncivFiles.gameInfoToString(gameInfo, forceZip = true, updateChecksum = true) + } + fileStorage().saveGameData(gameInfo.gameId, zippedGameInfo) // We upload the preview after the game because otherwise the following race condition will happen: // Current player ends turn -> Uploads Game Preview @@ -128,7 +147,7 @@ class OnlineMultiplayerServer( */ suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) { val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo) - fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo) + fileStorage().savePreviewData(gameInfo.gameId, zippedGameInfo) } /** @@ -136,7 +155,7 @@ class OnlineMultiplayerServer( * @throws FileNotFoundException if the file can't be found */ suspend fun tryDownloadGame(gameId: String): GameInfo { - val zippedGameInfo = fileStorage().loadFileData(gameId) + val zippedGameInfo = fileStorage().loadGameData(gameId) val gameInfo = UncivFiles.gameInfoFromString(zippedGameInfo) gameInfo.gameParameters.multiplayerServerUrl = UncivGame.Current.settings.multiplayer.server return gameInfo @@ -147,7 +166,7 @@ class OnlineMultiplayerServer( * @throws FileNotFoundException if the file can't be found */ suspend fun tryDownloadGamePreview(gameId: String): GameInfoPreview { - val zippedGameInfo = fileStorage().loadFileData("${gameId}_Preview") + val zippedGameInfo = fileStorage().loadPreviewData(gameId) return UncivFiles.gameInfoPreviewFromString(zippedGameInfo) } } diff --git a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt index f823fd0d6de0f..516099f318571 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt @@ -10,8 +10,8 @@ object UncivServerFileStorage : FileStorage { var serverUrl: String = "" var timeout: Int = 30000 - override fun saveFileData(fileName: String, data: String) { - SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(fileName), content=data, timeout=timeout, header=authHeader) { + override suspend fun saveGameData(gameId: String, data: String) { + SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(gameId), content=data, timeout=timeout, header=authHeader) { success, result, code -> if (!success) { debug("Error from UncivServer during save: %s", result) @@ -23,9 +23,13 @@ object UncivServerFileStorage : FileStorage { } } - override fun loadFileData(fileName: String): String { + override suspend fun savePreviewData(gameId: String, data: String) { + return saveGameData(gameId + PREVIEW_FILE_SUFFIX, data) + } + + override suspend fun loadGameData(gameId: String): String { var fileData = "" - SimpleHttp.sendGetRequest(fileUrl(fileName), timeout=timeout, header=authHeader) { + SimpleHttp.sendGetRequest(fileUrl(gameId), timeout=timeout, header=authHeader) { success, result, code -> if (!success) { debug("Error from UncivServer during load: %s", result) @@ -40,12 +44,16 @@ object UncivServerFileStorage : FileStorage { return fileData } - override fun getFileMetaData(fileName: String): FileMetaData { + override suspend fun loadPreviewData(gameId: String): String { + return loadGameData(gameId + PREVIEW_FILE_SUFFIX) + } + + override suspend fun getFileMetaData(fileName: String): FileMetaData { TODO("Not yet implemented") } - override fun deleteFile(fileName: String) { - SimpleHttp.sendRequest(Net.HttpMethods.DELETE, fileUrl(fileName), content="", timeout=timeout, header=authHeader) { + override suspend fun deleteGameData(gameId: String) { + SimpleHttp.sendRequest(Net.HttpMethods.DELETE, fileUrl(gameId), content="", timeout=timeout, header=authHeader) { success, result, code -> if (!success) { when (code) { @@ -56,6 +64,10 @@ object UncivServerFileStorage : FileStorage { } } + override suspend fun deletePreviewData(gameId: String) { + return deleteGameData(gameId + PREVIEW_FILE_SUFFIX) + } + override fun authenticate(userId: String, password: String): Boolean { var authenticated = false val preEncodedAuthValue = "$userId:$password" diff --git a/core/src/com/unciv/models/metadata/GameParameters.kt b/core/src/com/unciv/models/metadata/GameParameters.kt index 1c5d8351163a6..d1a4ee1c91482 100644 --- a/core/src/com/unciv/models/metadata/GameParameters.kt +++ b/core/src/com/unciv/models/metadata/GameParameters.kt @@ -18,7 +18,7 @@ class GameParameters : IsPartOfGameInfoSerialization { // Default values are the var randomNumberOfPlayers = false var minNumberOfPlayers = 3 var maxNumberOfPlayers = 3 - var players = ArrayList().apply { + var players: MutableList = ArrayList().apply { add(Player(playerType = PlayerType.Human)) repeat(3) { add(Player()) } } diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt new file mode 100644 index 0000000000000..a9dfe23a4b022 --- /dev/null +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -0,0 +1,32 @@ +package com.unciv.ui.components + +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.basescreen.BaseScreen + +open class SpecificButton(private val size: Float, private val path: String): Button(BaseScreen.skin) { + init { create() } + + private fun create() { + add(ImageGetter.getImage(path).apply { + setOrigin(Align.center) + setSize(size) + }) + } +} + +class RefreshButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Loading") +class SearchButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Search") +class ChatButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/DiplomacyW") +class CloseButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Close") +class MultiplayerButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Multiplayer") +class PencilButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Pencil") +class NewButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/New") +class ArrowButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/ArrowRight") +class CheckmarkButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Checkmark") +class OptionsButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Options") +class LockButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/LockSmall") +class SettingsButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButton(size, "OtherIcons/Settings") diff --git a/core/src/com/unciv/ui/popups/AuthPopup.kt b/core/src/com/unciv/ui/popups/AuthPopup.kt index abfca36af7938..1349ffa893fad 100644 --- a/core/src/com/unciv/ui/popups/AuthPopup.kt +++ b/core/src/com/unciv/ui/popups/AuthPopup.kt @@ -4,9 +4,10 @@ import com.badlogic.gdx.scenes.scene2d.Stage import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.unciv.UncivGame import com.unciv.ui.components.UncivTextField -import com.unciv.ui.components.input.onClick import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.onClick import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency class AuthPopup(stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) : Popup(stage) { @@ -20,7 +21,7 @@ class AuthPopup(stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) button.onClick { try { - UncivGame.Current.onlineMultiplayer.multiplayerServer.authenticate(passwordField.text) + Concurrency.runBlocking { UncivGame.Current.onlineMultiplayer.authenticate(passwordField.text) } authSuccessful?.invoke(true) close() } catch (_: Exception) { diff --git a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt new file mode 100644 index 0000000000000..fc34eacb6aa4b --- /dev/null +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -0,0 +1,68 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.toCheckBox +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.multiplayerscreens.LobbyScreen +import com.unciv.utils.Log + +/** + * Variant of [Popup] used to ask the questions related to opening a new [ApiVersion.APIv2] multiplayer lobby + */ +class CreateLobbyPopup(private val base: BaseScreen, me: AccountResponse) : Popup(base.stage, Scrollability.None) { + private var requirePassword: Boolean = false + private val nameField = UncivTextField.create("Lobby name", "${me.displayName}'s game").apply { this.maxLength = 64 } + private val passwordField = UncivTextField.create("Password", "").apply { this.maxLength = 64 } + private val checkbox = "Require password".toCheckBox(false) { + requirePassword = it + recreate() + } + + init { + if (base.game.onlineMultiplayer.apiVersion != ApiVersion.APIv2) { + Log.error("Popup to create a new lobby without a valid APIv2 server! This is not supported!") + } + recreate() + } + + private fun recreate() { + innerTable.clearChildren() + addGoodSizedLabel("Create new lobby", Constants.headingFontSize).center().colspan(2).row() + + addGoodSizedLabel("Please give your new lobby a recognizable name:").colspan(2).row() + add(nameField).growX().colspan(2).row() + + addGoodSizedLabel("You can choose to open a public lobby, where everyone may join, or protect it with a password.").colspan(2).row() + checkbox.isDisabled = false + checkbox.align(Align.left) + add(checkbox).colspan(2).row() + + if (requirePassword) { + add(passwordField).growX().colspan(2).row() + } + + addCloseButton() + addOKButton(action = ::onClose).row() + equalizeLastTwoButtonWidths() + } + + private fun onClose() { + Log.debug("Creating a new lobby '%s'", nameField.text) + val response = InfoPopup.load(base.stage) { + val openedLobby = base.game.onlineMultiplayer.api.lobby.open(nameField.text, if (requirePassword) passwordField.text else null) + if (openedLobby != null) { + base.game.onlineMultiplayer.api.lobby.get(openedLobby.lobbyUUID) + } else { + null + } + } + if (response != null) { + base.game.pushScreen(LobbyScreen(response)) + } + } + +} diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt new file mode 100644 index 0000000000000..900d9aef35472 --- /dev/null +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -0,0 +1,69 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.scenes.scene2d.Stage +import com.unciv.logic.UncivShowableException +import com.unciv.utils.Concurrency + +/** Variant of [Popup] with one label and a cancel button + * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] + * @param texts The texts for the popup, as separated good-sized labels + * @param action A lambda to execute when the button is pressed, after closing the popup + */ +class InfoPopup( + stageToShowOn: Stage, + vararg texts: String, + action: (() -> Unit)? = null +) : Popup(stageToShowOn) { + + init { + for (element in texts) { + addGoodSizedLabel(element).row() + } + addCloseButton(action = action).row() + open(force = true) + } + + companion object { + + /** + * Wrap the execution of a [coroutine] to display an [InfoPopup] when a [UncivShowableException] occurs + */ + suspend fun wrap(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { + try { + return coroutine() + } catch (e: UncivShowableException) { + Concurrency.runOnGLThread { + InfoPopup(stage, *texts, e.localizedMessage) + } + } + return null + } + + /** + * Show a loading popup while running a [coroutine] and return its optional result + * + * This function will display an [InfoPopup] when a [UncivShowableException] occurs. + */ + fun load(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { + val popup = Popup(stage).apply { addGoodSizedLabel("Working...").row() } + popup.open(force = true) + var result: T? = null + val job = Concurrency.run { + try { + result = coroutine() + Concurrency.runOnGLThread { + popup.close() + } + } catch (e: UncivShowableException) { + Concurrency.runOnGLThread { + popup.close() + InfoPopup(stage, *texts, e.localizedMessage) + } + } + } + Concurrency.runBlocking { job.join() } + return result + } + } + +} diff --git a/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt new file mode 100644 index 0000000000000..08cadf5ceff06 --- /dev/null +++ b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt @@ -0,0 +1,41 @@ +package com.unciv.ui.popups + +import com.unciv.logic.multiplayer.apiv2.IncomingInvite +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import kotlinx.coroutines.Job + +/** + * Popup that handles an [IncomingInvite] to a lobby + */ +class LobbyInvitationPopup( + baseScreen: BaseScreen, + private val lobbyInvite: IncomingInvite, + action: (() -> Unit)? = null +) : Popup(baseScreen) { + + private val api = baseScreen.game.onlineMultiplayer.api + private val setupJob: Job = Concurrency.run { + val lobby = api.lobby.get(lobbyInvite.lobbyUUID, suppress = true) + val name = lobby?.name ?: "?" + Concurrency.runOnGLThread { + addGoodSizedLabel("You have been invited to the lobby '[$name]' by ${lobbyInvite.from.displayName}. Do you want to accept this invitation? You will be headed to the lobby screen.").colspan(2).row() + addCloseButton(action = action) + addOKButton("Accept invitation") { + // TODO: Implement accepting invitations + ToastPopup("Accepting invitations is not yet implemented.", baseScreen.stage) + } + equalizeLastTwoButtonWidths() + row() + } + } + + suspend fun await() { + setupJob.join() + } + + override fun open(force: Boolean) { + Concurrency.runBlocking { setupJob.join() } + super.open(force) + } +} diff --git a/core/src/com/unciv/ui/popups/Popup.kt b/core/src/com/unciv/ui/popups/Popup.kt index 130ac7d94e947..89946c705566d 100644 --- a/core/src/com/unciv/ui/popups/Popup.kt +++ b/core/src/com/unciv/ui/popups/Popup.kt @@ -184,7 +184,7 @@ open class Popup( * Displays the Popup on the screen. If another popup is already open, this one will display after the other has * closed. Use [force] = true if you want to open this popup above the other one anyway. */ - fun open(force: Boolean = false) { + open fun open(force: Boolean = false) { stageToShowOn.addActor(this) recalculateInnerTableMaxHeight() innerTable.pack() diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt new file mode 100644 index 0000000000000..897f46f803b58 --- /dev/null +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -0,0 +1,212 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.scenes.scene2d.EventListener +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.InputListener +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.unciv.Constants +import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.models.translations.tr +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.activate +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import com.unciv.utils.launchOnGLThread + +/** + * Popup that asks for a username and password that should be used to login/register to APIv2 servers + * + * [UncivGame.Current.onlineMultiplayer] must be set to a [ApiVersion.APIv2] server, + * otherwise this pop-up will not work. It includes a popup window notifying the + * user that his/her player ID will be overwritten by the game, so that it's + * possible for the player to save the previous player ID somewhere to restore it. + */ +class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = false, private val authSuccessful: ((Boolean) -> Unit)? = null) : Popup(base.stage) { + + private val multiplayer = UncivGame.Current.onlineMultiplayer + private val usernameField = UncivTextField.create("Username") + private val passwordField = UncivTextField.create("Password") + private val loginButton = "Login".toTextButton() + private val registerButton = "Register".toTextButton() + private val listener: EventListener + + private var confirmationPopup: Popup? = null + + init { + /** Simple listener class for key presses on ENTER keys to trigger the login button */ + class SimpleEnterListener : InputListener() { + override fun keyUp(event: InputEvent?, keycode: Int): Boolean { + if (keycode in listOf(KeyCharAndCode.RETURN.code, KeyCharAndCode.NUMPAD_ENTER.code)) { + loginButton.activate() + } + return super.keyUp(event, keycode) + } + } + + listener = SimpleEnterListener() + + passwordField.isPasswordMode = true + passwordField.setPasswordCharacter('*') + + loginButton.onActivation { + if (usernameField.text == "") { + stage.keyboardFocus = usernameField + } else if (passwordField.text == "") { + stage.keyboardFocus = passwordField + } else { + stage.removeListener(listener) + login() + } + } + registerButton.onClick { + if (usernameField.text == "") { + stage.keyboardFocus = usernameField + } else if (passwordField.text == "") { + stage.keyboardFocus = passwordField + } else { + stage.removeListener(listener) + register() + } + } + + if (confirmUsage) { + confirmationPopup = askConfirmUsage { + build() + } + confirmationPopup?.open() + } else { + build() + } + } + + override fun close() { + stage?.removeListener(listener) + super.close() + } + + /** + * Build the popup stage + */ + private fun build() { + val negativeButtonStyle = BaseScreen.skin.get("negative", TextButton.TextButtonStyle::class.java) + + if (!multiplayer.isInitialized() || multiplayer.apiVersion != ApiVersion.APIv2) { + Log.error("Uninitialized online multiplayer instance or ${multiplayer.baseUrl} not APIv2 compatible") + addGoodSizedLabel("Uninitialized online multiplayer instance or ${multiplayer.baseUrl} not APIv2 compatible").colspan(2).row() + addCloseButton(style=negativeButtonStyle) { + stage?.removeListener(listener) + authSuccessful?.invoke(false) + }.growX().padRight(8f) + } else { + stage.addListener(listener) + + addGoodSizedLabel("It looks like you are playing for the first time on ${multiplayer.baseUrl} with this device. Please login if you have played on this server, otherwise you can register a new account as well.").colspan(3).row() + add(usernameField).colspan(3).growX().pad(16f, 0f, 16f, 0f).row() + add(passwordField).colspan(3).growX().pad(16f, 0f, 16f, 0f).row() + addCloseButton { + stage?.removeListener(listener) + authSuccessful?.invoke(false) + }.growX().padRight(8f) + add(registerButton).growX().padLeft(8f) + add(loginButton).growX().padLeft(8f).apply { keyShortcuts.add(KeyCharAndCode.RETURN) } + } + } + + private fun askConfirmUsage(block: () -> Unit): Popup { + val playerId = UncivGame.Current.settings.multiplayer.userId + val popup = Popup(base) + popup.addGoodSizedLabel("By using the new multiplayer servers, you overwrite your existing player ID. Games on other servers will not be accessible anymore, unless the player ID is properly restored. Keep your player ID safe before proceeding:").colspan(2) + popup.row() + popup.addGoodSizedLabel(playerId) + popup.addButton("Copy user ID") { + Gdx.app.clipboard.contents = base.game.settings.multiplayer.userId + ToastPopup("UserID copied to clipboard", base).open(force = true) + } + popup.row() + val cell = popup.addCloseButton(Constants.OK, action = block) + cell.colspan(2) + cell.actor.keyShortcuts.add(KeyCharAndCode.ESC) + cell.actor.keyShortcuts.add(KeyCharAndCode.BACK) + cell.actor.keyShortcuts.add(KeyCharAndCode.RETURN) + return popup + } + + private fun createPopup(msg: String? = null, force: Boolean = false): Popup { + val popup = Popup(base.stage) + popup.addGoodSizedLabel(msg?: "Working...") + popup.open(force) + return popup + } + + private fun login() { + val popup = createPopup(force = true) + Concurrency.run { + try { + val success = UncivGame.Current.onlineMultiplayer.api.auth.login( + usernameField.text, passwordField.text + ) + launchOnGLThread { + Log.debug("Updating username and password after successfully authenticating") + UncivGame.Current.settings.multiplayer.userName = usernameField.text + UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.onlineMultiplayer.baseUrl] = passwordField.text + UncivGame.Current.settings.save() + popup.close() + stage?.removeListener(listener) + close() + authSuccessful?.invoke(success) + } + } catch (e: UncivShowableException) { + launchOnGLThread { + popup.close() + InfoPopup(base.stage, e.localizedMessage) { + stage?.addListener(listener) + authSuccessful?.invoke(false) + } + } + } + } + } + + private fun register() { + val popup = createPopup(force = true) + Concurrency.run { + try { + UncivGame.Current.onlineMultiplayer.api.account.register( + usernameField.text, usernameField.text, passwordField.text + ) + UncivGame.Current.onlineMultiplayer.api.auth.login( + usernameField.text, passwordField.text + ) + launchOnGLThread { + Log.debug("Updating username and password after successfully authenticating") + UncivGame.Current.settings.multiplayer.userName = usernameField.text + UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.onlineMultiplayer.baseUrl] = passwordField.text + UncivGame.Current.settings.save() + popup.close() + close() + InfoPopup(base.stage, "Successfully registered new account".tr()) { + stage?.removeListener(listener) + authSuccessful?.invoke(true) + } + } + } catch (e: UncivShowableException) { + launchOnGLThread { + popup.close() + InfoPopup(base.stage, e.localizedMessage) { + stage?.addListener(listener) + authSuccessful?.invoke(false) + } + } + } + } + } +} diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 2765fec6dd837..fb1dc9be3ccb4 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -5,31 +5,37 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.unciv.UncivGame +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.models.UncivSound import com.unciv.models.metadata.GameSetting import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.translations.tr import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.brighten import com.unciv.ui.components.extensions.format import com.unciv.ui.components.extensions.isEnabled -import com.unciv.ui.components.input.onChange -import com.unciv.ui.components.input.onClick import com.unciv.ui.components.extensions.toGdxArray import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.onChange +import com.unciv.ui.components.input.onClick import com.unciv.ui.popups.AuthPopup +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.options.SettingsSelect.SelectItem import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Concurrency +import com.unciv.utils.Log import com.unciv.utils.launchOnGLThread import java.time.Duration import java.time.temporal.ChronoUnit +import java.util.UUID fun multiplayerTab( optionsPopup: OptionsPopup @@ -147,9 +153,23 @@ private fun addMultiplayerServerOptions( multiplayerServerTextField.programmaticChangeEvents = true val serverIpTable = Table() + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + val multiplayerUsernameTextField = UncivTextField.create("Multiplayer username") + multiplayerUsernameTextField.text = settings.multiplayer.userName + multiplayerUsernameTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" } + serverIpTable.add("Multiplayer username".toLabel()).colspan(2).row() + serverIpTable.add(multiplayerUsernameTextField) + .minWidth(optionsPopup.stageToShowOn.width / 2.5f) + .growX().padBottom(8f) + serverIpTable.add("Save username".toTextButton().onClick { + settings.multiplayer.userName = multiplayerUsernameTextField.text + settings.save() + }).padBottom(8f).row() + } + serverIpTable.add("Server address".toLabel().onClick { multiplayerServerTextField.text = Gdx.app.clipboard.contents - }).colspan(2).row() + }).colspan(2).row() multiplayerServerTextField.onChange { val isCustomServer = OnlineMultiplayer.usesCustomServer() connectionToServerButton.isEnabled = isCustomServer @@ -174,23 +194,90 @@ private fun addMultiplayerServerOptions( addGoodSizedLabel("Awaiting response...").row() open(true) } - - successfullyConnectedToServer { connectionSuccess, authSuccess -> - if (connectionSuccess && authSuccess) { - popup.reuseWith("Success!", true) - } else if (connectionSuccess) { - popup.close() - AuthPopup(optionsPopup.stageToShowOn) { - success -> popup.apply{ - reuseWith(if (success) "Success!" else "Failed!", true) - open(true) + Concurrency.runOnNonDaemonThreadPool { + try { + val apiVersion = ApiVersion.detect(multiplayerServerTextField.text, suppress = false) + if (apiVersion == ApiVersion.APIv1) { + val authSuccess = try { + UncivGame.Current.onlineMultiplayer.authenticate(null) + } catch (e: Exception) { + Log.debug("Failed to authenticate: %s", e.localizedMessage) + false } - }.open(true) - } else { - popup.reuseWith("Failed!", true) + if (!authSuccess) { + Concurrency.runOnGLThread { + popup.close() + AuthPopup(optionsPopup.stageToShowOn) { success -> + if (success) { + popup.reuseWith("Success! Detected $apiVersion!\nPlease wait...", false) + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } + } + } else { + popup.reuseWith("Failed!", true) + } + popup.open(true) + }.open(true) + } + } else { + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!\nPlease wait...", false) + } + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } + } + } + } else if (apiVersion != null) { + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!\nPlease wait...", false) + } + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } + } + } else { + Log.debug("Api version detection: null") + Concurrency.runOnGLThread { + popup.reuseWith("Failed!", true) + } + } + } catch (e: Exception) { + Log.debug("Connectivity exception: %s", e.localizedMessage) + Concurrency.runOnGLThread { + popup.reuseWith("Failed!", true) + } } + /* + successfullyConnectedToServer { connectionSuccess, authSuccess -> + if (connectionSuccess && authSuccess) { + popup.reuseWith("Success!", true) + } else if (connectionSuccess) { + if (UncivGame.Current.onlineMultiplayer.apiVersion != ApiVersion.APIv2) { + popup.close() + AuthPopup(optionsPopup.stageToShowOn) { + success -> popup.apply{ + reuseWith(if (success) "Success!" else "Failed!", true) + open(true) + } + }.open(true) + } else { + popup.reuseWith("Success!", true) + } + } else { + popup.reuseWith("Failed!", true) + } + } + */ } - }).row() + }).colspan(2).row() if (UncivGame.Current.onlineMultiplayer.multiplayerServer.featureSet.authVersion > 0) { val passwordTextField = UncivTextField.create( @@ -217,6 +304,65 @@ private fun addMultiplayerServerOptions( serverIpTable.add(passwordStatusTable).colspan(2).row() } + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + val logoutButton = "Logout".toTextButton() + logoutButton.onClick { + // Setting the button text as user response isn't the most beautiful way, but the easiest + logoutButton.setText("Loading...".tr()) + settings.multiplayer.passwords.remove(settings.multiplayer.server) + settings.save() + Concurrency.run { + try { + UncivGame.Current.onlineMultiplayer.api.auth.logout() + Concurrency.runOnGLThread { + // Since logging out is not possible anyways afterwards, just disable the button action + logoutButton.setText("Logout successfully".tr()) + logoutButton.onClick { } + } + } catch (e: ApiException) { + Concurrency.runOnGLThread { + logoutButton.setText(e.localizedMessage) + } + } + } + } + + val setUserIdButton = "Set user ID".toTextButton() + setUserIdButton.onClick { + val popup = Popup(optionsPopup.stageToShowOn) + popup.addGoodSizedLabel("You can restore a previous user ID here if you want to change back to another multiplayer server. Just insert your old user ID below or copy it from your clipboard. Note that changing the user ID has no effect for newer multiplayer servers, because it would be overwritten by login.").colspan(4).row() + + val inputField = UncivTextField.create("User ID") + popup.add(inputField).growX().colspan(3) + popup.add("From clipboard".toTextButton().onClick { + inputField.text = Gdx.app.clipboard.contents + }).padLeft(10f).row() + + popup.addCloseButton().colspan(2) + popup.addOKButton { + val newUserID = inputField.text + try { + UUID.fromString(newUserID) + Log.debug("Writing new user ID '%s'", newUserID) + UncivGame.Current.settings.multiplayer.userId = newUserID + UncivGame.Current.settings.save() + } catch (_: IllegalArgumentException) { + InfoPopup(optionsPopup.stageToShowOn, "This user ID seems to be invalid.") + } + }.colspan(2).row() + popup.open(force = true) + } + + val wrapper = Table() + if (UncivGame.Current.onlineMultiplayer.hasAuthentication()) { + wrapper.add(logoutButton).padRight(8f) + wrapper.add(setUserIdButton) + } else { + wrapper.add(setUserIdButton) + } + serverIpTable.add(wrapper).colspan(2).padTop(8f).row() + } + tab.add(serverIpTable).colspan(2).fillX().row() } @@ -252,6 +398,7 @@ private fun addTurnCheckerOptions( return turnCheckerSelect } +/* private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { Concurrency.run("TestIsAlive") { try { @@ -274,6 +421,7 @@ private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { } } } + */ private fun setPassword(password: String, optionsPopup: OptionsPopup) { if (password.isBlank()) diff --git a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt index e622eaaf4e8e4..007e0c7fcf63b 100644 --- a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt +++ b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt @@ -16,6 +16,12 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.badlogic.gdx.scenes.scene2d.utils.Drawable import com.badlogic.gdx.utils.viewport.ExtendViewport import com.unciv.UncivGame +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.apiv2.FriendshipChanged +import com.unciv.logic.multiplayer.apiv2.FriendshipEvent +import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage +import com.unciv.logic.multiplayer.apiv2.IncomingFriendRequest +import com.unciv.logic.multiplayer.apiv2.IncomingInvite import com.unciv.models.TutorialTrigger import com.unciv.models.skins.SkinStrings import com.unciv.ui.components.Fonts @@ -28,8 +34,11 @@ import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.crashhandling.CrashScreen import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.LobbyInvitationPopup +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.activePopup import com.unciv.ui.popups.options.OptionsPopup +import com.unciv.utils.Concurrency // Both `this is CrashScreen` and `this::createPopupBasedDispatcherVetoer` are flagged. // First - not a leak; second - passes out a pure function @@ -64,6 +73,39 @@ abstract class BaseScreen : Screen { @Suppress("LeakingThis") stage.installShortcutDispatcher(globalShortcuts, this::createDispatcherVetoer) + + // Handling chat messages and friend requests is done here so that it displays on every screen + val events = EventBus.EventReceiver() + events.receive(IncomingChatMessage::class, null) { + if (it.message.sender.uuid.toString() != game.settings.multiplayer.userId) { + ToastPopup("You received a new text message from [${it.message.sender.displayName}]:\n[${it.message.message}]", this) + } + } + events.receive(IncomingFriendRequest::class, null) { + ToastPopup("You received a friend request from [${it.from.displayName}]", this) + } + events.receive(FriendshipChanged::class, null) { + when (it.event) { + FriendshipEvent.Accepted -> { + ToastPopup("[${it.friend.displayName}] accepted your friend request", this) + } + FriendshipEvent.Rejected -> { + ToastPopup("[${it.friend.displayName}] rejected your friend request", this) + } + FriendshipEvent.Deleted -> { + ToastPopup("[${it.friend.displayName}] removed you as a friend", this) + } + } + } + events.receive(IncomingInvite::class, null) { + val lobbyInvitationPopup = LobbyInvitationPopup(this, it) + Concurrency.run { + lobbyInvitationPopup.await() + Concurrency.runOnGLThread { + lobbyInvitationPopup.open() + } + } + } } /** Hook allowing derived Screens to supply a key shortcut vetoer that can exclude parts of the diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index b0811f0413c3a..a022e8b9cfd24 100644 --- a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt @@ -17,6 +17,7 @@ import com.unciv.logic.map.MapSize import com.unciv.logic.map.MapSizeNew import com.unciv.logic.map.MapType import com.unciv.logic.map.mapgenerator.MapGenerator +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.models.metadata.BaseRuleset import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.Ruleset @@ -35,6 +36,7 @@ import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.tilegroups.TileGroupMap import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.RegisterLoginPopup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.closeAllPopups import com.unciv.ui.popups.hasOpenPopups @@ -45,6 +47,7 @@ import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen import com.unciv.ui.screens.mainmenuscreen.EasterEggRulesets.modifyForEasterEgg import com.unciv.ui.screens.mapeditorscreen.EditorMapHolder import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen +import com.unciv.ui.screens.multiplayerscreens.LobbyBrowserScreen import com.unciv.ui.screens.multiplayerscreens.MultiplayerScreen import com.unciv.ui.screens.newgamescreen.NewGameScreen import com.unciv.ui.screens.modmanager.ModManagementScreen @@ -53,6 +56,7 @@ import com.unciv.ui.screens.savescreens.QuickSave import com.unciv.ui.screens.worldscreen.BackgroundActor import com.unciv.ui.screens.worldscreen.WorldScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup +import com.unciv.utils.Log import com.unciv.utils.Concurrency import com.unciv.utils.launchOnGLThread import kotlinx.coroutines.Job @@ -151,8 +155,23 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { { game.pushScreen(LoadGameScreen()) } column1.add(loadGameTable).row() - val multiplayerTable = getMenuButton("Multiplayer", "OtherIcons/Multiplayer", KeyboardBinding.Multiplayer) - { game.pushScreen(MultiplayerScreen()) } + val multiplayerTable = getMenuButton("Multiplayer", "OtherIcons/Multiplayer", KeyboardBinding.Multiplayer) { + // Awaiting an initialized multiplayer instance here makes later usage in the multiplayer screen easier + val popup = Popup(stage) + popup.addGoodSizedLabel("Loading...") + if (!game.onlineMultiplayer.isInitialized()) { + popup.open() + Concurrency.runOnNonDaemonThreadPool { + game.onlineMultiplayer.awaitInitialized() + Concurrency.runOnGLThread { + popup.close() + openMultiplayerMenu() + } + } + } else { + openMultiplayerMenu() + } + } column2.add(multiplayerTable).row() val mapEditorScreenTable = getMenuButton("Map editor", "OtherIcons/MapEditor", KeyboardBinding.MapEditor) @@ -264,6 +283,54 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { currentJob.cancel() } + /** + * Helper to open the multiplayer menu table + */ + private fun openMultiplayerMenu() { + // The API version of the currently selected game server decides which screen will be shown + if (game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + if (!game.onlineMultiplayer.hasAuthentication()) { + Log.debug("Opening the register popup since no auth credentials were found for the server %s", game.onlineMultiplayer.baseUrl) + RegisterLoginPopup(this, confirmUsage = true) { + Log.debug("Register popup success state: %s", it) + if (it) { + game.pushScreen(LobbyBrowserScreen()) + } + }.open() + } else { + // Authentication is handled before the multiplayer screen is shown + if (!game.onlineMultiplayer.api.isAuthenticated()) { + val popup = Popup(stage) + popup.addGoodSizedLabel("Loading...") + popup.open() + Concurrency.run { + if (game.onlineMultiplayer.api.refreshSession()) { + Concurrency.runOnGLThread { + popup.close() + game.pushScreen(LobbyBrowserScreen()) + } + } else { + game.onlineMultiplayer.api.auth.logout(true) + Concurrency.runOnGLThread { + popup.close() + RegisterLoginPopup(this@MainMenuScreen, confirmUsage = true) { + Log.debug("Register popup success state: %s", it) + if (it) { + game.pushScreen(LobbyBrowserScreen()) + } + }.open() + } + } + } + } else { + game.pushScreen(LobbyBrowserScreen()) + } + } + } else { + game.pushScreen(MultiplayerScreen()) + } + } + private fun resumeGame() { if (GUI.isWorldLoaded()) { val currentTileSet = GUI.getMap().currentTileSetStrings diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt new file mode 100644 index 0000000000000..a4f1c97355889 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -0,0 +1,212 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.badlogic.gdx.utils.Disposable +import com.unciv.Constants +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.apiv2.ChatMessage +import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage +import com.unciv.models.translations.tr +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.extensions.formatShort +import com.unciv.ui.components.extensions.setFontColor +import com.unciv.ui.components.extensions.setFontSize +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant +import java.util.UUID + +/** Interval to redraw the chat message list in milliseconds */ +private const val REDRAW_INTERVAL = 5000L + +/** + * Simple list for messages from a multiplayer chat + * + * You most likely want to center this actor in a parent and wrap it in a + * [AutoScrollPane] and set [AutoScrollPane.setScrollingDisabled] for the X direction. + * You **must** set [parent] to this actor's parent, otherwise width calculations won't + * work, which is required to avoid scrolling in X direction due to overfull lines of text. + * + * @sample + * val chatMessages = ChatMessageList(UUID.randomUUID()) + * val chatScroll = AutoScrollPane(chatMessages, skin) + * chatScroll.setScrollingDisabled(true, false) + * + * Another good way is to use the [ChatTable] directly. Make sure to [dispose] + * this table, since it holds a coroutine which updates itself periodically. + */ +class ChatMessageList(private val showHeading: Boolean, private val type: Pair, private val chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table(), Disposable { + private val events = EventBus.EventReceiver() + private var messageCache: MutableList = mutableListOf() + private var redrawJob: Job = Concurrency.run { redrawPeriodically() } + private val listeners = mutableListOf<(Boolean) -> Unit>() + + init { + defaults().expandX().space(5f) + recreate(messageCache) + Concurrency.run { + var s: Stage? = stage + while (s == null) { + delay(10) + Concurrency.runOnGLThread { + s = stage + } + } + triggerRefresh(s!!) + } + + events.receive(IncomingChatMessage::class, { it.chatUUID == chatRoomUUID }) { + messageCache.add(it.message) + Concurrency.runOnGLThread { + recreate(messageCache) + for (listener in listeners) { + Concurrency.run { + listener(true) + } + } + } + } + } + + /** + * Send a [message] to the chat room by dispatching a coroutine which handles it + * + * Use [suppress] to avoid showing an [InfoPopup] for any failures. + */ + fun sendMessage(message: String, suppress: Boolean = false) { + if (message == "") { + return + } + Concurrency.run { + if (suppress) { + mp.api.chat.send(message, chatRoomUUID) + } else { + InfoPopup.wrap(stage) { + mp.api.chat.send(message, chatRoomUUID) + } + } + } + } + + /** + * Trigger a background refresh of the chat messages + * + * This will update the messages of the chat room by querying the server + * and then recreate the message list in a separate coroutine. + * Use [suppress] to avoid showing an [InfoPopup] for any failures. + */ + fun triggerRefresh(stage: Stage, suppress: Boolean = false) { + Concurrency.run { + if (suppress) { + val chatInfo = mp.api.chat.get(chatRoomUUID, true) + if (chatInfo != null) { + Concurrency.runOnGLThread { + messageCache = chatInfo.messages.toMutableList() + recreate(chatInfo.messages) + } + } + } else { + InfoPopup.wrap(stage) { + val chatInfo = mp.api.chat.get(chatRoomUUID, false) + if (chatInfo != null) { + Concurrency.runOnGLThread { + messageCache = chatInfo.messages.toMutableList() + recreate(chatInfo.messages) + } + } + } + } + } + } + + /** + * Recreate the table of messages using the given list of chat messages + */ + fun recreate(messages: List) { + clearChildren() + if (showHeading) { + add("${type.first.name} chat: ${type.second}".toLabel(fontSize = Constants.headingFontSize).apply { setAlignment(Align.center) }).growX().row() + } + + if (messages.isEmpty()) { + val label = "No messages here yet".toLabel() + label.setAlignment(Align.center) + add(label).fillX().fillY().center() + return + } + + val now = Instant.now() + for (message in messages) { + row() + addMessage(message, now) + } + + for (listener in listeners) { + Concurrency.run { + listener(false) + } + } + } + + /** + * Add a single message to the list of chat messages + */ + private fun addMessage(message: ChatMessage, now: Instant? = null) { + val time = "[${Duration.between(message.createdAt, now ?: Instant.now()).formatShort()}] ago".tr() + val infoLine = Label("${message.sender.displayName}, $time:", BaseScreen.skin) + infoLine.setFontColor(Color.GRAY) + infoLine.setFontSize(Constants.smallFontSize) + infoLine.setAlignment(Align.left) + add(infoLine).fillX() + row() + val msg = Label(message.message, BaseScreen.skin) + msg.setAlignment(Align.left) + msg.wrap = true + add(msg).fillX() + row() + } + + /** + * Redraw the chat message list in the background periodically (see [REDRAW_INTERVAL]) + * + * This function doesn't contain any networking functionality. Cancel it via [dispose]. + */ + private suspend fun redrawPeriodically() { + while (true) { + delay(REDRAW_INTERVAL) + Concurrency.runOnGLThread { + recreate(messageCache) + } + } + } + + /** + * Add a listener that gets called whenever the [recreate] function completed + * + * The callback function will be executed in a daemon thread without the GL context. + * The only argument of the callback will be set to true when [recreate] finished after + * an incoming message, otherwise false. + */ + fun addListener(callback: (Boolean) -> Unit) { + listeners.add(callback) + } + + /** + * Dispose this instance and cancel the [redrawJob] + */ + override fun dispose() { + events.stopReceiving() + redrawJob.cancel() + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomType.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomType.kt new file mode 100644 index 0000000000000..84e95ce250a8f --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomType.kt @@ -0,0 +1,8 @@ +package com.unciv.ui.screens.multiplayerscreens + +/** + * Enum of the three different chat room types currently available in APIv2 + */ +enum class ChatRoomType { + Friend, Game, Lobby; +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt new file mode 100644 index 0000000000000..2923a1bc6aa25 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -0,0 +1,103 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Disposable +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency + +/** + * A [Table] which combines [ChatMessageList] with a text input and send button to write a new message + * + * Optionally, it can replace the send message text box with a popup that asks for a message. + */ +class ChatTable( + private val chatMessageList: ChatMessageList, + useInputPopup: Boolean = false, + actorHeight: Float? = null, + maxMessageLength: Int? = null +): Table(), Disposable { + internal val messageField = UncivTextField.create("New message") + + init { + val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) + + // Callback listener to scroll to the bottom of the chat message list if the + // list has been provided with the initial messages or if a new message just arrived + var initialScrollToBottom = false + chatMessageList.addListener { + Concurrency.runOnGLThread { + if (chatScroll.maxY > 0f) { + if (!initialScrollToBottom) { + initialScrollToBottom = true + chatScroll.scrollY = chatScroll.maxY + } else if (it) { + chatScroll.scrollY = chatScroll.maxY + } + } + } + } + + val chatCell = add(chatScroll) + if (actorHeight != null) { + chatCell.actorHeight = actorHeight + } + val width = 2 + chatCell.colspan(width).fillX().expandY().padBottom(10f) + row() + + if (useInputPopup) { + val newButton = "New message".toTextButton() + newButton.onActivation { + val popup = Popup(stage) + popup.addGoodSizedLabel("Enter your new chat message below:").colspan(2).row() + val textField = UncivTextField.create("New message") + if (maxMessageLength != null) { + textField.maxLength = maxMessageLength + } + popup.add(textField).growX().padBottom(5f).colspan(2).minWidth(stage.width * 0.25f).row() + popup.addCloseButton() + popup.addOKButton { + chatMessageList.sendMessage(textField.text) + } + popup.equalizeLastTwoButtonWidths() + popup.open(force = true) + } + newButton.keyShortcuts.add(KeyCharAndCode.RETURN) + add(newButton).growX().padRight(10f).padLeft(10f).row() + + } else { + if (maxMessageLength != null) { + messageField.maxLength = maxMessageLength + } + val sendButton = ArrowButton() + sendButton.onActivation { + sendMessage() + } + sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) + + add(messageField).padLeft(5f).growX() + add(sendButton).padLeft(10f).padRight(5f) + row() + } + } + + /** + * Simulate the button click on the send button (useful for scripts or hotkeys) + */ + fun sendMessage() { + chatMessageList.sendMessage(messageField.text) + messageField.text = "" + } + + override fun dispose() { + chatMessageList.dispose() + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt new file mode 100644 index 0000000000000..51053fe297011 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -0,0 +1,253 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.Constants +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.logic.multiplayer.apiv2.ApiStatusCode +import com.unciv.logic.multiplayer.apiv2.FriendRequestResponse +import com.unciv.logic.multiplayer.apiv2.FriendResponse +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.ChatButton +import com.unciv.ui.components.CheckmarkButton +import com.unciv.ui.components.CloseButton +import com.unciv.ui.components.OptionsButton +import com.unciv.ui.components.SearchButton +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.addSeparatorVertical +import com.unciv.ui.components.extensions.setFontColor +import com.unciv.ui.components.extensions.setFontSize +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.popups.ConfirmPopup +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.multiplayerscreens.FriendListV2.Companion.showRemoveFriendshipPopup +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +/** + * A [Table] to display the user's friends as a convenient list + * + * Set [me] to the currently logged in user to correctly filter friend requests (if enabled). + * Set [requests] to show friend requests with buttons to accept or deny the request at the + * top. Use [chat] to specify a callback when a user wants to chat with someone. Use + * [select] to specify a callback that can be used to select a player by clicking a button + * next to it. Use [edit] to specify a callback that can be used to edit a friend. + * [chat], [select] and [edit] receive the friendship [UUID] as well as the [AccountResponse] + * of the friend. Only [chat] gets called with the UUID of the chat room as well. + * + * A sane default for the [edit] functionality is the [showRemoveFriendshipPopup] function. + * This table should be encapsulated into a [base]screen or pop-up containing one. + */ +internal class FriendListV2( + private val base: BaseScreen, + private val me: UUID, + friends: List = listOf(), + friendRequests: List = listOf(), + val requests: Boolean = false, + val chat: ((UUID, AccountResponse, UUID) -> Unit)? = null, + val select: ((UUID, AccountResponse) -> Unit)? = null, + val edit: ((UUID, AccountResponse) -> Unit)? = null +) : Table() { + init { + recreate(friends, friendRequests) + } + + /** + * Trigger a background refresh of the friend lists and recreate the table + * + * Use [suppress] to avoid showing an [InfoPopup] for any failures. + */ + fun triggerUpdate(suppress: Boolean = false) { + Concurrency.run { + if (suppress) { + val friendInfo = base.game.onlineMultiplayer.api.friend.list(true) + if (friendInfo != null) { + Concurrency.runOnGLThread { + recreate(friendInfo.first, friendInfo.second) + } + } + } else { + InfoPopup.wrap(base.stage) { + val friendInfo = base.game.onlineMultiplayer.api.friend.list(false) + if (friendInfo != null) { + Concurrency.runOnGLThread { + recreate(friendInfo.first, friendInfo.second) + } + } + } + } + } + } + + /** + * Recreate the table containing friends, requests and all those buttons + */ + fun recreate(friends: List, friendRequests: List = listOf()) { + val body = Table() + if (requests) { + body.add(getRequestTable(friendRequests)).padBottom(10f).growX().row() + body.addSeparatorVertical(Color.DARK_GRAY, 1f).padBottom(10f).row() + } + body.add(getFriendTable(friends)).growX() + + val scroll = AutoScrollPane(body, BaseScreen.skin) + scroll.setScrollingDisabled(true, false) + clearChildren() + add(scroll) + row() + } + + /** + * Construct the table containing friends + */ + private fun getFriendTable(friends: List): Table { + val table = Table(BaseScreen.skin) + if (friends.isEmpty()) { + table.add("You have no friends yet :/") + return table + } + + val width = 2 + (if (chat != null) 1 else 0) + (if (edit != null) 1 else 0) + (if (select != null) 1 else 0) + table.add("Friends".toLabel(fontSize = Constants.headingFontSize)).colspan(width).padBottom(10f).row() + + for (friend in friends) { + table.add("${friend.friend.displayName} (${friend.friend.username})").padBottom(5f) + if (chat != null) { + table.add(ChatButton().apply { onActivation { (chat)(friend.uuid, friend.friend.to(), friend.chatUUID) } }).padLeft(5f).padBottom(5f) + } + if (edit != null) { + table.add(OptionsButton().apply { onActivation { (edit)(friend.uuid, friend.friend.to()) } }).padLeft(5f).padBottom(5f) + } + if (select != null) { + table.add(ArrowButton().apply { onActivation { (select)(friend.uuid, friend.friend.to()) } }).padLeft(5f).padBottom(5f) + } + table.row() + } + return table + } + + /** + * Construct the table containing friend requests + */ + private fun getRequestTable(friendRequests: List): Table { + val table = Table(BaseScreen.skin) + table.add("Friend requests".toLabel(fontSize = Constants.headingFontSize)).colspan(3).padBottom(10f).row() + + val nameField = UncivTextField.create("Search player") + val searchButton = SearchButton() + searchButton.onActivation { + val searchString = nameField.text + if (searchString == "") { + return@onActivation + } + + Log.debug("Searching for player '%s'", searchString) + Concurrency.run { + val response = InfoPopup.wrap(base.stage) { + try { + base.game.onlineMultiplayer.api.account.lookup(searchString) + } catch (exc: ApiException) { + if (exc.error.statusCode == ApiStatusCode.InvalidUsername) { + Concurrency.runOnGLThread { + ToastPopup("No player [$searchString] found", stage).open(force = true) + } + null + } else { + throw exc + } + } + } + if (response != null) { + Concurrency.runOnGLThread { + Log.debug("Looked up '%s' as '%s'", response.username, response.uuid) + if (response.uuid == me) { + InfoPopup(base.stage, "You can't request a friendship from yourself!").open() + return@runOnGLThread + } + ConfirmPopup( + base.stage, + "Do you want to send [${response.username}] a friend request?", + "Yes", + true + ) { + InfoPopup.load(base.stage) { + base.game.onlineMultiplayer.api.friend.request(response.uuid) + Concurrency.runOnGLThread { + nameField.text = "" + } + } + }.open(force = true) + } + } + } + } + + searchButton.keyShortcuts.add(KeyCharAndCode.RETURN) + val nameCell = table.add(nameField).padLeft(5f).padRight(5f).padBottom(15f).growX() + if (friendRequests.isNotEmpty()) { + nameCell.colspan(2) + } + table.add(searchButton).padBottom(15f) + table.row() + + for (request in friendRequests.filter { it.to.uuid == me }) { + table.add("${request.from.displayName} (${request.from.username})").padBottom(5f) + table.add(CheckmarkButton().apply { onActivation { + InfoPopup.load(stage) { + base.game.onlineMultiplayer.api.friend.accept(request.uuid) + triggerUpdate() + } + } }).padBottom(5f).padLeft(5f) + table.add(CloseButton().apply { onActivation { + InfoPopup.load(stage) { + base.game.onlineMultiplayer.api.friend.delete(request.uuid) + triggerUpdate() + } + } }).padBottom(5f).padLeft(5f) + table.row() + } + + if (friendRequests.any { it.from.uuid == me }) { + table.addSeparator(color = Color.LIGHT_GRAY).pad(15f).row() + val infoLine = Label("Awaiting response:", BaseScreen.skin) + infoLine.setFontColor(Color.GRAY) + infoLine.setFontSize(Constants.smallFontSize) + table.add(infoLine).colspan(3).padBottom(5f).row() + } + for (request in friendRequests.filter { it.from.uuid == me }) { + table.add("${request.to.displayName} (${request.to.username})").colspan(3).padBottom(5f).row() + } + return table + } + + companion object { + fun showRemoveFriendshipPopup(friendship: UUID, friend: AccountResponse, screen: BaseScreen) { + val popup = ConfirmPopup( + screen.stage, + "Do you really want to remove [${friend.username}] as friend?", + "Yes", + false + ) { + Log.debug("Unfriending with %s (friendship UUID: %s)", friend.username, friendship) + InfoPopup.load(screen.stage) { + screen.game.onlineMultiplayer.api.friend.delete(friendship) + Concurrency.runOnGLThread { + ToastPopup("You removed [${friend.username}] as friend", screen.stage) + } + } + } + popup.open(true) + } + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt new file mode 100644 index 0000000000000..cd7647c01e91f --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -0,0 +1,142 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Disposable +import com.unciv.Constants +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.MultiplayerGameCanBeLoaded +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.models.translations.tr +import com.unciv.ui.components.ChatButton +import com.unciv.ui.components.PencilButton +import com.unciv.ui.components.extensions.formatShort +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant +import java.util.UUID + +/** + * Table listing the recently played open games for APIv2 multiplayer games + */ +class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOverviewResponse) -> Unit) : Table(BaseScreen.skin), Disposable { + private val disposables = mutableListOf() + private val noGames = "No recently played games here".toLabel() + private val games = mutableListOf() + private val events = EventBus.EventReceiver() + + init { + add(noGames).row() + triggerUpdate() + + events.receive(MultiplayerGameCanBeLoaded::class, null) { + Concurrency.run { + val updatedGame = screen.game.onlineMultiplayer.api.game.head(UUID.fromString(it.gameInfo.gameId), suppress = true) + if (updatedGame != null) { + Concurrency.runOnGLThread { + games.removeAll { game -> game.gameUUID.toString() == it.gameInfo.gameId } + games.add(updatedGame) + recreate() + } + } + } + } + } + + private fun addGame(game: GameOverviewResponse) { + add(game.name.toTextButton().onClick { onSelected(game) }).padRight(10f).padBottom(5f) + add(convertTime(game.lastActivity)).padRight(10f).padBottom(5f) + + add(ChatButton().apply { onClick { + Log.debug("Opening chat room ${game.chatRoomUUID} from game list") + val popup = Popup(screen.stage) + val chatMessageList = ChatMessageList( + true, + Pair(ChatRoomType.Game, game.name), + game.chatRoomUUID, + screen.game.onlineMultiplayer + ) + disposables.add(chatMessageList) + popup.add(ChatTable(chatMessageList)).padBottom(10f).row() + popup.addCloseButton() + popup.open(force = true) + } }).padBottom(5f) + add(PencilButton().apply { onClick { + GameEditPopup(screen, game).open() + } }).padRight(5f).padBottom(5f) + } + + companion object { + private fun convertTime(time: Instant): String { + return "[${Duration.between(time, Instant.now()).formatShort()}] ago".tr() + } + } + + /** + * Recreate the table of this game list using the supplied list of open games + */ + fun recreate() { + clearChildren() + if (games.isEmpty()) { + add(noGames).row() + return + } + for (game in games.sortedBy { it.lastActivity }.reversed()) { + addGame(game) + row() + } + } + + /** + * Detach updating the list of games in another coroutine + */ + private fun triggerUpdate() { + Concurrency.run("Update game list") { + while (stage == null) { + delay(20) // fixes race condition and null pointer exception in access to `stage` + } + val listOfOpenGames = InfoPopup.wrap(stage) { + screen.game.onlineMultiplayer.api.game.list() + } + if (listOfOpenGames != null) { + Concurrency.runOnGLThread { + games.clear() + listOfOpenGames.sortedBy { it.lastActivity }.reversed().forEach { games.add(it) } + recreate() + } + } + } + } + + private class GameEditPopup(screen: BaseScreen, game: GameOverviewResponse) : Popup(screen) { + init { + add(game.name.toLabel(fontSize = Constants.headingFontSize)).colspan(2).padBottom(5f).row() + add("Last played") + add(convertTime(game.lastActivity)).padBottom(5f).row() + add("Last player") + add(game.lastPlayer.displayName).padBottom(5f).row() + add("Max players") + add(game.maxPlayers.toString()).padBottom(5f).row() + add("Remove / Resign".toTextButton().apply { onActivation { + ToastPopup("This functionality is not implemented yet.", screen).open(force = true) + } }).colspan(2).row() + addCloseButton().colspan(2).row() + } + } + + /** + * Dispose children who need to be cleaned up properly + */ + override fun dispose() { + disposables.forEach { it.dispose() } + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt new file mode 100644 index 0000000000000..c61ce0812d677 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -0,0 +1,152 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.ui.components.MultiplayerButton +import com.unciv.ui.components.NewButton +import com.unciv.ui.components.RefreshButton +import com.unciv.ui.components.SettingsButton +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.addSeparatorVertical +import com.unciv.ui.components.extensions.brighten +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick +import com.unciv.ui.popups.CreateLobbyPopup +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Concurrency.runBlocking +import com.unciv.utils.Log +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import com.unciv.ui.components.AutoScrollPane as ScrollPane + +/** + * Screen that should list all open lobbies on the left side, with buttons to interact with them and a list of recently opened games on the right + */ +class LobbyBrowserScreen : BaseScreen() { + private val lobbyBrowserTable = LobbyBrowserTable(this) { updateJob.cancel() } + private val gameList = GameListV2(this, ::onSelect) + private var updateJob = startUpdateJob(false) + + private val me + get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! + + private val table = Table() // main table including all content of this screen + private val bottomTable = Table() // bottom bar including the cancel and help buttons + + private val newLobbyButton = NewButton() + private val socialButton = MultiplayerButton() + private val serverSettingsButton = SettingsButton() + private val helpButton = "Help".toTextButton() + private val updateButton = RefreshButton() + private val closeButton = Constants.close.toTextButton() + + init { + table.add("Lobby browser".toLabel(fontSize = Constants.headingFontSize)).padTop(20f).padBottom(10f) + table.add().colspan(2) // layout purposes only + table.add("Currently open games".toLabel(fontSize = Constants.headingFontSize)).padTop(20f).padBottom(10f) + table.row() + + val lobbyButtons = Table() + newLobbyButton.onClick { + CreateLobbyPopup(this as BaseScreen, me).open() + } + updateButton.onClick { + lobbyBrowserTable.triggerUpdate() + } + lobbyButtons.add(newLobbyButton).padBottom(5f).row() + lobbyButtons.add("F".toTextButton().apply { + label = "F".toLabel(fontSize = Constants.headingFontSize) + label.setAlignment(Align.center) + onClick { ToastPopup("Filtering is not implemented yet", stage) } + }).padBottom(5f).row() + lobbyButtons.add(updateButton).row() + + table.add(ScrollPane(lobbyBrowserTable).apply { setScrollingDisabled(true, false) }).growX().growY().padRight(10f) + table.add(lobbyButtons).padLeft(10f).growY() + table.addSeparatorVertical(Color.DARK_GRAY, 1f).height(0.75f * stage.height).padLeft(10f).padRight(10f).growY() + table.add(ScrollPane(gameList).apply { setScrollingDisabled(true, false) }).growX() + table.row() + + closeButton.keyShortcuts.add(KeyCharAndCode.ESC) + closeButton.keyShortcuts.add(KeyCharAndCode.BACK) + closeButton.onActivation { + game.popScreen() + } + socialButton.onClick { + val popup = Popup(stage) + popup.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() + popup.addCloseButton() + popup.open() + } + serverSettingsButton.onClick { + ToastPopup("The server settings feature is not implemented yet. A server list should be added here as well.", this).open() + } + helpButton.onClick { + val helpPopup = Popup(this) + helpPopup.addGoodSizedLabel("This should become a lobby browser.").row() // TODO + helpPopup.addCloseButton() + helpPopup.open() + } + bottomTable.add(closeButton).pad(20f) + bottomTable.add().growX() // layout purposes only + bottomTable.add(socialButton).pad(5f) + bottomTable.add(serverSettingsButton).padRight(5f) + bottomTable.add(helpButton).padRight(20f) + + table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 1f).width(stage.width * 0.85f).padTop(15f).row() + table.row().bottom().fillX().maxHeight(stage.height / 8) + table.add(bottomTable).colspan(4).fillX() + + table.setFillParent(true) + stage.addActor(table) + } + + private fun onSelect(gameOverview: GameOverviewResponse) { + Log.debug("Loading game '%s' (%s)", gameOverview.name, gameOverview.gameUUID) + val gameInfo = InfoPopup.load(stage) { + game.onlineMultiplayer.downloadGame(gameOverview.gameUUID.toString()) + } + if (gameInfo != null) { + Concurrency.runOnNonDaemonThreadPool { + game.loadGame(gameInfo) + } + } + } + + private fun startUpdateJob(updateNow: Boolean): Job { + return Concurrency.run { + if (updateNow) { + lobbyBrowserTable.triggerUpdate() + } + while (true) { + delay(30 * 1000) + lobbyBrowserTable.triggerUpdate() + } + } + } + + override fun resume() { + Log.debug("Resuming LobbyBrowserScreen") + updateJob.cancel() + updateJob = startUpdateJob(true) + super.resume() + } + + override fun dispose() { + updateJob.cancel() + gameList.dispose() + super.dispose() + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt new file mode 100644 index 0000000000000..f80383fb1c7ab --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -0,0 +1,121 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.logic.multiplayer.apiv2.ApiStatusCode +import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.LockButton +import com.unciv.ui.components.extensions.surroundWithCircle +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.AskTextPopup +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import kotlinx.coroutines.delay + +/** + * Table listing all available open lobbies and allow joining them by clicking on them + */ +internal class LobbyBrowserTable(private val screen: BaseScreen, private val lobbyJoinCallback: (() -> Unit)): Table(BaseScreen.skin) { + + private val noLobbies = "Sorry, no open lobbies at the moment!".toLabel() + private val enterLobbyPasswordText = "This lobby requires a password to join. Please enter it below:" + + init { + add(noLobbies).row() + triggerUpdate() + } + + /** + * Open a lobby by joining it (may ask for a passphrase for protected lobbies) + */ + private fun joinLobby(lobby: LobbyResponse) { + Log.debug("Trying to join lobby '${lobby.name}' (UUID ${lobby.uuid}) ...") + if (lobby.hasPassword) { + val popup = AskTextPopup( + screen, + enterLobbyPasswordText, + ImageGetter.getImage("OtherIcons/LockSmall") + .apply { this.color = Color.BLACK } + .surroundWithCircle(80f), + maxLength = 120 + ) { + InfoPopup.load(stage) { + try { + screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid, it) + Concurrency.runOnGLThread { + lobbyJoinCallback() + screen.game.pushScreen(LobbyScreen(lobby)) + } + } catch (e: ApiException) { + if (e.error.statusCode != ApiStatusCode.MissingPrivileges) { + throw e + } + Concurrency.runOnGLThread { + Popup(stage).apply { + addGoodSizedLabel("Invalid password") + addCloseButton() + }.open(force = true) + } + } + } + } + popup.open() + } else { + InfoPopup.load(stage) { + screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid) + Concurrency.runOnGLThread { + lobbyJoinCallback() + screen.game.pushScreen(LobbyScreen(lobby)) + } + } + } + } + + /** + * Recreate the table of this lobby browser using the supplied list of lobbies + */ + internal fun recreate(lobbies: List) { + clearChildren() + if (lobbies.isEmpty()) { + add(noLobbies).row() + return + } + + for (lobby in lobbies.sortedBy { it.createdAt }.reversed()) { + add(lobby.name).padRight(15f) + add("${lobby.currentPlayers}/${lobby.maxPlayers}").padRight(10f) + if (lobby.hasPassword) { + add(LockButton().onClick { joinLobby(lobby) }).padBottom(5f).row() + } else { + add(ArrowButton().onClick { joinLobby(lobby) }).padBottom(5f).row() + } + } + } + + /** + * Detach updating the list of lobbies in another coroutine + */ + fun triggerUpdate() { + Concurrency.run("Update lobby list") { + while (stage == null) { + delay(20) // fixes race condition and null pointer exception in access to `stage` + } + val listOfOpenLobbies = InfoPopup.wrap(stage) { + screen.game.onlineMultiplayer.api.lobby.list() + } + if (listOfOpenLobbies != null) { + Concurrency.runOnGLThread { + recreate(listOfOpenLobbies) + } + } + } + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt new file mode 100644 index 0000000000000..e71320e1343a3 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt @@ -0,0 +1,27 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.unciv.Constants +import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.models.metadata.Player + +/** + * A single player in a lobby for APIv2 games (with support for AI players) + * + * The goal is to be compatible with [Player], but don't extend it or + * implement a common interface, since this would decrease chances of + * easy backward compatibility without any further modifications. + * Human players are identified by a valid [account], use null for AI players. + */ +internal class LobbyPlayer(internal val account: AccountResponse?, var chosenCiv: String = Constants.random) { + val isAI: Boolean + get() = account == null + + fun to() = Player(chosenCiv = chosenCiv).apply { + playerType = PlayerType.AI + if (!isAI) { + playerType = PlayerType.Human + playerId = account!!.uuid.toString() + } + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt new file mode 100644 index 0000000000000..ee731537d341f --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -0,0 +1,258 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.VerticalGroup +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.surroundWithCircle +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.newgamescreen.MapOptionsInterface +import com.unciv.ui.screens.newgamescreen.NationPickerPopup +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +/** + * List of players in an APIv2 lobby screen + * + * The instantiation of this class **must** be on the GL thread or lead to undefined behavior and crashes. + * + * Implementation detail: the access to various internal attributes, e.g. [playersImpl], + * is not protected by locking mechanisms, since all accesses to them **must** go through + * the GL render thread, which is single-threaded (at least on Desktop; if you encounter + * any errors/crashes on other platforms, this means this assumption was probably wrong). + * + * See https://github.com/libgdx/libgdx/blob/master/backends/gdx-backend-android/src/com/badlogic/gdx/backends/android/AndroidGraphics.java#L496 + * and https://github.com/libgdx/libgdx/blob/master/backends/gdx-backend-lwjgl3/src/com/badlogic/gdx/backends/lwjgl3/Lwjgl3Application.java#L207 + * for details why it's certain that the coroutines are executed in-order (even though the order is not strictly defined). + */ +class LobbyPlayerList( + private val lobbyUUID: UUID, + private var editable: Boolean, + private val me: UUID, // the currently logged-in player UUID + private var api: ApiV2, + private val update: (() -> Unit)? = null, // use for signaling player changes via buttons to the caller + startPlayers: List = listOf(), + private val base: MapOptionsInterface +) : Table() { + // Access to this attribute **must** go through the GL render thread for synchronization after init + private val playersImpl: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() + /** Don't cache the [players] property, but get it freshly from this class every time */ + internal val players: List + get() = playersImpl.toList() + + private val addBotButton = "+".toLabel(Color.LIGHT_GRAY, 30) + .apply { this.setAlignment(Align.center) } + .surroundWithCircle(50f, color = Color.GRAY) + .onClick { + playersImpl.add(LobbyPlayer(null, Constants.random)) + recreate() + update?.invoke() + } + + init { + defaults().expandX() + recreate() + } + + /** + * Add the specified player to the player list and recreate the view + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + internal fun addPlayer(player: AccountResponse): Boolean { + playersImpl.add(LobbyPlayer(player)) + recreate() + return true + } + + /** + * Remove the specified player from the player list and recreate the view + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + internal fun removePlayer(player: UUID): Boolean { + val modified = playersImpl.removeAll { it.account?.uuid == player } + recreate() + return modified + } + + /** + * Recreate the table of players based on the list of internal player representations + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + fun recreate() { + clearChildren() + reassignRemovedModReferences() + if (players.isEmpty()) { + val label = "No players here yet".toLabel() + label.setAlignment(Align.center) + add(label).fillX().fillY().center().padBottom(15f).row() + if (editable) { + add(addBotButton) + } + return + } + + for (i in players.indices) { + row() + val movements = VerticalGroup() + movements.space(5f) + movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { + if (i > 0) { + val above = players[i - 1] + playersImpl[i - 1] = players[i] + playersImpl[i] = above + recreate() + } + }) + movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { + if (i < players.size - 1) { + val below = players[i + 1] + playersImpl[i + 1] = players[i] + playersImpl[i] = below + recreate() + } + }) + if (editable) { + add(movements) + } + + val player = players[i] + add(getNationTable(i)) + if (player.isAI) { + add("AI".toLabel()) + } else { + add(player.account!!.username.toLabel()) + } + + val kickButton = "❌".toLabel(Color.SCARLET, Constants.headingFontSize).apply { this.setAlignment(Align.center) } + // kickButton.surroundWithCircle(Constants.headingFontSize.toFloat(), color = color) + kickButton.onClick { + var success = true + Concurrency.run { + if (!player.isAI) { + success = true == InfoPopup.wrap(stage) { + api.lobby.kick(lobbyUUID, player.account!!.uuid) + } + } + Concurrency.runOnGLThread { + if (success) { + success = playersImpl.remove(player) + } else { + base.updateTables() + } + Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") + recreate() + update?.invoke() + } + } + } + if (editable && me != player.account?.uuid) { + add(kickButton) + } + + if (i < players.size - 1) { + row() + addSeparator(color = Color.DARK_GRAY).width(0.8f * width).pad(5f) + } + } + + row() + if (editable) { + add(addBotButton).colspan(columns).fillX().center() + } else { + add( + "Non-human players are not shown in this list.".toLabel( + alignment = Align.center, + fontSize = Constants.smallFontSize + ) + ).colspan(columns).growX().padTop(20f).center() + } + updateParameters() + } + + /** + * Update game parameters to reflect changes in the list of players + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + private fun updateParameters() { + base.gameSetupInfo.gameParameters.players = playersImpl.map { it.to() }.toMutableList() + } + + /** + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + private fun reassignRemovedModReferences() { + for (player in players) { + if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) { + player.chosenCiv = Constants.random + } + } + } + + /** + * Create clickable icon and nation name for some [LobbyPlayer] based on its index in [players], where clicking creates [NationPickerPopup] + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + private fun getNationTable(index: Int): Table { + val player = players[index] + val nationTable = Table() + val nationImage = + if (player.chosenCiv == Constants.random) + ImageGetter.getRandomNationPortrait(40f) + else ImageGetter.getNationPortrait(base.ruleset.nations[player.chosenCiv]!!, 40f) + nationTable.add(nationImage).padRight(10f) + nationTable.add(player.chosenCiv.toLabel()).padRight(5f) + nationTable.touchable = Touchable.enabled + val availableCivilisations = base.ruleset.nations.values.asSequence() + .filter { it.isMajorCiv } + .filter { it.name == player.chosenCiv || players.none { player -> player.chosenCiv == it.name } } + nationTable.onClick { + val p = player.to() + NationPickerPopup(p, 0.45f * stage.width, base as BaseScreen, base, false, { availableCivilisations }) { + players[index].chosenCiv = p.chosenCiv + updateParameters() + recreate() + }.open() + } + return nationTable + } + + /** + * Refresh the view of the human players based on the [currentPlayers] response from the server + * + * This method **must** be called on the GL thread or lead to undefined behavior and crashes. + */ + internal fun updateCurrentPlayers(currentPlayers: List) { + val humanPlayers = players.filter { !it.isAI }.map { it.account!! } + val toBeRemoved = mutableListOf() + for (oldPlayer in players) { + if (!oldPlayer.isAI && oldPlayer.account!!.uuid !in currentPlayers.map { it.uuid }) { + toBeRemoved.add(oldPlayer) + } + } + for (r in toBeRemoved) { + playersImpl.remove(r) + } + for (newPlayer in currentPlayers) { + if (newPlayer !in humanPlayers) { + playersImpl.add(LobbyPlayer(newPlayer)) + } + } + recreate() + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt new file mode 100644 index 0000000000000..cbede0bbad812 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -0,0 +1,446 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.Input +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.Stage +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.utils.Align +import com.badlogic.gdx.utils.Disposable +import com.unciv.Constants +import com.unciv.logic.GameInfo +import com.unciv.logic.GameStarter +import com.unciv.logic.event.EventBus +import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.GameStarted +import com.unciv.logic.multiplayer.apiv2.GetLobbyResponse +import com.unciv.logic.multiplayer.apiv2.LobbyClosed +import com.unciv.logic.multiplayer.apiv2.LobbyJoin +import com.unciv.logic.multiplayer.apiv2.LobbyKick +import com.unciv.logic.multiplayer.apiv2.LobbyLeave +import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.unciv.logic.multiplayer.apiv2.StartGameResponse +import com.unciv.logic.multiplayer.apiv2.UpdateGameData +import com.unciv.models.metadata.GameSetupInfo +import com.unciv.models.ruleset.RulesetCache +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.MultiplayerButton +import com.unciv.ui.components.PencilButton +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.brighten +import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.newgamescreen.GameOptionsTable +import com.unciv.ui.screens.newgamescreen.MapOptionsInterface +import com.unciv.ui.screens.newgamescreen.MapOptionsTable +import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.util.UUID + +/** + * Lobby screen for open lobbies + * + * On the left side, it provides a list of players and their selected civ. + * On the right side, it provides a chat bar for multiplayer lobby chats. + * Between those, there are four menu buttons for a) game settings, b) map settings, + * c) to invite new players and d) to start the game. It also has a footer section + * like the [PickerScreen] but smaller, with a leave button on the left and + * two buttons for the social tab and the in-game help on the right side. + */ +class LobbyScreen( + private val lobbyUUID: UUID, + private val lobbyChatUUID: UUID, + private var lobbyName: String, + private val maxPlayers: Int, + currentPlayers: MutableList, + private val hasPassword: Boolean, + private val owner: AccountResponse, + override val gameSetupInfo: GameSetupInfo +): BaseScreen(), MapOptionsInterface { + + constructor(lobby: LobbyResponse): this(lobby.uuid, lobby.chatRoomUUID, lobby.name, lobby.maxPlayers, mutableListOf(), lobby.hasPassword, lobby.owner, GameSetupInfo.fromSettings()) + constructor(lobby: GetLobbyResponse): this(lobby.uuid, lobby.chatRoomUUID, lobby.name, lobby.maxPlayers, lobby.currentPlayers.toMutableList(), lobby.hasPassword, lobby.owner, GameSetupInfo.fromSettings()) + + private var gameUUID: UUID? = null + override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) + private val events = EventBus.EventReceiver() + + private val gameOptionsTable: GameOptionsTable + private val mapOptionsTable = MapOptionsTable(this) + + private val me + get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! + private val screenTitle + get() = "Lobby: [$lobbyName] [${lobbyPlayerList.players.size}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) + + private val lobbyPlayerList: LobbyPlayerList + private var lobbyPlayerListInitialized = false + private val chatMessageList = ChatMessageList(false, Pair(ChatRoomType.Lobby, lobbyName), lobbyChatUUID, game.onlineMultiplayer) + private val disposables = mutableListOf() + + private val changeLobbyNameButton = PencilButton() + private val menuButtonGameOptions = "Game options".toTextButton() + private val menuButtonMapOptions = "Map options".toTextButton() + private val menuButtonInvite = "Invite friend".toTextButton() + private val menuButtonStartGame = "Start game".toTextButton() + private val chatTable = ChatTable(chatMessageList) + private val bottomButtonLeave = if (owner.uuid == me.uuid) "Close lobby".toTextButton() else "Leave".toTextButton() + private val bottomButtonSocial = MultiplayerButton() + private val bottomButtonHelp = "Help".toTextButton() + + init { + if (owner !in currentPlayers) { + currentPlayers.add(owner) + } + gameSetupInfo.gameParameters.isOnlineMultiplayer = true + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, me.uuid, game.onlineMultiplayer.api, ::recreate, currentPlayers, this) + lobbyPlayerListInitialized = true + gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> + Log.error("Updating player picker table with '%s' is not implemented yet.", x) + Concurrency.runOnGLThread { + lobbyPlayerList.recreate() + } + }) + + changeLobbyNameButton.onActivation { + ToastPopup("Renaming a lobby is not implemented.", stage) + } + + menuButtonGameOptions.onClick { + WrapPopup(stage, gameOptionsTable) + } + menuButtonMapOptions.onClick { + WrapPopup(stage, mapOptionsTable) + } + menuButtonInvite.onClick { + val friends = FriendListV2( + this as BaseScreen, + me.uuid, + select = { _, friend -> + InfoPopup.load(stage) { + game.onlineMultiplayer.api.invite.new(friend.uuid, lobbyUUID) + } + } + ) + InfoPopup.load(stage) { friends.triggerUpdate() } + WrapPopup(stage, friends) + } + menuButtonStartGame.onActivation { + val lobbyStartResponse = InfoPopup.load(stage) { + game.onlineMultiplayer.api.lobby.startGame(lobbyUUID) + } + if (lobbyStartResponse != null) { + startGame(lobbyStartResponse) + } + } + + bottomButtonLeave.keyShortcuts.add(KeyCharAndCode.ESC) + bottomButtonLeave.keyShortcuts.add(KeyCharAndCode.BACK) + bottomButtonLeave.onActivation { + InfoPopup.load(stage) { + if (game.onlineMultiplayer.api.account.get()!!.uuid == owner.uuid) { + game.onlineMultiplayer.api.lobby.close(lobbyUUID) + } else { + game.onlineMultiplayer.api.lobby.leave(lobbyUUID) + } + } + game.popScreen() + } + bottomButtonSocial.onActivation { + val popup = Popup(stage) + popup.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() + popup.addCloseButton() + popup.open() + } + bottomButtonHelp.keyShortcuts.add(Input.Keys.F1) + bottomButtonHelp.onActivation { + ToastPopup("The help feature has not been implemented yet.", stage) + } + + events.receive(LobbyJoin::class, { it.lobbyUUID == lobbyUUID }) { + Log.debug("Player %s joined lobby %s", it.player, lobbyUUID) + lobbyPlayerList.addPlayer(it.player) + recreate() + ToastPopup("${it.player.username} has joined the lobby", stage) + } + events.receive(LobbyLeave::class, { it.lobbyUUID == lobbyUUID }) { + Log.debug("Player %s left lobby %s", it.player, lobbyUUID) + lobbyPlayerList.removePlayer(it.player.uuid) + ToastPopup("${it.player.username} has left the lobby", stage) + } + events.receive(LobbyKick::class, { it.lobbyUUID == lobbyUUID }) { + if (it.player.uuid == me.uuid) { + InfoPopup(stage, "You have been kicked out of this lobby!") { + game.popScreen() + } + return@receive + } + val success = lobbyPlayerList.removePlayer(it.player.uuid) + Log.debug("Removing player %s from lobby %s", it.player, if (success) "succeeded" else "failed") + if (success) { + recreate() + ToastPopup("${it.player.username} has been kicked", stage) + } + } + events.receive(LobbyClosed::class, { it.lobbyUUID == lobbyUUID }) { + Log.debug("Lobby %s has been closed", lobbyUUID) + InfoPopup(stage, "This lobby has been closed.") { + game.popScreen() + } + } + + val startingGamePopup = object: Popup(stage) { + fun getTable() = innerTable + } + events.receive(GameStarted::class, { it.lobbyUUID == lobbyUUID }) { + Log.debug("Game in lobby %s has been started", lobbyUUID) + gameUUID = it.gameUUID + startingGamePopup.reuseWith("The game is starting. Waiting for host...") + startingGamePopup.getTable().cells.last().colspan(2) + startingGamePopup.row() + startingGamePopup.addGoodSizedLabel("Closing this popup will return you to the lobby browser.") + startingGamePopup.getTable().cells.last().colspan(2) + startingGamePopup.row() + startingGamePopup.getTable().columns.inc() + startingGamePopup.addCloseButton { + game.popScreen() + } + startingGamePopup.addButton("Open game chat", KeyCharAndCode.RETURN) { + Log.debug("Opening game chat %s for game %s of lobby %s", it.gameChatUUID, it.gameUUID, lobbyName) + val gameChat = ChatMessageList(true, Pair(ChatRoomType.Game, lobbyName), it.gameChatUUID, game.onlineMultiplayer) + disposables.add(gameChat) + val wrapper = WrapPopup(stage, ChatTable(gameChat)) + wrapper.open(force = true) + } + startingGamePopup.equalizeLastTwoButtonWidths() + startingGamePopup.open() + } + events.receive(UpdateGameData::class, { gameUUID != null && it.gameUUID == gameUUID }) { + Concurrency.runOnGLThread { + startingGamePopup.reuseWith("Working...") + startingGamePopup.close() + startingGamePopup.open(force = true) + } + Concurrency.runOnNonDaemonThreadPool { + val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + Log.debug("Successfully loaded game %s from WebSocket event", gameInfo.gameId) + game.loadGame(gameInfo) + Concurrency.runOnGLThread { + startingGamePopup.close() + } + } + } + + recreate(true) + Concurrency.run { + refresh() + } + chatMessageList.triggerRefresh(stage) + } + + override fun dispose() { + events.stopReceiving() + chatMessageList.dispose() + for (disposable in disposables) { + disposable.dispose() + } + super.dispose() + } + + private class WrapPopup(stage: Stage, other: Actor, action: (() -> Unit)? = null) : Popup(stage) { + init { + add(other).center().expandX().row() + addCloseButton(action = action) + open() + } + } + + /** + * Refresh the cached data for this lobby and recreate the screen + */ + private suspend fun refresh() { + val lobby = try { + game.onlineMultiplayer.api.lobby.get(lobbyUUID) + } catch (e: Exception) { + Log.error("Refreshing lobby %s failed: %s", lobbyUUID, e) + null + } + if (lobby != null) { + val refreshedLobbyPlayers = lobby.currentPlayers.toMutableList() + if (owner !in refreshedLobbyPlayers) { + refreshedLobbyPlayers.add(owner) + } + + // This construction prevents later null pointer exceptions when `refresh` + // is executed concurrently to the constructor of this class, because + // `lobbyPlayerList` might be uninitialized when this point is reached + while (!lobbyPlayerListInitialized) { + delay(10) + } + + Concurrency.runOnGLThread { + lobbyPlayerList.updateCurrentPlayers(refreshedLobbyPlayers) + lobbyName = lobby.name + recreate() + } + } + } + + /** + * Recreate the screen including some of its elements + */ + fun recreate(initial: Boolean = false): BaseScreen { + val table = Table() + + val playerScroll = AutoScrollPane(lobbyPlayerList, skin) + playerScroll.setScrollingDisabled(true, false) + + val optionsTable = Table().apply { + align(Align.center) + } + optionsTable.add(menuButtonGameOptions).row() + optionsTable.add(menuButtonMapOptions).padTop(10f).row() + optionsTable.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 0.5f).padTop(25f).padBottom(25f).row() + optionsTable.add(menuButtonInvite).padBottom(10f).row() + optionsTable.add(menuButtonStartGame).row() + + val menuBar = Table() + menuBar.align(Align.bottom) + menuBar.add(bottomButtonLeave).pad(20f) + menuBar.add().fillX().expandX() + menuBar.add(bottomButtonSocial).pad(5f) // lower padding since the help button has padding as well + menuBar.add(bottomButtonHelp).padRight(20f) + + // Construct the table which makes up the whole lobby screen + table.row() + val topLine = HorizontalGroup() + if (hasPassword) { + topLine.addActor(Container(ImageGetter.getImage("OtherIcons/LockSmall").apply { + setOrigin(Align.center) + setSize(Constants.headingFontSize.toFloat()) + }).apply { padRight(10f) }) + } + topLine.addActor(Container(screenTitle).padRight(10f)) + topLine.addActor(changeLobbyNameButton) + table.add(topLine.pad(10f).center()).colspan(3).fillX() + table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 0.5f).width(stage.width * 0.85f).padBottom(15f).row() + table.row().expandX().expandY() + table.add(playerScroll).fillX().expandY().prefWidth(stage.width * 0.4f).padLeft(5f) + // TODO: The options table is way to big, reduce its width somehow + table.add(optionsTable).prefWidth(stage.width * 0.1f).padLeft(0f).padRight(0f) + // TODO: Add vertical horizontal bar like a left border for the chat screen + // table.addSeparatorVertical(skinStrings.skinConfig.baseColor.brighten(0.1f), width = 0.5f).height(0.5f * stage.height).width(0.1f).pad(0f).space(0f) + table.add(chatTable).fillX().expandY().prefWidth(stage.width * 0.5f).padRight(5f) + table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 0.5f).width(stage.width * 0.85f).padTop(15f).row() + table.row().bottom().fillX().maxHeight(stage.height / 8) + table.add(menuBar).colspan(3).fillX() + table.setFillParent(true) + stage.clear() + stage.addActor(table) + if (initial) { + stage.keyboardFocus = chatTable.messageField + } + return this + } + + /** + * Build a new [GameInfo], upload it to the server and start the game + * + * This function will detach the work and return almost instantly. + */ + private fun startGame(lobbyStart: StartGameResponse) { + Log.debug("Starting lobby '%s' (%s) as game %s", lobbyName, lobbyUUID, lobbyStart.gameUUID) + val popup = Popup(this) + Concurrency.runOnGLThread { + popup.addGoodSizedLabel("Working...").row() + popup.open(force = true) + } + + Concurrency.runOnNonDaemonThreadPool { + val gameInfo = try { + GameStarter.startNewGame(gameSetupInfo, lobbyStart.gameUUID.toString()) + } catch (exception: Exception) { + Log.error( + "Failed to create a new GameInfo for game %s: %s\n%s", + lobbyStart.gameUUID, + exception, + exception.stackTraceToString() + ) + Concurrency.runOnGLThread { + popup.apply { + reuseWith("It looks like we can't make a map with the parameters you requested!") + row() + addGoodSizedLabel("Maybe you put too many players into too small a map?").row() + addCloseButton() + } + } + return@runOnNonDaemonThreadPool + } + + Log.debug("Successfully created new game %s", gameInfo.gameId) + Concurrency.runOnGLThread { + popup.reuseWith("Uploading...") + } + val uploadSuccess = InfoPopup.wrap(stage) { + game.onlineMultiplayer.createGame(gameInfo) + true + } + if (uploadSuccess != true) { + // If the upload was not successful, there is an InfoPopup behind the popup now, + // so we close the popup to show the InfoPopup behind it indicating an error + Concurrency.runOnGLThread { + popup.close() + } + } else { + Log.debug("Successfully uploaded game %s", lobbyStart.gameUUID) + Concurrency.runOnGLThread { + popup.close() + game.loadGame(gameInfo) + } + } + } + } + + override fun lockTables() { + Log.error("Not yet implemented: lockTables") + } + + override fun unlockTables() { + Log.error("Not yet implemented: unlockTables") + } + + override fun updateTables() { + Concurrency.run { + refresh() + } + } + + override fun tryUpdateRuleset(): Boolean { + updateRuleset() + return true + } + + override fun updateRuleset() { + Log.error("Not yet implemented") + } + + override fun getColumnWidth(): Float { + return stage.width / (if (isNarrowerThan4to3()) 1 else 3) + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt new file mode 100644 index 0000000000000..8c77c25ce1c82 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt @@ -0,0 +1,223 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.Stack +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.badlogic.gdx.utils.Disposable +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.models.translations.tr +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.ChatButton +import com.unciv.ui.components.NewButton +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.enable +import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.ConfirmPopup +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.ui.screens.worldscreen.status.MultiplayerStatusButtonV2 +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +/** + * Screen holding an overview of the current game's multiplayer functionality + * + * It's mainly used in the [MultiplayerStatusButtonV2], but could be embedded + * somewhere else as well. It requires the [UUID] of the currently playing user, + * the initially shown chat room, which should be the game's chat room, and + * the list of playing major civilisations in the game. Note that this list + * should include the fallen major civilisations, because it allows + * communication even with players who already lost the game. Those players + * will still receive game updates, but won't be able to perform any moves + * and aren't required to perform turns, i.e. the game will silently continue + * without them properly. + */ +class MultiplayerGameScreen(private val me: UUID, initialChatRoom: Triple? = null, civilizations: List) : PickerScreen(horizontally = true), Disposable { + private val playerTable = Table() + private val friendList = FriendListV2( + this, + me, + requests = true, + chat = { _, a, c -> startFriendChatting(c, a.displayName) }, + edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, this) } + ) + private val helpButton = "Help".toTextButton().onClick { + val helpPopup = Popup(this) + helpPopup.addGoodSizedLabel("It would be nice if this screen was documented.").row() + helpPopup.addCloseButton() + helpPopup.open() + } + private val gameList = GameListV2(this, ::loadGame) + private val gameListButton = "Games".toTextButton().onClick { + val gameListPopup = Popup(this) + gameListPopup.add(gameList).row() + gameListPopup.addCloseButton() + gameListPopup.open() + } + + init { + Concurrency.run { + Concurrency.runOnGLThread { stage } // accessing the stage here avoids errors later + populatePlayerTable(civilizations, stage) + } + + topTable.add(playerTable).padRight(10f).expandY() + if (initialChatRoom != null) { + topTable.add( + ChatTable( + ChatMessageList(true, Pair(ChatRoomType.Game, initialChatRoom.third), initialChatRoom.first, this.game.onlineMultiplayer) + ) + ).growX().expandY() + } else { + topTable.add("Chat not found".toLabel()).grow().expandY() + } + + setDefaultCloseAction() + rightSideButton.setText("Friends".tr()) + rightSideButton.enable() + rightSideButton.onClick { + Concurrency.run { + friendList.triggerUpdate(true) + } + val popup = Popup(this) + popup.add(friendList).growX().minWidth(this.stage.width * 0.5f).row() + popup.addCloseButton() + popup.open() + } + rightSideGroup.addActor(Container(gameListButton).padRight(5f).padLeft(5f)) + rightSideGroup.addActor(Container(helpButton).padRight(5f).padLeft(5f)) + } + + private fun startFriendChatting(chatRoom: UUID, name: String) { + val popup = Popup(this) + popup.add( + ChatTable( + ChatMessageList(true, Pair(ChatRoomType.Friend, name), chatRoom, this.game.onlineMultiplayer) + ) + ).row() + popup.addCloseButton() + popup.open(force = true) + } + + private suspend fun populatePlayerTable(civilizations: List, stage: Stage) { + val friendsOnline = InfoPopup.wrap(stage) { + game.onlineMultiplayer.api.friend.list() + }?.first + val playerMap: MutableMap = mutableMapOf() + for (civ in civilizations) { + if (civ.playerType != PlayerType.Human) { + continue + } + val playerAccount = InfoPopup.wrap(stage) { + game.onlineMultiplayer.api.account.lookup(UUID.fromString(civ.playerId)) + } + if (playerAccount != null) { + playerMap[civ.playerId] = playerAccount + } + } + + Concurrency.runOnGLThread { + var firstDone = false + for (civ in civilizations) { + if (civ.playerType != PlayerType.Human) { + continue + } + val playerAccount = playerMap[civ.playerId] ?: throw RuntimeException("Player ID ${civ.playerId} not found") + if (firstDone) { + playerTable.addSeparator(color = Color.LIGHT_GRAY).colspan(4).padLeft(30f).padRight(30f).padTop(10f).padBottom(10f).row() + } + firstDone = true + + val identifiactionTable = Table(skin) + identifiactionTable.add(civ.civName).padBottom(5f).padLeft(15f).padRight(15f).colspan(2).row() + val playerNameCell = identifiactionTable.add(playerAccount.displayName).padLeft(15f).padRight(10f) + if (friendsOnline != null && UUID.fromString(civ.playerId) in friendsOnline.filter { it.friend.online }.map { it.friend.uuid }) { + identifiactionTable.add("Online").padRight(15f) + } else if (friendsOnline != null && UUID.fromString(civ.playerId) in friendsOnline.filter { !it.friend.online }.map { it.friend.uuid }) { + identifiactionTable.add("Offline").padRight(15f) + } else { + playerNameCell.colspan(2).padRight(15f) + } + + val civImage = Stack() + civImage.addActor(ImageGetter.getNationPortrait(civ.nation, 50f)) + if (!civ.isAlive()) { + civImage.addActor(ImageGetter.getImage("OtherIcons/Close").apply { + setOrigin(Align.center) + setSize(50f) + color = Color.RED + }) + } + playerTable.add(civImage).padLeft(20f).padRight(5f) + playerTable.add(identifiactionTable).padRight(5f) + + if (civ.playerId != me.toString()) { + playerTable.add(ChatButton().apply { + onClick { + // TODO: Implement 1:1 chats (also not supported by runciv at the moment) + Log.debug("The 1:1 in-game chat with ${civ.playerId} is not implemented yet") + ToastPopup("Sorry, 1:1 in-game chats are not implemented yet", stage).open() + } + }).padLeft(5f).padRight(5f) + if (friendsOnline != null && UUID.fromString(civ.playerId) in friendsOnline.map { it.friend.uuid }) { + playerTable.add(ArrowButton().apply { + onClick { + val friend = friendsOnline.filter { it.friend.uuid == UUID.fromString(civ.playerId) }[0] + startFriendChatting(friend.chatUUID, friend.friend.displayName) + } + }).padRight(20f).row() + } else if (friendsOnline != null) { + playerTable.add(NewButton().apply { + onClick { + ConfirmPopup( + stage, + "Do you want to send [${playerAccount.username}] a friend request?", + "Yes", + true + ) { + InfoPopup.load(stage) { + game.onlineMultiplayer.api.friend.request(playerAccount.uuid) + } + }.open(force = true) + } + }).padRight(20f).row() + } else { + playerTable.add().padRight(20f).row() + } + } else { + playerTable.add() + playerTable.add().row() + } + } + } + } + + private fun loadGame(gameOverview: GameOverviewResponse) { + val gameInfo = InfoPopup.load(stage) { + game.onlineMultiplayer.downloadGame(gameOverview.gameUUID.toString()) + } + if (gameInfo != null) { + Concurrency.runOnNonDaemonThreadPool { + game.loadGame(gameInfo) + } + } + } + + override fun dispose() { + gameList.dispose() + super.dispose() + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt index 4efee6060d23d..98503552ce2e8 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt @@ -7,13 +7,13 @@ import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.MultiplayerGameDeleted import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.models.translations.tr -import com.unciv.ui.screens.pickerscreens.PickerScreen -import com.unciv.ui.popups.Popup -import com.unciv.ui.popups.ToastPopup import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.input.onClick import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.components.AutoScrollPane as ScrollPane class MultiplayerScreen : PickerScreen() { @@ -82,13 +82,13 @@ class MultiplayerScreen : PickerScreen() { return table } - fun createRefreshButton(): TextButton { + private fun createRefreshButton(): TextButton { val btn = refreshText.toTextButton() btn.onClick { game.onlineMultiplayer.requestUpdate() } return btn } - fun createAddGameButton(): TextButton { + private fun createAddGameButton(): TextButton { val btn = addGameText.toTextButton() btn.onClick { game.pushScreen(AddMultiplayerGameScreen()) @@ -96,7 +96,7 @@ class MultiplayerScreen : PickerScreen() { return btn } - fun createEditButton(): TextButton { + private fun createEditButton(): TextButton { val btn = editButtonText.toTextButton().apply { disable() } btn.onClick { game.pushScreen(EditMultiplayerGameInfoScreen(selectedGame!!)) @@ -104,7 +104,7 @@ class MultiplayerScreen : PickerScreen() { return btn } - fun createCopyGameIdButton(): TextButton { + private fun createCopyGameIdButton(): TextButton { val btn = copyGameIdText.toTextButton().apply { disable() } btn.onClick { val gameInfo = selectedGame?.preview @@ -116,7 +116,7 @@ class MultiplayerScreen : PickerScreen() { return btn } - fun createFriendsListButton(): TextButton { + private fun createFriendsListButton(): TextButton { val btn = friendsListText.toTextButton() btn.onClick { game.pushScreen(ViewFriendsListScreen()) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt new file mode 100644 index 0000000000000..52c181decaf5a --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -0,0 +1,62 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Disposable +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Concurrency +import kotlinx.coroutines.delay +import java.util.UUID + +class SocialMenuTable( + private val base: BaseScreen, + me: UUID, + initialChatRoom: Triple? = null, + private val chatHeadingFilter: List = listOf(), + friendRequests: Boolean = true, + maxChatHeight: Float = 0.8f * base.stage.height +): Table(BaseScreen.skin), Disposable { + private val friendList = FriendListV2( + base, + me, + requests = friendRequests, + chat = { _, a, c -> startChatting(c, ChatRoomType.Friend, a.displayName) }, + edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, base) } + ) + private val chatContainer = Container() + private var lastSelectedChat: UUID? = null + + init { + add(friendList).growX().minWidth(base.stage.width * 0.45f).padRight(5f) + add(chatContainer).maxHeight(maxChatHeight).growX() + Concurrency.run { + while (stage == null) { + delay(10) + } + InfoPopup.wrap(stage) { friendList.triggerUpdate() } + } + if (initialChatRoom != null) { + startChatting(initialChatRoom.first, initialChatRoom.second, initialChatRoom.third) + } + } + + private fun startChatting(chatRoom: UUID, chatRoomType: ChatRoomType, name: String) { + if (lastSelectedChat == chatRoom) { + chatContainer.actor?.dispose() + chatContainer.actor = null + lastSelectedChat = null + return + } + lastSelectedChat = chatRoom + chatContainer.actor?.dispose() + chatContainer.actor = ChatTable( + ChatMessageList(chatRoomType in chatHeadingFilter, Pair(chatRoomType, name), chatRoom, base.game.onlineMultiplayer) + ).apply { padLeft(15f) } + } + + override fun dispose() { + chatContainer.actor?.dispose() + chatContainer.actor = null + } +} diff --git a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt index 996c2edb56d6a..1f55dded5ed22 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt @@ -8,6 +8,7 @@ import com.badlogic.gdx.utils.Align import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.models.metadata.BaseRuleset import com.unciv.models.metadata.GameParameters import com.unciv.models.metadata.Player @@ -33,6 +34,7 @@ import com.unciv.ui.components.input.onChange import com.unciv.ui.components.input.onClick import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.multiplayerscreens.MultiplayerHelpers import kotlin.reflect.KMutableProperty0 @@ -40,6 +42,7 @@ import kotlin.reflect.KMutableProperty0 class GameOptionsTable( private val previousScreen: IPreviousScreen, private val isPortrait: Boolean = false, + private val multiplayerOnly: Boolean = false, private val updatePlayerPickerTable: (desiredCiv: String) -> Unit, private val updatePlayerPickerRandomLabel: () -> Unit ) : Table(BaseScreen.skin) { @@ -95,11 +98,13 @@ class GameOptionsTable( }).row() addVictoryTypeCheckboxes() - val checkboxTable = Table().apply { defaults().left().pad(2.5f) } - checkboxTable.addIsOnlineMultiplayerCheckbox() - if (gameParameters.isOnlineMultiplayer) - checkboxTable.addAnyoneCanSpectateCheckbox() - add(checkboxTable).center().row() + if (!multiplayerOnly) { + val checkboxTable = Table().apply { defaults().left().pad(2.5f) } + checkboxTable.addIsOnlineMultiplayerCheckbox() + if (gameParameters.isOnlineMultiplayer) + checkboxTable.addAnyoneCanSpectateCheckbox() + add(checkboxTable).center().row() + } val expander = ExpanderTab( "Advanced Settings", @@ -164,14 +169,24 @@ class GameOptionsTable( { gameParameters.nuclearWeaponsEnabled = it } private fun Table.addIsOnlineMultiplayerCheckbox() = - addCheckbox("Online Multiplayer", gameParameters.isOnlineMultiplayer) - { shouldUseMultiplayer -> - gameParameters.isOnlineMultiplayer = shouldUseMultiplayer - updatePlayerPickerTable("") - if (shouldUseMultiplayer) { - MultiplayerHelpers.showDropboxWarning(previousScreen as BaseScreen) + if (UncivGame.Current.onlineMultiplayer.isInitialized() && UncivGame.Current.onlineMultiplayer.apiVersion != ApiVersion.APIv2) { + addCheckbox("Online Multiplayer", gameParameters.isOnlineMultiplayer) + { shouldUseMultiplayer -> + gameParameters.isOnlineMultiplayer = shouldUseMultiplayer + updatePlayerPickerTable("") + if (shouldUseMultiplayer) { + MultiplayerHelpers.showDropboxWarning(previousScreen as BaseScreen) + } + update() + } + } else { + gameParameters.isOnlineMultiplayer = false + val checkBox = addCheckbox("Online Multiplayer", initialState = false) {} + checkBox.onChange { + checkBox.isChecked = false + ToastPopup("To use new multiplayer games, go back to the main menu and create a lobby from the multiplayer menu instead.", stage).open() } - update() + checkBox } private fun Table.addAnyoneCanSpectateCheckbox() = diff --git a/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt index 93ded075f9b49..218cd0c166ca9 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt @@ -7,11 +7,13 @@ import com.badlogic.gdx.scenes.scene2d.ui.Container import com.badlogic.gdx.scenes.scene2d.ui.SelectBox import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException import com.unciv.logic.files.MapSaver import com.unciv.logic.map.MapParameters import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.Popup import com.unciv.ui.components.input.onChange import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.victoryscreen.LoadMapPreview @@ -21,7 +23,7 @@ import kotlinx.coroutines.isActive import com.badlogic.gdx.utils.Array as GdxArray class MapFileSelectTable( - private val newGameScreen: NewGameScreen, + private val newGameScreen: MapOptionsInterface, private val mapParameters: MapParameters ) : Table() { @@ -134,6 +136,19 @@ class MapFileSelectTable( private fun onSelectBoxChange() { cancelBackgroundJobs() val mapFile = mapFileSelectBox.selected.fileHandle + val mapParams = try { + MapSaver.loadMapParameters(mapFile) + } catch (ex:Exception){ + ex.printStackTrace() + Popup(stage).apply { + addGoodSizedLabel("Could not load map!").row() + if (ex is UncivShowableException) + addGoodSizedLabel(ex.message).row() + addCloseButton() + open() + } + return + } mapParameters.name = mapFile.name() newGameScreen.gameSetupInfo.mapFile = mapFile val mapMods = mapFileSelectBox.selected.mapParameters.mods.partition { RulesetCache[it]?.modOptions?.isBaseRuleset == true } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsInterface.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsInterface.kt new file mode 100644 index 0000000000000..ded7b4c163a61 --- /dev/null +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsInterface.kt @@ -0,0 +1,15 @@ +package com.unciv.ui.screens.newgamescreen + +/** + * Interface to implement for all screens using [MapOptionsTable] for universal usage + * @see IPreviousScreen + */ +interface MapOptionsInterface: IPreviousScreen { + fun isNarrowerThan4to3(): Boolean + fun lockTables() + fun unlockTables() + fun updateTables() + fun updateRuleset() + fun tryUpdateRuleset(): Boolean + fun getColumnWidth(): Float +} diff --git a/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt index d76880271e54d..f06879f5999da 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt @@ -7,7 +7,7 @@ import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.input.onChange import com.unciv.ui.screens.basescreen.BaseScreen -class MapOptionsTable(private val newGameScreen: NewGameScreen, isReset: Boolean = true): Table() { +class MapOptionsTable(private val newGameScreen: MapOptionsInterface, isReset: Boolean = true): Table() { private val mapParameters = newGameScreen.gameSetupInfo.mapParameters private var mapTypeSpecificTable = Table() diff --git a/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt index 392e795fd7115..59264d21fd0e4 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt @@ -378,7 +378,7 @@ class MapParametersTable( fun addTextButton(text: String, shouldAddToTable: Boolean = false, action: ((Boolean) -> Unit)) { val button = text.toTextButton() - button.onClick { action.invoke(true) } + button.onClick { action(true) } if (shouldAddToTable) table.add(button).colspan(2).padTop(10f).row() } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NationPickerPopup.kt b/core/src/com/unciv/ui/screens/newgamescreen/NationPickerPopup.kt index fd36bb7aa9799..18e821ac28144 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NationPickerPopup.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NationPickerPopup.kt @@ -33,11 +33,18 @@ import com.unciv.ui.screens.basescreen.BaseScreen import kotlin.math.PI import kotlin.math.cos +/** + * Popup that lets the user choose a nation for a player (human or AI) + */ internal class NationPickerPopup( - private val playerPicker: PlayerPickerTable, private val player: Player, - private val noRandom: Boolean -) : Popup(playerPicker.previousScreen as BaseScreen, Scrollability.None) { + private val civBlocksWidth: Float, + baseScreen: BaseScreen, + private val previousScreen: IPreviousScreen, + private val noRandom: Boolean, + private val getAvailablePlayerCivs: (String) -> Sequence, + private val update: () -> Unit +) : Popup(baseScreen, Scrollability.None) { companion object { // Note - innerTable has pad(20f) and defaults().pad(5f), so content bottomLeft is at x=25/y=25 // These are used for the Close/OK buttons in the lower left/right corners: @@ -55,14 +62,12 @@ internal class NationPickerPopup( const val iconViewPadHorz = iconViewSpacing / 2 // a little empiric } - private val previousScreen = playerPicker.previousScreen private val ruleset = previousScreen.ruleset private val settings = GUI.getSettings() // This Popup's body has two halves of same size, either side by side or arranged vertically // depending on screen proportions - determine height for one of those private val partHeight = stageToShowOn.height * (if (stageToShowOn.isNarrowerThan4to3()) 0.45f else 0.8f) - private val civBlocksWidth = playerPicker.civBlocksWidth private val nationListTable = Table() private val nationListScroll = AutoScrollPane(nationListTable) @@ -143,7 +148,7 @@ internal class NationPickerPopup( player.chosenCiv = selectedNation close() - playerPicker.update() + update() } private data class NationIterationElement( @@ -262,8 +267,8 @@ internal class NationPickerPopup( if (spectator != null && player.playerType != PlayerType.AI) // only humans can spectate, sorry robots yield(NationIterationElement(spectator)) } - // Then what PlayerPickerTable says we should display - see its doc - val part2 = playerPicker.getAvailablePlayerCivs(player.chosenCiv) + // Then whatever player civs are available + val part2 = getAvailablePlayerCivs(player.chosenCiv) .map { NationIterationElement(it) } // Combine and Sort return part1 + diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt index 4e572a80b7ecb..c43318a3c1a17 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt @@ -37,6 +37,7 @@ import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.RecreateOnResize +import com.unciv.ui.screens.pickerscreens.HorizontalPickerScreen import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Concurrency import com.unciv.utils.Log @@ -50,7 +51,7 @@ import com.unciv.ui.components.AutoScrollPane as ScrollPane class NewGameScreen( defaultGameSetupInfo: GameSetupInfo? = null, isReset: Boolean = false -): IPreviousScreen, PickerScreen(), RecreateOnResize { +): MapOptionsInterface, HorizontalPickerScreen() /* to get more space */, RecreateOnResize { override var gameSetupInfo = defaultGameSetupInfo ?: GameSetupInfo.fromSettings() override val ruleset = Ruleset() // updateRuleset will clear and add @@ -232,7 +233,7 @@ class NewGameScreen( /** Subtables may need an upper limit to their width - they can ask this function. */ // In sync with isPortrait in init, here so UI details need not know about 3-column vs 1-column layout - internal fun getColumnWidth() = floor(stage.width / (if (isNarrowerThan4to3()) 1 else 3)) + override fun getColumnWidth() = floor(stage.width / (if (isNarrowerThan4to3()) 1 else 3)) private fun initLandscape() { scrollPane.setScrollingDisabled(true,true) @@ -299,7 +300,7 @@ class NewGameScreen( popup.open() } - val newGame:GameInfo + val newGame: GameInfo try { newGame = GameStarter.startNewGame(gameSetupInfo) } catch (exception: Exception) { @@ -362,7 +363,7 @@ class NewGameScreen( * * @return Success - failure means gameSetupInfo was reset to defaults and the Ruleset was reverted to G&K */ - fun tryUpdateRuleset(): Boolean { + override fun tryUpdateRuleset(): Boolean { var success = true fun handleFailure(message: String): Ruleset { success = false @@ -388,17 +389,24 @@ class NewGameScreen( return success } - fun lockTables() { + override fun updateRuleset() { + ruleset.clear() + ruleset.add(RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters)) + ImageGetter.setNewRuleset(ruleset) + game.musicController.setModList(gameSetupInfo.gameParameters.getModsAndBaseRuleset()) + } + + override fun lockTables() { playerPickerTable.locked = true newGameOptionsTable.locked = true } - fun unlockTables() { + override fun unlockTables() { playerPickerTable.locked = false newGameOptionsTable.locked = false } - fun updateTables() { + override fun updateTables() { playerPickerTable.gameParameters = gameSetupInfo.gameParameters playerPickerTable.update() newGameOptionsTable.gameParameters = gameSetupInfo.gameParameters diff --git a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt index 8613b57de482f..b33bc1ba263a8 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt @@ -16,18 +16,18 @@ import com.unciv.models.metadata.Player import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.nation.Nation import com.unciv.models.translations.tr -import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.WrappableLabel import com.unciv.ui.components.extensions.darken import com.unciv.ui.components.extensions.isEnabled -import com.unciv.ui.components.input.keyShortcuts -import com.unciv.ui.components.input.onActivation -import com.unciv.ui.components.input.onClick import com.unciv.ui.components.extensions.setFontColor import com.unciv.ui.components.extensions.surroundWithCircle import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.components.input.keyShortcuts +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.Popup import com.unciv.ui.screens.basescreen.BaseScreen @@ -307,7 +307,14 @@ class PlayerPickerTable( * @param player current player */ private fun popupNationPicker(player: Player, noRandom: Boolean) { - NationPickerPopup(this, player, noRandom).open() + NationPickerPopup( + player, + civBlocksWidth, + previousScreen as BaseScreen, + previousScreen, + noRandom, + { getAvailablePlayerCivs(player.chosenCiv) } + ) { update() }.open() update() } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt b/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt index c3dd3ecb493bc..c4dc23253300c 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt @@ -1,7 +1,9 @@ package com.unciv.ui.screens.overviewscreen import com.badlogic.gdx.utils.Align +import com.unciv.UncivGame import com.unciv.logic.civilization.Civilization +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.models.ruleset.tile.ResourceType import com.unciv.ui.screens.overviewscreen.EmpireOverviewTab.EmpireOverviewTabPersistableData import com.unciv.ui.components.input.KeyCharAndCode @@ -69,6 +71,13 @@ enum class EmpireOverviewCategories( override fun createTab(viewingPlayer: Civilization, overviewScreen: EmpireOverviewScreen, persistedData: EmpireOverviewTabPersistableData?) = NotificationsOverviewTable(viewingPlayer, overviewScreen, persistedData) override fun showDisabled(viewingPlayer: Civilization) = viewingPlayer.notifications.isEmpty() && viewingPlayer.notificationsLog.isEmpty() + }, + Multiplayer("OtherIcons/Multiplayer", 'M', Align.top) { + override fun createTab(viewingPlayer: Civilization, overviewScreen: EmpireOverviewScreen, persistedData: EmpireOverviewTabPersistableData?) = + MultiplayerOverviewTable(viewingPlayer, overviewScreen, persistedData) + override fun testState(viewingPlayer: Civilization) = + if (UncivGame.Current.gameInfo?.gameParameters?.isOnlineMultiplayer == true && UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) EmpireOverviewTabState.Normal + else EmpireOverviewTabState.Hidden } ; diff --git a/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt b/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt new file mode 100644 index 0000000000000..bc9869bc6f586 --- /dev/null +++ b/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt @@ -0,0 +1,104 @@ +package com.unciv.ui.screens.overviewscreen + +import com.badlogic.gdx.scenes.scene2d.ui.Cell +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.utils.Disposable +import com.unciv.UncivGame +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.multiplayerscreens.ChatMessageList +import com.unciv.ui.screens.multiplayerscreens.ChatRoomType +import com.unciv.ui.screens.multiplayerscreens.ChatTable +import com.unciv.ui.screens.multiplayerscreens.FriendListV2 +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +class MultiplayerOverviewTable( + viewingPlayer: Civilization, + overviewScreen: EmpireOverviewScreen, + persistedData: EmpireOverviewTabPersistableData? = null +) : EmpireOverviewTab(viewingPlayer, overviewScreen) { + class MultiplayerTabPersistableData( + // If the game is no APIv2 and no online multiplayer game, this values are just null + private val chatRoomUUID: UUID? = null, + val gameName: String, + val chatMessageList: ChatMessageList? = + if (chatRoomUUID == null) null + else ChatMessageList(true, Pair(ChatRoomType.Game, gameName), chatRoomUUID, UncivGame.Current.onlineMultiplayer), + val disposables: MutableList = mutableListOf() + ) : EmpireOverviewTabPersistableData() { + constructor(overview: GameOverviewResponse?) : this(overview?.chatRoomUUID, overview?.name ?: "failed to load multiplayer game") + override fun isEmpty() = false + } + + override val persistableData = (persistedData as? MultiplayerTabPersistableData) ?: MultiplayerTabPersistableData( + Concurrency.runBlocking { + val result = InfoPopup.wrap(overviewScreen.stage) { + overviewScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(gameInfo.gameId)) + } + if (result == null) { + Log.debug("Failed to fetch multiplayer game details for ${gameInfo.gameId}") + } + result + } + ) + + private val chatTable = if (persistableData.chatMessageList != null) ChatTable( + persistableData.chatMessageList, + useInputPopup = true + ) else null + + private var friendContainer: Container? = null + private var chatContainer: Container? = null + private var friendCell: Cell?>? = null + private var chatCell: Cell?>? = null + + init { + val tablePadding = 30f + defaults().pad(tablePadding).top() + + friendContainer = Container() + chatContainer = Container() + chatContainer?.actor = chatTable + persistableData.disposables.forEach { it.dispose() } + persistableData.disposables.clear() + + friendCell = add(friendContainer).grow() + chatCell = add(chatContainer).padLeft(15f).growX() + + // Detaching the creation of the friend list as well as the resizing of the cells + // provides two advantages: the surrounding stage is known and the UI delay is reduced. + // This assumes that networking is slower than the UI, otherwise it crashes with NPE. + Concurrency.run { + val me = overviewScreen.game.onlineMultiplayer.api.account.get()!! + Concurrency.runOnGLThread { + val friendList = FriendListV2( + overviewScreen, + me.uuid, + requests = true, + chat = { _, a, c -> startChatting(a, c) }, + edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, overviewScreen) } + ) + friendList.triggerUpdate(true) + friendContainer?.actor = friendList + if (stage != null) { + friendCell?.prefWidth(stage.width * 0.3f) + chatCell?.prefWidth(stage.width * 0.7f) + } + } + } + } + + private fun startChatting(friend: AccountResponse, chatRoom: UUID) { + Log.debug("Opening chat dialog with friend %s (room %s)", friend, chatRoom) + val chatList = ChatMessageList(true, Pair(ChatRoomType.Friend, friend.displayName), chatRoom, overviewScreen.game.onlineMultiplayer) + persistableData.disposables.add(chatList) + chatContainer?.actor = ChatTable( + chatList, + useInputPopup = true + ) + } +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/HorizontalPickerScreen.kt b/core/src/com/unciv/ui/screens/pickerscreens/HorizontalPickerScreen.kt new file mode 100644 index 0000000000000..45be86755a6a0 --- /dev/null +++ b/core/src/com/unciv/ui/screens/pickerscreens/HorizontalPickerScreen.kt @@ -0,0 +1,6 @@ +package com.unciv.ui.screens.pickerscreens + +/** + * Picker screen that aligns the buttons of the right side group horizontally instead of vertically + */ +open class HorizontalPickerScreen(disableScroll: Boolean = false): PickerScreen(disableScroll = disableScroll, horizontally = true) diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PickerPane.kt b/core/src/com/unciv/ui/screens/pickerscreens/PickerPane.kt index af7351ff77334..ae5ab65db94f1 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/PickerPane.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/PickerPane.kt @@ -2,12 +2,12 @@ package com.unciv.ui.screens.pickerscreens import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup import com.badlogic.gdx.scenes.scene2d.ui.SplitPane import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.VerticalGroup import com.unciv.Constants import com.unciv.GUI -import com.unciv.UncivGame import com.unciv.ui.images.IconTextButton import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.screens.basescreen.BaseScreen @@ -19,14 +19,15 @@ import com.unciv.ui.components.extensions.toTextButton class PickerPane( disableScroll: Boolean = false, + horizontally: Boolean = false, // use a horizontal group instead of a vertical group for layout ) : Table() { /** The close button on the lower left of [bottomTable], see [PickerScreen.setDefaultCloseAction]. * Note if you don't use that helper, you'll need to do both click and keyboard support yourself. */ val closeButton = Constants.close.toTextButton() /** A scrollable wrapped Label you can use to show descriptions in the [bottomTable], starts empty */ val descriptionLabel = "".toLabel() - /** A wrapper containing [rightSideButton]. You can add buttons, they will be arranged vertically */ - val rightSideGroup = VerticalGroup() + /** A wrapper containing [rightSideButton]. You can add buttons, they will be arranged vertically if not set otherwise */ + val rightSideGroup = if (horizontally) HorizontalGroup() else VerticalGroup() /** A button on the lower right of [bottomTable] you can use for a "OK"-type action, starts disabled */ val rightSideButton = "".toTextButton() diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PickerScreen.kt b/core/src/com/unciv/ui/screens/pickerscreens/PickerScreen.kt index c72ad33cf96a9..26eb97315185a 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/PickerScreen.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/PickerScreen.kt @@ -5,9 +5,9 @@ import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.onActivation -open class PickerScreen(disableScroll: Boolean = false) : BaseScreen() { +open class PickerScreen(disableScroll: Boolean = false, horizontally: Boolean = false) : BaseScreen() { - val pickerPane = PickerPane(disableScroll = disableScroll) + val pickerPane = PickerPane(disableScroll = disableScroll, horizontally = horizontally) /** @see PickerPane.closeButton */ val closeButton by pickerPane::closeButton diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 3fed17ebd664f..37994e781367e 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -16,6 +16,8 @@ import com.unciv.logic.civilization.PlayerType import com.unciv.logic.civilization.diplomacy.DiplomaticStatus import com.unciv.logic.event.EventBus import com.unciv.logic.map.MapVisualization +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.MultiplayerGameCanBeLoaded import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException @@ -37,6 +39,7 @@ import com.unciv.ui.components.input.KeyboardPanningListener import com.unciv.ui.components.input.onClick import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.AuthPopup +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.hasOpenPopups @@ -57,7 +60,8 @@ import com.unciv.ui.screens.worldscreen.bottombar.BattleTable import com.unciv.ui.screens.worldscreen.bottombar.TileInfoTable import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMusicPopup import com.unciv.ui.screens.worldscreen.minimap.MinimapHolder -import com.unciv.ui.screens.worldscreen.status.MultiplayerStatusButton +import com.unciv.ui.screens.worldscreen.status.MultiplayerStatusButtonV1 +import com.unciv.ui.screens.worldscreen.status.MultiplayerStatusButtonV2 import com.unciv.ui.screens.worldscreen.status.NextTurnButton import com.unciv.ui.screens.worldscreen.status.NextTurnProgress import com.unciv.ui.screens.worldscreen.status.StatusButtons @@ -207,6 +211,71 @@ class WorldScreen( loadLatestMultiplayerState() } } + + // APIv2-based online multiplayer games use this event to notify about changes for the game + events.receive(MultiplayerGameCanBeLoaded::class, { it.gameInfo.gameId == gameId }) { + if (it.gameInfo.gameId == UncivGame.Current.gameInfo?.gameId) { + val currentScreen = UncivGame.Current.screen + // Reload instantly if the WorldScreen is shown, otherwise ask whether to continue when the WorldScreen + // is in the screen stack. If neither of them holds true, another or no game is currently played. + if (currentScreen == this) { + it.gameInfo.isUpToDate = true + Concurrency.run { + UncivGame.Current.loadGame(it.gameInfo) + Concurrency.runOnGLThread { + UncivGame.Current.notifyTurnStarted() + } + } + } else if (this in UncivGame.Current.screenStack && currentScreen != null) { + val popup = Popup(currentScreen) + if (it.gameName == null) { + popup.addGoodSizedLabel("It's your turn in game '${it.gameInfo.gameId}' now!").colspan(2).row() + } else { + popup.addGoodSizedLabel("It's your turn in game '${it.gameName}' now!").colspan(2).row() + } + popup.addCloseButton { + val updateNotification = Popup(this) + updateNotification.addGoodSizedLabel("Another player just completed their turn.").colspan(2).row() + updateNotification.addCloseButton() + updateNotification.addOKButton("Reload game") { + updateNotification.reuseWith("Working...") + updateNotification.open(force = true) + val updatedGameInfo = InfoPopup.load(this.stage) { + game.onlineMultiplayer.downloadGame(it.gameInfo.gameId) + } + if (updatedGameInfo != null) { + Concurrency.runOnNonDaemonThreadPool { + game.loadGame(updatedGameInfo) + Concurrency.runOnGLThread { + UncivGame.Current.notifyTurnStarted() + } + } + } + } + updateNotification.equalizeLastTwoButtonWidths() + updateNotification.open() + } + popup.addOKButton("Switch to game") { + popup.reuseWith("Working...") + popup.open(force = true) + it.gameInfo.isUpToDate = true + Concurrency.run { + UncivGame.Current.loadGame(it.gameInfo) + Concurrency.runOnGLThread { + UncivGame.Current.notifyTurnStarted() + } + } + } + popup.equalizeLastTwoButtonWidths() + popup.open(force = true) + } + } + } + + // Ensure a WebSocket connection is established for APIv2 games + if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.hasAuthentication() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + Concurrency.run { game.onlineMultiplayer.api.ensureConnectedWebSocket() } + } } if (restoreState != null) restore(restoreState) @@ -696,7 +765,11 @@ class WorldScreen( 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)) + if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + statusButtons.multiplayerStatusButton = MultiplayerStatusButtonV2(this, gameInfo.gameId, gameInfo.civilizations.filter { it.isMajorCiv() }) + } else { + statusButtons.multiplayerStatusButton = MultiplayerStatusButtonV1(this, game.onlineMultiplayer.getGameByGameId(gameInfo.gameId)) + } } else { if (statusButtons.multiplayerStatusButton == null) return statusButtons.multiplayerStatusButton = null diff --git a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt index a4c526b9eda17..84994dc19a85b 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -12,6 +12,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Stack import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame +import com.unciv.logic.civilization.Civilization import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.HasMultiplayerGameName import com.unciv.logic.multiplayer.MultiplayerGameNameChanged @@ -19,22 +20,45 @@ 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.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse import com.unciv.logic.multiplayer.isUsersTurn +import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.input.onClick import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen -import com.unciv.ui.components.input.onClick -import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.screens.multiplayerscreens.ChatRoomType +import com.unciv.ui.screens.multiplayerscreens.MultiplayerGameScreen import com.unciv.utils.Concurrency import com.unciv.utils.launchOnGLThread import kotlinx.coroutines.Job import kotlinx.coroutines.delay import java.time.Duration import java.time.Instant +import java.util.UUID + +abstract class MultiplayerStatusButton : Button(BaseScreen.skin), Disposable { + protected fun createMultiplayerImage(): Image { + val img = ImageGetter.getImage("OtherIcons/Multiplayer") + img.setSize(40f) + return img + } + + protected fun createLoadingImage(): Image { + val img = ImageGetter.getImage("OtherIcons/Loading") + img.setSize(40f) + img.isVisible = false + img.setOrigin(Align.center) + return img + } +} -class MultiplayerStatusButton( +class MultiplayerStatusButtonV1( screen: BaseScreen, curGame: OnlineMultiplayerGame? -) : Button(BaseScreen.skin), Disposable { +) : MultiplayerStatusButton() { private var curGameName = curGame?.name private val multiplayerImage = createMultiplayerImage() private val loadingImage = createLoadingImage() @@ -121,21 +145,6 @@ class MultiplayerStatusButton( .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() @@ -196,3 +205,54 @@ private class TurnIndicator : HorizontalGroup(), Disposable { job?.cancel() } } + +/** + * Multiplayer status button for APIv2 games only + * + * It shows a completely different user interfaces than the previous V1 buttons above. + */ +class MultiplayerStatusButtonV2(screen: BaseScreen, private val gameUUID: UUID, private val civilizations: List) : MultiplayerStatusButton() { + constructor(screen: BaseScreen, gameId: String, civilizations: List) : this(screen, UUID.fromString(gameId), civilizations) + + private var me: AccountResponse? = null + private var gameDetails: GameOverviewResponse? = null + private val events = EventBus.EventReceiver() + + private val turnIndicator = TurnIndicator() + private val turnIndicatorCell: Cell + private val multiplayerImage = createMultiplayerImage() + + init { + turnIndicatorCell = add().padTop(10f).padBottom(10f) + add(multiplayerImage).pad(5f) + + Concurrency.runOnNonDaemonThreadPool{ me = screen.game.onlineMultiplayer.api.account.get() } + Concurrency.run { + gameDetails = InfoPopup.wrap(screen.stage) { + screen.game.onlineMultiplayer.api.game.head(gameUUID) + } + } + + onActivation { + var details = gameDetails + // Retrying in case the cache was broken somehow + if (details == null) { + details = InfoPopup.load(screen.stage) { + screen.game.onlineMultiplayer.api.game.head(gameUUID) + } + } + // If there are no details after the retry, the game is most likely not played on that server + if (details == null) { + screen.game.pushScreen(MultiplayerGameScreen(me!!.uuid, null, civilizations)) + } else { + gameDetails = details + screen.game.pushScreen(MultiplayerGameScreen(me!!.uuid, Triple(details.chatRoomUUID, ChatRoomType.Game, details.name), civilizations)) + } + } + } + + override fun dispose() { + events.stopReceiving() + turnIndicator.dispose() + } +} diff --git a/core/src/com/unciv/ui/screens/worldscreen/status/StatusButtons.kt b/core/src/com/unciv/ui/screens/worldscreen/status/StatusButtons.kt index 9a89e5d3746a5..e982ce9f6d7a4 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/StatusButtons.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/StatusButtons.kt @@ -10,6 +10,7 @@ class StatusButtons( var multiplayerStatusButton: MultiplayerStatusButton? = multiplayerStatusButton set(button) { multiplayerStatusButton?.remove() + multiplayerStatusButton?.dispose() field = button if (button != null) { addActorAt(0, button) diff --git a/gradle.properties b/gradle.properties index 363aa2f1801c9..78e3d7b7b06e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,4 @@ android.useAndroidX=true android.enableJetifier=true org.gradle.parallel=true org.gradle.caching=true -org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m \ No newline at end of file +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m