From 8fe9b45319e7015914b5a5305d79234ce10b5634 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 5 Mar 2023 02:42:40 +0100 Subject: [PATCH 001/152] Added new dependencies for networking in the new multiplayer (ktor) --- build.gradle.kts | 31 +++++++++++++++++++++++++++++++ gradle.properties | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3649a46733908..437492db24055 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,8 @@ import com.unciv.build.BuildConfig.gdxVersion import com.unciv.build.BuildConfig.roboVMVersion +val ktorVersion: String by project + // You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used configurations.all { resolutionStrategy { @@ -28,6 +30,18 @@ buildscript { } } +// Fixes the error "Please initialize at least one Kotlin target in 'Unciv (:)'" +kotlin { + jvm() +} + +// Plugins used for serialization of JSON for networking +plugins { + kotlin("multiplatform") version "1.8.10" + kotlin("plugin.serialization") version "1.8.10" +} + + allprojects { apply(plugin = "eclipse") apply(plugin = "idea") @@ -113,11 +127,28 @@ project(":ios") { project(":core") { apply(plugin = "kotlin") + // Serialization features (especially JSON) + apply(plugin = "kotlinx-serialization") dependencies { "implementation"("com.badlogicgames.gdx:gdx:$gdxVersion") "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") + + // Ktor core + "implementation"("io.ktor:ktor-client-core:$ktorVersion") + // CIO engine + "implementation"("io.ktor:ktor-client-cio:$ktorVersion") + // WebSocket support + "implementation"("io.ktor:ktor-client-websockets:$ktorVersion") + // Gzip transport encoding + "implementation"("io.ktor:ktor-client-encoding:$ktorVersion") + // Logging support + "implementation"("io.ktor:ktor-client-logging:$ktorVersion") + // Content negotiation + "implementation"("io.ktor:ktor-client-content-negotiation:$ktorVersion") + // JSON serialization and de-serialization + "implementation"("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") } diff --git a/gradle.properties b/gradle.properties index 363aa2f1801c9..25fed11a6baab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,5 @@ 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 +ktorVersion=2.2.3 From 24fdb17b92df19af74f606193b8cf007e5d1427c Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 6 Mar 2023 03:26:12 +0100 Subject: [PATCH 002/152] Added some utilities and first structs --- .../logic/multiplayer/structs/LoginRequest.kt | 10 +++++++ .../multiplayer/structs/RegisterRequest.kt | 12 +++++++++ .../multiplayer/utils/ByteArrayAdapter.kt | 15 +++++++++++ .../logic/multiplayer/utils/DataSerializer.kt | 26 +++++++++++++++++++ .../multiplayer/utils/LocalDateAdapter.kt | 21 +++++++++++++++ .../multiplayer/utils/LocalDateTimeAdapter.kt | 21 +++++++++++++++ .../utils/OffsetDateTimeAdapter.kt | 21 +++++++++++++++ .../logic/multiplayer/utils/UUIDAdapter.kt | 16 ++++++++++++ 8 files changed, 142 insertions(+) create mode 100644 core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt create mode 100644 core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt create mode 100644 core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt diff --git a/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt b/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt new file mode 100644 index 0000000000000..50f024392fcc8 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt @@ -0,0 +1,10 @@ +package com.unciv.logic.multiplayer.structs + +import com.squareup.moshi.Json + +data class LoginRequest ( + @Json(name = "username") + var username: String, + @Json(name = "password") + var password: String +) diff --git a/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt b/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt new file mode 100644 index 0000000000000..97343bfc4284c --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt @@ -0,0 +1,12 @@ +package com.unciv.logic.multiplayer.structs + +import com.squareup.moshi.Json + +data class RegisterRequest ( + @Json(name = "username") + var username: String, + @Json(name = "display_name") + var displayName: String, + @Json(name = "password") + var password: String +) diff --git a/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt new file mode 100644 index 0000000000000..b53187e6f0ef5 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt @@ -0,0 +1,15 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + +/** + * Adapter for moshi to handle byte arrays from and to JSON + */ +class ByteArrayAdapter { + @ToJson + fun toJson(data: ByteArray): String = String(data) + + @FromJson + fun fromJson(data: String): ByteArray = data.toByteArray() +} diff --git a/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt b/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt new file mode 100644 index 0000000000000..697a5abce73e8 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt @@ -0,0 +1,26 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import java.util.Date + +/** + * Data serialization utility object + */ +object DataSerializer { + @JvmStatic + val moshiBuilder: Moshi.Builder = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter().nullSafe()) + .add(OffsetDateTimeAdapter()) + .add(LocalDateTimeAdapter()) + .add(LocalDateAdapter()) + .add(UUIDAdapter()) + .add(ByteArrayAdapter()) + .add(KotlinJsonAdapterFactory()) + + @JvmStatic + val moshi: Moshi by lazy { + moshiBuilder.build() + } +} diff --git a/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt new file mode 100644 index 0000000000000..528a3fb9765e4 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt @@ -0,0 +1,21 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * Adapter for moshi to handle datetime data as local dates from and to JSON + */ +class LocalDateAdapter { + @ToJson + fun toJson(value: LocalDate): String { + return DateTimeFormatter.ISO_LOCAL_DATE.format(value) + } + + @FromJson + fun fromJson(value: String): LocalDate { + return LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt new file mode 100644 index 0000000000000..c7b83f4f222a3 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt @@ -0,0 +1,21 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * Adapter for moshi to handle local datetime data from and to JSON + */ +class LocalDateTimeAdapter { + @ToJson + fun toJson(value: LocalDateTime): String { + return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value) + } + + @FromJson + fun fromJson(value: String): LocalDateTime { + return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt new file mode 100644 index 0000000000000..c45edc6e024e8 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt @@ -0,0 +1,21 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +/** + * Adapter for moshi to handle datetime data with offsets from and to JSON + */ +class OffsetDateTimeAdapter { + @ToJson + fun toJson(value: OffsetDateTime): String { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value) + } + + @FromJson + fun fromJson(value: String): OffsetDateTime { + return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt new file mode 100644 index 0000000000000..07f2199e992b0 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt @@ -0,0 +1,16 @@ +package com.unciv.logic.multiplayer.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.util.UUID + +/** + * Adapter for moshi to handle UUIDs from and to JSON + */ +class UUIDAdapter { + @ToJson + fun toJson(uuid: UUID) = uuid.toString() + + @FromJson + fun fromJson(s: String): UUID = UUID.fromString(s) +} From 668014413816c01392a9969fbfd7246a39f83780 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 6 Mar 2023 13:37:26 +0100 Subject: [PATCH 003/152] Replace moshi serializer with kotlinx.json, added the API structs --- .../logic/multiplayer/api/RequestStructs.kt | 61 ++++++++++++ .../logic/multiplayer/api/ResponseStructs.kt | 97 +++++++++++++++++++ .../logic/multiplayer/structs/LoginRequest.kt | 10 -- .../multiplayer/structs/RegisterRequest.kt | 12 --- .../multiplayer/utils/ByteArrayAdapter.kt | 15 --- .../logic/multiplayer/utils/DataSerializer.kt | 26 ----- .../multiplayer/utils/LocalDateAdapter.kt | 21 ---- .../multiplayer/utils/LocalDateTimeAdapter.kt | 21 ---- .../utils/OffsetDateTimeAdapter.kt | 21 ---- .../logic/multiplayer/utils/UUIDAdapter.kt | 16 --- 10 files changed, 158 insertions(+), 142 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt create mode 100644 core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt delete mode 100644 core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt new file mode 100644 index 0000000000000..eafeae8d6eb32 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -0,0 +1,61 @@ +/** + * Collection of API request structs in a single file for simplicity + */ + +package com.unciv.logic.multiplayer.api + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +/** + * The content to register a new account + */ +@Serializable +data class AccountRegistrationRequest( + val username: String, + @SerialName("display_name") + val displayName: String, + val password: String +) + +/** + * The request of a new friendship + */ +@Serializable +data class CreateFriendRequest( + val username: String +) + +/** + * The request data of a login request + */ +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +/** + * The set password request data + * + * The parameter new_password must not be empty + */ +@Serializable +data class SetPasswordRequest( + @SerialName("old_password") + val oldPassword: String, + @SerialName("new_password") + val newPassword: String +) + +/** + * Update account request data + * + * All parameter are optional, but at least one of them is required. + */ +@Serializable +data class UpdateAccountRequest( + val username: String?, + @SerialName("display_name") + val displayName: String? +) diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt new file mode 100644 index 0000000000000..ea9334e58e345 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -0,0 +1,97 @@ +/** + * Collection of API response structs in a single file for simplicity + */ + +package com.unciv.logic.multiplayer.api + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +/** + * The account data + */ +@Serializable +data class AccountResponse( + val username: String, + @SerialName("display_name") + val displayName: String +) + +/** + * The Response that is returned in case of an error + * + * For client errors the HTTP status code will be 400, for server errors the 500 will be used. + */ +@Serializable +data class ApiErrorResponse( + val message: String, + @SerialName("status_code") + // TODO: @JsonValue or something similar, at least in Jackson + val statusCode: ApiStatusCode +) + +/** + * The status code represents a unique identifier for an error. Error codes in the range of 1000..2000 represent client errors that could be handled by the client. Error codes in the range of 2000..3000 represent server errors. + * Values: C1000,C1001,C1002,C1003,C1004,C1005,C1006,C1007,C1008,C1009,C1010,C1011,C1012,C1013,C1014,C2000,C2001,C2002 + */ +@Serializable // TODO: Is the following required: (with = ApiStatusCode.Serializer::class) +enum class ApiStatusCode(val value: Int) { + C1000(1000), + C1001(1001), + C1002(1002), + C1003(1003), + C1004(1004), + C1005(1005), + C1006(1006), + C1007(1007), + C1008(1008), + C1009(1009), + C1010(1010), + C1011(1011), + C1012(1012), + C1013(1013), + C1014(1014), + C2000(2000), + C2001(2001), + C2002(2002); + + /** + * This override toString avoids using the enum var name and uses the actual api value instead. + * In cases the var name and value are different, the client would send incorrect enums to the server. + */ + override fun toString(): String { + return value.toString() + } + + // TODO: Verify this enum works as expected + //object Serializer : CommonEnumSerializer("ApiStatusCode", values(), values().map { it.value.toString() }.toTypedArray()) +} + + +/** + * A single friend or friend request + */ +@Serializable +data class FriendResponse( + val id: Long, + @SerialName("is_request") + val isRequest: Boolean, + val from: String, + val to: String +) + +/** + * A list of your friends and friend requests + */ +@Serializable +data class GetFriendResponse( + val friends: List +) + +/** + * The version data for clients + */ +@Serializable +data class VersionResponse( + val version: Int +) diff --git a/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt b/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt deleted file mode 100644 index 50f024392fcc8..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/structs/LoginRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.unciv.logic.multiplayer.structs - -import com.squareup.moshi.Json - -data class LoginRequest ( - @Json(name = "username") - var username: String, - @Json(name = "password") - var password: String -) diff --git a/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt b/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt deleted file mode 100644 index 97343bfc4284c..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/structs/RegisterRequest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.unciv.logic.multiplayer.structs - -import com.squareup.moshi.Json - -data class RegisterRequest ( - @Json(name = "username") - var username: String, - @Json(name = "display_name") - var displayName: String, - @Json(name = "password") - var password: String -) diff --git a/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt deleted file mode 100644 index b53187e6f0ef5..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/ByteArrayAdapter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.FromJson -import com.squareup.moshi.ToJson - -/** - * Adapter for moshi to handle byte arrays from and to JSON - */ -class ByteArrayAdapter { - @ToJson - fun toJson(data: ByteArray): String = String(data) - - @FromJson - fun fromJson(data: String): ByteArray = data.toByteArray() -} diff --git a/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt b/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt deleted file mode 100644 index 697a5abce73e8..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/DataSerializer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import java.util.Date - -/** - * Data serialization utility object - */ -object DataSerializer { - @JvmStatic - val moshiBuilder: Moshi.Builder = Moshi.Builder() - .add(Date::class.java, Rfc3339DateJsonAdapter().nullSafe()) - .add(OffsetDateTimeAdapter()) - .add(LocalDateTimeAdapter()) - .add(LocalDateAdapter()) - .add(UUIDAdapter()) - .add(ByteArrayAdapter()) - .add(KotlinJsonAdapterFactory()) - - @JvmStatic - val moshi: Moshi by lazy { - moshiBuilder.build() - } -} diff --git a/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt deleted file mode 100644 index 528a3fb9765e4..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/LocalDateAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.FromJson -import com.squareup.moshi.ToJson -import java.time.LocalDate -import java.time.format.DateTimeFormatter - -/** - * Adapter for moshi to handle datetime data as local dates from and to JSON - */ -class LocalDateAdapter { - @ToJson - fun toJson(value: LocalDate): String { - return DateTimeFormatter.ISO_LOCAL_DATE.format(value) - } - - @FromJson - fun fromJson(value: String): LocalDate { - return LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE) - } -} diff --git a/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt deleted file mode 100644 index c7b83f4f222a3..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/LocalDateTimeAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.FromJson -import com.squareup.moshi.ToJson -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -/** - * Adapter for moshi to handle local datetime data from and to JSON - */ -class LocalDateTimeAdapter { - @ToJson - fun toJson(value: LocalDateTime): String { - return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value) - } - - @FromJson - fun fromJson(value: String): LocalDateTime { - return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME) - } -} diff --git a/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt deleted file mode 100644 index c45edc6e024e8..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/OffsetDateTimeAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.FromJson -import com.squareup.moshi.ToJson -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -/** - * Adapter for moshi to handle datetime data with offsets from and to JSON - */ -class OffsetDateTimeAdapter { - @ToJson - fun toJson(value: OffsetDateTime): String { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value) - } - - @FromJson - fun fromJson(value: String): OffsetDateTime { - return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME) - } -} diff --git a/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt b/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt deleted file mode 100644 index 07f2199e992b0..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/utils/UUIDAdapter.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.unciv.logic.multiplayer.utils - -import com.squareup.moshi.FromJson -import com.squareup.moshi.ToJson -import java.util.UUID - -/** - * Adapter for moshi to handle UUIDs from and to JSON - */ -class UUIDAdapter { - @ToJson - fun toJson(uuid: UUID) = uuid.toString() - - @FromJson - fun fromJson(s: String): UUID = UUID.fromString(s) -} From 55f49bfbeb9c15ab176158ee790dd97b75391b6d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Mar 2023 02:23:52 +0100 Subject: [PATCH 004/152] Added the first endpoint implementations, added logging dependency --- build.gradle.kts | 3 + .../api/EndpointImplementations.kt | 166 ++++++++++++++++++ .../logic/multiplayer/api/ResponseStructs.kt | 4 +- 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt diff --git a/build.gradle.kts b/build.gradle.kts index 437492db24055..959675d19048a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,6 +77,9 @@ project(":desktop") { "implementation"("net.java.dev.jna:jna:5.11.0") "implementation"("net.java.dev.jna:jna-platform:5.11.0") + + // Logging for easier desktop development + "implementation"("ch.qos.logback:logback-classic:1.2.5") } } diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt new file mode 100644 index 0000000000000..c37467a748573 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -0,0 +1,166 @@ +/** + * Collection of endpoint implementations + * + * Those classes are not meant to be used directly. Take a look at the Api class for common usage. + */ + +package com.unciv.logic.multiplayer.api + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +/** + * API wrapper for account handling (do not use directly; use the Api class instead) + */ +class AccountsApi constructor(private val client: HttpClient) { + + /** + * Retrieve information about the currently logged in user + */ + suspend fun get(): AccountResponse { + val response = client.get("/api/v2/accounts/me") + if (response.status.equals(200)) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Update the currently logged in user information + * + * At least one value must be set to a non-null value. + */ + suspend fun update(username: String?, displayName: String?): Boolean { + return update(UpdateAccountRequest(username, displayName)) + } + + /** + * Update the currently logged in user information + * + * At least one value must be set to a non-null value. + */ + suspend fun update(r: UpdateAccountRequest): Boolean { + val response = client.put("/api/v2/accounts/me") { + contentType(ContentType.Application.Json) + setBody(r) + } + if (response.status.equals(200)) { + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Deletes the currently logged-in account + */ + suspend fun delete(): Boolean { + val response = client.delete("/api/v2/accounts/me") + if (response.status.equals(200)) { + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Set a new password for the currently logged-in account, provided the old password was accepted as valid + */ + suspend fun setPassword(oldPassword: String, newPassword: String): Boolean { + return setPassword(SetPasswordRequest(oldPassword, newPassword)) + } + + /** + * Set a new password for the currently logged-in account, provided the old password was accepted as valid + */ + suspend fun setPassword(r: SetPasswordRequest): Boolean { + val response = client.post("/api/v2/accounts/setPassword") { + contentType(ContentType.Application.Json) + setBody(r) + } + if (response.status.equals(200)) { + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Register a new user account + */ + suspend fun register(username: String, displayName: String, password: String): Boolean { + return register(AccountRegistrationRequest(username, displayName, password)) + } + + /** + * Register a new user account + */ + suspend fun register(r: AccountRegistrationRequest): Boolean { + val response = client.post("/api/v2/accounts/register") { + contentType(ContentType.Application.Json) + setBody(r) + } + if (response.status.equals(200)) { + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} + +/** + * API wrapper for authentication handling (do not use directly; use the Api class instead) + */ +class AuthApi constructor(private val client: HttpClient) { + + /** + * Try logging in with username and password + * + * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate further API calls + */ + suspend fun login(username: String, password: String): Boolean { + return login(LoginRequest(username, password)) + } + + /** + * Try logging in with username and password + * + * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate further API calls + */ + suspend fun login(r: LoginRequest): Boolean { + val response = client.post("/api/v2/auth/login") { + contentType(ContentType.Application.Json) + setBody(r) + } + return if (response.status.equals(200)) { + true + } else { + response.body() + } + } + + /** + * Logs out the currently logged in user + * + * This method will also clear the cookie on success only to avoid further authenticated API calls + */ + suspend fun logout(): Boolean { + val response = client.post("/api/v2/auth/logout") + return if (response.status.equals(200)) { + // TODO: Maybe clear cookie here + true + } else { + response.body() + } + } + +} diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index ea9334e58e345..07079934babe4 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -24,11 +24,11 @@ data class AccountResponse( */ @Serializable data class ApiErrorResponse( - val message: String, + override val message: String, @SerialName("status_code") // TODO: @JsonValue or something similar, at least in Jackson val statusCode: ApiStatusCode -) +): Throwable() /** * The status code represents a unique identifier for an error. Error codes in the range of 1000..2000 represent client errors that could be handled by the client. Error codes in the range of 2000..3000 represent server errors. From b88c564d5963456e742a7e47a8dffc63c5f2a59c Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Mar 2023 03:32:11 +0100 Subject: [PATCH 005/152] Added the Api core class, fixed enum and response status handling --- .../com/unciv/logic/multiplayer/api/Api.kt | 67 +++++++++++++++++++ .../api/EndpointImplementations.kt | 24 ++++--- .../logic/multiplayer/api/ResponseStructs.kt | 46 +++++++++---- 3 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/Api.kt diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt new file mode 100644 index 0000000000000..ea9aac0fda5cc --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -0,0 +1,67 @@ +/** + * TODO: Comment this file + */ + +package com.unciv.logic.multiplayer.api + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.cookies.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +/** + * API wrapper around the newly implemented REST API for multiplayer game handling + * + * Note that this does not include the handling of messages via the + * WebSocket connection, but rather only the pure HTTP-based API. + * Almost any method may throw certain OS or network errors as well as the + * [ApiErrorResponse] for invalid requests (4xx) or server failures (5xx). + */ +class Api(private val baseUrl: String) { + + // HTTP client to handle the server connections, logging, content parsing and cookies + private val client = HttpClient(CIO) { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + install(HttpCookies) + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + }) + } + install(WebSockets) { + pingInterval = 15_000 + } + defaultRequest { + url(baseUrl) + } + } + + /** + * API for account management + */ + val accounts = AccountsApi(client) + + /** + * API for authentication management + */ + val auth = AuthApi(client) + + /** + * Retrieve the currently available API version of the connected server + */ + suspend fun version(): VersionResponse { + return client.get("/api/version").body() + } + +} diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index c37467a748573..1a5dffc00cb2c 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -21,7 +21,7 @@ class AccountsApi constructor(private val client: HttpClient) { */ suspend fun get(): AccountResponse { val response = client.get("/api/v2/accounts/me") - if (response.status.equals(200)) { + if (response.status.isSuccess()) { return response.body() } else { val err: ApiErrorResponse = response.body() @@ -48,7 +48,7 @@ class AccountsApi constructor(private val client: HttpClient) { contentType(ContentType.Application.Json) setBody(r) } - if (response.status.equals(200)) { + if (response.status.isSuccess()) { return true } else { val err: ApiErrorResponse = response.body() @@ -61,7 +61,7 @@ class AccountsApi constructor(private val client: HttpClient) { */ suspend fun delete(): Boolean { val response = client.delete("/api/v2/accounts/me") - if (response.status.equals(200)) { + if (response.status.isSuccess()) { return true } else { val err: ApiErrorResponse = response.body() @@ -84,7 +84,7 @@ class AccountsApi constructor(private val client: HttpClient) { contentType(ContentType.Application.Json) setBody(r) } - if (response.status.equals(200)) { + if (response.status.isSuccess()) { return true } else { val err: ApiErrorResponse = response.body() @@ -107,7 +107,7 @@ class AccountsApi constructor(private val client: HttpClient) { contentType(ContentType.Application.Json) setBody(r) } - if (response.status.equals(200)) { + if (response.status.isSuccess()) { return true } else { val err: ApiErrorResponse = response.body() @@ -141,10 +141,11 @@ class AuthApi constructor(private val client: HttpClient) { contentType(ContentType.Application.Json) setBody(r) } - return if (response.status.equals(200)) { - true + if (response.status.isSuccess()) { + return true } else { - response.body() + val err: ApiErrorResponse = response.body() + throw err } } @@ -155,11 +156,12 @@ class AuthApi constructor(private val client: HttpClient) { */ suspend fun logout(): Boolean { val response = client.post("/api/v2/auth/logout") - return if (response.status.equals(200)) { + if (response.status.isSuccess()) { // TODO: Maybe clear cookie here - true + return true } else { - response.body() + val err: ApiErrorResponse = response.body() + throw err } } diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index 07079934babe4..5699efa6e2d9d 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -4,8 +4,16 @@ package com.unciv.logic.multiplayer.api -import kotlinx.serialization.Serializable +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder /** * The account data @@ -28,13 +36,32 @@ data class ApiErrorResponse( @SerialName("status_code") // TODO: @JsonValue or something similar, at least in Jackson val statusCode: ApiStatusCode -): Throwable() +) : Throwable() + +/** + * Experimental serializer for the ApiStatusCode enum to make encoding/decoding as integer work + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = ApiStatusCode::class) +class ApiStatusCodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ApiStatusCode", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: ApiStatusCode) { + encoder.encodeInt(value.value) + } + + override fun deserialize(decoder: Decoder): ApiStatusCode { + val key = decoder.decodeInt() + return ApiStatusCode.getByValue(key) + } +} /** * The status code represents a unique identifier for an error. Error codes in the range of 1000..2000 represent client errors that could be handled by the client. Error codes in the range of 2000..3000 represent server errors. * Values: C1000,C1001,C1002,C1003,C1004,C1005,C1006,C1007,C1008,C1009,C1010,C1011,C1012,C1013,C1014,C2000,C2001,C2002 */ -@Serializable // TODO: Is the following required: (with = ApiStatusCode.Serializer::class) +@Serializable(with = ApiStatusCodeSerializer::class) enum class ApiStatusCode(val value: Int) { C1000(1000), C1001(1001), @@ -55,19 +82,12 @@ enum class ApiStatusCode(val value: Int) { C2001(2001), C2002(2002); - /** - * This override toString avoids using the enum var name and uses the actual api value instead. - * In cases the var name and value are different, the client would send incorrect enums to the server. - */ - override fun toString(): String { - return value.toString() + companion object { + private val VALUES = values() + fun getByValue(value: Int) = VALUES.first { it.value == value } } - - // TODO: Verify this enum works as expected - //object Serializer : CommonEnumSerializer("ApiStatusCode", values(), values().map { it.value.toString() }.toTypedArray()) } - /** * A single friend or friend request */ From c741f4408f4e9db2c36df1f1aafcbb847646b554 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Mar 2023 17:37:51 +0100 Subject: [PATCH 006/152] Added a cookie helper to fix authentication flow problems --- .../com/unciv/logic/multiplayer/api/Api.kt | 20 ++++++++-- .../logic/multiplayer/api/AuthCookieHelper.kt | 37 +++++++++++++++++++ .../api/EndpointImplementations.kt | 1 + 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index ea9aac0fda5cc..2df12d28e3976 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -4,17 +4,20 @@ package com.unciv.logic.multiplayer.api +import com.unciv.UncivGame import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.cookies.* import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.websocket.* import kotlinx.serialization.json.Json +import java.util.* /** * API wrapper around the newly implemented REST API for multiplayer game handling @@ -28,11 +31,11 @@ class Api(private val baseUrl: String) { // HTTP client to handle the server connections, logging, content parsing and cookies private val client = HttpClient(CIO) { + // Do not add install(HttpCookies) because it will break Cookie handling install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } - install(HttpCookies) install(ContentNegotiation) { json(Json { prettyPrint = true @@ -47,15 +50,24 @@ class Api(private val baseUrl: String) { } } + private val authCookieHelper = AuthCookieHelper() + + init { + client.plugin(HttpSend).intercept { request -> + request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") + execute(request) + } + } + /** * API for account management */ - val accounts = AccountsApi(client) + val accounts = AccountsApi(client, authCookieHelper) /** * API for authentication management */ - val auth = AuthApi(client) + val auth = AuthApi(client, authCookieHelper) /** * Retrieve the currently available API version of the connected server diff --git a/core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt b/core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt new file mode 100644 index 0000000000000..504424fc46b67 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt @@ -0,0 +1,37 @@ +package com.unciv.logic.multiplayer.api + +import io.ktor.client.request.* +import io.ktor.http.* + +const val cookieName = "id" + +/** + * Simple authentication cookie helper which doesn't support multiple cookies, but just does the job correctly + * + * Do not use [HttpCookies] since the url-encoded cookie values break the authentication flow. + */ +class AuthCookieHelper { + private var cookieValue: String? = null + + fun set(value: String) { + cookieValue = value + } + + fun unset() { + cookieValue = null + } + + fun get(): String? { + return cookieValue + } + + fun add(request: HttpRequestBuilder) { + val currentValue = cookieValue + request.headers + if (currentValue != null) { + request.header(HttpHeaders.Cookie, encodeCookieValue( + "$cookieName=$currentValue", encoding = CookieEncoding.RAW + )) + } + } +} diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index 1a5dffc00cb2c..42afd4eb66ddd 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -8,6 +8,7 @@ package com.unciv.logic.multiplayer.api import io.ktor.client.* import io.ktor.client.call.* +import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* import io.ktor.http.* From 07d0fb5e9299a6471afcac1fc369468ab339f4e9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Mar 2023 01:47:32 +0100 Subject: [PATCH 007/152] Added WebSocket handling and logger functionality, fixed cookie handling --- .../com/unciv/logic/multiplayer/api/Api.kt | 80 ++++++++++++++++++- .../api/EndpointImplementations.kt | 28 +++++-- .../logic/multiplayer/api/ResponseStructs.kt | 40 +++++----- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 2df12d28e3976..4d4df28d5aea7 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -5,6 +5,7 @@ package com.unciv.logic.multiplayer.api import com.unciv.UncivGame +import com.unciv.utils.concurrency.Concurrency import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -14,10 +15,16 @@ import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.serialization.kotlinx.* import io.ktor.serialization.kotlinx.json.* import io.ktor.websocket.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json -import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue /** * API wrapper around the newly implemented REST API for multiplayer game handling @@ -27,7 +34,8 @@ import java.util.* * Almost any method may throw certain OS or network errors as well as the * [ApiErrorResponse] for invalid requests (4xx) or server failures (5xx). */ -class Api(private val baseUrl: String) { +class Api(val baseUrl: String) { + private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) // HTTP client to handle the server connections, logging, content parsing and cookies private val client = HttpClient(CIO) { @@ -44,6 +52,7 @@ class Api(private val baseUrl: String) { } install(WebSockets) { pingInterval = 15_000 + contentConverter = KotlinxWebsocketSerializationConverter(Json) } defaultRequest { url(baseUrl) @@ -52,6 +61,8 @@ class Api(private val baseUrl: String) { private val authCookieHelper = AuthCookieHelper() + private var websocketJobs = ConcurrentLinkedQueue() + init { client.plugin(HttpSend).intercept { request -> request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") @@ -62,12 +73,73 @@ class Api(private val baseUrl: String) { /** * API for account management */ - val accounts = AccountsApi(client, authCookieHelper) + val accounts = AccountsApi(client, authCookieHelper, logger) /** * API for authentication management */ - val auth = AuthApi(client, authCookieHelper) + val auth = AuthApi(client, authCookieHelper, logger) + + /** + * Handle existing WebSocket connections + * + * This method should be dispatched to a non-daemon thread pool executor. + */ + private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { + try { + val incomingMessage = session.incoming.receive() + logger.info("Incoming message: $incomingMessage") + if (incomingMessage.frameType == FrameType.PING) { + logger.info("Received PING frame") + session.send( + Frame.byType( + false, + FrameType.PONG, + byteArrayOf(), + rsv1 = true, + rsv2 = true, + rsv3 = true + ) + ) + } + } catch (e: ClosedReceiveChannelException) { + logger.severe("The channel was closed: $e") + } + } + + /** + * Start a new WebSocket connection + */ + suspend fun websocket(): Boolean { + logger.info("Starting a new WebSocket connection ...") + + coroutineScope { + try { + val session = client.webSocketSession { + method = HttpMethod.Get + authCookieHelper.add(this) + url { + takeFrom(baseUrl) + protocol = + URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well + path("/api/v2/ws") + } + } + val job = Concurrency.runOnNonDaemonThreadPool { + launch { + handleWebSocketSession(session) + } + } + websocketJobs.add(job) + logger.info("A new WebSocket has been created, running in job $job") + } catch (e: SerializationException) { + logger.warning("Failed to create a WebSocket: $e") + return@coroutineScope false + } + } + + return true + } /** * Retrieve the currently available API version of the connected server diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index 42afd4eb66ddd..064651ed300ff 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -6,6 +6,7 @@ package com.unciv.logic.multiplayer.api +import java.util.logging.Logger import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.cookies.* @@ -15,13 +16,15 @@ import io.ktor.http.* /** * API wrapper for account handling (do not use directly; use the Api class instead) */ -class AccountsApi constructor(private val client: HttpClient) { +class AccountsApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { /** * Retrieve information about the currently logged in user */ suspend fun get(): AccountResponse { - val response = client.get("/api/v2/accounts/me") + val response = client.get("/api/v2/accounts/me") { + authCookieHelper.add(this) + } if (response.status.isSuccess()) { return response.body() } else { @@ -48,6 +51,7 @@ class AccountsApi constructor(private val client: HttpClient) { val response = client.put("/api/v2/accounts/me") { contentType(ContentType.Application.Json) setBody(r) + authCookieHelper.add(this) } if (response.status.isSuccess()) { return true @@ -61,8 +65,12 @@ class AccountsApi constructor(private val client: HttpClient) { * Deletes the currently logged-in account */ suspend fun delete(): Boolean { - val response = client.delete("/api/v2/accounts/me") + val response = client.delete("/api/v2/accounts/me") { + authCookieHelper.add(this) + } if (response.status.isSuccess()) { + logger.info("The current user has been deleted") + authCookieHelper.unset() return true } else { val err: ApiErrorResponse = response.body() @@ -84,8 +92,10 @@ class AccountsApi constructor(private val client: HttpClient) { val response = client.post("/api/v2/accounts/setPassword") { contentType(ContentType.Application.Json) setBody(r) + authCookieHelper.add(this) } if (response.status.isSuccess()) { + logger.info("Password has been changed successfully") return true } else { val err: ApiErrorResponse = response.body() @@ -107,8 +117,10 @@ class AccountsApi constructor(private val client: HttpClient) { val response = client.post("/api/v2/accounts/register") { contentType(ContentType.Application.Json) setBody(r) + authCookieHelper.add(this) } if (response.status.isSuccess()) { + logger.info("A new account for username ${r.username} has been created") return true } else { val err: ApiErrorResponse = response.body() @@ -121,7 +133,7 @@ class AccountsApi constructor(private val client: HttpClient) { /** * API wrapper for authentication handling (do not use directly; use the Api class instead) */ -class AuthApi constructor(private val client: HttpClient) { +class AuthApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { /** * Try logging in with username and password @@ -143,6 +155,11 @@ class AuthApi constructor(private val client: HttpClient) { setBody(r) } if (response.status.isSuccess()) { + val authCookie = response.setCookie()["id"] + logger.info("Received new session cookie: $authCookie") + if (authCookie != null) { + authCookieHelper.set(authCookie.value) + } return true } else { val err: ApiErrorResponse = response.body() @@ -158,7 +175,8 @@ class AuthApi constructor(private val client: HttpClient) { suspend fun logout(): Boolean { val response = client.post("/api/v2/auth/logout") if (response.status.isSuccess()) { - // TODO: Maybe clear cookie here + logger.info("Logged out successfully (dropping session cookie...)") + authCookieHelper.unset() return true } else { val err: ApiErrorResponse = response.body() diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index 5699efa6e2d9d..b7cf8364a069a 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -63,24 +63,28 @@ class ApiStatusCodeSerializer : KSerializer { */ @Serializable(with = ApiStatusCodeSerializer::class) enum class ApiStatusCode(val value: Int) { - C1000(1000), - C1001(1001), - C1002(1002), - C1003(1003), - C1004(1004), - C1005(1005), - C1006(1006), - C1007(1007), - C1008(1008), - C1009(1009), - C1010(1010), - C1011(1011), - C1012(1012), - C1013(1013), - C1014(1014), - C2000(2000), - C2001(2001), - C2002(2002); + Unauthenticated(1000), + NotFound(1001), + InvalidContentType(1002), + InvalidJson(1003), + PayloadOverflow(1004), + + LoginFailed(1005), + UsernameAlreadyOccupied(1006), + InvalidPassword(1007), + EmptyJson(1008), + InvalidUsername(1009), + InvalidDisplayName(1010), + FriendshipAlreadyRequested(1011), + AlreadyFriends(1012), + InvalidId(1013), + MissingPrivileges(1014), + InvalidMaxPlayersCount(1017), + AlreadyInALobby(1018), + + InternalServerError(2000), + DatabaseError(2001), + SessionError(2002); companion object { private val VALUES = values() From b09ee86aa4e972c92e26583435a998e27c2794fb Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Mar 2023 02:18:40 +0100 Subject: [PATCH 008/152] Added support for lobby handling to the Api wrapper --- .../api/EndpointImplementations.kt | 70 +++++++++++++++++++ .../logic/multiplayer/api/RequestStructs.kt | 15 +++- .../logic/multiplayer/api/ResponseStructs.kt | 35 ++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index 064651ed300ff..a33d1f49899ad 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -185,3 +185,73 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth } } + +/** + * API wrapper for lobby handling (do not use directly; use the Api class instead) + */ +class LobbyApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + + /** + * Retrieves all open lobbies + * + * If hasPassword is true, the lobby is secured by a user-set password + */ + suspend fun list(): List { + val response = client.get("/api/v2/lobbies") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: GetLobbiesResponse = response.body() + return responseBody.lobbies + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Create a new lobby and return the new lobby ID + * + * If you are already in another lobby, an error is returned. + * ``max_players`` must be between 2 and 34 (inclusive). + * If password is an empty string, an error is returned. + */ + suspend fun open(name: String, maxPlayers: Int): Long { + return open(CreateLobbyRequest(name, null, maxPlayers)) + } + + /** + * Create a new lobby and return the new lobby ID + * + * If you are already in another lobby, an error is returned. + * ``max_players`` must be between 2 and 34 (inclusive). + * If password is an empty string, an error is returned. + */ + suspend fun open(name: String, password: String?, maxPlayers: Int): Long { + return open(CreateLobbyRequest(name, password, maxPlayers)) + } + + /** + * Create a new lobby and return the new lobby ID + * + * If you are already in another lobby, an error is returned. + * ``max_players`` must be between 2 and 34 (inclusive). + * If password is an empty string, an error is returned. + */ + suspend fun open(r: CreateLobbyRequest): Long { + val response = client.post("/api/v2/lobbies") { + contentType(ContentType.Application.Json) + setBody(r) + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: CreateLobbyResponse = response.body() + logger.info("A new lobby with ID ${responseBody.lobbyID} has been created") + return responseBody.lobbyID + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt index eafeae8d6eb32..1292ae1a2e1df 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -26,6 +26,19 @@ data class CreateFriendRequest( val username: String ) +/** + * The parameters to create a lobby + * + * The parameter ``max_players`` must be greater or equals 2. + */ +@Serializable +data class CreateLobbyRequest( + val name: String, + val password: String?, + @SerialName("max_players") + val maxPlayers: Int +) + /** * The request data of a login request */ @@ -38,7 +51,7 @@ data class LoginRequest( /** * The set password request data * - * The parameter new_password must not be empty + * The parameter ``new_password`` must not be empty. */ @Serializable data class SetPasswordRequest( diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index b7cf8364a069a..f3c3e568ae5b9 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -92,6 +92,17 @@ enum class ApiStatusCode(val value: Int) { } } +/** + * The response of a create lobby request + * + * It contains the ``id`` of the created lobby. + */ +@Serializable +data class CreateLobbyResponse( + @SerialName("lobby_name") + val lobbyID: Long +) + /** * A single friend or friend request */ @@ -112,6 +123,30 @@ data class GetFriendResponse( val friends: List ) +/** + * The lobbies that are open + */ +@Serializable +data class GetLobbiesResponse( + val lobbies: List +) + +/** + * A single lobby + */ +@Serializable +data class LobbyResponse( + val name: String, + @SerialName("max_players") + val maxPlayers: Int, + @SerialName("current_players") + val currentPlayers: Int, + @SerialName("created_at") + val createdAt: Int, + @SerialName("password") + val hasPassword: Boolean +) + /** * The version data for clients */ From 8051c9a5f617d45e8a51e46b6e36f28e868de137 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Mar 2023 02:56:20 +0100 Subject: [PATCH 009/152] Added friendship handling support to the Api wrapper --- .../com/unciv/logic/multiplayer/api/Api.kt | 10 ++ .../api/EndpointImplementations.kt | 126 ++++++++++++++++++ .../logic/multiplayer/api/ResponseStructs.kt | 9 +- 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 4d4df28d5aea7..039ec6d2b9e24 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -80,6 +80,16 @@ class Api(val baseUrl: String) { */ val auth = AuthApi(client, authCookieHelper, logger) + /** + * API for friendship management + */ + val friend = FriendApi(client, authCookieHelper, logger) + + /** + * API for lobby management + */ + val lobby = LobbyApi(client, authCookieHelper, logger) + /** * Handle existing WebSocket connections * diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index a33d1f49899ad..4162bcd9fbde8 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -186,6 +186,132 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth } +/** + * API wrapper for friend handling (do not use directly; use the Api class instead) + */ +class FriendApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + + /** + * Retrieve a list of your established friendships + */ + suspend fun listFriends(): List { + val response = client.get("/api/v2/friends") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: GetFriendResponse = response.body() + return responseBody.friends + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Retrieve a list of your open friendship requests (incoming and outgoing) + * + * If you have a request with ``from`` equal to your username, it means you + * have requested a friendship, but the destination hasn't accepted yet. + * In the other case, if your username is in ``to``, you have received a friend request. + */ + suspend fun listFriendRequests(): List { + val response = client.get("/api/v2/friends") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: GetFriendResponse = response.body() + return responseBody.friendRequests + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Retrieve a list of your open incoming friendship requests + * + * The argument [myUsername] should be filled with the username of the currently logged in user. + */ + suspend fun listIncomingFriendRequests(myUsername: String): List { + return listFriendRequests().filter { + it.to == myUsername + } + } + + /** + * Retrieve a list of your open outgoing friendship requests + * + * The argument [myUsername] should be filled with the username of the currently logged in user. + */ + suspend fun listOutgoingFriendRequests(myUsername: String): List { + return listFriendRequests().filter { + it.from == myUsername + } + } + + /** + * Request friendship with another user + */ + suspend fun request(other: String): Boolean { + return request(CreateFriendRequest(other)) + } + + /** + * Request friendship with another user + */ + suspend fun request(r: CreateFriendRequest): Boolean { + val response = client.post("/api/v2/friends") { + contentType(ContentType.Application.Json) + setBody(r) + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + logger.info("You have requested friendship with user ${r.username}") + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Accept a friend request + */ + suspend fun accept(friendRequestID: Long): Boolean { + val response = client.delete("/api/v2/friends/$friendRequestID") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + logger.info("You have successfully accepted friendship request ID $friendRequestID") + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Don't want your friends anymore? Just delete them! + * + * This function accepts both friend IDs and friendship request IDs, + * since they are the same thing in the server's database anyways. + */ + suspend fun delete(friendID: Long): Boolean { + val response = client.delete("/api/v2/friends/$friendID") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + logger.info("You have successfully dropped friendship ID $friendID") + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} + + /** * API wrapper for lobby handling (do not use directly; use the Api class instead) */ diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index f3c3e568ae5b9..8aa86304ad8ce 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -109,18 +109,21 @@ data class CreateLobbyResponse( @Serializable data class FriendResponse( val id: Long, - @SerialName("is_request") - val isRequest: Boolean, val from: String, val to: String ) /** * A list of your friends and friend requests + * + * ``friends`` is a list of already established friendships + * ``friend_requests`` is a list of friend requests (ingoing and outgoing) */ @Serializable data class GetFriendResponse( - val friends: List + val friends: List, + @SerialName("friend_requests") + val friendRequests: List ) /** From 39516cb0329b8e428b899e05794543fdb22793f6 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 9 Mar 2023 02:22:49 +0100 Subject: [PATCH 010/152] Updated the API structs --- .../api/EndpointImplementations.kt | 22 +++--- .../logic/multiplayer/api/JsonSerializers.kt | 55 +++++++++++++ .../logic/multiplayer/api/RequestStructs.kt | 4 +- .../logic/multiplayer/api/ResponseStructs.kt | 78 ++++++++++--------- 4 files changed, 113 insertions(+), 46 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index 4162bcd9fbde8..0d65f33658607 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -12,6 +12,7 @@ import io.ktor.client.call.* import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* import io.ktor.http.* +import java.util.* /** * API wrapper for account handling (do not use directly; use the Api class instead) @@ -163,6 +164,9 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth return true } else { val err: ApiErrorResponse = response.body() + if (err.statusCode == ApiStatusCode.LoginFailed) { + return false + } throw err } } @@ -214,7 +218,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au * have requested a friendship, but the destination hasn't accepted yet. * In the other case, if your username is in ``to``, you have received a friend request. */ - suspend fun listFriendRequests(): List { + suspend fun listFriendRequests(): List { val response = client.get("/api/v2/friends") { authCookieHelper.add(this) } @@ -230,29 +234,29 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au /** * Retrieve a list of your open incoming friendship requests * - * The argument [myUsername] should be filled with the username of the currently logged in user. + * The argument [myUUID] should be filled with the username of the currently logged in user. */ - suspend fun listIncomingFriendRequests(myUsername: String): List { + suspend fun listIncomingFriendRequests(myUUID: UUID): List { return listFriendRequests().filter { - it.to == myUsername + it.to.uuid == myUUID } } /** * Retrieve a list of your open outgoing friendship requests * - * The argument [myUsername] should be filled with the username of the currently logged in user. + * The argument [myUUID] should be filled with the username of the currently logged in user. */ - suspend fun listOutgoingFriendRequests(myUsername: String): List { + suspend fun listOutgoingFriendRequests(myUUID: UUID): List { return listFriendRequests().filter { - it.from == myUsername + it.from.uuid == myUUID } } /** * Request friendship with another user */ - suspend fun request(other: String): Boolean { + suspend fun request(other: UUID): Boolean { return request(CreateFriendRequest(other)) } @@ -266,7 +270,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("You have requested friendship with user ${r.username}") + logger.info("You have requested friendship with user ${r.uuid}") return true } else { val err: ApiErrorResponse = response.body() diff --git a/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt new file mode 100644 index 0000000000000..6bc3aaf3ff5f2 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt @@ -0,0 +1,55 @@ +package com.unciv.logic.multiplayer.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant +import java.util.UUID + +/** + * Serializer for the ApiStatusCode enum to make encoding/decoding as integer work + */ +internal class ApiStatusCodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ApiStatusCode", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: ApiStatusCode) { + encoder.encodeInt(value.value) + } + + override fun deserialize(decoder: Decoder): ApiStatusCode { + return ApiStatusCode.getByValue(decoder.decodeInt()) + } +} + +/** + * Serializer for instants (date times) from/to strings in ISO 8601 format + */ +internal class InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) + } +} + +/** + * Serializer for UUIDs from/to strings + */ +internal class UUIDSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UUID) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): UUID { + return UUID.fromString(decoder.decodeString()) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt index 1292ae1a2e1df..134270ceb812a 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -6,6 +6,7 @@ package com.unciv.logic.multiplayer.api import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import java.util.UUID /** * The content to register a new account @@ -23,7 +24,8 @@ data class AccountRegistrationRequest( */ @Serializable data class CreateFriendRequest( - val username: String + @Serializable(with = UUIDSerializer::class) + val uuid: UUID ) /** diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index 8aa86304ad8ce..b8d3efcc84508 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -4,16 +4,9 @@ package com.unciv.logic.multiplayer.api -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder +import java.util.UUID /** * The account data @@ -22,7 +15,9 @@ import kotlinx.serialization.encoding.Encoder data class AccountResponse( val username: String, @SerialName("display_name") - val displayName: String + val displayName: String, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID ) /** @@ -34,32 +29,16 @@ data class AccountResponse( data class ApiErrorResponse( override val message: String, @SerialName("status_code") - // TODO: @JsonValue or something similar, at least in Jackson + @Serializable(with = ApiStatusCodeSerializer::class) val statusCode: ApiStatusCode ) : Throwable() /** - * Experimental serializer for the ApiStatusCode enum to make encoding/decoding as integer work - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializer(forClass = ApiStatusCode::class) -class ApiStatusCodeSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ApiStatusCode", PrimitiveKind.INT) - - override fun serialize(encoder: Encoder, value: ApiStatusCode) { - encoder.encodeInt(value.value) - } - - override fun deserialize(decoder: Decoder): ApiStatusCode { - val key = decoder.decodeInt() - return ApiStatusCode.getByValue(key) - } -} - -/** - * The status code represents a unique identifier for an error. Error codes in the range of 1000..2000 represent client errors that could be handled by the client. Error codes in the range of 2000..3000 represent server errors. - * Values: C1000,C1001,C1002,C1003,C1004,C1005,C1006,C1007,C1008,C1009,C1010,C1011,C1012,C1013,C1014,C2000,C2001,C2002 + * API status code enum for mapping integer codes to names + * + * The status code represents a unique identifier for an error. + * Error codes in the range of 1000..2000 represent client errors that could be handled + * by the client. Error codes in the range of 2000..3000 represent server errors. */ @Serializable(with = ApiStatusCodeSerializer::class) enum class ApiStatusCode(val value: Int) { @@ -104,13 +83,23 @@ data class CreateLobbyResponse( ) /** - * A single friend or friend request + * A single friend */ @Serializable data class FriendResponse( val id: Long, - val from: String, - val to: String + val from: AccountResponse, + val to: OnlineAccountResponse +) + +/** + * A single friend request + */ +@Serializable +data class FriendRequestResponse( + val id: Long, + val from: AccountResponse, + val to: AccountResponse ) /** @@ -123,7 +112,7 @@ data class FriendResponse( data class GetFriendResponse( val friends: List, @SerialName("friend_requests") - val friendRequests: List + val friendRequests: List ) /** @@ -139,6 +128,7 @@ data class GetLobbiesResponse( */ @Serializable data class LobbyResponse( + val id: Long, val name: String, @SerialName("max_players") val maxPlayers: Int, @@ -147,7 +137,23 @@ data class LobbyResponse( @SerialName("created_at") val createdAt: Int, @SerialName("password") - val hasPassword: Boolean + val hasPassword: Boolean, + val owner: AccountResponse +) + +/** + * The account data + * + * It provides the extra field ``online`` indicating whether the account has any connected client. + */ +@Serializable +data class OnlineAccountResponse( + val online: Boolean, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val username: String, + @SerialName("display_name") + val displayName: String ) /** From 9fbca562ae8422f8819a25f5f10c1c3659a3bc16 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Mar 2023 01:20:49 +0100 Subject: [PATCH 011/152] Renamed the old multiplayer handler to start reworking the mechanics --- .../logic/multiplayer/OldOnlineMultiplayer.kt | 462 ++++++++++++++++++ .../logic/multiplayer/OnlineMultiplayer.kt | 112 ++--- 2 files changed, 501 insertions(+), 73 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt diff --git a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt new file mode 100644 index 0000000000000..f295fb10b2f6c --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt @@ -0,0 +1,462 @@ +package com.unciv.logic.multiplayer + +import com.badlogic.gdx.files.FileHandle +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.civilization.NotificationCategory +import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.MultiplayerAuthException +import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles +import com.unciv.ui.components.extensions.isLargerThan +import com.unciv.logic.multiplayer.storage.SimpleHttp +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.Dispatcher +import com.unciv.utils.concurrency.launchOnThreadPool +import com.unciv.utils.concurrency.withGLContext +import com.unciv.utils.debug +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import java.time.Duration +import java.time.Instant +import java.util.* +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. + */ +private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) + +/** + * Provides multiplayer functionality to the rest of the game. + * + * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. + */ +class OldOnlineMultiplayer { + private val files = UncivGame.Current.files + private val multiplayerFiles = OnlineMultiplayerFiles() + private var featureSet = ServerFeatureSet() + + private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) + + private val lastFileUpdate: AtomicReference = AtomicReference() + private val lastAllGamesRefresh: AtomicReference = AtomicReference() + private val lastCurGameRefresh: AtomicReference = AtomicReference() + + val games: Set get() = savedGames.values.toSet() + val serverFeatureSet: ServerFeatureSet get() = featureSet + + init { + 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)) + } + + private fun getCurrentGame(): OnlineMultiplayerGame? { + val gameInfo = UncivGame.Current.gameInfo + return if (gameInfo != null) { + getGameByGameId(gameInfo.gameId) + } else null + } + + /** + * Requests an update of all multiplayer game state. Does automatic throttling to try to prevent hitting rate limits. + * + * Use [forceUpdate] = true to circumvent this throttling. + * + * Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed] + */ + fun requestUpdate(forceUpdate: Boolean = false, doNotUpdate: List = listOf()) { + Concurrency.run("Update all multiplayer games") { + val fileThrottleInterval = if (forceUpdate) Duration.ZERO else FILE_UPDATE_THROTTLE_PERIOD + // An exception only happens here if the files can't be listed, should basically never happen + throttle(lastFileUpdate, fileThrottleInterval, {}, action = ::updateSavesFromFiles) + + for (game in savedGames.values) { + if (game in doNotUpdate) continue + launchOnThreadPool { + game.requestUpdate(forceUpdate) + } + } + } + } + + private suspend fun updateSavesFromFiles() { + val saves = files.getMultiplayerSaves() + + val removedSaves = savedGames.keys - saves.toSet() + for (saveFile in removedSaves) { + deleteGame(saveFile) + } + + val newSaves = saves - savedGames.keys + for (saveFile in newSaves) { + addGame(saveFile) + } + } + + + /** + * Fires [MultiplayerGameAdded] + * + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + */ + suspend fun createGame(newGame: GameInfo) { + multiplayerFiles.tryUploadGame(newGame, withPreview = true) + addGame(newGame) + } + + /** + * Fires [MultiplayerGameAdded] + * + * @param gameName if this is null or blank, will use the gameId as the game name + * @return the final name the game was added under + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + */ + suspend fun addGame(gameId: String, gameName: String? = null) { + val saveFileName = if (gameName.isNullOrBlank()) gameId else gameName + var gamePreview: GameInfoPreview + try { + gamePreview = multiplayerFiles.tryDownloadGamePreview(gameId) + } catch (ex: MultiplayerFileNotFoundException) { + // Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead + gamePreview = multiplayerFiles.tryDownloadGame(gameId).asPreview() + } + addGame(gamePreview, saveFileName) + } + + private suspend fun addGame(newGame: GameInfo) { + val newGamePreview = newGame.asPreview() + addGame(newGamePreview, newGamePreview.gameId) + } + + private suspend fun addGame(preview: GameInfoPreview, saveFileName: String) { + val fileHandle = files.saveGame(preview, saveFileName) + return addGame(fileHandle, preview) + } + + private suspend fun addGame(fileHandle: FileHandle, preview: GameInfoPreview? = null) { + debug("Adding game %s", fileHandle.name()) + val game = OnlineMultiplayerGame(fileHandle, preview, if (preview != null) Instant.now() else null) + savedGames[fileHandle] = game + withGLContext { + EventBus.send(MultiplayerGameAdded(game.name)) + } + } + + fun getGameByName(name: String): OnlineMultiplayerGame? { + return savedGames.values.firstOrNull { it.name == name } + } + + fun getGameByGameId(gameId: String): OnlineMultiplayerGame? { + return savedGames.values.firstOrNull { it.preview?.gameId == gameId } + } + + /** + * Resigns from the given multiplayer [game]. Can only resign if it's currently the user's turn, + * to ensure that no one else can upload the game in the meantime. + * + * Fires [MultiplayerGameUpdated] + * + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + * @throws MultiplayerAuthException if the authentication failed + * @return false if it's not the user's turn and thus resigning did not happen + */ + suspend fun resign(game: OnlineMultiplayerGame): Boolean { + val preview = game.preview ?: throw game.error!! + // download to work with the latest game state + val gameInfo = multiplayerFiles.tryDownloadGame(preview.gameId) + val playerCiv = gameInfo.getCurrentPlayerCivilization() + + if (!gameInfo.isUsersTurn()) { + return false + } + + //Set own civ info to AI + playerCiv.playerType = PlayerType.AI + playerCiv.playerId = "" + + //call next turn so turn gets simulated by AI + gameInfo.nextTurn() + + //Add notification so everyone knows what happened + //call for every civ cause AI players are skipped anyway + for (civ in gameInfo.civilizations) { + civ.addNotification("[${playerCiv.civName}] resigned and is now controlled by AI", + NotificationCategory.General, playerCiv.civName) + } + + val newPreview = gameInfo.asPreview() + files.saveGame(newPreview, game.fileHandle) + multiplayerFiles.tryUploadGame(gameInfo, withPreview = true) + game.doManualUpdate(newPreview) + return true + } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + */ + suspend fun loadGame(game: OnlineMultiplayerGame) { + val preview = game.preview ?: throw game.error!! + loadGame(preview.gameId) + } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + */ + suspend fun loadGame(gameId: String) = coroutineScope { + val gameInfo = downloadGame(gameId) + val preview = gameInfo.asPreview() + val onlineGame = getGameByGameId(gameId) + val onlinePreview = onlineGame?.preview + if (onlineGame == null) { + createGame(gameInfo) + } else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){ + onlineGame.doManualUpdate(preview) + } + UncivGame.Current.loadGame(gameInfo) + } + + /** + * Checks if the given game is current and loads it, otherwise loads the game from the server + */ + suspend fun loadGame(gameInfo: GameInfo) = coroutineScope { + val gameId = gameInfo.gameId + val preview = multiplayerFiles.tryDownloadGamePreview(gameId) + if (hasLatestGameState(gameInfo, preview)) { + gameInfo.isUpToDate = true + UncivGame.Current.loadGame(gameInfo) + } else { + loadGame(gameId) + } + } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + */ + suspend fun downloadGame(gameId: String): GameInfo { + val latestGame = multiplayerFiles.tryDownloadGame(gameId) + latestGame.isUpToDate = true + return latestGame + } + + /** + * Deletes the game from disk, does not delete it remotely. + * + * Fires [MultiplayerGameDeleted] + */ + fun deleteGame(multiplayerGame: OnlineMultiplayerGame) { + deleteGame(multiplayerGame.fileHandle) + } + + private fun deleteGame(fileHandle: FileHandle) { + files.deleteSave(fileHandle) + + val game = savedGames[fileHandle] ?: return + + debug("Deleting game %s with id %s", fileHandle.name(), game.preview?.gameId) + savedGames.remove(game.fileHandle) + Concurrency.runOnGLThread { EventBus.send(MultiplayerGameDeleted(game.name)) } + } + + /** + * Fires [MultiplayerGameNameChanged] + */ + fun changeGameName(game: OnlineMultiplayerGame, newName: String, onException:(Exception?)->Unit) { + debug("Changing name of game %s to", game.name, newName) + val oldPreview = game.preview ?: throw game.error!! + val oldLastUpdate = game.lastUpdate + val oldName = game.name + + val newFileHandle = files.saveGame(oldPreview, newName, onException) + val newGame = OnlineMultiplayerGame(newFileHandle, oldPreview, oldLastUpdate) + savedGames[newFileHandle] = newGame + + savedGames.remove(game.fileHandle) + files.deleteSave(game.fileHandle) + EventBus.send(MultiplayerGameNameChanged(oldName, newName)) + } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerFileNotFoundException if the file can't be found + * @throws MultiplayerAuthException if the authentication failed + */ + suspend fun updateGame(gameInfo: GameInfo) { + debug("Updating remote game %s", gameInfo.gameId) + multiplayerFiles.tryUploadGame(gameInfo, withPreview = true) + val game = getGameByGameId(gameInfo.gameId) + debug("Existing OnlineMultiplayerGame: %s", game) + if (game == null) { + addGame(gameInfo) + } else { + game.doManualUpdate(gameInfo.asPreview()) + } + } + + /** + * Checks if [gameInfo] and [preview] are up-to-date with each other. + */ + fun hasLatestGameState(gameInfo: GameInfo, preview: GameInfoPreview): Boolean { + // TODO look into how to maybe extract interfaces to not make this take two different methods + return gameInfo.currentPlayer == preview.currentPlayer + && gameInfo.turns == preview.turns + } + + /** + * Checks if the server is alive and sets the [serverFeatureSet] accordingly. + * @return true if the server is alive, false otherwise + */ + fun checkServerStatus(): Boolean { + var statusOk = false + 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() + } + } + } + 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 { + if (featureSet.authVersion == 0) { + return true + } + + + val settings = UncivGame.Current.settings.multiplayer + + val success = multiplayerFiles.fileStorage().authenticate( + userId=settings.userId, + password=password ?: settings.passwords[settings.server] ?: "" + ) + if (password != null && success) { + settings.passwords[settings.server] = password + } + return success + } + + /** + * @return true if setting the password was successful, false otherwise. + * @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 { + if ( + featureSet.authVersion > 0 && + multiplayerFiles.fileStorage().setPassword(newPassword = password) + ) { + val settings = UncivGame.Current.settings.multiplayer + settings.passwords[settings.server] = password + return true + } + + return false + } + + /** + * Checks if [preview1] has a more recent game state than [preview2] + */ + private fun hasNewerGameState(preview1: GameInfoPreview, preview2: GameInfoPreview): Boolean { + return preview1.turns > preview2.turns + } + + companion object { + fun usesCustomServer() = UncivGame.Current.settings.multiplayer.server != Constants.dropboxMultiplayerServer + fun usesDropbox() = !usesCustomServer() + } + +} + +/** + * Calls the given [action] when [lastSuccessfulExecution] lies further in the past than [throttleInterval]. + * + * Also updates [lastSuccessfulExecution] to [Instant.now], but only when [action] did not result in an exception. + * + * Any exception thrown by [action] is propagated. + * + * @return true if the update happened + */ +suspend fun throttle( + lastSuccessfulExecution: AtomicReference, + throttleInterval: Duration, + onNoExecution: () -> T, + onFailed: (Exception) -> T = { throw it }, + action: suspend () -> T +): T { + val lastExecution = lastSuccessfulExecution.get() + val now = Instant.now() + val shouldRunAction = lastExecution == null || Duration.between(lastExecution, now).isLargerThan(throttleInterval) + return if (shouldRunAction) { + attemptAction(lastSuccessfulExecution, onNoExecution, onFailed, action) + } else { + onNoExecution() + } +} + +/** + * Attempts to run the [action], changing [lastSuccessfulExecution], but only if no other thread changed [lastSuccessfulExecution] in the meantime + * and [action] did not throw an exception. + */ +suspend fun attemptAction( + lastSuccessfulExecution: AtomicReference, + onNoExecution: () -> T, + onFailed: (Exception) -> T = { throw it }, + action: suspend () -> T +): T { + val lastExecution = lastSuccessfulExecution.get() + val now = Instant.now() + return if (lastSuccessfulExecution.compareAndSet(lastExecution, now)) { + try { + action() + } catch (e: Exception) { + lastSuccessfulExecution.compareAndSet(now, lastExecution) + onFailed(e) + } + } else { + onNoExecution() + } +} + + +fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId +fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId + diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index ce731b1eae87e..2f571e22943ab 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -9,15 +9,19 @@ import com.unciv.logic.GameInfoPreview import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.api.AccountResponse +import com.unciv.logic.multiplayer.api.Api +import com.unciv.logic.multiplayer.api.ApiErrorResponse +import com.unciv.logic.multiplayer.api.ApiStatusCode 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.OnlineMultiplayerFiles -import com.unciv.ui.components.extensions.isLargerThan import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Dispatcher +import com.unciv.utils.concurrency.launchOnNonDaemonThreadPool import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug @@ -26,11 +30,13 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json import java.time.Duration import java.time.Instant import java.util.* import java.util.concurrent.atomic.AtomicReference - +import java.util.logging.Logger /** * 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 @@ -39,11 +45,13 @@ import java.util.concurrent.atomic.AtomicReference 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 using the v2 API. * * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. */ class OnlineMultiplayer { + private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) + private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() private var featureSet = ServerFeatureSet() @@ -57,22 +65,34 @@ class OnlineMultiplayer { val games: Set get() = savedGames.values.toSet() val serverFeatureSet: ServerFeatureSet get() = featureSet + private val api = Api(UncivGame.Current.settings.multiplayer.server) + init { - 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() } + var password = UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server] + if (password == null) { + password = "SomePasswordForThoseFolksWhoDoNotHaveAnyStrongPasswordYet!" // TODO: Obviously, replace this password + } + val username = UncivGame.Current.settings.multiplayer.userName + runBlocking { + coroutineScope { + launchOnNonDaemonThreadPool { + if (api.auth.login(username, password)) { + logger.warning("Login failed. Trying to create account for $username") + try { + api.accounts.register(username, username, password) + } catch (e: ApiErrorResponse) { + // TODO: Improve exception handling + if (e.statusCode == ApiStatusCode.InvalidUsername || e.statusCode == ApiStatusCode.InvalidDisplayName || e.statusCode == ApiStatusCode.InvalidPassword) { + logger.warning("Invalid credentials: $e") + } + throw e + } + api.auth.login(username, password) + api.websocket() + } } - - val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) - throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } } - }.launchIn(CoroutineScope(Dispatcher.DAEMON)) + } } private fun getCurrentGame(): OnlineMultiplayerGame? { @@ -125,6 +145,7 @@ class OnlineMultiplayer { * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */ suspend fun createGame(newGame: GameInfo) { + logger.info("Creating game") multiplayerFiles.tryUploadGame(newGame, withPreview = true) addGame(newGame) } @@ -381,8 +402,8 @@ class OnlineMultiplayer { */ fun setPassword(password: String): Boolean { if ( - featureSet.authVersion > 0 && - multiplayerFiles.fileStorage().setPassword(newPassword = password) + featureSet.authVersion > 0 && + multiplayerFiles.fileStorage().setPassword(newPassword = password) ) { val settings = UncivGame.Current.settings.multiplayer settings.passwords[settings.server] = password @@ -405,58 +426,3 @@ class OnlineMultiplayer { } } - -/** - * Calls the given [action] when [lastSuccessfulExecution] lies further in the past than [throttleInterval]. - * - * Also updates [lastSuccessfulExecution] to [Instant.now], but only when [action] did not result in an exception. - * - * Any exception thrown by [action] is propagated. - * - * @return true if the update happened - */ -suspend fun throttle( - lastSuccessfulExecution: AtomicReference, - throttleInterval: Duration, - onNoExecution: () -> T, - onFailed: (Exception) -> T = { throw it }, - action: suspend () -> T -): T { - val lastExecution = lastSuccessfulExecution.get() - val now = Instant.now() - val shouldRunAction = lastExecution == null || Duration.between(lastExecution, now).isLargerThan(throttleInterval) - return if (shouldRunAction) { - attemptAction(lastSuccessfulExecution, onNoExecution, onFailed, action) - } else { - onNoExecution() - } -} - -/** - * Attempts to run the [action], changing [lastSuccessfulExecution], but only if no other thread changed [lastSuccessfulExecution] in the meantime - * and [action] did not throw an exception. - */ -suspend fun attemptAction( - lastSuccessfulExecution: AtomicReference, - onNoExecution: () -> T, - onFailed: (Exception) -> T = { throw it }, - action: suspend () -> T -): T { - val lastExecution = lastSuccessfulExecution.get() - val now = Instant.now() - return if (lastSuccessfulExecution.compareAndSet(lastExecution, now)) { - try { - action() - } catch (e: Exception) { - lastSuccessfulExecution.compareAndSet(now, lastExecution) - onFailed(e) - } - } else { - onNoExecution() - } -} - - -fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId -fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId - From 4fbc68657f6f7ab086154aeaf8d62dde3ea958a3 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Mar 2023 01:29:15 +0100 Subject: [PATCH 012/152] Added WebSocket serializers, structs and message types --- core/src/com/unciv/logic/files/UncivFiles.kt | 8 ++ .../logic/multiplayer/api/JsonSerializers.kt | 42 +++++++++- .../logic/multiplayer/api/WebSocketStructs.kt | 82 +++++++++++++++++++ .../storage/OnlineMultiplayerFiles.kt | 2 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt diff --git a/core/src/com/unciv/logic/files/UncivFiles.kt b/core/src/com/unciv/logic/files/UncivFiles.kt index e7a8e14c73a20..24938582d0b6b 100644 --- a/core/src/com/unciv/logic/files/UncivFiles.kt +++ b/core/src/com/unciv/logic/files/UncivFiles.kt @@ -361,6 +361,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().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): String { val plainJson = json().toJson(game) diff --git a/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt index 6bc3aaf3ff5f2..a5776ec973260 100644 --- a/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt @@ -6,8 +6,12 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.time.Instant -import java.util.UUID +import java.util.* /** * Serializer for the ApiStatusCode enum to make encoding/decoding as integer work @@ -53,3 +57,39 @@ internal class UUIDSerializer : KSerializer { return UUID.fromString(decoder.decodeString()) } } + +/** + * Serializer for incoming and outgoing WebSocket messages that also differentiate by type + */ +internal class WebSocketMessageSerializer : JsonContentPolymorphicSerializer(WebSocketMessage::class) { + override fun selectDeserializer(element: JsonElement) = when { + // Text frames in JSON format but without 'type' field are invalid + "type" !in element.jsonObject -> InvalidMessage.serializer() + else -> { + // This mapping of the enum enforces to specify all serializer types at compile time + when (WebSocketMessageType.getByValue(element.jsonObject["type"]!!.jsonPrimitive.content)) { + WebSocketMessageType.InvalidMessage -> InvalidMessage.serializer() + WebSocketMessageType.FinishedTurn -> FinishedTurnMessage.serializer() + WebSocketMessageType.UpdateGameData -> UpdateGameDataMessage.serializer() + WebSocketMessageType.ClientDisconnected -> ClientDisconnectedMessage.serializer() + WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer() + WebSocketMessageType.IncomingChatMessage -> IncomingChatMessageMessage.serializer() + } + } + } +} + +/** + * Serializer for the WebSocket message type enum to make encoding/decoding as string work + */ +internal class WebSocketMessageTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("WebSocketMessageType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: WebSocketMessageType) { + encoder.encodeString(value.type) + } + + override fun deserialize(decoder: Decoder): WebSocketMessageType { + return WebSocketMessageType.getByValue(decoder.decodeString()) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt new file mode 100644 index 0000000000000..f1aca60dfcf6e --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt @@ -0,0 +1,82 @@ +package com.unciv.logic.multiplayer.api + +import kotlinx.serialization.Serializable + +/** + * The base WebSocket message, encapsulating only the type of the message + */ +interface WebSocketMessage { + val type: WebSocketMessageType +} + +/** + * Message when a previously sent WebSocket frame a received frame is invalid + */ +@Serializable +data class InvalidMessage( + override val type: WebSocketMessageType, +) : WebSocketMessage + +/** + * Message to upload the game state after finishing the turn + */ +@Serializable +data class FinishedTurnMessage ( + override val type: WebSocketMessageType, + val content: String // TODO +) : WebSocketMessage + +/** + * Message to publish the new game state from the server to all clients + */ +@Serializable +data class UpdateGameDataMessage ( + override val type: WebSocketMessageType, + val content: String // TODO +) : WebSocketMessage + +/** + * Message to indicate that a client disconnected + */ +@Serializable +data class ClientDisconnectedMessage ( + override val type: WebSocketMessageType, + val content: String // TODO +) : WebSocketMessage + +/** + * Message to indicate that a client, who previously disconnected, reconnected + */ +@Serializable +data class ClientReconnectedMessage ( + override val type: WebSocketMessageType, + val content: String // TODO +) : WebSocketMessage + +/** + * Message to indicate that a user received a new text message via the chat feature + */ +@Serializable +data class IncomingChatMessageMessage ( + override val type: WebSocketMessageType, + val content: String // TODO +) : WebSocketMessage + + +/** + * Type enum of all known WebSocket messages + */ +@Serializable(with = WebSocketMessageTypeSerializer::class) +enum class WebSocketMessageType(val type: String) { + InvalidMessage("invalidMessage"), + FinishedTurn("finishedTurn"), + UpdateGameData("updateGameData"), + ClientDisconnected("clientDisconnected"), + ClientReconnected("clientReconnected"), + IncomingChatMessage("incomingChatMessage"); + + companion object { + private val VALUES = values() + fun getByValue(type: String) = VALUES.first { it.type == type } + } +} diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index ca151f69276ac..9d418f7d8c853 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -45,7 +45,7 @@ class OnlineMultiplayerFiles( * @throws MultiplayerAuthException if the authentication failed */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { - val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo, forceZip = true) + val zippedGameInfo = UncivFiles.gameInfoToPrettyString(gameInfo, useZip = true) fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo) // We upload the preview after the game because otherwise the following race condition will happen: From dd04aff66b860b69537cde9d14161d0e11eb1fb3 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Mar 2023 23:40:50 +0100 Subject: [PATCH 013/152] Updated the client's implementation of the REST API --- .../com/unciv/logic/multiplayer/api/Api.kt | 7 ++ .../api/EndpointImplementations.kt | 94 +++++++++++++++++-- .../logic/multiplayer/api/RequestStructs.kt | 8 ++ .../logic/multiplayer/api/ResponseStructs.kt | 53 +++++++++-- 4 files changed, 148 insertions(+), 14 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 039ec6d2b9e24..58204adaf6aaf 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -26,6 +26,8 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentLinkedQueue +internal const val LOBBY_MAX_PLAYERS = 34 + /** * API wrapper around the newly implemented REST API for multiplayer game handling * @@ -80,6 +82,11 @@ class Api(val baseUrl: String) { */ val auth = AuthApi(client, authCookieHelper, logger) + /** + * API for chat management + */ + val chat = ChatApi(client, authCookieHelper, logger) + /** * API for friendship management */ diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index 0d65f33658607..e3911c8c48862 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -34,6 +34,53 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: } } + /** + * Retrieve details for an account by its UUID (always preferred to using usernames) + */ + suspend fun lookup(uuid: UUID): AccountResponse { + val response = client.get("/api/v2/accounts/$uuid") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Retrieve details for an account by its username + * + * Important note: Usernames can be changed, so don't assume they can be + * cached to do lookups for their display names or UUIDs later. Always convert usernames + * to UUIDs when handling any user interactions (e.g., inviting, sending messages, ...). + */ + suspend fun lookup(username: String): AccountResponse { + return lookup(LookupAccountUsernameRequest(username)) + } + + /** + * Retrieve details for an account by its username + * + * Important note: Usernames can be changed, so don't assume they can be + * cached to do lookups for their display names or UUIDs later. Always convert usernames + * to UUIDs when handling any user interactions (e.g., inviting, sending messages, ...). + */ + suspend fun lookup(r: LookupAccountUsernameRequest): AccountResponse { + val response = client.post("/api/v2/accounts/lookup") { + contentType(ContentType.Application.Json) + setBody(r) + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + /** * Update the currently logged in user information * @@ -190,6 +237,30 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth } +/** + * API wrapper for chat room handling (do not use directly; use the Api class instead) + */ +class ChatApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + + /** + * Retrieve the messages of a chatroom + * + * [GetChatResponse.members] holds information about all members that are currently in the chat room (including yourself) + */ + suspend fun get(roomID: Long): GetChatResponse { + val response = client.get("/api/v2/chats/$roomID") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} + /** * API wrapper for friend handling (do not use directly; use the Api class instead) */ @@ -198,7 +269,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au /** * Retrieve a list of your established friendships */ - suspend fun listFriends(): List { + suspend fun list(): List { val response = client.get("/api/v2/friends") { authCookieHelper.add(this) } @@ -218,7 +289,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au * have requested a friendship, but the destination hasn't accepted yet. * In the other case, if your username is in ``to``, you have received a friend request. */ - suspend fun listFriendRequests(): List { + suspend fun listRequests(): List { val response = client.get("/api/v2/friends") { authCookieHelper.add(this) } @@ -236,8 +307,8 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au * * The argument [myUUID] should be filled with the username of the currently logged in user. */ - suspend fun listIncomingFriendRequests(myUUID: UUID): List { - return listFriendRequests().filter { + suspend fun listIncomingRequests(myUUID: UUID): List { + return listRequests().filter { it.to.uuid == myUUID } } @@ -247,8 +318,8 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au * * The argument [myUUID] should be filled with the username of the currently logged in user. */ - suspend fun listOutgoingFriendRequests(myUUID: UUID): List { - return listFriendRequests().filter { + suspend fun listOutgoingRequests(myUUID: UUID): List { + return listRequests().filter { it.from.uuid == myUUID } } @@ -315,7 +386,6 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au } - /** * API wrapper for lobby handling (do not use directly; use the Api class instead) */ @@ -339,12 +409,20 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut } } + /** + * Create a new lobby and return the new lobby ID + * + * If you are already in another lobby, an error is returned. + */ + suspend fun open(name: String): Long { + return open(CreateLobbyRequest(name, null, LOBBY_MAX_PLAYERS)) + } + /** * Create a new lobby and return the new lobby ID * * If you are already in another lobby, an error is returned. * ``max_players`` must be between 2 and 34 (inclusive). - * If password is an empty string, an error is returned. */ suspend fun open(name: String, maxPlayers: Int): Long { return open(CreateLobbyRequest(name, null, maxPlayers)) diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt index 134270ceb812a..3a91ddda7945a 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -50,6 +50,14 @@ data class LoginRequest( val password: String ) +/** + * The request to lookup an account by its username + */ +@Serializable +data class LookupAccountUsernameRequest( + val username: String +) + /** * The set password request data * diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index b8d3efcc84508..75a5072be1ace 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -6,6 +6,7 @@ package com.unciv.logic.multiplayer.api import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.time.Instant import java.util.UUID /** @@ -72,13 +73,41 @@ enum class ApiStatusCode(val value: Int) { } /** - * The response of a create lobby request + * A member of a chatroom + */ +@Serializable +data class ChatMember( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val username: String, + @SerialName("display_name") + val displayName: String, + @SerialName("joined_at") + @Serializable(with = InstantSerializer::class) + val joinedAt: Instant +) + +/** + * The message of a chatroom * - * It contains the ``id`` of the created lobby. + * The parameter [id] should be used to uniquely identify a message. + */ +@Serializable +data class ChatMessage( + val id: Long, + val sender: AccountResponse, + val message: String, + @SerialName("created_at") + @Serializable(with = InstantSerializer::class) + val createdAt: Instant +) + +/** + * The response of a create lobby request, which contains the [lobbyID] of the created lobby */ @Serializable data class CreateLobbyResponse( - @SerialName("lobby_name") + @SerialName("lobby_id") val lobbyID: Long ) @@ -102,11 +131,22 @@ data class FriendRequestResponse( val to: AccountResponse ) +/** + * The response to a get chat + * + * [messages] should be sorted by the datetime of message.created_at. + */ +@Serializable +data class GetChatResponse( + val members: List, + val messages: List +) + /** * A list of your friends and friend requests * - * ``friends`` is a list of already established friendships - * ``friend_requests`` is a list of friend requests (ingoing and outgoing) + * [friends] is a list of already established friendships + * [friendRequests] is a list of friend requests (incoming and outgoing) */ @Serializable data class GetFriendResponse( @@ -135,7 +175,8 @@ data class LobbyResponse( @SerialName("current_players") val currentPlayers: Int, @SerialName("created_at") - val createdAt: Int, + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, @SerialName("password") val hasPassword: Boolean, val owner: AccountResponse From 1fdb8d0c251dcbf373551eeb7822958dcf1b506a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Mar 2023 00:07:09 +0100 Subject: [PATCH 014/152] Added first WebSocket handler --- .../logic/multiplayer/OnlineMultiplayer.kt | 66 +++++++++++++++---- .../com/unciv/logic/multiplayer/api/Api.kt | 14 ++-- .../com/unciv/models/metadata/GameSettings.kt | 1 + 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 2f571e22943ab..b76dcf4479c33 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -9,10 +9,10 @@ import com.unciv.logic.GameInfoPreview import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.api.AccountResponse import com.unciv.logic.multiplayer.api.Api import com.unciv.logic.multiplayer.api.ApiErrorResponse import com.unciv.logic.multiplayer.api.ApiStatusCode +import com.unciv.logic.multiplayer.api.WebSocketMessageSerializer import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException @@ -20,23 +20,22 @@ import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency -import com.unciv.utils.concurrency.Dispatcher -import com.unciv.utils.concurrency.launchOnNonDaemonThreadPool import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug -import kotlinx.coroutines.CoroutineScope +import io.ktor.client.plugins.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import java.time.Duration import java.time.Instant import java.util.* import java.util.concurrent.atomic.AtomicReference -import java.util.logging.Logger /** * 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 @@ -72,11 +71,15 @@ class OnlineMultiplayer { if (password == null) { password = "SomePasswordForThoseFolksWhoDoNotHaveAnyStrongPasswordYet!" // TODO: Obviously, replace this password } - val username = UncivGame.Current.settings.multiplayer.userName + var username = UncivGame.Current.settings.multiplayer.username + // TODO: Since the username is currently never used and therefore unset, update the username below and re-compile! + if (username == "") { + username = "MyValidUsername" + } runBlocking { coroutineScope { - launchOnNonDaemonThreadPool { - if (api.auth.login(username, password)) { + Concurrency.runOnNonDaemonThreadPool { + if (!api.auth.login(username, password)) { logger.warning("Login failed. Trying to create account for $username") try { api.accounts.register(username, username, password) @@ -88,13 +91,54 @@ class OnlineMultiplayer { throw e } api.auth.login(username, password) - api.websocket() } + api.websocket(::handleWS) } } } } + private var sendChannel: SendChannel? = null + + /** + * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * + * Returns [Unit] if no exception is thrown, otherwise the exception is thrown + */ + internal suspend fun sendText(text: String): Unit { + if (sendChannel == null) { + return + } + try { + sendChannel!!.send(Frame.Text(text)) + } catch (e: Throwable) { + logger.warning(e.localizedMessage) + logger.warning(e.stackTraceToString()) + throw e + } + } + + /** + * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * + * Returns true on success, false otherwise. Any error is suppressed! + */ + internal suspend fun sendTextSuppressed(text: String): Boolean { + if (sendChannel == null) { + return false + } + try { + sendChannel!!.send(Frame.Text(text)) + } catch (e: Throwable) { + logger.severe(e.localizedMessage) + logger.severe(e.stackTraceToString()) + } + return true + } + + private suspend fun handleWS(session: ClientWebSocketSession) { + } + private fun getCurrentGame(): OnlineMultiplayerGame? { val gameInfo = UncivGame.Current.gameInfo return if (gameInfo != null) { diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 58204adaf6aaf..75a2dad4f954f 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -63,6 +63,7 @@ class Api(val baseUrl: String) { private val authCookieHelper = AuthCookieHelper() + // Queue to keep references to all opened WebSocket handler jobs private var websocketJobs = ConcurrentLinkedQueue() init { @@ -105,6 +106,7 @@ class Api(val baseUrl: String) { private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { try { val incomingMessage = session.incoming.receive() + logger.info("Incoming message: $incomingMessage") if (incomingMessage.frameType == FrameType.PING) { logger.info("Received PING frame") @@ -126,8 +128,13 @@ class Api(val baseUrl: String) { /** * Start a new WebSocket connection + * + * The parameter [handler] is a coroutine that will be fed the established + * [ClientWebSocketSession] on success at a later point. Note that this + * method does instantly return, detaching the creation of the WebSocket. + * The [handler] coroutine might not get called, if opening the WS fails. */ - suspend fun websocket(): Boolean { + suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit): Boolean { logger.info("Starting a new WebSocket connection ...") coroutineScope { @@ -137,14 +144,13 @@ class Api(val baseUrl: String) { authCookieHelper.add(this) url { takeFrom(baseUrl) - protocol = - URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well + protocol = URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well path("/api/v2/ws") } } val job = Concurrency.runOnNonDaemonThreadPool { launch { - handleWebSocketSession(session) + handler(session) } } websocketJobs.add(job) diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index ee78f8abe1c13..728407702813c 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -225,6 +225,7 @@ class GameSettingsMultiplayer { var passwords = mutableMapOf() @Suppress("unused") // @GGuenni knows what he intended with this field var userName: String = "" + var username: String = "" // duplicate to avoid using a field which may have other intentions at the moment var server = Constants.uncivXyzServer var friendList: MutableList = mutableListOf() var turnCheckerEnabled = true From 9888bed04fe340af16b9da3bc3172d85322ff734 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Mar 2023 00:42:18 +0100 Subject: [PATCH 015/152] Added frame handling, added functional WS sending, added apiVersion to server feature set --- .../logic/multiplayer/OldOnlineMultiplayer.kt | 4 +- .../logic/multiplayer/OnlineMultiplayer.kt | 78 ++++++++++++++++++- .../logic/multiplayer/ServerFeatureSet.kt | 7 ++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt index f295fb10b2f6c..e9abb8d6207e0 100644 --- a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt @@ -46,7 +46,7 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) class OldOnlineMultiplayer { private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() - private var featureSet = ServerFeatureSet() + private var featureSet = ServerFeatureSet(apiVersion = 0) private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -344,7 +344,7 @@ class OldOnlineMultiplayer { json().fromJson(ServerFeatureSet::class.java, result) } catch (ex: Exception) { Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") - ServerFeatureSet() + ServerFeatureSet(apiVersion = 1) } } } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index b76dcf4479c33..8fd15a6e69a52 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -12,7 +12,9 @@ import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.api.Api import com.unciv.logic.multiplayer.api.ApiErrorResponse import com.unciv.logic.multiplayer.api.ApiStatusCode +import com.unciv.logic.multiplayer.api.WebSocketMessage import com.unciv.logic.multiplayer.api.WebSocketMessageSerializer +import com.unciv.logic.multiplayer.api.WebSocketMessageType import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException @@ -36,6 +38,7 @@ import java.time.Duration import java.time.Instant import java.util.* import java.util.concurrent.atomic.AtomicReference +import java.util.logging.Level /** * 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 @@ -53,7 +56,7 @@ class OnlineMultiplayer { private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() - private var featureSet = ServerFeatureSet() + private var featureSet = ServerFeatureSet(apiVersion = 2) private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -67,6 +70,7 @@ class OnlineMultiplayer { private val api = Api(UncivGame.Current.settings.multiplayer.server) init { + logger.level = Level.FINER // for debugging var password = UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server] if (password == null) { password = "SomePasswordForThoseFolksWhoDoNotHaveAnyStrongPasswordYet!" // TODO: Obviously, replace this password @@ -136,7 +140,77 @@ class OnlineMultiplayer { return true } + /** + * Handle incoming WebSocket messages + */ + private fun handleIncomingWSMessage(msg: WebSocketMessage) { + when (msg.type) { + WebSocketMessageType.InvalidMessage -> { + logger.warning("Received invalid message from WebSocket connection") + } + WebSocketMessageType.FinishedTurn -> { + // This message type is not meant to be received from the server + logger.warning("Received FinishedTurn message from WebSocket connection") + } + WebSocketMessageType.UpdateGameData -> { + // TODO: The body of this message contains a whole game state, so we need to unpack and use it here + } + WebSocketMessageType.ClientDisconnected -> { + logger.info("Received ClientDisconnected message from WebSocket connection") + // TODO: Implement client connectivity handling + } + WebSocketMessageType.ClientReconnected -> { + logger.info("Received ClientReconnected message from WebSocket connection") + // TODO: Implement client connectivity handling + } + WebSocketMessageType.IncomingChatMessage -> { + logger.info("Received IncomingChatMessage message from WebSocket connection") + // TODO: Implement chat message handling + } + } + } + + /** + * Handle a newly established WebSocket connection + */ private suspend fun handleWS(session: ClientWebSocketSession) { + sendChannel?.close() + sendChannel = session.outgoing + + try { + while (true) { + val incomingFrame = session.incoming.receive() + when (incomingFrame.frameType) { + FrameType.CLOSE, FrameType.PING, FrameType.PONG -> { + // This handler won't handle control frames + logger.info("Received CLOSE, PING or PONG as message") + } + FrameType.BINARY -> { + logger.warning("Received binary packet which can't be parsed at the moment") + } + FrameType.TEXT -> { + try { + logger.fine("Incoming text message: $incomingFrame") + val text = (incomingFrame as Frame.Text).readText() + logger.fine("Message text: $text") + val msg = Json.decodeFromString(WebSocketMessageSerializer(), text) + logger.fine("Message type: ${msg::class.java.canonicalName}") + logger.fine("Deserialized: $msg") + handleIncomingWSMessage(msg) + } catch (e: Throwable) { + logger.severe(e.localizedMessage) + logger.severe(e.stackTraceToString()) + } + } + } + } + } catch (e: ClosedReceiveChannelException) { + logger.warning("The WebSocket channel was closed: $e") + } catch (e: Throwable) { + logger.severe(e.localizedMessage) + logger.severe(e.stackTraceToString()) + throw e + } } private fun getCurrentGame(): OnlineMultiplayerGame? { @@ -409,7 +483,7 @@ class OnlineMultiplayer { json().fromJson(ServerFeatureSet::class.java, result) } catch (ex: Exception) { Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") - ServerFeatureSet() + ServerFeatureSet(apiVersion = 0) } } } diff --git a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt index b053be218ad23..fb5bcda5160d4 100644 --- a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt +++ b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt @@ -9,7 +9,14 @@ 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. + * + * The API developed to replace dropbox which uses preview files and + * polling via HTTP is referred to as API v1, [apiVersion] = 1. + * It may or may not support auth. The new WebSocket-based and extended + * API is referred to as API v2, [apiVersion] = 2. It's not directly a + * feature set, but rather another refined interface. */ data class ServerFeatureSet( val authVersion: Int = 0, + val apiVersion: Int ) From 8dce89dc35900b6215fcd32772fb5046bafce43d Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Mar 2023 22:15:01 +0100 Subject: [PATCH 016/152] Added get all chat rooms endpoint --- .../multiplayer/api/EndpointImplementations.kt | 17 +++++++++++++++++ .../logic/multiplayer/api/ResponseStructs.kt | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index e3911c8c48862..e3784baa61175 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -242,6 +242,23 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth */ class ChatApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + /** + * Retrieve all messages a user has access to + * + * In the response, you will find different categories, currently friend rooms and lobby rooms. + */ + suspend fun list(): GetAllChatsResponse { + val response = client.get("/api/v2/chats") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + /** * Retrieve the messages of a chatroom * diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index 75a5072be1ace..f9d57581a343f 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -131,6 +131,17 @@ data class FriendRequestResponse( val to: AccountResponse ) +/** + * All chat rooms your user has access to + */ +@Serializable +data class GetAllChatsResponse( + @SerialName("friend_chat_rooms") + val friendChatRooms: List, + @SerialName("lobby_chat_rooms") + val lobbyChatRooms: List +) + /** * The response to a get chat * From 204e4a38440b57946b7778b40d68c95d346f2cf8 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 12 Mar 2023 16:52:07 +0100 Subject: [PATCH 017/152] Added WebSocket data structs --- .../logic/multiplayer/api/WebSocketStructs.kt | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt index f1aca60dfcf6e..156279e0aa236 100644 --- a/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt @@ -1,6 +1,66 @@ package com.unciv.logic.multiplayer.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.util.* + +/** + * Upload a new game state from a client after finishing a turn + */ +@Serializable +data class FinishedTurn( + @SerialName("gameId") + val gameID: Long, + val gameData: String, // base64-encoded, gzipped game state +) + +/** + * An update of the game data + * + * This variant is sent from the server to all accounts that are in the game. + */ +@Serializable +data class UpdateGameData( + @SerialName("gameId") + val gameID: Long, + val gameData: String, // base64-encoded, gzipped game state + /** A unique counter that is incremented every time a [FinishedTurn] + * is received from the same `game_id`. */ + @SerialName("gameDataId") + val gameDataID: Long +) + +/** + * Notification for clients if a client in their game disconnected + */ +@Serializable +data class ClientDisconnected( + @SerialName("gameId") + val gameID: Long, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID // client identifier +) + +/** + * Notification for clients if a client in their game reconnected + */ +@Serializable +data class ClientReconnected( + @SerialName("gameId") + val gameID: Long, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID // client identifier +) + +/** + * A new chat message is sent to the client + */ +@Serializable +data class IncomingChatMessage( + @SerialName("chatId") + val chatID: Long, + val message: ChatMessage +) /** * The base WebSocket message, encapsulating only the type of the message @@ -23,7 +83,7 @@ data class InvalidMessage( @Serializable data class FinishedTurnMessage ( override val type: WebSocketMessageType, - val content: String // TODO + val content: FinishedTurn ) : WebSocketMessage /** @@ -32,7 +92,7 @@ data class FinishedTurnMessage ( @Serializable data class UpdateGameDataMessage ( override val type: WebSocketMessageType, - val content: String // TODO + val content: UpdateGameData ) : WebSocketMessage /** @@ -41,7 +101,7 @@ data class UpdateGameDataMessage ( @Serializable data class ClientDisconnectedMessage ( override val type: WebSocketMessageType, - val content: String // TODO + val content: ClientDisconnected ) : WebSocketMessage /** @@ -50,7 +110,7 @@ data class ClientDisconnectedMessage ( @Serializable data class ClientReconnectedMessage ( override val type: WebSocketMessageType, - val content: String // TODO + val content: ClientReconnected ) : WebSocketMessage /** @@ -59,10 +119,9 @@ data class ClientReconnectedMessage ( @Serializable data class IncomingChatMessageMessage ( override val type: WebSocketMessageType, - val content: String // TODO + val content: IncomingChatMessage ) : WebSocketMessage - /** * Type enum of all known WebSocket messages */ From 4557dc8ea4a35e9c4a525af32831f17dcd4c6d64 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 00:56:26 +0100 Subject: [PATCH 018/152] Added game and invite API and structs --- .../com/unciv/logic/multiplayer/api/Api.kt | 10 ++ .../api/EndpointImplementations.kt | 140 ++++++++++++++++++ .../logic/multiplayer/api/RequestStructs.kt | 28 +++- .../logic/multiplayer/api/ResponseStructs.kt | 89 +++++++++++ 4 files changed, 265 insertions(+), 2 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 75a2dad4f954f..70b740ebf2a6f 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -93,6 +93,16 @@ class Api(val baseUrl: String) { */ val friend = FriendApi(client, authCookieHelper, logger) + /** + * API for game management + */ + val games = GameApi(client, authCookieHelper, logger) + + /** + * API for invite management + */ + val invites = InviteApi(client, authCookieHelper, logger) + /** * API for lobby management */ diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index e3784baa61175..c9db04fd5d03b 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -403,6 +403,146 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au } +/** + * API wrapper for game handling (do not use directly; use the Api class instead) + */ +class GameApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + + /** + * Retrieves an overview of all open games of a player + * + * The response does not contain any full game state, but rather a + * shortened game state identified by its ID and state identifier. + * If the state ([GameOverviewResponse.gameDataID]) of a known game + * differs from the last known identifier, the server has a newer + * state of the game. The [GameOverviewResponse.lastActivity] field + * is a convenience attribute and shouldn't be used for update checks. + */ + suspend fun list(): List { + val response = client.get("/api/v2/games") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Retrieves a single game which is currently open (actively played) + * + * If the game has been completed or aborted, it will + * respond with a GameNotFound in [ApiErrorResponse]. + */ + suspend fun get(gameID: Long): GameStateResponse { + val response = client.get("/api/v2/games/$gameID") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + return response.body() + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Upload a new game state for an existing game + * + * If the game can't be updated (maybe it has been already completed + * or aborted), it will respond with a GameNotFound in [ApiErrorResponse]. + * Use the [gameID] retrieved from the server in a previous API call. + * + * On success, returns the new game data ID that can be used to verify + * that the client and server use the same state (prevents re-querying). + */ + suspend fun upload(gameID: Long, gameData: String): Long { + return upload(GameUploadRequest(gameData, gameID)) + } + + /** + * Upload a new game state for an existing game + * + * If the game can't be updated (maybe it has been already completed + * or aborted), it will respond with a GameNotFound in [ApiErrorResponse]. + * + * On success, returns the new game data ID that can be used to verify + * that the client and server use the same state (prevents re-querying). + */ + suspend fun upload(r: GameUploadRequest): Long { + val response = client.put("/api/v2/games") { + contentType(ContentType.Application.Json) + setBody(r) + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: GameUploadResponse = response.body() + logger.info("The game with ID ${r.gameID} has been uploaded, the new data ID is ${responseBody.gameDataID}") + return responseBody.gameDataID + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} + +/** + * API wrapper for invite handling (do not use directly; use the Api class instead) + */ +class InviteApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + + /** + * Retrieve all invites for the executing user + */ + suspend fun list(): List { + val response = client.get("/api/v2/invites") { + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + val responseBody: GetInvitesResponse = response.body() + return responseBody.invites + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + + /** + * Invite a friend to a lobby + * + * The executing user must be in the specified open lobby. + * The invited friend must not be in a friend request state. + */ + suspend fun new(friend: UUID, lobbyID: Long): Boolean { + return new(CreateInviteRequest(friend, lobbyID)) + } + + /** + * Invite a friend to a lobby + * + * The executing user must be in the specified open lobby. + * The invited friend must not be in a friend request state. + */ + suspend fun new(r: CreateInviteRequest): Boolean { + val response = client.post("/api/v2/invites") { + contentType(ContentType.Application.Json) + setBody(r) + authCookieHelper.add(this) + } + if (response.status.isSuccess()) { + logger.info("The friend ${r.friend} has been invited to lobby ${r.lobbyID}") + return true + } else { + val err: ApiErrorResponse = response.body() + throw err + } + } + +} + /** * API wrapper for lobby handling (do not use directly; use the Api class instead) */ diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt index 3a91ddda7945a..51b7f56e982e6 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -28,10 +28,21 @@ data class CreateFriendRequest( val uuid: UUID ) +/** + * The request to invite a friend into a lobby + */ +@Serializable +data class CreateInviteRequest( + @Serializable(with = UUIDSerializer::class) + val friend: UUID, + @SerialName("lobby_id") + val lobbyID: Long +) + /** * The parameters to create a lobby * - * The parameter ``max_players`` must be greater or equals 2. + * The parameter [maxPlayers] must be greater or equals 2. */ @Serializable data class CreateLobbyRequest( @@ -41,6 +52,19 @@ data class CreateLobbyRequest( val maxPlayers: Int ) +/** + * The request a user sends to the server to upload a new game state (non-WebSocket API) + * + * The [gameID] was received by the server in a previous get or create API call. + */ +@Serializable +data class GameUploadRequest( + @SerialName("game_data") + val gameData: String, + @SerialName("game_id") + val gameID: Long +) + /** * The request data of a login request */ @@ -61,7 +85,7 @@ data class LookupAccountUsernameRequest( /** * The set password request data * - * The parameter ``new_password`` must not be empty. + * The parameter [newPassword] must not be empty. */ @Serializable data class SetPasswordRequest( diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index f9d57581a343f..491cc493911bc 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -131,6 +131,65 @@ data class FriendRequestResponse( val to: AccountResponse ) +/** + * A shortened game state identified by its ID and state identifier + * + * If the state ([gameDataID]) of a known game differs from the last known + * identifier, the server has a newer state of the game. The [lastActivity] + * field is a convenience attribute and shouldn't be used for update checks. + */ +@Serializable +data class GameOverviewResponse( + @SerialName("chat_room_id") + val chatRoomID: Long, + @SerialName("game_data_id") + val gameDataID: Long, + @SerialName("game_id") + val gameID: Long, + @SerialName("last_activity") + @Serializable(with = InstantSerializer::class) + val lastActivity: Instant, + @SerialName("last_player") + val lastPlayer: AccountResponse, + @SerialName("max_players") + val maxPlayers: Int, + val name: String +) + +/** + * A single game state identified by its ID and state identifier; see [gameData] + * + * If the state ([gameDataID]) of a known game differs from the last known + * identifier, the server has a newer state of the game. The [lastActivity] + * field is a convenience attribute and shouldn't be used for update checks. + */ +@Serializable +data class GameStateResponse( + @SerialName("chat_room_id") + val chatRoomID: Long, + @SerialName("game_data") + val gameData: String, + @SerialName("game_data_id") + val gameDataID: Long, + @SerialName("last_activity") + @Serializable(with = InstantSerializer::class) + val lastActivity: Instant, + @SerialName("last_player") + val lastPlayer: AccountResponse, + @SerialName("max_players") + val maxPlayers: Int, + val name: String +) + +/** + * The response a user receives after uploading a new game state successfully + */ +@Serializable +data class GameUploadResponse( + @SerialName("game_data_id") + val gameDataID: Long +) + /** * All chat rooms your user has access to */ @@ -166,6 +225,36 @@ data class GetFriendResponse( val friendRequests: List ) +/** + * An overview of games a player participates in + */ +@Serializable +data class GetGameOverviewResponse( + val games: List +) + +/** + * A single invite + */ +@Serializable +data class GetInvite( + @SerialName("created_at") + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + val from: AccountResponse, + val id: Long, + @SerialName("lobby_id") + val lobbyID: Long +) + +/** + * The invites that an account has received + */ +@Serializable +data class GetInvitesResponse( + val invites: List +) + /** * The lobbies that are open */ From 8a16d4ba5afbc626c5a6e7edf07d115cad769041 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 04:25:40 +0100 Subject: [PATCH 019/152] Switched to a non-blocking file storage API --- .../unciv/logic/multiplayer/OldOnlineMultiplayer.kt | 5 ++--- .../unciv/logic/multiplayer/OnlineMultiplayer.kt | 5 ++--- .../com/unciv/logic/multiplayer/storage/DropBox.kt | 12 ++++++------ .../unciv/logic/multiplayer/storage/FileStorage.kt | 13 +++++++------ .../multiplayer/storage/UncivServerFileStorage.kt | 12 ++++++------ core/src/com/unciv/ui/popups/AuthPopup.kt | 3 ++- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt index e9abb8d6207e0..ebe6212527d3f 100644 --- a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt @@ -356,12 +356,11 @@ class OldOnlineMultiplayer { * @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 val success = multiplayerFiles.fileStorage().authenticate( @@ -379,7 +378,7 @@ class OldOnlineMultiplayer { * @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 && multiplayerFiles.fileStorage().setPassword(newPassword = password) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 8fd15a6e69a52..fb0ee3fad0675 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -495,12 +495,11 @@ class OnlineMultiplayer { * @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 val success = multiplayerFiles.fileStorage().authenticate( @@ -518,7 +517,7 @@ class OnlineMultiplayer { * @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 && multiplayerFiles.fileStorage().setPassword(newPassword = password) diff --git a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt index 2f891c0e26e2c..32c55f0e24fab 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt @@ -71,7 +71,7 @@ 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 deleteFile(fileName: String){ dropboxApi( url="https://api.dropboxapi.com/2/files/delete_v2", data="{\"path\":\"${getLocalGameLocation(fileName)}\"}", @@ -79,7 +79,7 @@ object DropBox: FileStorage { ) } - 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,7 +89,7 @@ object DropBox: FileStorage { return json().fromJson(MetaData::class.java, reader.readText()) } - override fun saveFileData(fileName: String, data: String) { + override suspend fun saveFileData(fileName: String, data: String) { dropboxApi( url="https://content.dropboxapi.com/2/files/upload", data=data, @@ -98,16 +98,16 @@ object DropBox: FileStorage { )!! } - override fun loadFileData(fileName: String): String { + override suspend fun loadFileData(fileName: String): String { val inputStream = downloadFile(getLocalGameLocation(fileName)) return BufferedReader(InputStreamReader(inputStream)).readText() } - override fun authenticate(userId: String, password: String): Boolean { + override suspend fun authenticate(userId: String, password: String): Boolean { throw NotImplementedError() } - override fun setPassword(newPassword: String): Boolean { + override suspend fun setPassword(newPassword: String): Boolean { throw NotImplementedError() } diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt index ff8346213981c..7545e19a61ea8 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt @@ -2,6 +2,7 @@ package com.unciv.logic.multiplayer.storage import com.unciv.logic.UncivShowableException import java.util.* +import java.io.FileNotFoundException class FileStorageConflictException : Exception() class FileStorageRateLimitReached(val limitRemainingSeconds: Int) : UncivShowableException("Server limit reached! Please wait for [${limitRemainingSeconds}] seconds") @@ -17,31 +18,31 @@ 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 saveFileData(fileName: 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 loadFileData(fileName: 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 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 */ - fun deleteFile(fileName: String) + suspend fun deleteFile(fileName: 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 + suspend 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 + suspend fun setPassword(newPassword: String): Boolean } diff --git a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt index f823fd0d6de0f..a8661a11f7e09 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt @@ -10,7 +10,7 @@ object UncivServerFileStorage : FileStorage { var serverUrl: String = "" var timeout: Int = 30000 - override fun saveFileData(fileName: String, data: String) { + override suspend fun saveFileData(fileName: String, data: String) { SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(fileName), content=data, timeout=timeout, header=authHeader) { success, result, code -> if (!success) { @@ -23,7 +23,7 @@ object UncivServerFileStorage : FileStorage { } } - override fun loadFileData(fileName: String): String { + override suspend fun loadFileData(fileName: String): String { var fileData = "" SimpleHttp.sendGetRequest(fileUrl(fileName), timeout=timeout, header=authHeader) { success, result, code -> @@ -40,11 +40,11 @@ object UncivServerFileStorage : FileStorage { return fileData } - override fun getFileMetaData(fileName: String): FileMetaData { + override suspend fun getFileMetaData(fileName: String): FileMetaData { TODO("Not yet implemented") } - override fun deleteFile(fileName: String) { + override suspend fun deleteFile(fileName: String) { SimpleHttp.sendRequest(Net.HttpMethods.DELETE, fileUrl(fileName), content="", timeout=timeout, header=authHeader) { success, result, code -> if (!success) { @@ -56,7 +56,7 @@ object UncivServerFileStorage : FileStorage { } } - override fun authenticate(userId: String, password: String): Boolean { + override suspend fun authenticate(userId: String, password: String): Boolean { var authenticated = false val preEncodedAuthValue = "$userId:$password" authHeader = mapOf("Authorization" to "Basic ${Base64Coder.encodeString(preEncodedAuthValue)}") @@ -76,7 +76,7 @@ object UncivServerFileStorage : FileStorage { return authenticated } - override fun setPassword(newPassword: String): Boolean { + override suspend fun setPassword(newPassword: String): Boolean { if (authHeader == null) return false diff --git a/core/src/com/unciv/ui/popups/AuthPopup.kt b/core/src/com/unciv/ui/popups/AuthPopup.kt index 0945b856ba93a..8d4292464132a 100644 --- a/core/src/com/unciv/ui/popups/AuthPopup.kt +++ b/core/src/com/unciv/ui/popups/AuthPopup.kt @@ -7,6 +7,7 @@ import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.screens.basescreen.BaseScreen +import kotlinx.coroutines.runBlocking 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.authenticate(passwordField.text) + runBlocking { UncivGame.Current.onlineMultiplayer.authenticate(passwordField.text) } authSuccessful?.invoke(true) close() } catch (ex: Exception) { From 1390c8b8b26a21be3e61866f4f80a96ae9f2b67a Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 05:12:41 +0100 Subject: [PATCH 020/152] Switch to UUIDs for game identification, added a auth testing function --- .../api/EndpointImplementations.kt | 25 ++++++++++++++----- .../logic/multiplayer/api/RequestStructs.kt | 7 +++--- .../logic/multiplayer/api/ResponseStructs.kt | 5 ++-- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt index c9db04fd5d03b..16ca44d5ae0d0 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt @@ -183,6 +183,19 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: */ class AuthApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { + /** + * Try logging in with username and password for testing purposes, don't set the session cookie + * + * This method won't raise *any* exception, just return the boolean value if login worked. + */ + suspend fun loginOnly(username: String, password: String): Boolean { + val response = client.post("/api/v2/auth/login") { + contentType(ContentType.Application.Json) + setBody(LoginRequest(username, password)) + } + return response.status.isSuccess() + } + /** * Try logging in with username and password * @@ -436,8 +449,8 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth * If the game has been completed or aborted, it will * respond with a GameNotFound in [ApiErrorResponse]. */ - suspend fun get(gameID: Long): GameStateResponse { - val response = client.get("/api/v2/games/$gameID") { + suspend fun get(gameUUID: UUID): GameStateResponse { + val response = client.get("/api/v2/games/$gameUUID") { authCookieHelper.add(this) } if (response.status.isSuccess()) { @@ -453,13 +466,13 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth * * If the game can't be updated (maybe it has been already completed * or aborted), it will respond with a GameNotFound in [ApiErrorResponse]. - * Use the [gameID] retrieved from the server in a previous API call. + * Use the [gameUUID] retrieved from the server in a previous API call. * * On success, returns the new game data ID that can be used to verify * that the client and server use the same state (prevents re-querying). */ - suspend fun upload(gameID: Long, gameData: String): Long { - return upload(GameUploadRequest(gameData, gameID)) + suspend fun upload(gameUUID: UUID, gameData: String): Long { + return upload(GameUploadRequest(gameData, gameUUID)) } /** @@ -479,7 +492,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth } if (response.status.isSuccess()) { val responseBody: GameUploadResponse = response.body() - logger.info("The game with ID ${r.gameID} has been uploaded, the new data ID is ${responseBody.gameDataID}") + logger.info("The game with ID ${r.gameUUID} has been uploaded, the new data ID is ${responseBody.gameDataID}") return responseBody.gameDataID } else { val err: ApiErrorResponse = response.body() diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt index 51b7f56e982e6..db532fa3ac200 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt @@ -55,14 +55,15 @@ data class CreateLobbyRequest( /** * The request a user sends to the server to upload a new game state (non-WebSocket API) * - * The [gameID] was received by the server in a previous get or create API call. + * The [gameUUID] was received by the server in a previous get or create API call. */ @Serializable data class GameUploadRequest( @SerialName("game_data") val gameData: String, - @SerialName("game_id") - val gameID: Long + @SerialName("game_uuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID ) /** diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt index 491cc493911bc..8f85bedc4e697 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt @@ -144,8 +144,9 @@ data class GameOverviewResponse( val chatRoomID: Long, @SerialName("game_data_id") val gameDataID: Long, - @SerialName("game_id") - val gameID: Long, + @SerialName("game_uuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, @SerialName("last_activity") @Serializable(with = InstantSerializer::class) val lastActivity: Instant, From 6a2f1a2c5046194415a48bce0a4d5fe398eba51d Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 05:16:28 +0100 Subject: [PATCH 021/152] Added the possibility to set specific game IDs during game creation This is mandatory since for API v2, the server will create and validate the game UUIDs. Generating them on the client is therefore no viable solution. --- core/src/com/unciv/logic/GameInfo.kt | 4 ++-- core/src/com/unciv/logic/GameStarter.kt | 5 ++++- .../unciv/logic/multiplayer/OnlineMultiplayer.kt | 15 +++++++++++++++ .../ui/screens/newgamescreen/NewGameScreen.kt | 7 +++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index ce65a40305c1a..78c382c1517ae 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -72,7 +72,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. */ @@ -97,7 +97,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 victoryData:VictoryData? = null diff --git a/core/src/com/unciv/logic/GameStarter.kt b/core/src/com/unciv/logic/GameStarter.kt index d9ef67761dc4f..9434c6ac55964 100644 --- a/core/src/com/unciv/logic/GameStarter.kt +++ b/core/src/com/unciv/logic/GameStarter.kt @@ -29,12 +29,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/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index fb0ee3fad0675..01e867db7a25b 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -170,6 +170,21 @@ class OnlineMultiplayer { } } + /** + * Allocate a new game ID on the server and return it + * + * IMPORTANT: This is a temporary solution until proper handling of lobbies is implemented. + * When this is done, this function should be changed to something like `startLobby`. + */ + fun allocateGameId(): String? { + // TODO: Make backward-compatible by ignoring remote backends which can't create game IDs + runBlocking { + // TODO: Implement the server endpoint for the function api.lobby.create() + return@runBlocking UUID.randomUUID().toString() + } + return null + } + /** * Handle a newly established WebSocket connection */ diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt index 15dce0fdfae57..1596669d34673 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt @@ -283,9 +283,12 @@ class NewGameScreen( popup.open() } - val newGame:GameInfo + // TODO: Introduce proper lobby handling, this is a temporary solution + val gameId = UncivGame.Current.onlineMultiplayer.allocateGameId() + + val newGame: GameInfo try { - newGame = GameStarter.startNewGame(gameSetupInfo) + newGame = GameStarter.startNewGame(gameSetupInfo, gameId) } catch (exception: Exception) { exception.printStackTrace() launchOnGLThread { From 775d8fd2bcc3b0970b784879080823dc8c6cedaa Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 05:30:25 +0100 Subject: [PATCH 022/152] Split the FileStorage interface to differentiate between game and preview data --- .../logic/multiplayer/OnlineMultiplayer.kt | 2 +- .../logic/multiplayer/storage/DropBox.kt | 34 +++++++++++++++---- .../logic/multiplayer/storage/FileStorage.kt | 31 +++++++++++++++-- .../storage/OnlineMultiplayerFiles.kt | 9 ++--- .../storage/UncivServerFileStorage.kt | 24 +++++++++---- .../com/unciv/models/metadata/GameSettings.kt | 1 - 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 01e867db7a25b..17741d63edca3 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -75,7 +75,7 @@ class OnlineMultiplayer { if (password == null) { password = "SomePasswordForThoseFolksWhoDoNotHaveAnyStrongPasswordYet!" // TODO: Obviously, replace this password } - var username = UncivGame.Current.settings.multiplayer.username + var username = UncivGame.Current.settings.multiplayer.userName // TODO: Since the username is currently never used and therefore unset, update the username below and re-compile! if (username == "") { username = "MyValidUsername" diff --git a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt index 32c55f0e24fab..f9b3bcd56a555 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt @@ -71,10 +71,18 @@ object DropBox: FileStorage { // This is the location in Dropbox only private fun getLocalGameLocation(fileName: String) = "/MultiplayerGames/$fileName" - override suspend 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" ) } @@ -89,17 +97,31 @@ object DropBox: FileStorage { return json().fromJson(MetaData::class.java, reader.readText()) } - override suspend 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 suspend 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)).readText() } diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt index 7545e19a61ea8..589ed351f4cb8 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 */ - suspend 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 */ - suspend 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 + */ + 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 */ - suspend 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 */ suspend 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 */ suspend fun setPassword(newPassword: String): Boolean + } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index 9d418f7d8c853..ccb65828c4dd2 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -5,6 +5,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles +import java.io.FileNotFoundException /** * Allows access to games stored on a server for multiplayer purposes. @@ -46,7 +47,7 @@ class OnlineMultiplayerFiles( */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { val zippedGameInfo = UncivFiles.gameInfoToPrettyString(gameInfo, useZip = true) - fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo) + 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 @@ -72,7 +73,7 @@ class OnlineMultiplayerFiles( */ suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) { val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo) - fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo) + fileStorage().savePreviewData(gameInfo.gameId, zippedGameInfo) } /** @@ -80,7 +81,7 @@ class OnlineMultiplayerFiles( * @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) return UncivFiles.gameInfoFromString(zippedGameInfo) } @@ -89,7 +90,7 @@ class OnlineMultiplayerFiles( * @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 a8661a11f7e09..33cd082e478e6 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 suspend 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 suspend 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 suspend fun loadPreviewData(gameId: String): String { + return loadGameData(gameId + PREVIEW_FILE_SUFFIX) + } + override suspend fun getFileMetaData(fileName: String): FileMetaData { TODO("Not yet implemented") } - override suspend 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 suspend fun authenticate(userId: String, password: String): Boolean { var authenticated = false val preEncodedAuthValue = "$userId:$password" diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 728407702813c..ee78f8abe1c13 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -225,7 +225,6 @@ class GameSettingsMultiplayer { var passwords = mutableMapOf() @Suppress("unused") // @GGuenni knows what he intended with this field var userName: String = "" - var username: String = "" // duplicate to avoid using a field which may have other intentions at the moment var server = Constants.uncivXyzServer var friendList: MutableList = mutableListOf() var turnCheckerEnabled = true From a102a8ff4ce438a93b5fe81cda9528d9f8005d1c Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Mar 2023 15:38:25 +0100 Subject: [PATCH 023/152] Allow setting the username from the multiplayer options dialogue --- .../com/unciv/ui/popups/options/MultiplayerTab.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 63f26331bb202..66c169f7f25d7 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -147,6 +147,19 @@ private fun addMultiplayerServerOptions( multiplayerServerTextField.programmaticChangeEvents = true val serverIpTable = Table() + // TODO: This is a quick workaround to allow setting the username and should be extended and improved + 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) + .colspan(2).growX().padBottom(8f).row() + serverIpTable.add("Save username".toTextButton().onClick { + settings.multiplayer.userName = multiplayerUsernameTextField.text + settings.save() + }).colspan(2).padBottom(8f).row() + serverIpTable.add("Server address".toLabel().onClick { multiplayerServerTextField.text = Gdx.app.clipboard.contents }).colspan(2).row() From 94a5ebdad1ec62ac5f61bbdf94d97e2e312447b0 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 21 Mar 2023 04:07:44 +0100 Subject: [PATCH 024/152] Added a file storage emulator as workaround to use API v2 --- .../storage/ApiV2FileStorageEmulator.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt new file mode 100644 index 0000000000000..02d3fc3c6234a --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -0,0 +1,65 @@ +package com.unciv.logic.multiplayer.storage + +import com.unciv.logic.multiplayer.api.Api +import java.util.* + +/** + * Transition helper that emulates file storage behavior using the API v2 + */ +class ApiV2FileStorageEmulator(private val api: Api): FileStorage { + + override suspend fun saveGameData(gameId: String, data: String) { + val uuid = UUID.fromString(gameId.lowercase()) + api.games.upload(uuid, data) + } + + override suspend fun savePreviewData(gameId: String, data: String) { + // Not implemented for this API + throw NotImplementedError("Outdated API") + } + + override suspend fun loadGameData(gameId: String): String { + val uuid = UUID.fromString(gameId.lowercase()) + return api.games.get(uuid).gameData + } + + override suspend fun loadPreviewData(gameId: String): String { + // Not implemented for this API + throw NotImplementedError("Outdated API") + } + + override suspend fun getFileMetaData(fileName: String): FileMetaData { + TODO("Not implemented for this API") + } + + override suspend fun deleteGameData(gameId: String) { + TODO("Not yet implemented") + } + + override suspend fun deletePreviewData(gameId: String) { + // Not implemented for this API + throw NotImplementedError("Outdated API") + } + + override suspend fun authenticate(userId: String, password: String): Boolean { + return api.auth.loginOnly(userId, password) + } + + override suspend fun setPassword(newPassword: String): Boolean { + api.accounts.setPassword("", newPassword) + TODO("Not yet implemented") + } + +} + +/** + * 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: Api? = null + var storage: ApiV2FileStorageEmulator? = null +} From e8f15c3b505327cf55fd434944a1710dd4b511a7 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 21 Mar 2023 04:41:07 +0100 Subject: [PATCH 025/152] Added some API helpers and a lot of glue to enable the APIv2 file handler --- .../logic/multiplayer/OnlineMultiplayer.kt | 24 ++++++++ .../com/unciv/logic/multiplayer/api/Api.kt | 61 +++++++++++++++++++ .../storage/OnlineMultiplayerFiles.kt | 14 +++++ 3 files changed, 99 insertions(+) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 17741d63edca3..f827c3136adcd 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -15,6 +15,8 @@ import com.unciv.logic.multiplayer.api.ApiStatusCode import com.unciv.logic.multiplayer.api.WebSocketMessage import com.unciv.logic.multiplayer.api.WebSocketMessageSerializer import com.unciv.logic.multiplayer.api.WebSocketMessageType +import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator +import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException @@ -80,9 +82,17 @@ class OnlineMultiplayer { if (username == "") { username = "MyValidUsername" } + ApiV2FileStorageWrapper.api = api + runBlocking { coroutineScope { Concurrency.runOnNonDaemonThreadPool { + if (!api.isServerCompatible()) { + logger.warning("Server API at ${UncivGame.Current.settings.multiplayer.server} is not APIv2-compatible") + return@runOnNonDaemonThreadPool + } + ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) + if (!api.auth.login(username, password)) { logger.warning("Login failed. Trying to create account for $username") try { @@ -490,6 +500,20 @@ class OnlineMultiplayer { * @return true if the server is alive, false otherwise */ fun checkServerStatus(): Boolean { + if (api.getCompatibilityCheck() == null) { + runBlocking { + api.isServerCompatible() + } + if (api.getCompatibilityCheck()!!) { + return true // if the compatibility check succeeded, the server is obviously running + } + } else if (api.getCompatibilityCheck()!!) { + runBlocking { + api.version() + } + return true // no exception means the server responded with the excepted answer type + } + var statusOk = false SimpleHttp.sendGetRequest("${UncivGame.Current.settings.multiplayer.server}/isalive") { success, result, _ -> statusOk = success diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/api/Api.kt index 70b740ebf2a6f..ab095e8e32de4 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/api/Api.kt @@ -61,6 +61,10 @@ class Api(val baseUrl: String) { } } + // Cache the result of the last server API compatibility check + private var compatibilityCheck: Boolean? = null + + // Helper that replaces library cookie storages to fix cookie serialization problems private val authCookieHelper = AuthCookieHelper() // Queue to keep references to all opened WebSocket handler jobs @@ -181,4 +185,61 @@ class Api(val baseUrl: String) { return client.get("/api/version").body() } + /** + * Determine if the remote server is compatible with this API implementation + * + * 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 + * responses are retrieved in both cases, the API is surely incompatible. + * + * This method won't raise any exception other than network-related. + * It should be used to verify server URLs to determine the further handling. + */ + suspend fun isServerCompatible(): Boolean { + val versionInfo = try { + val r = client.get("/api/version") + if (!r.status.isSuccess()) { + false + } else { + val b: VersionResponse = r.body() + b.version == 2 + } + } catch (e: ApiErrorResponse) { + logger.warning("Existing endpoint '/api/version' from '$baseUrl' returned: $e") + false + } catch (e: IllegalArgumentException) { + false + } + + if (!versionInfo) { + compatibilityCheck = false + return false + } + + val websocketSupport = try { + val r = client.get("/api/v2/ws") + if (r.status.isSuccess()) { + logger.severe("Websocket endpoint from '$baseUrl' accepted unauthenticated request") + false + } else { + val b: ApiErrorResponse = r.body() + b.statusCode == ApiStatusCode.Unauthenticated + } + } catch (e: IllegalArgumentException) { + false + } + + compatibilityCheck = websocketSupport + return websocketSupport + } + + /** + * Getter for [compatibilityCheck] + */ + fun getCompatibilityCheck(): Boolean? { + return compatibilityCheck + } + } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index ccb65828c4dd2..b46323a965942 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -5,6 +5,8 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles +import com.unciv.utils.Log +import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException /** @@ -34,6 +36,18 @@ class OnlineMultiplayerFiles( return if (identifier == Constants.dropboxMultiplayerServer) { DropBox } else { + if (ApiV2FileStorageWrapper.api != null) { + if (ApiV2FileStorageWrapper.api!!.getCompatibilityCheck() == null) { + runBlocking { + ApiV2FileStorageWrapper.api!!.isServerCompatible() + } + } + if (ApiV2FileStorageWrapper.api!!.getCompatibilityCheck()!!) { + return ApiV2FileStorageWrapper.storage!! + } + } else { + Log.error("API v2 file storage wrapper was null, it may be uninitialized due to race condition") + } UncivServerFileStorage.apply { serverUrl = identifier!! this.authHeader = authHeader From 303cc5652439a19f29683e607335498db0db9ad2 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Mar 2023 01:52:20 +0100 Subject: [PATCH 026/152] Renamed api package, added ApiVersion enum for easier auto-detection --- .../com/unciv/logic/multiplayer/ApiVersion.kt | 27 +++++++++++++ .../logic/multiplayer/OnlineMultiplayer.kt | 39 +++++++++++++++---- .../logic/multiplayer/ServerFeatureSet.kt | 10 +---- .../logic/multiplayer/{api => apiv2}/Api.kt | 2 +- .../{api => apiv2}/AuthCookieHelper.kt | 2 +- .../{api => apiv2}/EndpointImplementations.kt | 2 +- .../{api => apiv2}/JsonSerializers.kt | 2 +- .../{api => apiv2}/RequestStructs.kt | 2 +- .../{api => apiv2}/ResponseStructs.kt | 2 +- .../{api => apiv2}/WebSocketStructs.kt | 2 +- .../storage/ApiV2FileStorageEmulator.kt | 2 +- .../storage/OnlineMultiplayerFiles.kt | 2 +- 12 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/ApiVersion.kt rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/Api.kt (99%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/AuthCookieHelper.kt (95%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/EndpointImplementations.kt (99%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/JsonSerializers.kt (98%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/RequestStructs.kt (98%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/ResponseStructs.kt (99%) rename core/src/com/unciv/logic/multiplayer/{api => apiv2}/WebSocketStructs.kt (98%) diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt new file mode 100644 index 0000000000000..4b34d7f3064d5 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -0,0 +1,27 @@ +package com.unciv.logic.multiplayer + +/** + * Enum determining the version of a remote server API implementation + * + * [APIv0] is used to reference DropBox. It doesn't support any further features. + * [APIv1] is used for the UncivServer built-in server implementation as well as + * for servers implementing this interface. Examples thereof include: + * - https://github.com/Mape6/Unciv_server (Python) + * - https://gitlab.com/azzurite/unciv-server (NodeJS) + * - https://github.com/oynqr/rust_unciv_server (Rust) + * - https://github.com/touhidurrr/UncivServer.xyz (NodeJS) + * This servers may or may not support authentication. The [ServerFeatureSet] may + * be used to inspect their functionality. [APIv2] is used to reference + * the heavily extended REST-like HTTP API in combination with a WebSocket + * functionality for communication. Examples thereof include: + * - https://github.com/hopfenspace/runciv + * + * A particular server may implement multiple interfaces simultaneously. + * There's a server version check in the constructor of [OnlineMultiplayer] + * which handles API auto-detection. The precedence of various APIs is + * determined by that function: + * @see [OnlineMultiplayer.determineServerAPI] + */ +enum class ApiVersion { + APIv0, APIv1, APIv2 +} diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index f827c3136adcd..6bb1daffe32ba 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -9,12 +9,12 @@ import com.unciv.logic.GameInfoPreview import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.api.Api -import com.unciv.logic.multiplayer.api.ApiErrorResponse -import com.unciv.logic.multiplayer.api.ApiStatusCode -import com.unciv.logic.multiplayer.api.WebSocketMessage -import com.unciv.logic.multiplayer.api.WebSocketMessageSerializer -import com.unciv.logic.multiplayer.api.WebSocketMessageType +import com.unciv.logic.multiplayer.apiv2.Api +import com.unciv.logic.multiplayer.apiv2.ApiErrorResponse +import com.unciv.logic.multiplayer.apiv2.ApiStatusCode +import com.unciv.logic.multiplayer.apiv2.WebSocketMessage +import com.unciv.logic.multiplayer.apiv2.WebSocketMessageSerializer +import com.unciv.logic.multiplayer.apiv2.WebSocketMessageType import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached @@ -56,6 +56,10 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) class OnlineMultiplayer { private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) + // Updating the multiplayer server URL in the Api is out of scope, just drop this class and create a new one + private val baseUrl = UncivGame.Current.settings.multiplayer.server + private val api = Api(baseUrl) + private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() private var featureSet = ServerFeatureSet(apiVersion = 2) @@ -69,9 +73,15 @@ class OnlineMultiplayer { val games: Set get() = savedGames.values.toSet() val serverFeatureSet: ServerFeatureSet get() = featureSet - private val api = Api(UncivGame.Current.settings.multiplayer.server) + // Server API auto-detection happens in a coroutine triggered in the constructor + private lateinit var apiVersion: ApiVersion init { + // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized + Concurrency.run { + apiVersion = determineServerAPI() + } + logger.level = Level.FINER // for debugging var password = UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server] if (password == null) { @@ -114,6 +124,21 @@ class OnlineMultiplayer { private var sendChannel: SendChannel? = null + /** + * 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 (api.isServerCompatible()) { + ApiVersion.APIv2 + } else { + ApiVersion.APIv1 + } + } + /** * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) * diff --git a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt index fb5bcda5160d4..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. * @@ -10,13 +9,8 @@ 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. * - * The API developed to replace dropbox which uses preview files and - * polling via HTTP is referred to as API v1, [apiVersion] = 1. - * It may or may not support auth. The new WebSocket-based and extended - * API is referred to as API v2, [apiVersion] = 2. It's not directly a - * feature set, but rather another refined interface. + * @see [ApiVersion] */ data class ServerFeatureSet( - val authVersion: Int = 0, - val apiVersion: Int + val authVersion: Int = 0 ) diff --git a/core/src/com/unciv/logic/multiplayer/api/Api.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt similarity index 99% rename from core/src/com/unciv/logic/multiplayer/api/Api.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/Api.kt index ab095e8e32de4..f0f406f35f93c 100644 --- a/core/src/com/unciv/logic/multiplayer/api/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt @@ -2,7 +2,7 @@ * TODO: Comment this file */ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import com.unciv.UncivGame import com.unciv.utils.concurrency.Concurrency diff --git a/core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt similarity index 95% rename from core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt index 504424fc46b67..50c50cd5378e3 100644 --- a/core/src/com/unciv/logic/multiplayer/api/AuthCookieHelper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import io.ktor.client.request.* import io.ktor.http.* diff --git a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt similarity index 99% rename from core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 16ca44d5ae0d0..891d610c3d9e8 100644 --- a/core/src/com/unciv/logic/multiplayer/api/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -4,7 +4,7 @@ * Those classes are not meant to be used directly. Take a look at the Api class for common usage. */ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import java.util.logging.Logger import io.ktor.client.* diff --git a/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt similarity index 98% rename from core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index a5776ec973260..a52dde80e272a 100644 --- a/core/src/com/unciv/logic/multiplayer/api/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt similarity index 98% rename from core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt index db532fa3ac200..67623a9de4132 100644 --- a/core/src/com/unciv/logic/multiplayer/api/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -2,7 +2,7 @@ * Collection of API request structs in a single file for simplicity */ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName diff --git a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt similarity index 99% rename from core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 8f85bedc4e697..509b13434f343 100644 --- a/core/src/com/unciv/logic/multiplayer/api/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -2,7 +2,7 @@ * Collection of API response structs in a single file for simplicity */ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt similarity index 98% rename from core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 156279e0aa236..71aadba74d1e1 100644 --- a/core/src/com/unciv/logic/multiplayer/api/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer.api +package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 02d3fc3c6234a..19800f321db8a 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -1,6 +1,6 @@ package com.unciv.logic.multiplayer.storage -import com.unciv.logic.multiplayer.api.Api +import com.unciv.logic.multiplayer.apiv2.Api import java.util.* /** diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index b46323a965942..80fe843769203 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -15,7 +15,7 @@ import java.io.FileNotFoundException * * 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 */ From fb7f056db85bc1564b95e3aef4ef2c26cc221c72 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Mar 2023 01:54:27 +0100 Subject: [PATCH 027/152] Added the poll checker, changed the android notification worker poller --- .../unciv/app/MultiplayerTurnCheckWorker.kt | 4 +- .../logic/multiplayer/OldOnlineMultiplayer.kt | 4 +- .../logic/multiplayer/OnlineMultiplayer.kt | 46 +++++++++++++++---- .../com/unciv/logic/multiplayer/apiv2/Api.kt | 6 +++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt index 91e54d86c2299..cfc284a8c3dc9 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt @@ -19,6 +19,7 @@ import androidx.work.* 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.logic.GameInfo import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached @@ -299,7 +300,8 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame try { Log.d(LOG_TAG, "doWork download $gameId") - val gamePreview = OnlineMultiplayerFiles(fileStorage, mapOf("Authorization" to authHeader)).tryDownloadGamePreview(gameId) + val gamePreview = UncivGame.Current.onlineMultiplayer.multiplayerFiles.tryDownloadGamePreview(gameId) + //val gamePreview = OnlineMultiplayerFiles(fileStorage, mapOf("Authorization" to authHeader)).tryDownloadGamePreview(gameId) Log.d(LOG_TAG, "doWork download $gameId done") val currentTurnPlayer = gamePreview.getCivilization(gamePreview.currentPlayer) diff --git a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt index ebe6212527d3f..bf267264dd635 100644 --- a/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OldOnlineMultiplayer.kt @@ -46,7 +46,7 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) class OldOnlineMultiplayer { private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() - private var featureSet = ServerFeatureSet(apiVersion = 0) + private var featureSet = ServerFeatureSet() private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -344,7 +344,7 @@ class OldOnlineMultiplayer { json().fromJson(ServerFeatureSet::class.java, result) } catch (ex: Exception) { Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") - ServerFeatureSet(apiVersion = 1) + ServerFeatureSet() } } } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 6bb1daffe32ba..e0423bfcfb8ef 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -24,16 +24,19 @@ import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.Dispatcher import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug import io.ktor.client.plugins.websocket.* import io.ktor.websocket.* +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import java.time.Duration @@ -49,11 +52,14 @@ import java.util.logging.Level private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) /** - * Provides multiplayer functionality to the rest of the game using the v2 API. + * Provides multiplayer functionality to the rest of the game + * + * The other parts of the game should not use any other classes from the multiplayer package. * * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. */ class OnlineMultiplayer { + private val settings = UncivGame.Current.settings private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) // Updating the multiplayer server URL in the Api is out of scope, just drop this class and create a new one @@ -61,8 +67,8 @@ class OnlineMultiplayer { private val api = Api(baseUrl) private val files = UncivGame.Current.files - private val multiplayerFiles = OnlineMultiplayerFiles() - private var featureSet = ServerFeatureSet(apiVersion = 2) + val multiplayerFiles = OnlineMultiplayerFiles() + private lateinit var featureSet: ServerFeatureSet private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -80,6 +86,7 @@ class OnlineMultiplayer { // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized Concurrency.run { apiVersion = determineServerAPI() + startPollChecker() } logger.level = Level.FINER // for debugging @@ -142,7 +149,7 @@ class OnlineMultiplayer { /** * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) * - * Returns [Unit] if no exception is thrown, otherwise the exception is thrown + * Returns [Unit] if no exception is thrown */ internal suspend fun sendText(text: String): Unit { if (sendChannel == null) { @@ -306,7 +313,6 @@ class OnlineMultiplayer { } } - /** * Fires [MultiplayerGameAdded] * @@ -524,7 +530,7 @@ class OnlineMultiplayer { * Checks if the server is alive and sets the [serverFeatureSet] accordingly. * @return true if the server is alive, false otherwise */ - fun checkServerStatus(): Boolean { + suspend fun checkServerStatus(): Boolean { if (api.getCompatibilityCheck() == null) { runBlocking { api.isServerCompatible() @@ -547,7 +553,7 @@ class OnlineMultiplayer { json().fromJson(ServerFeatureSet::class.java, result) } catch (ex: Exception) { Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") - ServerFeatureSet(apiVersion = 0) + ServerFeatureSet() } } } @@ -601,6 +607,30 @@ class OnlineMultiplayer { return preview1.turns > preview2.turns } + /** + * Start a background runner that periodically checks for new game updates ([ApiVersion.APIv0] and [ApiVersion.APIv1] only) + */ + private fun startPollChecker() { + if (apiVersion in listOf(ApiVersion.APIv0, ApiVersion.APIv1)) { + logger.info("Starting poll service for remote games ...") + flow { + while (true) { + delay(500) + + val currentGame = getCurrentGame() + val multiplayerSettings = UncivGame.Current.settings.multiplayer + val preview = currentGame?.preview + if (currentGame != null && (OldOnlineMultiplayer.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)) + } + } + companion object { fun usesCustomServer() = UncivGame.Current.settings.multiplayer.server != Constants.dropboxMultiplayerServer fun usesDropbox() = !usesCustomServer() diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt index f0f406f35f93c..b5cc5e8e4742d 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt @@ -211,6 +211,9 @@ class Api(val baseUrl: String) { false } catch (e: IllegalArgumentException) { false + } catch (e: Throwable) { + logger.warning("Unexpected exception calling '$baseUrl': $e") + false } if (!versionInfo) { @@ -229,6 +232,9 @@ class Api(val baseUrl: String) { } } catch (e: IllegalArgumentException) { false + } catch (e: Throwable) { + logger.warning("Unexpected exception calling '$baseUrl': $e") + false } compatibilityCheck = websocketSupport From 6130bebc00fe3bf32d476d2137f05deb2794f5b4 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Mar 2023 04:49:07 +0100 Subject: [PATCH 028/152] Fixed a bunch of problems related to error handling --- .../logic/multiplayer/OnlineMultiplayer.kt | 7 +-- .../com/unciv/logic/multiplayer/apiv2/Api.kt | 17 +++++-- .../apiv2/EndpointImplementations.kt | 46 +++++++++---------- .../multiplayer/apiv2/ResponseStructs.kt | 10 +++- .../storage/ApiV2FileStorageEmulator.kt | 14 ++++-- 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index e0423bfcfb8ef..b30aab754fcf8 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -11,6 +11,7 @@ import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.apiv2.Api import com.unciv.logic.multiplayer.apiv2.ApiErrorResponse +import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.apiv2.ApiStatusCode import com.unciv.logic.multiplayer.apiv2.WebSocketMessage import com.unciv.logic.multiplayer.apiv2.WebSocketMessageSerializer @@ -80,7 +81,7 @@ class OnlineMultiplayer { val serverFeatureSet: ServerFeatureSet get() = featureSet // Server API auto-detection happens in a coroutine triggered in the constructor - private lateinit var apiVersion: ApiVersion + lateinit var apiVersion: ApiVersion init { // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized @@ -114,9 +115,9 @@ class OnlineMultiplayer { logger.warning("Login failed. Trying to create account for $username") try { api.accounts.register(username, username, password) - } catch (e: ApiErrorResponse) { + } catch (e: ApiException) { // TODO: Improve exception handling - if (e.statusCode == ApiStatusCode.InvalidUsername || e.statusCode == ApiStatusCode.InvalidDisplayName || e.statusCode == ApiStatusCode.InvalidPassword) { + if (e.error.statusCode == ApiStatusCode.InvalidUsername || e.error.statusCode == ApiStatusCode.InvalidDisplayName || e.error.statusCode == ApiStatusCode.InvalidPassword) { logger.warning("Invalid credentials: $e") } throw e diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt index b5cc5e8e4742d..a8d0777fdc2e1 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt @@ -5,6 +5,7 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException import com.unciv.utils.concurrency.Concurrency import io.ktor.client.* import io.ktor.client.call.* @@ -206,9 +207,6 @@ class Api(val baseUrl: String) { val b: VersionResponse = r.body() b.version == 2 } - } catch (e: ApiErrorResponse) { - logger.warning("Existing endpoint '/api/version' from '$baseUrl' returned: $e") - false } catch (e: IllegalArgumentException) { false } catch (e: Throwable) { @@ -249,3 +247,16 @@ class Api(val baseUrl: String) { } } + +/** + * APIv2 exception class that is compatible with [UncivShowableException] + */ +class ApiException(val error: ApiErrorResponse) : UncivShowableException(lookupErrorMessage(error.statusCode)) + +/** + * Convert an API status code to a string that can be translated and shown to users + */ +private fun lookupErrorMessage(statusCode: ApiStatusCode): String { + // TODO: Implement translations + return statusCode.name +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 891d610c3d9e8..d91b403df73c4 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -30,7 +30,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -45,7 +45,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -77,7 +77,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -105,7 +105,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -122,7 +122,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -147,7 +147,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -172,7 +172,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -227,7 +227,7 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth if (err.statusCode == ApiStatusCode.LoginFailed) { return false } - throw err + throw err.to() } } @@ -244,7 +244,7 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -268,7 +268,7 @@ class ChatApi(private val client: HttpClient, private val authCookieHelper: Auth return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -285,7 +285,7 @@ class ChatApi(private val client: HttpClient, private val authCookieHelper: Auth return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -308,7 +308,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return responseBody.friends } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -328,7 +328,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return responseBody.friendRequests } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -375,7 +375,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -391,7 +391,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -410,7 +410,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -439,7 +439,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -457,7 +457,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth return response.body() } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -496,7 +496,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth return responseBody.gameDataID } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -519,7 +519,7 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au return responseBody.invites } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -550,7 +550,7 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au return true } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -575,7 +575,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut return responseBody.lobbies } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } @@ -628,7 +628,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut return responseBody.lobbyID } else { val err: ApiErrorResponse = response.body() - throw err + throw err.to() } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 509b13434f343..a4f0f2b1cac2f 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -28,11 +28,17 @@ data class AccountResponse( */ @Serializable data class ApiErrorResponse( - override val message: String, + val message: String, @SerialName("status_code") @Serializable(with = ApiStatusCodeSerializer::class) val statusCode: ApiStatusCode -) : Throwable() +) { + + /** + * Convert the [ApiErrorResponse] to a [ApiException] for throwing and showing to users + */ + fun to() = ApiException(this) +} /** * API status code enum for mapping integer codes to names diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 19800f321db8a..d441c857e6b1a 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -1,6 +1,8 @@ package com.unciv.logic.multiplayer.storage +import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.apiv2.Api +import com.unciv.utils.Log import java.util.* /** @@ -15,7 +17,7 @@ class ApiV2FileStorageEmulator(private val api: Api): FileStorage { override suspend fun savePreviewData(gameId: String, data: String) { // Not implemented for this API - throw NotImplementedError("Outdated API") + Log.debug("Call to deprecated API 'savePreviewData'") } override suspend fun loadGameData(gameId: String): String { @@ -25,7 +27,9 @@ class ApiV2FileStorageEmulator(private val api: Api): FileStorage { override suspend fun loadPreviewData(gameId: String): String { // Not implemented for this API - throw NotImplementedError("Outdated 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 suspend fun getFileMetaData(fileName: String): FileMetaData { @@ -38,7 +42,8 @@ class ApiV2FileStorageEmulator(private val api: Api): FileStorage { override suspend fun deletePreviewData(gameId: String) { // Not implemented for this API - throw NotImplementedError("Outdated API") + Log.debug("Call to deprecated API 'deletedPreviewData'") + deleteGameData(gameId) } override suspend fun authenticate(userId: String, password: String): Boolean { @@ -47,7 +52,8 @@ class ApiV2FileStorageEmulator(private val api: Api): FileStorage { override suspend fun setPassword(newPassword: String): Boolean { api.accounts.setPassword("", newPassword) - TODO("Not yet implemented") + // TODO: Not yet implemented + return false } } From 2b8d4d655dfca6b33dabc3a2e345bb3b4a0e4ed9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Mar 2023 05:53:39 +0100 Subject: [PATCH 029/152] Added a lobby browser screen for the APIv2 --- .../logic/multiplayer/OnlineMultiplayer.kt | 2 +- .../multiplayerscreens/LobbyBrowserScreen.kt | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index b30aab754fcf8..2adb339d28c22 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -65,7 +65,7 @@ class OnlineMultiplayer { // Updating the multiplayer server URL in the Api is out of scope, just drop this class and create a new one private val baseUrl = UncivGame.Current.settings.multiplayer.server - private val api = Api(baseUrl) + val api = Api(baseUrl) private val files = UncivGame.Current.files val multiplayerFiles = OnlineMultiplayerFiles() 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..bb3fd2a87e3bc --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -0,0 +1,108 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.* +import com.unciv.UncivGame +import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.ui.popups.Popup +import com.unciv.ui.components.extensions.disable +import com.unciv.ui.components.extensions.enable +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread +import com.unciv.ui.components.AutoScrollPane as ScrollPane + +/** + * Screen that should list all open lobbies in the left side and details about a selected lobby in the right side + * + * The right side is not fully implemented yet. The right side button should join a lobby. + * A lobby might be password-protected (=private), in that case a pop-up should ask for the password. + */ +class LobbyBrowserScreen : PickerScreen() { + private val leftSideTable = Table() // use to list all lobbies in a scrollable way + private val rightSideTable = Table() // use for details about a lobby + + private val updateListButton = "Update".toTextButton() + + private val noLobbies = "Sorry, no open lobbies at the moment!" + + init { + setDefaultCloseAction() + + // This will be updated concurrently, but it shows some text to fix the layout + leftSideTable.add(noLobbies.toLabel()).row() + leftSideTable.add(updateListButton).padTop(30f).row() + + Concurrency.run("Update lobby list") { + val listOfOpenLobbies = UncivGame.Current.onlineMultiplayer.api.lobby.list() + launchOnGLThread { + refreshLobbyList(listOfOpenLobbies) + } + } + updateListButton.onClick { + Concurrency.run("Update lobby list") { + val listOfOpenLobbies = UncivGame.Current.onlineMultiplayer.api.lobby.list() + launchOnGLThread { + refreshLobbyList(listOfOpenLobbies) + } + } + } + + rightSideButton.setText("Join lobby") + rightSideButton.disable() + rightSideButton.onClick { + println("TODO") // TODO: Join lobby + } + + val tab = Table() + val helpButton = "Help".toTextButton() + helpButton.onClick { + val helpPopup = Popup(this) + helpPopup.addGoodSizedLabel("This should become a lobby browser.").row() // TODO + helpPopup.addCloseButton() + helpPopup.open() + } + tab.add(helpButton) + tab.x = (stage.width - helpButton.width) + tab.y = (stage.height - helpButton.height) + stage.addActor(tab) + + val mainTable = Table() + mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).height(stage.height * 2 / 3) + mainTable.add(rightSideTable) + topTable.add(mainTable).row() + scrollPane.setScrollingDisabled(false, true) + + rightSideTable.defaults().fillX() + rightSideTable.defaults().pad(20.0f) + rightSideTable.add("Lobby details".toLabel()).padBottom(10f).row() + } + + /** + * Refresh the list of lobbies (called after finishing the coroutine of the update button) + */ + private fun refreshLobbyList(lobbies: List) { + leftSideTable.clear() + if (lobbies.isEmpty()) { + leftSideTable.add(noLobbies.toLabel()).row() + leftSideTable.add(updateListButton).padTop(30f).row() + return + } + + lobbies.sortedBy { it.createdAt } + for (lobby in lobbies) { + val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() + btn.onClick { + println("Button on ${lobby.name}") // TODO: Select this lobby and handle extra details + rightSideButton.enable() + rightSideButton.onClick { + println("Join lobby ${lobby.name}") + } + } + leftSideTable.add(btn).row() + } + leftSideTable.add(updateListButton).padTop(30f).row() + } +} From ffbb66159b86e832c403f989ed46755c5ad112ce Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Mar 2023 06:20:25 +0100 Subject: [PATCH 030/152] Improved lobby handling with information in the right side table --- .../multiplayer/apiv2/ResponseStructs.kt | 2 ++ .../multiplayerscreens/LobbyBrowserScreen.kt | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index a4f0f2b1cac2f..dd380f882a7f2 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -281,6 +281,8 @@ data class LobbyResponse( val maxPlayers: Int, @SerialName("current_players") val currentPlayers: Int, + @SerialName("chat_room_id") + val chatRoomID: Long, @SerialName("created_at") @Serializable(with = InstantSerializer::class) val createdAt: Instant, diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index bb3fd2a87e3bc..c187d6c8e659e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -10,6 +10,7 @@ import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.ui.components.AutoScrollPane as ScrollPane @@ -27,6 +28,7 @@ class LobbyBrowserScreen : PickerScreen() { private val updateListButton = "Update".toTextButton() private val noLobbies = "Sorry, no open lobbies at the moment!" + private val noLobbySelected = "Select a lobby to show details" init { setDefaultCloseAction() @@ -50,11 +52,9 @@ class LobbyBrowserScreen : PickerScreen() { } } + // The functionality of joining a lobby will be added on-demand in [refreshLobbyList] rightSideButton.setText("Join lobby") rightSideButton.disable() - rightSideButton.onClick { - println("TODO") // TODO: Join lobby - } val tab = Table() val helpButton = "Help".toTextButton() @@ -77,7 +77,21 @@ class LobbyBrowserScreen : PickerScreen() { rightSideTable.defaults().fillX() rightSideTable.defaults().pad(20.0f) - rightSideTable.add("Lobby details".toLabel()).padBottom(10f).row() + rightSideTable.add(noLobbySelected.toLabel()).padBottom(10f).row() + } + + /** + * Update the right side table with details about a specific lobby + */ + private fun updateRightSideTable(selectedLobby: LobbyResponse) { + rightSideTable.clear() + // TODO: This texts need translation + rightSideTable.add("${selectedLobby.name} (${selectedLobby.currentPlayers}/${selectedLobby.maxPlayers} players)".toLabel()).padBottom(10f).row() + if (selectedLobby.hasPassword) { + rightSideTable.add("This lobby requires a password to join.".toLabel()).row() + } + rightSideTable.add("Created: ${selectedLobby.createdAt}.".toLabel()).row() + rightSideTable.add("Owner: ${selectedLobby.owner.displayName}".toLabel()).row() } /** @@ -92,14 +106,16 @@ class LobbyBrowserScreen : PickerScreen() { } lobbies.sortedBy { it.createdAt } - for (lobby in lobbies) { + for (lobby in lobbies.reversed()) { + // TODO: The button may be styled with icons and the texts may be translated val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() btn.onClick { - println("Button on ${lobby.name}") // TODO: Select this lobby and handle extra details - rightSideButton.enable() + updateRightSideTable(lobby) + // TODO: Un-selecting a lobby is not implemented yet rightSideButton.onClick { - println("Join lobby ${lobby.name}") + Log.debug("Joining lobby '${lobby.name}' (ID ${lobby.id})") } + rightSideButton.enable() } leftSideTable.add(btn).row() } From 0fc201c769dfb08478393040d1d170ca69c15620 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 23 Mar 2023 05:28:41 +0100 Subject: [PATCH 031/152] Added two new popup types, InfoPopup and RegisterLoginPopup --- core/src/com/unciv/ui/popups/InfoPopup.kt | 28 +++++ .../com/unciv/ui/popups/RegisterLoginPopup.kt | 106 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 core/src/com/unciv/ui/popups/InfoPopup.kt create mode 100644 core/src/com/unciv/ui/popups/RegisterLoginPopup.kt 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..89adfeeeee20c --- /dev/null +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -0,0 +1,28 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.utils.Align +import com.unciv.ui.components.extensions.toLabel + +/** Variant of [Popup] with one label and a cancel button + * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] + * @param text The text for the label + * @param action A lambda to execute when the button is pressed, after closing the popup + */ +open class InfoPopup( + stageToShowOn: Stage, + text: String, + action: (() -> Unit)? = null +) : Popup(stageToShowOn) { + + /** The [Label][com.badlogic.gdx.scenes.scene2d.ui.Label] created for parameter `text` for optional layout tweaking */ + private val label = text.toLabel() + + init { + label.setAlignment(Align.center) + add(label).colspan(2).row() + addCloseButton(action = action) + open() + } + +} 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..f9285ad081fae --- /dev/null +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -0,0 +1,106 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.unciv.UncivGame +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.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. + */ +class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) : Popup(stage) { + + private val multiplayer = UncivGame.Current.onlineMultiplayer + + init { + 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) { authSuccessful?.invoke(false) }.growX().padRight(8f) + } else { + val usernameField = UncivTextField.create("Username") + val passwordField = UncivTextField.create("Password") + + val loginButton = "Login existing".toTextButton() + val registerButton = "Register new".toTextButton() + + loginButton.onClick { + val popup = createPopup(force = true) + Concurrency.run { + try { + val success = UncivGame.Current.onlineMultiplayer.api.auth.login( + usernameField.text, passwordField.text + ) + launchOnGLThread { + popup.close() + close() + authSuccessful?.invoke(success) + } + } catch (e: ApiException) { + launchOnGLThread { + popup.close() + close() + // TODO: This popups doesn't work, the RegisterLoginPopup just closes + InfoPopup(stage, "Failed to login with existing account:\n${e.localizedMessage}") { + authSuccessful?.invoke(false) + } + } + } + } + } + + registerButton.onClick { + val popup = createPopup(force = true) + Concurrency.run { + try { + UncivGame.Current.onlineMultiplayer.api.accounts.register( + usernameField.text, usernameField.text, passwordField.text + ) + launchOnGLThread { + popup.close() + close() + InfoPopup(stage, "Successfully registered new account") { + authSuccessful?.invoke(true) + } + } + } catch (e: ApiException) { + launchOnGLThread { + popup.close() + close() + InfoPopup(stage, "Failed to register new account:\n${e.localizedMessage}") { + authSuccessful?.invoke(false) + } + } + } + } + } + + 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(2).row() + add(usernameField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() + add(passwordField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() + addCloseButton(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) + add(loginButton).growX().padLeft(8f) + add(registerButton).growX().padLeft(8f) + } + } + + private fun createPopup(msg: String? = null, force: Boolean = false): Popup { + val popup = Popup(stage) + popup.addGoodSizedLabel(msg?: "Working...") + popup.open(force) + return popup + } +} From 6bf91a85d973c69aa7416fb2cdf92d81c43aec15 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 23 Mar 2023 05:52:34 +0100 Subject: [PATCH 032/152] Improved OnlineMultiplayer with various new endpoints and concurrency fixes --- .../logic/multiplayer/OnlineMultiplayer.kt | 129 +++++++++++++----- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 2adb339d28c22..85a60b4dadcc1 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -52,6 +52,8 @@ import java.util.logging.Level */ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) +private val SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) + /** * Provides multiplayer functionality to the rest of the game * @@ -64,7 +66,7 @@ class OnlineMultiplayer { private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) // Updating the multiplayer server URL in the Api is out of scope, just drop this class and create a new one - private val baseUrl = UncivGame.Current.settings.multiplayer.server + val baseUrl = UncivGame.Current.settings.multiplayer.server val api = Api(baseUrl) private val files = UncivGame.Current.files @@ -80,6 +82,12 @@ class OnlineMultiplayer { val games: Set get() = savedGames.values.toSet() val serverFeatureSet: ServerFeatureSet get() = featureSet + private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() + + // Channel to send frames via WebSocket to the server, may be null + // for unsupported servers or unauthenticated/uninitialized clients + private var sendChannel: SendChannel? = null + // Server API auto-detection happens in a coroutine triggered in the constructor lateinit var apiVersion: ApiVersion @@ -87,21 +95,24 @@ class OnlineMultiplayer { // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized Concurrency.run { apiVersion = determineServerAPI() + checkServerStatus() startPollChecker() + isAliveAPIv1() // this is called for any API version since it sets the featureSet implicitly + if (apiVersion == ApiVersion.APIv2) { + // Trying to log in with the stored credentials at this step decreases latency later + if (hasAuthentication()) { + if (!api.auth.login(UncivGame.Current.settings.multiplayer.userName, UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server]!!)) { + logger.warning("Login failed using stored credentials") + } else { + lastSuccessfulAuthentication.set(Instant.now()) + api.websocket(::handleWS) + } + } + ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) + ApiV2FileStorageWrapper.api = api + } } - logger.level = Level.FINER // for debugging - var password = UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server] - if (password == null) { - password = "SomePasswordForThoseFolksWhoDoNotHaveAnyStrongPasswordYet!" // TODO: Obviously, replace this password - } - var username = UncivGame.Current.settings.multiplayer.userName - // TODO: Since the username is currently never used and therefore unset, update the username below and re-compile! - if (username == "") { - username = "MyValidUsername" - } - ApiV2FileStorageWrapper.api = api - runBlocking { coroutineScope { Concurrency.runOnNonDaemonThreadPool { @@ -110,27 +121,35 @@ class OnlineMultiplayer { return@runOnNonDaemonThreadPool } ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) - - if (!api.auth.login(username, password)) { - logger.warning("Login failed. Trying to create account for $username") - try { - api.accounts.register(username, username, password) - } catch (e: ApiException) { - // TODO: Improve exception handling - if (e.error.statusCode == ApiStatusCode.InvalidUsername || e.error.statusCode == ApiStatusCode.InvalidDisplayName || e.error.statusCode == ApiStatusCode.InvalidPassword) { - logger.warning("Invalid credentials: $e") - } - throw e - } - api.auth.login(username, password) - } - api.websocket(::handleWS) } } } } - private var sendChannel: SendChannel? = null + /** + * Determine whether the object has been initialized. + */ + fun isInitialized(): Boolean { + return (this::featureSet.isInitialized) && (this::apiVersion.isInitialized) + } + + /** + * Actively sleeping loop that awaits [isInitialized]. + */ + suspend fun awaitInitialized() { + while (!isInitialized()) { + delay(1) + } + } + + /** + * Determine if the user is authenticated by comparing timestamps (APIv2 only) + * + * This method is not reliable. The server might have configured another session timeout. + */ + fun isAuthenticated(): Boolean { + return (lastSuccessfulAuthentication.get() != null && (lastSuccessfulAuthentication.get()!! + SESSION_TIMEOUT) > Instant.now()) + } /** * Determine the server API version of the remote server @@ -533,19 +552,31 @@ class OnlineMultiplayer { */ suspend fun checkServerStatus(): Boolean { if (api.getCompatibilityCheck() == null) { - runBlocking { - api.isServerCompatible() - } + api.isServerCompatible() if (api.getCompatibilityCheck()!!) { return true // if the compatibility check succeeded, the server is obviously running } } else if (api.getCompatibilityCheck()!!) { - runBlocking { + try { api.version() + } catch (e: Throwable) { // the only expected exception type is network-related (e.g. timeout or unreachable) + Log.error("Suppressed error in 'checkServerStatus': $e") + return false } return true // no exception means the server responded with the excepted answer type } + 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. + */ + private fun isAliveAPIv1(): Boolean { var statusOk = false SimpleHttp.sendGetRequest("${UncivGame.Current.settings.multiplayer.server}/isalive") { success, result, _ -> statusOk = success @@ -583,6 +614,38 @@ class OnlineMultiplayer { 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) + } + + /** + * Refresh the currently used session by logging in with username and password stored in the game settings + * + * Any errors are suppressed. Differentiating invalid logins from network issues is therefore impossible. + */ + suspend fun refreshSession(): Boolean { + if (!hasAuthentication()) { + return false + } + val success = try { + api.auth.login( + UncivGame.Current.settings.multiplayer.userName, + UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server]!! + ) + } catch (e: Throwable) { + Log.error("Suppressed error in 'refreshSession': $e") + false + } + if (success) { + lastSuccessfulAuthentication.set(Instant.now()) + } + return success + } + /** * @return true if setting the password was successful, false otherwise. * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time From 0333bb36bdc6965949d355759ebb38c81ac981e6 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Mar 2023 03:21:17 +0100 Subject: [PATCH 033/152] Added a separate multiplayer screen using the APIv2 --- .../multiplayerscreens/MultiplayerScreen.kt | 10 +- .../multiplayerscreens/MultiplayerScreenV2.kt | 230 ++++++++++++++++++ 2 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt index 2c0d4b18f29f0..33d9be00ffe6e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt @@ -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/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt new file mode 100644 index 0000000000000..3da7dcb7d7825 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -0,0 +1,230 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.MultiplayerGameDeleted +import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.models.translations.tr +import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.ui.popups.Popup +import com.unciv.ui.components.extensions.disable +import com.unciv.ui.components.extensions.enable +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.InfoPopup +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.ui.components.AutoScrollPane as ScrollPane + +class MultiplayerScreenV2 : PickerScreen() { + private var selectedGame: Pair? = null // pair of game UUID to file handle + private var cachedGames: Map = mutableMapOf() + + private val leftSideTable = Table() // list friend requests, then online friends, then offline friends, see recreateLeftSideTable() + private val rightSideTable = Table() // this should be used for game details and buttons ™ // GameList(::selectGame) + + private val updateFriendListButton = "Update friend list".toTextButton() + private val requestFriendshipButton = "Request friendship".toTextButton() + private val updateGameListButton = "Update games".toTextButton() + // TODO: Align lobby button horizontally to the join game button, if possible + private val lobbyBrowserButton = "Browse open lobbies".toTextButton() + + private val events = EventBus.EventReceiver() + + init { + lobbyBrowserButton.onClick { + game.pushScreen(LobbyBrowserScreen()) + } + updateFriendListButton.onClick { + Concurrency.run { + reloadFriendList() + } + } + updateGameListButton.onClick { + Concurrency.run { + reloadGameList() + } + } + + setDefaultCloseAction() + recreateLeftSideTable() + + scrollPane.setScrollingDisabled(false, true) + topTable.add(createMainContent()).row() + + setupHelpButton() + + rightSideGroup.addActor(lobbyBrowserButton) + rightSideButton.setText("Join game".tr()) + rightSideButton.onClick { + if (selectedGame != null) { + MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!.second) + } + } + + events.receive(MultiplayerGameDeleted::class, { it.name == selectedGame?.first }) { + unselectGame() + } + + pickerPane.bottomTable.background = skinStrings.getUiBackground("MultiplayerScreen/BottomTable", tintColor = skinStrings.skinConfig.clearColor) + pickerPane.topTable.background = skinStrings.getUiBackground("MultiplayerScreen/TopTable", tintColor = skinStrings.skinConfig.clearColor) + + Concurrency.run { + reloadGameList() + } + } + + /** + * Reload the list of friends and friend requests from the server + */ + private suspend fun reloadFriendList() { + reloadFriendList() + } + + /** + * Reload the list of open games from the server, disabling the button if it's not available anymore + */ + private suspend fun reloadGameList() { + try { + // Map of game UUID to game overview + val newCachedGames = game.onlineMultiplayer.api.games.list().associateBy({ it.gameUUID.toString() }, { it }) + Concurrency.runOnGLThread { + if (selectedGame != null && !newCachedGames.containsKey(selectedGame!!.first)) { + unselectGame() + } + cachedGames = newCachedGames + recreateRightSideTable() + } + } catch (e: ApiException) { + Concurrency.runOnGLThread { + InfoPopup(stage, e.localizedMessage) + } + } + } + + /** + * Recreate a scrollable table of all friend requests and friends, sorted by their online status + */ + // TODO: This method is a stub at the moment and needs expansion + private fun recreateLeftSideTable() { + leftSideTable.clear() + leftSideTable.add("Friends".toLabel()).colspan(2).row() + + leftSideTable.defaults().uniformX() + leftSideTable.defaults().fillX() + leftSideTable.defaults().pad(10.0f) + + leftSideTable.add("label A".toLabel()).colspan(2).row() + leftSideTable.add("label B".toLabel()).colspan(2).row() + leftSideTable.add("label C".toLabel()).colspan(2).row() + leftSideTable.add("label D".toLabel()).colspan(2).row() + + for (y in 0..24) { + leftSideTable.add("Friend $y ${if (y % 2 == 0) "ONLINE" else "OFFLINE"}".toLabel()).row() + } + + leftSideTable.add(updateFriendListButton) + leftSideTable.add(requestFriendshipButton).row() + } + + /** + * Recreate a list of all games stored on the server + */ + private fun recreateRightSideTable() { + rightSideTable.clear() + rightSideTable.add("Games".toLabel()).row() + + rightSideTable.defaults().uniformX() + rightSideTable.defaults().fillX() + rightSideTable.defaults().pad(10.0f) + + for (y in 0..24) { + rightSideTable.add("Game $y ${if (y % 2 == 0) "ONLINE" else "OFFLINE"}".toLabel()).row() + } + + cachedGames.forEach { + val btn = "Game ${it.key}".toTextButton() + btn.onClick { + selectGame(it.key) + } + rightSideTable.add(btn).row() + } + + rightSideTable.add(updateGameListButton).row() + } + + private fun createMainContent(): Table { + val mainTable = Table() + mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).center() + mainTable.add(ScrollPane(rightSideTable).apply { setScrollingDisabled(true, false) }).center() + return mainTable + } + + private fun setupHelpButton() { + val tab = Table() + val helpButton = "Help".toTextButton() + helpButton.onClick { + val helpPopup = Popup(this) + helpPopup.addGoodSizedLabel("To create a multiplayer game, check the 'multiplayer' toggle in the New Game screen, and for each human player insert that player's user ID.") + .row() + helpPopup.addGoodSizedLabel("You can assign your own user ID there easily, and other players can copy their user IDs here and send them to you for you to include them in the game.") + .row() + helpPopup.addGoodSizedLabel("").row() + + helpPopup.addGoodSizedLabel("Once you've created your game, the Game ID gets automatically copied to your clipboard so you can send it to the other players.") + .row() + helpPopup.addGoodSizedLabel("Players can enter your game by copying the game ID to the clipboard, and clicking on the 'Add multiplayer game' button") + .row() + helpPopup.addGoodSizedLabel("").row() + + helpPopup.addGoodSizedLabel("The symbol of your nation will appear next to the game when it's your turn").row() + + helpPopup.addCloseButton() + helpPopup.open() + } + tab.add(helpButton) + tab.x = (stage.width - helpButton.width) + tab.y = (stage.height - helpButton.height) + + stage.addActor(tab) + } + + private fun unselectGame() { + selectedGame = null + rightSideButton.disable() + descriptionLabel.setText("") + } + + private fun selectGame(name: String) { + if (!cachedGames.containsKey(name)) { + Log.error("UI game cache key '$name' doesn't exist") + unselectGame() + return + } + + val storedMultiplayerGame = game.onlineMultiplayer.getGameByName(name) + if (storedMultiplayerGame == null) { + InfoPopup(stage, "The game $name was not downloaded yet.") // TODO + } else { + selectedGame = Pair(name, storedMultiplayerGame) + } + + rightSideButton.enable() + descriptionLabel.setText(describeGame(cachedGames[name]!!, storedMultiplayerGame)) + } + + private fun describeGame(cachedGame: GameOverviewResponse, storedMultiplayerGame: OnlineMultiplayerGame?): String { + var details = "More details are being loaded ..." + if (storedMultiplayerGame != null) { + val preview = storedMultiplayerGame.preview + if (preview != null) { + details = "Turns: ${preview.turns}\nDifficulty: ${preview.difficulty}\nCivilizations: ${preview.civilizations}" + } + } + return "${cachedGame.name}\nGame ID: ${cachedGame.gameUUID}\nData version: ${cachedGame.gameDataID}\nLast activity: ${cachedGame.lastActivity}\nLast player: ${cachedGame.lastPlayer.displayName}\n$details" + } +} + From 9b403ce3ff90782b5ced3f9fe686d59956340eb8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Mar 2023 04:13:31 +0100 Subject: [PATCH 034/152] Fixed some API incompatibilities, added getFriends() method --- .../logic/multiplayer/OnlineMultiplayer.kt | 16 ++++++++++ .../com/unciv/logic/multiplayer/apiv2/Api.kt | 2 +- .../apiv2/EndpointImplementations.kt | 30 ++++++++----------- .../multiplayer/apiv2/ResponseStructs.kt | 2 ++ 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 85a60b4dadcc1..d9003d437378d 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -9,10 +9,12 @@ import com.unciv.logic.GameInfoPreview import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.Api import com.unciv.logic.multiplayer.apiv2.ApiErrorResponse import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.apiv2.ApiStatusCode +import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse import com.unciv.logic.multiplayer.apiv2.WebSocketMessage import com.unciv.logic.multiplayer.apiv2.WebSocketMessageSerializer import com.unciv.logic.multiplayer.apiv2.WebSocketMessageType @@ -433,6 +435,20 @@ class OnlineMultiplayer { return true } + /** + * Load all friends and friend requests (split by incoming and outgoing) of the currently logged-in user + */ + suspend fun getFriends(): Triple, List, List> { + val (friends, requests) = api.friend.listAll() + // TODO: The user's UUID should be cached, when this class is extended to a game manager class + val myUUID = api.accounts.get().uuid + return Triple( + friends.map { it.to }, + requests.filter { it.to.uuid == myUUID }.map{ it.from }, + requests.filter { it.from.uuid == myUUID }.map { it.to } + ) + } + /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerFileNotFoundException if the file can't be found diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt index a8d0777fdc2e1..4f64ec58ead86 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt @@ -45,7 +45,7 @@ class Api(val baseUrl: String) { // Do not add install(HttpCookies) because it will break Cookie handling install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL + level = LogLevel.INFO } install(ContentNegotiation) { json(Json { diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index d91b403df73c4..e31e933954ef9 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -224,9 +224,6 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth return true } else { val err: ApiErrorResponse = response.body() - if (err.statusCode == ApiStatusCode.LoginFailed) { - return false - } throw err.to() } } @@ -297,21 +294,28 @@ class ChatApi(private val client: HttpClient, private val authCookieHelper: Auth class FriendApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { /** - * Retrieve a list of your established friendships + * Retrieve a pair of the list of your established friendships and the list of your open friendship requests (incoming and outgoing) */ - suspend fun list(): List { + suspend fun listAll(): Pair, List> { val response = client.get("/api/v2/friends") { authCookieHelper.add(this) } if (response.status.isSuccess()) { val responseBody: GetFriendResponse = response.body() - return responseBody.friends + return Pair(responseBody.friends, responseBody.friendRequests) } else { val err: ApiErrorResponse = response.body() throw err.to() } } + /** + * Retrieve a list of your established friendships + */ + suspend fun listFriends(): List { + return listAll().first + } + /** * Retrieve a list of your open friendship requests (incoming and outgoing) * @@ -320,16 +324,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au * In the other case, if your username is in ``to``, you have received a friend request. */ suspend fun listRequests(): List { - val response = client.get("/api/v2/friends") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: GetFriendResponse = response.body() - return responseBody.friendRequests - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + return listAll().second } /** @@ -436,7 +431,8 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth authCookieHelper.add(this) } if (response.status.isSuccess()) { - return response.body() + val body: GetGameOverviewResponse = response.body() + return body.games } else { val err: ApiErrorResponse = response.body() throw err.to() diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index dd380f882a7f2..6b08d70c19cde 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -122,6 +122,8 @@ data class CreateLobbyResponse( */ @Serializable data class FriendResponse( + @SerialName("chat_id") + val chatID: Long, val id: Long, val from: AccountResponse, val to: OnlineAccountResponse From 9973a116a2dd6c9654b10a07946b08681ea84f02 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Mar 2023 04:16:52 +0100 Subject: [PATCH 035/152] Added a helper to open the new multiplayer screen or the register popup --- .../screens/mainmenuscreen/MainMenuScreen.kt | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index 987afda1ea527..bc88f6625f73e 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 @@ -33,6 +34,7 @@ import com.unciv.ui.components.extensions.toLabel 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 @@ -44,6 +46,7 @@ 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.MultiplayerScreen +import com.unciv.ui.screens.multiplayerscreens.MultiplayerScreenV2 import com.unciv.ui.screens.newgamescreen.NewGameScreen import com.unciv.ui.screens.pickerscreens.ModManagementScreen import com.unciv.ui.screens.savescreens.LoadGameScreen @@ -51,6 +54,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.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.Job @@ -148,8 +152,23 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { column1.add(loadGameTable).row() } - val multiplayerTable = getMenuButton("Multiplayer", "OtherIcons/Multiplayer", 'm') - { game.pushScreen(MultiplayerScreen()) } + val multiplayerTable = getMenuButton("Multiplayer", "OtherIcons/Multiplayer", 'm') { + // 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", 'e') @@ -266,6 +285,48 @@ 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.stage) { + Log.debug("Register popup success state: %s", it) + if (it) { + game.pushScreen(MultiplayerScreenV2()) + } + }.open() + } else { + // Authentication is handled before the multiplayer screen is shown + val popup = Popup(stage) + popup.addGoodSizedLabel("Loading...") + if (!game.onlineMultiplayer.isAuthenticated()) { + popup.open() + Concurrency.run { + if (game.onlineMultiplayer.refreshSession()) { + Concurrency.runOnGLThread { + popup.close() + game.pushScreen(MultiplayerScreenV2()) + } + } else { + Concurrency.runOnGLThread { + popup.close() + ToastPopup("Please login again.", this@MainMenuScreen).isVisible = true + } + } + } + } else { + game.pushScreen(MultiplayerScreenV2()) + } + } + } else { + game.pushScreen(MultiplayerScreen()) + } + } + private fun resumeGame() { if (GUI.isWorldLoaded()) { val currentTileSet = GUI.getMap().currentTileSetStrings From c30b53d3eca2a4100b74ce96554ea2c17537039d Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Mar 2023 04:43:58 +0100 Subject: [PATCH 036/152] Implemented the friend screen --- .../multiplayerscreens/MultiplayerScreenV2.kt | 82 +++++++++++++++---- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index 3da7dcb7d7825..3e1959547dc62 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -4,27 +4,33 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.MultiplayerGameDeleted import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse import com.unciv.models.translations.tr -import com.unciv.ui.screens.pickerscreens.PickerScreen -import com.unciv.ui.popups.Popup import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton 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.utils.Log import com.unciv.utils.concurrency.Concurrency +import java.time.Instant +import java.util.* import com.unciv.ui.components.AutoScrollPane as ScrollPane class MultiplayerScreenV2 : PickerScreen() { private var selectedGame: Pair? = null // pair of game UUID to file handle private var cachedGames: Map = mutableMapOf() + private var cachedFriendResponse: Triple, List, List>? = null private val leftSideTable = Table() // list friend requests, then online friends, then offline friends, see recreateLeftSideTable() - private val rightSideTable = Table() // this should be used for game details and buttons ™ // GameList(::selectGame) + private val rightSideTable = Table() // list open games to re-join quickly private val updateFriendListButton = "Update friend list".toTextButton() private val requestFriendshipButton = "Request friendship".toTextButton() @@ -38,6 +44,9 @@ class MultiplayerScreenV2 : PickerScreen() { lobbyBrowserButton.onClick { game.pushScreen(LobbyBrowserScreen()) } + requestFriendshipButton.onClick { + ToastPopup("Friend requests are not implemented yet", stage) + } updateFriendListButton.onClick { Concurrency.run { reloadFriendList() @@ -61,6 +70,7 @@ class MultiplayerScreenV2 : PickerScreen() { rightSideButton.setText("Join game".tr()) rightSideButton.onClick { if (selectedGame != null) { + Log.debug("Loading multiplayer game ${selectedGame!!.first}") MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!.second) } } @@ -75,13 +85,25 @@ class MultiplayerScreenV2 : PickerScreen() { Concurrency.run { reloadGameList() } + Concurrency.run { + reloadFriendList() + } } /** * Reload the list of friends and friend requests from the server */ private suspend fun reloadFriendList() { - reloadFriendList() + try { + cachedFriendResponse = game.onlineMultiplayer.getFriends() + Concurrency.runOnGLThread { + recreateLeftSideTable() + } + } catch (e: ApiException) { + Concurrency.runOnGLThread { + InfoPopup(stage, e.localizedMessage) + } + } } /** @@ -111,19 +133,48 @@ class MultiplayerScreenV2 : PickerScreen() { // TODO: This method is a stub at the moment and needs expansion private fun recreateLeftSideTable() { leftSideTable.clear() - leftSideTable.add("Friends".toLabel()).colspan(2).row() - leftSideTable.defaults().uniformX() leftSideTable.defaults().fillX() leftSideTable.defaults().pad(10.0f) - leftSideTable.add("label A".toLabel()).colspan(2).row() - leftSideTable.add("label B".toLabel()).colspan(2).row() - leftSideTable.add("label C".toLabel()).colspan(2).row() - leftSideTable.add("label D".toLabel()).colspan(2).row() + if (cachedFriendResponse == null) { + leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).row() + } else { + var anything = false + if (cachedFriendResponse!!.second.isNotEmpty()) { + anything = true + leftSideTable.add("Friend requests".toLabel()).colspan(2).row() + cachedFriendResponse?.second!!.sortedBy { + it.displayName + }.forEach { // incoming friend requests + leftSideTable.add("${it.displayName} wants to be your friend".toLabel()) + val btn = "Options".toTextButton() + btn.onClick { + ToastPopup("Options are not implemented yet", stage) + } + leftSideTable.add(btn) + } + } - for (y in 0..24) { - leftSideTable.add("Friend $y ${if (y % 2 == 0) "ONLINE" else "OFFLINE"}".toLabel()).row() + if (cachedFriendResponse!!.first.isNotEmpty()) { + anything = true + cachedFriendResponse?.first!!.sortedBy { + it.displayName + }.sortedBy { + if (it.online) 0 else 1 + }.forEach {// alphabetically sorted friends + leftSideTable.add("${it.displayName} (${if (it.online) "online" else "offline"})".toLabel()) + val btn = "Options".toTextButton() + btn.onClick { + ToastPopup("Options are not implemented yet", stage) + } + leftSideTable.add(btn) + } + } + + if (!anything) { + leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).row() + } } leftSideTable.add(updateFriendListButton) @@ -133,6 +184,7 @@ class MultiplayerScreenV2 : PickerScreen() { /** * Recreate a list of all games stored on the server */ + // TODO: This method is a stub at the moment and needs expansion private fun recreateRightSideTable() { rightSideTable.clear() rightSideTable.add("Games".toLabel()).row() @@ -141,12 +193,8 @@ class MultiplayerScreenV2 : PickerScreen() { rightSideTable.defaults().fillX() rightSideTable.defaults().pad(10.0f) - for (y in 0..24) { - rightSideTable.add("Game $y ${if (y % 2 == 0) "ONLINE" else "OFFLINE"}".toLabel()).row() - } - cachedGames.forEach { - val btn = "Game ${it.key}".toTextButton() + val btn = "Game '${it.value.name}'".toTextButton() btn.onClick { selectGame(it.key) } From 459c81c6dd0dcd5d916b1b56af2231541ebdfd55 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Mar 2023 05:09:19 +0100 Subject: [PATCH 037/152] Moved I/O dependency to desktop only, fixed InfoPopus in register popup --- build.gradle.kts | 5 +++-- core/src/com/unciv/ui/popups/RegisterLoginPopup.kt | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 959675d19048a..e10396384f8d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,6 +80,9 @@ project(":desktop") { // Logging for easier desktop development "implementation"("ch.qos.logback:logback-classic:1.2.5") + + // Logging support for I/O operations + "implementation"("io.ktor:ktor-client-logging:$ktorVersion") } } @@ -146,8 +149,6 @@ project(":core") { "implementation"("io.ktor:ktor-client-websockets:$ktorVersion") // Gzip transport encoding "implementation"("io.ktor:ktor-client-encoding:$ktorVersion") - // Logging support - "implementation"("io.ktor:ktor-client-logging:$ktorVersion") // Content negotiation "implementation"("io.ktor:ktor-client-content-negotiation:$ktorVersion") // JSON serialization and de-serialization diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index f9285ad081fae..e371e2b07958d 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -5,6 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.unciv.UncivGame import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.models.translations.tr import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toTextButton @@ -53,8 +54,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> launchOnGLThread { popup.close() close() - // TODO: This popups doesn't work, the RegisterLoginPopup just closes - InfoPopup(stage, "Failed to login with existing account:\n${e.localizedMessage}") { + InfoPopup(stage, "Failed to login with existing account".tr() + ":\n${e.localizedMessage}") { authSuccessful?.invoke(false) } } @@ -69,10 +69,13 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> UncivGame.Current.onlineMultiplayer.api.accounts.register( usernameField.text, usernameField.text, passwordField.text ) + UncivGame.Current.onlineMultiplayer.api.auth.login( + usernameField.text, passwordField.text + ) launchOnGLThread { popup.close() close() - InfoPopup(stage, "Successfully registered new account") { + InfoPopup(stage, "Successfully registered new account".tr()) { authSuccessful?.invoke(true) } } @@ -80,7 +83,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> launchOnGLThread { popup.close() close() - InfoPopup(stage, "Failed to register new account:\n${e.localizedMessage}") { + InfoPopup(stage, "Failed to register new account".tr() + ":\n${e.localizedMessage}") { authSuccessful?.invoke(false) } } From 1473f04875c6f1a497c629050ac190cff74a5b8e Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 25 Mar 2023 05:01:23 +0100 Subject: [PATCH 038/152] Rename the Api class to ApiV2Wrapper, added a chat room screen --- build.gradle.kts | 5 +- .../logic/multiplayer/OnlineMultiplayer.kt | 7 +- .../apiv2/{Api.kt => ApiV2Wrapper.kt} | 5 +- .../storage/ApiV2FileStorageEmulator.kt | 6 +- .../multiplayerscreens/ChatRoomScreen.kt | 101 ++++++++++++++++++ .../multiplayerscreens/MultiplayerScreenV2.kt | 3 + 6 files changed, 118 insertions(+), 9 deletions(-) rename core/src/com/unciv/logic/multiplayer/apiv2/{Api.kt => ApiV2Wrapper.kt} (97%) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt diff --git a/build.gradle.kts b/build.gradle.kts index e10396384f8d9..6e503461f5b20 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,9 +80,6 @@ project(":desktop") { // Logging for easier desktop development "implementation"("ch.qos.logback:logback-classic:1.2.5") - - // Logging support for I/O operations - "implementation"("io.ktor:ktor-client-logging:$ktorVersion") } } @@ -153,6 +150,8 @@ project(":core") { "implementation"("io.ktor:ktor-client-content-negotiation:$ktorVersion") // JSON serialization and de-serialization "implementation"("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + // Logging support for I/O operations + "implementation"("io.ktor:ktor-client-logging:$ktorVersion") } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index d9003d437378d..694e9b2778627 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -10,7 +10,7 @@ import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.Api +import com.unciv.logic.multiplayer.apiv2.ApiV2Wrapper import com.unciv.logic.multiplayer.apiv2.ApiErrorResponse import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.apiv2.ApiStatusCode @@ -69,7 +69,7 @@ class OnlineMultiplayer { // 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 - val api = Api(baseUrl) + val api = ApiV2Wrapper(baseUrl) private val files = UncivGame.Current.files val multiplayerFiles = OnlineMultiplayerFiles() @@ -93,6 +93,8 @@ class OnlineMultiplayer { // Server API auto-detection happens in a coroutine triggered in the constructor lateinit var apiVersion: ApiVersion + lateinit var user: AccountResponse + init { // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized Concurrency.run { @@ -108,6 +110,7 @@ class OnlineMultiplayer { } else { lastSuccessfulAuthentication.set(Instant.now()) api.websocket(::handleWS) + user = api.accounts.get() } } ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt similarity index 97% rename from core/src/com/unciv/logic/multiplayer/apiv2/Api.kt rename to core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 4f64ec58ead86..4e472f6cfc2a2 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Api.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -36,8 +36,11 @@ internal const val LOBBY_MAX_PLAYERS = 34 * WebSocket connection, but rather only the pure HTTP-based API. * Almost any method may throw certain OS or network errors as well as the * [ApiErrorResponse] for invalid requests (4xx) or server failures (5xx). + * + * This class should be considered implementation detail, since it just + * abstracts HTTP endpoint names from other modules in this package. */ -class Api(val baseUrl: String) { +class ApiV2Wrapper(private val baseUrl: String) { private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) // HTTP client to handle the server connections, logging, content parsing and cookies diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index d441c857e6b1a..8e6a64026d8b4 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -1,14 +1,14 @@ package com.unciv.logic.multiplayer.storage import com.unciv.logic.files.UncivFiles -import com.unciv.logic.multiplayer.apiv2.Api +import com.unciv.logic.multiplayer.apiv2.ApiV2Wrapper import com.unciv.utils.Log import java.util.* /** * Transition helper that emulates file storage behavior using the API v2 */ -class ApiV2FileStorageEmulator(private val api: Api): FileStorage { +class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { override suspend fun saveGameData(gameId: String, data: String) { val uuid = UUID.fromString(gameId.lowercase()) @@ -66,6 +66,6 @@ class ApiV2FileStorageEmulator(private val api: Api): FileStorage { * This object keeps references which are populated during program startup at runtime. */ object ApiV2FileStorageWrapper { - var api: Api? = null + var api: ApiV2Wrapper? = null var storage: ApiV2FileStorageEmulator? = null } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt new file mode 100644 index 0000000000000..a5b25daf33cee --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -0,0 +1,101 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.ChatMessage +import com.unciv.models.translations.tr +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.AskTextPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay +import java.time.Instant +import java.util.* +import com.unciv.ui.components.AutoScrollPane as ScrollPane + +class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { + + private val messageTable = Table() + + private val events = EventBus.EventReceiver() // listen for incoming chat messages in the current chat + + init { + setDefaultCloseAction() + + scrollPane.setScrollingDisabled(false, true) + topTable.add(ScrollPane(messageTable).apply { setScrollingDisabled(true, false) }).center() + + setupHelpButton() + recreateMessageTable(listOf()) + + rightSideButton.setText("New message".tr()) + rightSideButton.onClick { + AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { + Log.debug("Sending '$it' to room $chatRoomID") // TODO: Implement this + }) + } + + Concurrency.run { + updateMessages() + } + } + + /** + * Update the messages of the chat room by querying the server, then recreate the message table + */ + private suspend fun updateMessages() { + // TODO: Implement querying the server + Concurrency.runOnGLThread { + recreateMessageTable(listOf()) + } + } + + /** + * Recreate the central table of all available messages + */ + private fun recreateMessageTable(messages: List) { + messageTable.clear() + if (messages.isEmpty()) { + messageTable.add("No messages here yet".toLabel()).center().row() + return + } + + messageTable.add("Messages".toLabel()).center().row() + messageTable.defaults().uniformX() + messageTable.defaults().fillX() + messageTable.defaults().pad(10.0f) + + messages.forEach { + val text = "${it.sender.displayName} (${it.createdAt}):\n${it.message}" + if (it.sender.uuid == game.onlineMultiplayer.user.uuid) { + messageTable.add(text.toLabel()).right().row() + } else { + messageTable.add(text.toLabel()).left().row() + } + } + } + + /** + * Construct a help button + */ + private fun setupHelpButton() { + val tab = Table() + val helpButton = "Help".toTextButton() + helpButton.onClick { + val helpPopup = Popup(this) + helpPopup.addGoodSizedLabel("It would be nice if this screen was documented.").row() + helpPopup.addCloseButton() + helpPopup.open() + } + tab.add(helpButton) + tab.x = (stage.width - helpButton.width) + tab.y = (stage.height - helpButton.height) + stage.addActor(tab) + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index 3e1959547dc62..f35d6c11f78e4 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -211,6 +211,9 @@ class MultiplayerScreenV2 : PickerScreen() { return mainTable } + /** + * Construct a help button + */ private fun setupHelpButton() { val tab = Table() val helpButton = "Help".toTextButton() From c51bc17de27b14446ea00d981652cf26f9d8d2da Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 01:01:54 +0100 Subject: [PATCH 039/152] Improved the chat room screen --- .../multiplayerscreens/ChatRoomScreen.kt | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index a5b25daf33cee..812f6ad842ae5 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -1,21 +1,26 @@ package com.unciv.ui.screens.multiplayerscreens +import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ChatMessage import com.unciv.models.translations.tr +import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.popups.AskTextPopup import com.unciv.ui.popups.Popup +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.delay import java.time.Instant import java.util.* +import kotlin.collections.ArrayList +import kotlin.math.max import com.unciv.ui.components.AutoScrollPane as ScrollPane class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { @@ -30,17 +35,21 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { scrollPane.setScrollingDisabled(false, true) topTable.add(ScrollPane(messageTable).apply { setScrollingDisabled(true, false) }).center() - setupHelpButton() + setupTopButtons() recreateMessageTable(listOf()) rightSideButton.setText("New message".tr()) + rightSideButton.enable() rightSideButton.onClick { - AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { + val ask = AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { Log.debug("Sending '$it' to room $chatRoomID") // TODO: Implement this }) + ask.open() } Concurrency.run { + // TODO: Remove this workaround fix by implementing a serious API handler + game.onlineMultiplayer.user = game.onlineMultiplayer.api.accounts.get() updateMessages() } } @@ -65,26 +74,53 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { return } + messageTable.add().minWidth(0.96f * stage.width).expandX().fillX().row() // empty cell to make the table span the whole X screen messageTable.add("Messages".toLabel()).center().row() messageTable.defaults().uniformX() - messageTable.defaults().fillX() messageTable.defaults().pad(10.0f) messages.forEach { - val text = "${it.sender.displayName} (${it.createdAt}):\n${it.message}" + // This block splits the message by spaces to make it fit on the screen. + // It might be inefficient but at least it works reliably for any amount of text. + val msgList = ArrayList() + var currentLine = "" + for (word in it.message.split(" ")) { + currentLine = if (Label("$currentLine $word", skin).width < 0.7f * stage.width) { + if (currentLine == "") { + word + } else { + "$currentLine $word" + } + } else { + msgList.add(currentLine) + word + } + } + msgList.add(currentLine) + + val label = "${it.sender.displayName} [${it.sender.username}] (${it.createdAt}):\n${msgList.joinToString("\n")}".toLabel() + // TODO: Maybe add a border around each label to differentiate between messages visually clearer if (it.sender.uuid == game.onlineMultiplayer.user.uuid) { - messageTable.add(text.toLabel()).right().row() + messageTable.add(label).maxWidth(label.width).prefWidth(label.width).right().row() } else { - messageTable.add(text.toLabel()).left().row() + messageTable.add(label).maxWidth(label.width).prefWidth(label.width).left().row() } } } /** - * Construct a help button + * Construct two buttons for chat members and help */ - private fun setupHelpButton() { + private fun setupTopButtons() { + val padding = 8.0f val tab = Table() + val membersButton = "Chat members".toTextButton() + membersButton.onClick { + ToastPopup("Chat member list is not implemented yet.", stage) // TODO + } + membersButton.padRight(padding) + tab.add(membersButton) + val helpButton = "Help".toTextButton() helpButton.onClick { val helpPopup = Popup(this) @@ -93,7 +129,7 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { helpPopup.open() } tab.add(helpButton) - tab.x = (stage.width - helpButton.width) + tab.x = (stage.width - helpButton.width - membersButton.width) tab.y = (stage.height - helpButton.height) stage.addActor(tab) } From db9a81a3858f7b77cb87d402131abd8ccba86625 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 01:07:46 +0100 Subject: [PATCH 040/152] Added a button in the WorldScreenTopBar that should open the chat room --- .../screens/worldscreen/WorldScreenTopBar.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 486803df72f12..27053e6803e9d 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -9,6 +9,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.logic.civilization.Civilization +import com.unciv.logic.multiplayer.ApiVersion import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.tile.TileResource import com.unciv.models.ruleset.unique.UniqueType @@ -27,6 +28,7 @@ import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toStringSigned import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.popups import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories @@ -37,6 +39,7 @@ import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen import com.unciv.ui.screens.pickerscreens.TechPickerScreen import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup +import com.unciv.utils.Log import kotlin.math.ceil import kotlin.math.max import kotlin.math.roundToInt @@ -68,6 +71,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { private val resourcesWrapper = Table() private val resourceTable = getResourceTable() private val selectedCivTable = SelectedCivilizationTable(worldScreen) + private val openGameChatButton = OpenGameChatTable(worldScreen) private val overviewButton = OverviewAndSupplyTable(worldScreen) private val leftFillerCell: Cell private val rightFillerCell: Cell @@ -169,6 +173,21 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { return resourceTable } + private class OpenGameChatTable(worldScreen: WorldScreen) : Table(BaseScreen.skin) { + init { + // The chat feature will only be enabled if the multiplayer server has support for it + if (worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + val openChatButton = "Chat".toTextButton() + openChatButton.onClick { + // TODO: Implement this + ToastPopup("In-game chat has not been implemented yet.", worldScreen.stage) + } + add(openChatButton).pad(10f) + pack() + } + } + } + private class OverviewAndSupplyTable(worldScreen: WorldScreen) : Table(BaseScreen.skin) { val unitSupplyImage = ImageGetter.getImage("OtherIcons/ExclamationMark") .apply { color = Color.FIREBRICK } @@ -248,6 +267,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { val statsWidth = statsTable.minWidth val resourceWidth = resourceTable.minWidth + val chatWidth = openGameChatButton.minWidth val overviewWidth = overviewButton.minWidth val selectedCivWidth = selectedCivTable.minWidth val leftRightNeeded = max(selectedCivWidth, overviewWidth) @@ -276,7 +296,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { } val leftFillerWidth = if (fillerHeight > 0f) selectedCivWidth else 0f - val rightFillerWidth = if (fillerHeight > 0f) overviewWidth else 0f + val rightFillerWidth = if (fillerHeight > 0f) (overviewWidth + chatWidth) else 0f if (leftFillerCell.minHeight != fillerHeight || leftFillerCell.minWidth != leftFillerWidth || rightFillerCell.minWidth != rightFillerWidth) { @@ -291,8 +311,10 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { setPosition(0f, stage.height, Align.topLeft) selectedCivTable.setPosition(1f, buttonY, Align.left) + openGameChatButton.setPosition(stage.width - overviewButton.width - 5f, buttonY, Align.right) overviewButton.setPosition(stage.width, buttonY, Align.right) addActor(selectedCivTable) // needs to be after pack + addActor(openGameChatButton) addActor(overviewButton) } From aec990488a159d595983555eb52e6e0d3f00f8f3 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 01:08:20 +0100 Subject: [PATCH 041/152] Fixed RegisterLoginPopup colspan, added key shortcuts --- core/src/com/unciv/ui/popups/RegisterLoginPopup.kt | 11 +++++++---- .../screens/multiplayerscreens/MultiplayerScreenV2.kt | 1 - .../unciv/ui/screens/worldscreen/WorldScreenTopBar.kt | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index e371e2b07958d..e95862beab42f 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -6,7 +6,9 @@ import com.unciv.UncivGame import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.models.translations.tr +import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.screens.basescreen.BaseScreen @@ -36,6 +38,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> val passwordField = UncivTextField.create("Password") val loginButton = "Login existing".toTextButton() + loginButton.keyShortcuts.add(KeyCharAndCode.RETURN) val registerButton = "Register new".toTextButton() loginButton.onClick { @@ -91,12 +94,12 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> } } - 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(2).row() - add(usernameField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() - add(passwordField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() + 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(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) - add(loginButton).growX().padLeft(8f) add(registerButton).growX().padLeft(8f) + add(loginButton).growX().padLeft(8f) } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index f35d6c11f78e4..bfc41c11647b6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -278,4 +278,3 @@ class MultiplayerScreenV2 : PickerScreen() { return "${cachedGame.name}\nGame ID: ${cachedGame.gameUUID}\nData version: ${cachedGame.gameDataID}\nLast activity: ${cachedGame.lastActivity}\nLast player: ${cachedGame.lastPlayer.displayName}\n$details" } } - diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 27053e6803e9d..f4811f6a77d12 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -39,7 +39,6 @@ import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen import com.unciv.ui.screens.pickerscreens.TechPickerScreen import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup -import com.unciv.utils.Log import kotlin.math.ceil import kotlin.math.max import kotlin.math.roundToInt From 817bb6ff80318d17ebb06655dabf335944233bc6 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 04:30:58 +0200 Subject: [PATCH 042/152] Replaced logging dependency, renamed the endpoint implementations --- build.gradle.kts | 2 -- .../logic/multiplayer/OnlineMultiplayer.kt | 5 ++-- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 25 +++++++++++-------- .../storage/ApiV2FileStorageEmulator.kt | 6 ++--- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 2 +- .../multiplayerscreens/ChatRoomScreen.kt | 2 +- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6e503461f5b20..2ef97c0902c90 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -150,8 +150,6 @@ project(":core") { "implementation"("io.ktor:ktor-client-content-negotiation:$ktorVersion") // JSON serialization and de-serialization "implementation"("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - // Logging support for I/O operations - "implementation"("io.ktor:ktor-client-logging:$ktorVersion") } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 694e9b2778627..26649b35b8d10 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -46,7 +46,6 @@ import java.time.Duration import java.time.Instant import java.util.* import java.util.concurrent.atomic.AtomicReference -import java.util.logging.Level /** * 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 @@ -110,7 +109,7 @@ class OnlineMultiplayer { } else { lastSuccessfulAuthentication.set(Instant.now()) api.websocket(::handleWS) - user = api.accounts.get() + user = api.account.get() } } ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) @@ -444,7 +443,7 @@ class OnlineMultiplayer { suspend fun getFriends(): Triple, List, List> { val (friends, requests) = api.friend.listAll() // TODO: The user's UUID should be cached, when this class is extended to a game manager class - val myUUID = api.accounts.get().uuid + val myUUID = api.account.get().uuid return Triple( friends.map { it.to }, requests.filter { it.to.uuid == myUUID }.map{ it.from }, diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 4e472f6cfc2a2..96e4dd0dcf96e 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -6,13 +6,13 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.UncivGame import com.unciv.logic.UncivShowableException +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* @@ -46,10 +46,6 @@ class ApiV2Wrapper(private val baseUrl: String) { // HTTP client to handle the server connections, logging, content parsing and cookies private val client = HttpClient(CIO) { // Do not add install(HttpCookies) because it will break Cookie handling - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.INFO - } install(ContentNegotiation) { json(Json { prettyPrint = true @@ -57,7 +53,7 @@ class ApiV2Wrapper(private val baseUrl: String) { }) } install(WebSockets) { - pingInterval = 15_000 + pingInterval = 90_000 contentConverter = KotlinxWebsocketSerializationConverter(Json) } defaultRequest { @@ -77,14 +73,23 @@ class ApiV2Wrapper(private val baseUrl: String) { init { client.plugin(HttpSend).intercept { request -> request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") - execute(request) + val clientCall = execute(request) + Log.debug( + "'%s %s%s': %s (%d ms)", + request.method.value, + if (baseUrl.endsWith("/")) baseUrl.subSequence(0, baseUrl.length - 2) else baseUrl, + request.url.encodedPath, + clientCall.response.status, + clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp + ) + clientCall } } /** * API for account management */ - val accounts = AccountsApi(client, authCookieHelper, logger) + val account = AccountsApi(client, authCookieHelper, logger) /** * API for authentication management @@ -104,12 +109,12 @@ class ApiV2Wrapper(private val baseUrl: String) { /** * API for game management */ - val games = GameApi(client, authCookieHelper, logger) + val game = GameApi(client, authCookieHelper, logger) /** * API for invite management */ - val invites = InviteApi(client, authCookieHelper, logger) + val invite = InviteApi(client, authCookieHelper, logger) /** * API for lobby management diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 8e6a64026d8b4..3ee00c0cfa9e7 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -12,7 +12,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { override suspend fun saveGameData(gameId: String, data: String) { val uuid = UUID.fromString(gameId.lowercase()) - api.games.upload(uuid, data) + api.game.upload(uuid, data) } override suspend fun savePreviewData(gameId: String, data: String) { @@ -22,7 +22,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { override suspend fun loadGameData(gameId: String): String { val uuid = UUID.fromString(gameId.lowercase()) - return api.games.get(uuid).gameData + return api.game.get(uuid).gameData } override suspend fun loadPreviewData(gameId: String): String { @@ -51,7 +51,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { } override suspend fun setPassword(newPassword: String): Boolean { - api.accounts.setPassword("", newPassword) + api.account.setPassword("", newPassword) // TODO: Not yet implemented return false } diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index e95862beab42f..788a99e2cc430 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -69,7 +69,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> val popup = createPopup(force = true) Concurrency.run { try { - UncivGame.Current.onlineMultiplayer.api.accounts.register( + UncivGame.Current.onlineMultiplayer.api.account.register( usernameField.text, usernameField.text, passwordField.text ) UncivGame.Current.onlineMultiplayer.api.auth.login( diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 812f6ad842ae5..1a4338a07fcd7 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -49,7 +49,7 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { Concurrency.run { // TODO: Remove this workaround fix by implementing a serious API handler - game.onlineMultiplayer.user = game.onlineMultiplayer.api.accounts.get() + game.onlineMultiplayer.user = game.onlineMultiplayer.api.account.get() updateMessages() } } From af0ee11b5c52023abcd6669df675466a244456c6 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 06:39:10 +0200 Subject: [PATCH 043/152] Dropped the extra logger to remove dependencies, added the APIv2 class --- build.gradle.kts | 3 - .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 312 ++++++++++++++++++ .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 39 ++- .../apiv2/EndpointImplementations.kt | 71 ++-- 4 files changed, 350 insertions(+), 75 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2ef97c0902c90..fe116a108b852 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,9 +77,6 @@ project(":desktop") { "implementation"("net.java.dev.jna:jna:5.11.0") "implementation"("net.java.dev.jna:jna-platform:5.11.0") - - // Logging for easier desktop development - "implementation"("ch.qos.logback:logback-classic:1.2.5") } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt new file mode 100644 index 0000000000000..bd7a706672fa9 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -0,0 +1,312 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.MultiplayerGameAdded +import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper +import com.unciv.utils.Log +import com.unciv.utils.concurrency.withGLContext +import io.ktor.client.call.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.websocket.* +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.SendChannel +import kotlinx.serialization.json.Json +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +/** Default session timeout expected from multiplayer servers (unreliable) */ +private val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) + +/** + * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] + */ +class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { + + /** Cache the result of the last server API compatibility check */ + private var compatibilityCheck: Boolean? = null + + /** Channel to send frames via WebSocket to the server, may be null + * for unsupported servers or unauthenticated/uninitialized clients */ + private var sendChannel: SendChannel? = null + + /** Info whether this class is fully initialized and ready to use */ + private var isInitialized = false + + /** Credentials used during the last successful login */ + private var lastSuccessfulCredentials: Pair? = null + + /** Timestamp of the last successful login */ + private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() + + /** User identification on the server */ + private var user: AccountResponse? = null + + /** + * Initialize this class (performing actual networking connectivity) + * + * It's recommended to set the credentials correctly + */ + suspend fun initialize(credentials: Pair? = null) { + if (compatibilityCheck == null) { + isCompatible() + } + if (!isCompatible()) { + Log.error("Incompatible API detected at '$baseUrl'! Further APIv2 usage will most likely break!") + } + + if (credentials != null) { + if (!auth.login(credentials.first, credentials.second)) { + Log.debug("Login failed using stored credentials") + } else { + lastSuccessfulAuthentication.set(Instant.now()) + lastSuccessfulCredentials = credentials + user = account.get() + websocket(::handleWebSocket) + } + } + ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) + ApiV2FileStorageWrapper.api = this + isInitialized = true + } + + // ---------------- SIMPLE GETTER ---------------- + + /** + * Determine if the user is authenticated by comparing timestamps + * + * This method is not reliable. The server might have configured another session timeout. + */ + fun isAuthenticated(): Boolean { + return (lastSuccessfulAuthentication.get() != null && (lastSuccessfulAuthentication.get()!! + DEFAULT_SESSION_TIMEOUT) > Instant.now()) + } + + // ---------------- COMPATIBILITY FUNCTIONALITY ---------------- + + /** + * Determine if the remote server is compatible with this API implementation + * + * 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 + * responses are retrieved in both cases, the API is surely incompatible. + * + * This method won't raise any exception other than network-related. + * It should be used to verify server URLs to determine the further handling. + * + * It caches its result once completed; set [update] for actually requesting. + */ + suspend fun isCompatible(update: Boolean = false): Boolean { + if (compatibilityCheck != null && !update) { + return compatibilityCheck!! + } + + val versionInfo = try { + val r = client.get("/api/version") + if (!r.status.isSuccess()) { + false + } else { + val b: VersionResponse = r.body() + b.version == 2 + } + } catch (e: IllegalArgumentException) { + false + } catch (e: Throwable) { + Log.error("Unexpected exception calling version endpoint for '$baseUrl': $e") + false + } + + if (!versionInfo) { + compatibilityCheck = false + return false + } + + val websocketSupport = try { + val r = client.get("/api/v2/ws") + if (r.status.isSuccess()) { + Log.error("Websocket endpoint from '$baseUrl' accepted unauthenticated request") + false + } else { + val b: ApiErrorResponse = r.body() + b.statusCode == ApiStatusCode.Unauthenticated + } + } catch (e: IllegalArgumentException) { + false + } catch (e: Throwable) { + Log.error("Unexpected exception calling WebSocket endpoint for '$baseUrl': $e") + false + } + + compatibilityCheck = websocketSupport + return websocketSupport + } + + // ---------------- WEBSOCKET FUNCTIONALITY ---------------- + + /** + * Handle existing WebSocket connections + * + * This method should be dispatched to a daemon thread pool executor. + */ + private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { + try { + val incomingMessage = session.incoming.receive() + + Log.debug("Incoming WebSocket message: $incomingMessage") + if (incomingMessage.frameType == FrameType.PING) { + session.send( + Frame.byType( + false, + FrameType.PONG, + byteArrayOf(), + rsv1 = true, + rsv2 = true, + rsv3 = true + ) + ) + } + } catch (e: ClosedReceiveChannelException) { + Log.debug("The WebSocket channel was unexpectedly closed: $e") + } + } + + /** + * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * + * Returns [Unit] if no exception is thrown + */ + internal suspend fun sendText(text: String): Unit { + if (sendChannel == null) { + Log.debug("No WebSocket connection, can't send text frame to server: '$text'") + return + } + try { + sendChannel?.send(Frame.Text(text)) + } catch (e: Throwable) { + Log.debug("%s\n%s", e.localizedMessage, e.stackTraceToString()) + throw e + } + } + + /** + * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * + * Returns true on success, false otherwise. Any error is suppressed! + */ + internal suspend fun sendTextSuppressed(text: String): Boolean { + if (sendChannel == null) { + Log.debug("No WebSocket connection, can't send text frame to server: '$text'") + return false + } + try { + sendChannel!!.send(Frame.Text(text)) + } catch (e: Throwable) { + Log.debug("%s\n%s", e.localizedMessage, e.stackTraceToString()) + } + return true + } + + /** + * Handle incoming WebSocket messages + */ + private suspend fun handleIncomingWSMessage(msg: WebSocketMessage) { + when (msg.type) { + WebSocketMessageType.InvalidMessage -> { + Log.debug("Received invalid message from WebSocket connection") + } + WebSocketMessageType.FinishedTurn -> { + // This message type is not meant to be received from the server + Log.debug("Received FinishedTurn message from WebSocket connection") + } + WebSocketMessageType.UpdateGameData -> { + // TODO: The body of this message contains a whole game state, so we need to unpack and use it here + withGLContext { + EventBus.send(MultiplayerGameAdded(game.name)) + } + } + WebSocketMessageType.ClientDisconnected -> { + Log.debug("Received ClientDisconnected message from WebSocket connection") + // TODO: Implement client connectivity handling + } + WebSocketMessageType.ClientReconnected -> { + Log.debug("Received ClientReconnected message from WebSocket connection") + // TODO: Implement client connectivity handling + } + WebSocketMessageType.IncomingChatMessage -> { + Log.debug("Received IncomingChatMessage message from WebSocket connection") + // TODO: Implement chat message handling + } + } + } + + /** + * Handle a newly established WebSocket connection + */ + private suspend fun handleWebSocket(session: ClientWebSocketSession) { + sendChannel?.close() + sendChannel = session.outgoing + + try { + while (true) { + val incomingFrame = session.incoming.receive() + when (incomingFrame.frameType) { + FrameType.CLOSE, FrameType.PING, FrameType.PONG -> { + // This handler won't handle control frames + Log.debug("Received CLOSE, PING or PONG as message") + } + FrameType.BINARY -> { + Log.debug("Received binary packet which can't be parsed at the moment") + } + FrameType.TEXT -> { + try { + val text = (incomingFrame as Frame.Text).readText() + val msg = Json.decodeFromString(WebSocketMessageSerializer(), text) + Log.debug("Incoming WebSocket message ${msg::class.java.canonicalName}: $msg") + handleIncomingWSMessage(msg) + } catch (e: Throwable) { + Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString()) + } + } + } + } + } catch (e: ClosedReceiveChannelException) { + Log.debug("The WebSocket channel was closed: $e") + sendChannel?.close() + session.close() + } catch (e: Throwable) { + Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString()) + sendChannel?.close() + session.close() + throw e + } + } + + // ---------------- SESSION FUNCTIONALITY ---------------- + + /** + * Refresh the currently used session by logging in with username and password stored in the game settings + * + * Any errors are suppressed. Differentiating invalid logins from network issues is therefore impossible. + */ + suspend fun refreshSession(): Boolean { + if (lastSuccessfulCredentials == null) { + return false + } + val success = try { + auth.login(lastSuccessfulCredentials!!.first, lastSuccessfulCredentials!!.second) + } catch (e: Throwable) { + Log.error("Suppressed error in 'refreshSession': $e") + false + } + if (success) { + lastSuccessfulAuthentication.set(Instant.now()) + } + return success + } + +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 96e4dd0dcf96e..13cf136ee3f17 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -32,16 +32,16 @@ internal const val LOBBY_MAX_PLAYERS = 34 /** * API wrapper around the newly implemented REST API for multiplayer game handling * - * Note that this does not include the handling of messages via the + * Note that this class does not include the handling of messages via the * WebSocket connection, but rather only the pure HTTP-based API. * Almost any method may throw certain OS or network errors as well as the * [ApiErrorResponse] for invalid requests (4xx) or server failures (5xx). * * This class should be considered implementation detail, since it just * abstracts HTTP endpoint names from other modules in this package. + * Use the [ApiV2] class for public methods to interact with the server. */ -class ApiV2Wrapper(private val baseUrl: String) { - private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) +open class ApiV2Wrapper(private val baseUrl: String) { // HTTP client to handle the server connections, logging, content parsing and cookies private val client = HttpClient(CIO) { @@ -89,50 +89,49 @@ class ApiV2Wrapper(private val baseUrl: String) { /** * API for account management */ - val account = AccountsApi(client, authCookieHelper, logger) + internal val account = AccountsApi(client, authCookieHelper) /** * API for authentication management */ - val auth = AuthApi(client, authCookieHelper, logger) + internal val auth = AuthApi(client, authCookieHelper) /** * API for chat management */ - val chat = ChatApi(client, authCookieHelper, logger) + internal val chat = ChatApi(client, authCookieHelper) /** * API for friendship management */ - val friend = FriendApi(client, authCookieHelper, logger) + internal val friend = FriendApi(client, authCookieHelper) /** * API for game management */ - val game = GameApi(client, authCookieHelper, logger) + internal val game = GameApi(client, authCookieHelper) /** * API for invite management */ - val invite = InviteApi(client, authCookieHelper, logger) + internal val invite = InviteApi(client, authCookieHelper) /** * API for lobby management */ - val lobby = LobbyApi(client, authCookieHelper, logger) + internal val lobby = LobbyApi(client, authCookieHelper) /** * Handle existing WebSocket connections * - * This method should be dispatched to a non-daemon thread pool executor. + * This method should be dispatched to a daemon thread pool executor. */ private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { try { val incomingMessage = session.incoming.receive() - logger.info("Incoming message: $incomingMessage") + Log.debug("Incoming WebSocket message: $incomingMessage") if (incomingMessage.frameType == FrameType.PING) { - logger.info("Received PING frame") session.send( Frame.byType( false, @@ -145,7 +144,7 @@ class ApiV2Wrapper(private val baseUrl: String) { ) } } catch (e: ClosedReceiveChannelException) { - logger.severe("The channel was closed: $e") + Log.error("The WebSocket channel was unexpectedly closed: $e") } } @@ -158,7 +157,7 @@ class ApiV2Wrapper(private val baseUrl: String) { * The [handler] coroutine might not get called, if opening the WS fails. */ suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit): Boolean { - logger.info("Starting a new WebSocket connection ...") + Log.debug("Starting a new WebSocket connection ...") coroutineScope { try { @@ -177,9 +176,9 @@ class ApiV2Wrapper(private val baseUrl: String) { } } websocketJobs.add(job) - logger.info("A new WebSocket has been created, running in job $job") + Log.debug("A new WebSocket has been created, running in job $job") } catch (e: SerializationException) { - logger.warning("Failed to create a WebSocket: $e") + Log.debug("Failed to create a WebSocket: $e") return@coroutineScope false } } @@ -218,7 +217,7 @@ class ApiV2Wrapper(private val baseUrl: String) { } catch (e: IllegalArgumentException) { false } catch (e: Throwable) { - logger.warning("Unexpected exception calling '$baseUrl': $e") + Log.error("Unexpected exception calling version endpoint for '$baseUrl': $e") false } @@ -230,7 +229,7 @@ class ApiV2Wrapper(private val baseUrl: String) { val websocketSupport = try { val r = client.get("/api/v2/ws") if (r.status.isSuccess()) { - logger.severe("Websocket endpoint from '$baseUrl' accepted unauthenticated request") + Log.error("Websocket endpoint from '$baseUrl' accepted unauthenticated request") false } else { val b: ApiErrorResponse = r.body() @@ -239,7 +238,7 @@ class ApiV2Wrapper(private val baseUrl: String) { } catch (e: IllegalArgumentException) { false } catch (e: Throwable) { - logger.warning("Unexpected exception calling '$baseUrl': $e") + Log.error("Unexpected exception calling WebSocket endpoint for '$baseUrl': $e") false } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index e31e933954ef9..7db658b0d0dd4 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -6,7 +6,7 @@ package com.unciv.logic.multiplayer.apiv2 -import java.util.logging.Logger +import com.unciv.utils.Log import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.cookies.* @@ -17,7 +17,7 @@ import java.util.* /** * API wrapper for account handling (do not use directly; use the Api class instead) */ -class AccountsApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class AccountsApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieve information about the currently logged in user @@ -117,7 +117,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("The current user has been deleted") + Log.debug("The current user has been deleted") authCookieHelper.unset() return true } else { @@ -143,7 +143,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("Password has been changed successfully") + Log.debug("User's password has been changed successfully") return true } else { val err: ApiErrorResponse = response.body() @@ -168,7 +168,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("A new account for username ${r.username} has been created") + Log.debug("A new account for username ${r.username} has been created") return true } else { val err: ApiErrorResponse = response.body() @@ -181,7 +181,7 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: /** * API wrapper for authentication handling (do not use directly; use the Api class instead) */ -class AuthApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class AuthApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Try logging in with username and password for testing purposes, don't set the session cookie @@ -217,7 +217,7 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth } if (response.status.isSuccess()) { val authCookie = response.setCookie()["id"] - logger.info("Received new session cookie: $authCookie") + Log.debug("Received new session cookie: $authCookie") if (authCookie != null) { authCookieHelper.set(authCookie.value) } @@ -236,7 +236,7 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth suspend fun logout(): Boolean { val response = client.post("/api/v2/auth/logout") if (response.status.isSuccess()) { - logger.info("Logged out successfully (dropping session cookie...)") + Log.debug("Logged out successfully (dropping session cookie...)") authCookieHelper.unset() return true } else { @@ -250,7 +250,7 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for chat room handling (do not use directly; use the Api class instead) */ -class ChatApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class ChatApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieve all messages a user has access to @@ -291,7 +291,7 @@ class ChatApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for friend handling (do not use directly; use the Api class instead) */ -class FriendApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class FriendApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieve a pair of the list of your established friendships and the list of your open friendship requests (incoming and outgoing) @@ -327,28 +327,6 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au return listAll().second } - /** - * Retrieve a list of your open incoming friendship requests - * - * The argument [myUUID] should be filled with the username of the currently logged in user. - */ - suspend fun listIncomingRequests(myUUID: UUID): List { - return listRequests().filter { - it.to.uuid == myUUID - } - } - - /** - * Retrieve a list of your open outgoing friendship requests - * - * The argument [myUUID] should be filled with the username of the currently logged in user. - */ - suspend fun listOutgoingRequests(myUUID: UUID): List { - return listRequests().filter { - it.from.uuid == myUUID - } - } - /** * Request friendship with another user */ @@ -366,7 +344,6 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("You have requested friendship with user ${r.uuid}") return true } else { val err: ApiErrorResponse = response.body() @@ -382,7 +359,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("You have successfully accepted friendship request ID $friendRequestID") + Log.debug("Successfully accepted friendship request ID $friendRequestID") return true } else { val err: ApiErrorResponse = response.body() @@ -401,7 +378,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("You have successfully dropped friendship ID $friendID") + Log.debug("Successfully rejected/dropped friendship ID $friendID") return true } else { val err: ApiErrorResponse = response.body() @@ -414,7 +391,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au /** * API wrapper for game handling (do not use directly; use the Api class instead) */ -class GameApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class GameApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieves an overview of all open games of a player @@ -488,7 +465,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth } if (response.status.isSuccess()) { val responseBody: GameUploadResponse = response.body() - logger.info("The game with ID ${r.gameUUID} has been uploaded, the new data ID is ${responseBody.gameDataID}") + Log.debug("The game with ID ${r.gameUUID} has been uploaded, the new data ID is ${responseBody.gameDataID}") return responseBody.gameDataID } else { val err: ApiErrorResponse = response.body() @@ -501,7 +478,7 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for invite handling (do not use directly; use the Api class instead) */ -class InviteApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class InviteApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieve all invites for the executing user @@ -542,7 +519,7 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - logger.info("The friend ${r.friend} has been invited to lobby ${r.lobbyID}") + Log.debug("The friend ${r.friend} has been invited to lobby ${r.lobbyID}") return true } else { val err: ApiErrorResponse = response.body() @@ -555,7 +532,7 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au /** * API wrapper for lobby handling (do not use directly; use the Api class instead) */ -class LobbyApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper, private val logger: Logger) { +class LobbyApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { /** * Retrieves all open lobbies @@ -575,22 +552,13 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut } } - /** - * Create a new lobby and return the new lobby ID - * - * If you are already in another lobby, an error is returned. - */ - suspend fun open(name: String): Long { - return open(CreateLobbyRequest(name, null, LOBBY_MAX_PLAYERS)) - } - /** * Create a new lobby and return the new lobby ID * * If you are already in another lobby, an error is returned. * ``max_players`` must be between 2 and 34 (inclusive). */ - suspend fun open(name: String, maxPlayers: Int): Long { + suspend fun open(name: String, maxPlayers: Int = LOBBY_MAX_PLAYERS): Long { return open(CreateLobbyRequest(name, null, maxPlayers)) } @@ -601,7 +569,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut * ``max_players`` must be between 2 and 34 (inclusive). * If password is an empty string, an error is returned. */ - suspend fun open(name: String, password: String?, maxPlayers: Int): Long { + suspend fun open(name: String, password: String?, maxPlayers: Int = LOBBY_MAX_PLAYERS): Long { return open(CreateLobbyRequest(name, password, maxPlayers)) } @@ -620,7 +588,6 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut } if (response.status.isSuccess()) { val responseBody: CreateLobbyResponse = response.body() - logger.info("A new lobby with ID ${responseBody.lobbyID} has been created") return responseBody.lobbyID } else { val err: ApiErrorResponse = response.body() From ceffacf4a1022dad9dcc1a616999620c853f88e2 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 26 Mar 2023 07:29:07 +0200 Subject: [PATCH 044/152] Restructured the project to make ApiV2 class the center --- core/src/com/unciv/UncivGame.kt | 4 +- .../logic/multiplayer/OnlineMultiplayer.kt | 270 ++++-------------- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 57 ++-- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 103 +------ .../storage/OnlineMultiplayerFiles.kt | 16 +- .../screens/mainmenuscreen/MainMenuScreen.kt | 4 +- .../multiplayerscreens/ChatRoomScreen.kt | 4 +- 7 files changed, 86 insertions(+), 372 deletions(-) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 68680986a7fcf..7e6783349dd9d 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -178,7 +178,9 @@ 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.checkServerStatus() diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 26649b35b8d10..7666f48653380 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -6,18 +6,13 @@ 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.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.ApiV2Wrapper -import com.unciv.logic.multiplayer.apiv2.ApiErrorResponse -import com.unciv.logic.multiplayer.apiv2.ApiException -import com.unciv.logic.multiplayer.apiv2.ApiStatusCode +import com.unciv.logic.multiplayer.apiv2.ApiV2 import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse -import com.unciv.logic.multiplayer.apiv2.WebSocketMessage -import com.unciv.logic.multiplayer.apiv2.WebSocketMessageSerializer -import com.unciv.logic.multiplayer.apiv2.WebSocketMessageType import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached @@ -34,14 +29,11 @@ import com.unciv.utils.debug import io.ktor.client.plugins.websocket.* import io.ktor.websocket.* import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json import java.time.Duration import java.time.Instant import java.util.* @@ -53,22 +45,29 @@ import java.util.concurrent.atomic.AtomicReference */ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) -private val SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) - /** * Provides multiplayer functionality to the rest of the game * - * The other parts of the game should not use any other classes from the multiplayer package. + * 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. * * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. */ class OnlineMultiplayer { - private val settings = UncivGame.Current.settings - private val logger = java.util.logging.Logger.getLogger(this::class.qualifiedName) + 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 - val api = ApiV2Wrapper(baseUrl) + private val apiImpl = ApiV2(baseUrl) + val api: ApiV2 + get() { + if (runBlocking { apiImpl.isCompatible() }) { + return apiImpl + } + throw UncivShowableException("Unsupported server API") + } private val files = UncivGame.Current.files val multiplayerFiles = OnlineMultiplayerFiles() @@ -83,62 +82,38 @@ class OnlineMultiplayer { val games: Set get() = savedGames.values.toSet() val serverFeatureSet: ServerFeatureSet get() = featureSet - private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() - - // Channel to send frames via WebSocket to the server, may be null - // for unsupported servers or unauthenticated/uninitialized clients - private var sendChannel: SendChannel? = null - - // Server API auto-detection happens in a coroutine triggered in the constructor + /** Server API auto-detection happens in the coroutine [initialize] */ lateinit var apiVersion: ApiVersion - lateinit var user: AccountResponse - - init { - // Run the server auto-detection in a coroutine, only afterwards this class can be considered initialized - Concurrency.run { - apiVersion = determineServerAPI() - checkServerStatus() - startPollChecker() - isAliveAPIv1() // this is called for any API version since it sets the featureSet implicitly - if (apiVersion == ApiVersion.APIv2) { - // Trying to log in with the stored credentials at this step decreases latency later - if (hasAuthentication()) { - if (!api.auth.login(UncivGame.Current.settings.multiplayer.userName, UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server]!!)) { - logger.warning("Login failed using stored credentials") - } else { - lastSuccessfulAuthentication.set(Instant.now()) - api.websocket(::handleWS) - user = api.account.get() - } - } - ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) - ApiV2FileStorageWrapper.api = api - } - } - - runBlocking { - coroutineScope { - Concurrency.runOnNonDaemonThreadPool { - if (!api.isServerCompatible()) { - logger.warning("Server API at ${UncivGame.Current.settings.multiplayer.server} is not APIv2-compatible") - return@runOnNonDaemonThreadPool - } - ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(api) - } + /** + * 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. + */ + suspend fun initialize() { + apiVersion = determineServerAPI() + Log.debug("Server at '$baseUrl' detected API version: $apiVersion") + checkServerStatus() + startPollChecker() + isAliveAPIv1() // this is called for any API version since it sets the featureSet implicitly + if (apiVersion == ApiVersion.APIv2) { + if (hasAuthentication()) { + apiImpl.initialize(Pair(settings.multiplayer.userName, settings.multiplayer.passwords[baseUrl]?:"")) + } else { + apiImpl.initialize() } } } /** - * Determine whether the object has been initialized. + * Determine whether the object has been initialized */ fun isInitialized(): Boolean { - return (this::featureSet.isInitialized) && (this::apiVersion.isInitialized) + return (this::featureSet.isInitialized) && (this::apiVersion.isInitialized) && (apiVersion != ApiVersion.APIv2 || apiImpl.isInitialized()) } /** - * Actively sleeping loop that awaits [isInitialized]. + * Actively sleeping loop that awaits [isInitialized] */ suspend fun awaitInitialized() { while (!isInitialized()) { @@ -146,15 +121,6 @@ class OnlineMultiplayer { } } - /** - * Determine if the user is authenticated by comparing timestamps (APIv2 only) - * - * This method is not reliable. The server might have configured another session timeout. - */ - fun isAuthenticated(): Boolean { - return (lastSuccessfulAuthentication.get() != null && (lastSuccessfulAuthentication.get()!! + SESSION_TIMEOUT) > Instant.now()) - } - /** * Determine the server API version of the remote server * @@ -163,79 +129,13 @@ class OnlineMultiplayer { private suspend fun determineServerAPI(): ApiVersion { return if (usesDropbox()) { ApiVersion.APIv0 - } else if (api.isServerCompatible()) { + } else if (apiImpl.isCompatible()) { ApiVersion.APIv2 } else { ApiVersion.APIv1 } } - /** - * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) - * - * Returns [Unit] if no exception is thrown - */ - internal suspend fun sendText(text: String): Unit { - if (sendChannel == null) { - return - } - try { - sendChannel!!.send(Frame.Text(text)) - } catch (e: Throwable) { - logger.warning(e.localizedMessage) - logger.warning(e.stackTraceToString()) - throw e - } - } - - /** - * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) - * - * Returns true on success, false otherwise. Any error is suppressed! - */ - internal suspend fun sendTextSuppressed(text: String): Boolean { - if (sendChannel == null) { - return false - } - try { - sendChannel!!.send(Frame.Text(text)) - } catch (e: Throwable) { - logger.severe(e.localizedMessage) - logger.severe(e.stackTraceToString()) - } - return true - } - - /** - * Handle incoming WebSocket messages - */ - private fun handleIncomingWSMessage(msg: WebSocketMessage) { - when (msg.type) { - WebSocketMessageType.InvalidMessage -> { - logger.warning("Received invalid message from WebSocket connection") - } - WebSocketMessageType.FinishedTurn -> { - // This message type is not meant to be received from the server - logger.warning("Received FinishedTurn message from WebSocket connection") - } - WebSocketMessageType.UpdateGameData -> { - // TODO: The body of this message contains a whole game state, so we need to unpack and use it here - } - WebSocketMessageType.ClientDisconnected -> { - logger.info("Received ClientDisconnected message from WebSocket connection") - // TODO: Implement client connectivity handling - } - WebSocketMessageType.ClientReconnected -> { - logger.info("Received ClientReconnected message from WebSocket connection") - // TODO: Implement client connectivity handling - } - WebSocketMessageType.IncomingChatMessage -> { - logger.info("Received IncomingChatMessage message from WebSocket connection") - // TODO: Implement chat message handling - } - } - } - /** * Allocate a new game ID on the server and return it * @@ -251,48 +151,6 @@ class OnlineMultiplayer { return null } - /** - * Handle a newly established WebSocket connection - */ - private suspend fun handleWS(session: ClientWebSocketSession) { - sendChannel?.close() - sendChannel = session.outgoing - - try { - while (true) { - val incomingFrame = session.incoming.receive() - when (incomingFrame.frameType) { - FrameType.CLOSE, FrameType.PING, FrameType.PONG -> { - // This handler won't handle control frames - logger.info("Received CLOSE, PING or PONG as message") - } - FrameType.BINARY -> { - logger.warning("Received binary packet which can't be parsed at the moment") - } - FrameType.TEXT -> { - try { - logger.fine("Incoming text message: $incomingFrame") - val text = (incomingFrame as Frame.Text).readText() - logger.fine("Message text: $text") - val msg = Json.decodeFromString(WebSocketMessageSerializer(), text) - logger.fine("Message type: ${msg::class.java.canonicalName}") - logger.fine("Deserialized: $msg") - handleIncomingWSMessage(msg) - } catch (e: Throwable) { - logger.severe(e.localizedMessage) - logger.severe(e.stackTraceToString()) - } - } - } - } - } catch (e: ClosedReceiveChannelException) { - logger.warning("The WebSocket channel was closed: $e") - } catch (e: Throwable) { - logger.severe(e.localizedMessage) - logger.severe(e.stackTraceToString()) - throw e - } - } private fun getCurrentGame(): OnlineMultiplayerGame? { val gameInfo = UncivGame.Current.gameInfo @@ -343,7 +201,6 @@ class OnlineMultiplayer { * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */ suspend fun createGame(newGame: GameInfo) { - logger.info("Creating game") multiplayerFiles.tryUploadGame(newGame, withPreview = true) addGame(newGame) } @@ -441,9 +298,9 @@ class OnlineMultiplayer { * Load all friends and friend requests (split by incoming and outgoing) of the currently logged-in user */ suspend fun getFriends(): Triple, List, List> { - val (friends, requests) = api.friend.listAll() + val (friends, requests) = apiImpl.friend.listAll() // TODO: The user's UUID should be cached, when this class is extended to a game manager class - val myUUID = api.account.get().uuid + val myUUID = apiImpl.account.get().uuid return Triple( friends.map { it.to }, requests.filter { it.to.uuid == myUUID }.map{ it.from }, @@ -569,19 +426,14 @@ class OnlineMultiplayer { * @return true if the server is alive, false otherwise */ suspend fun checkServerStatus(): Boolean { - if (api.getCompatibilityCheck() == null) { - api.isServerCompatible() - if (api.getCompatibilityCheck()!!) { - return true // if the compatibility check succeeded, the server is obviously running - } - } else if (api.getCompatibilityCheck()!!) { + if (apiImpl.isCompatible()) { try { - api.version() - } catch (e: Throwable) { // the only expected exception type is network-related (e.g. timeout or unreachable) - Log.error("Suppressed error in 'checkServerStatus': $e") + apiImpl.version() + } catch (e: Throwable) { + Log.error("Unexpected error during server status query: ${e.localizedMessage}") return false } - return true // no exception means the server responded with the excepted answer type + return true } return isAliveAPIv1() @@ -593,6 +445,8 @@ class OnlineMultiplayer { * 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. */ private fun isAliveAPIv1(): Boolean { var statusOk = false @@ -620,14 +474,12 @@ class OnlineMultiplayer { return true } - val settings = UncivGame.Current.settings.multiplayer - val success = multiplayerFiles.fileStorage().authenticate( - userId=settings.userId, - password=password ?: settings.passwords[settings.server] ?: "" + userId=settings.multiplayer.userId, + password=password ?: settings.multiplayer.passwords[settings.multiplayer.server] ?: "" ) if (password != null && success) { - settings.passwords[settings.server] = password + settings.multiplayer.passwords[settings.multiplayer.server] = password } return success } @@ -640,30 +492,6 @@ class OnlineMultiplayer { return settings.passwords.containsKey(settings.server) } - /** - * Refresh the currently used session by logging in with username and password stored in the game settings - * - * Any errors are suppressed. Differentiating invalid logins from network issues is therefore impossible. - */ - suspend fun refreshSession(): Boolean { - if (!hasAuthentication()) { - return false - } - val success = try { - api.auth.login( - UncivGame.Current.settings.multiplayer.userName, - UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.settings.multiplayer.server]!! - ) - } catch (e: Throwable) { - Log.error("Suppressed error in 'refreshSession': $e") - false - } - if (success) { - lastSuccessfulAuthentication.set(Instant.now()) - } - return success - } - /** * @return true if setting the password was successful, false otherwise. * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time @@ -694,7 +522,7 @@ class OnlineMultiplayer { */ private fun startPollChecker() { if (apiVersion in listOf(ApiVersion.APIv0, ApiVersion.APIv1)) { - logger.info("Starting poll service for remote games ...") + Log.debug("Starting poll service for remote games ...") flow { while (true) { delay(500) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index bd7a706672fa9..e6a175ff4fa0c 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -1,12 +1,9 @@ package com.unciv.logic.multiplayer.apiv2 -import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator import com.unciv.logic.multiplayer.ApiVersion -import com.unciv.logic.multiplayer.MultiplayerGameAdded import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.utils.Log -import com.unciv.utils.concurrency.withGLContext import io.ktor.client.call.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* @@ -35,7 +32,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { private var sendChannel: SendChannel? = null /** Info whether this class is fully initialized and ready to use */ - private var isInitialized = false + private var initialized = false /** Credentials used during the last successful login */ private var lastSuccessfulCredentials: Pair? = null @@ -44,12 +41,12 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() /** User identification on the server */ - private var user: AccountResponse? = null + var user: AccountResponse? = null /** * Initialize this class (performing actual networking connectivity) * - * It's recommended to set the credentials correctly + * It's recommended to set the credentials correctly in the first run, if possible. */ suspend fun initialize(credentials: Pair? = null) { if (compatibilityCheck == null) { @@ -61,7 +58,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { if (credentials != null) { if (!auth.login(credentials.first, credentials.second)) { - Log.debug("Login failed using stored credentials") + Log.debug("Login failed using provided credentials (username '${credentials.first}')") } else { lastSuccessfulAuthentication.set(Instant.now()) lastSuccessfulCredentials = credentials @@ -71,7 +68,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { } ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) ApiV2FileStorageWrapper.api = this - isInitialized = true + initialized = true } // ---------------- SIMPLE GETTER ---------------- @@ -85,6 +82,13 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { return (lastSuccessfulAuthentication.get() != null && (lastSuccessfulAuthentication.get()!! + DEFAULT_SESSION_TIMEOUT) > Instant.now()) } + /** + * Determine if this class has been fully initialized + */ + fun isInitialized(): Boolean { + return initialized + } + // ---------------- COMPATIBILITY FUNCTIONALITY ---------------- /** @@ -148,33 +152,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { // ---------------- WEBSOCKET FUNCTIONALITY ---------------- - /** - * Handle existing WebSocket connections - * - * This method should be dispatched to a daemon thread pool executor. - */ - private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { - try { - val incomingMessage = session.incoming.receive() - - Log.debug("Incoming WebSocket message: $incomingMessage") - if (incomingMessage.frameType == FrameType.PING) { - session.send( - Frame.byType( - false, - FrameType.PONG, - byteArrayOf(), - rsv1 = true, - rsv2 = true, - rsv3 = true - ) - ) - } - } catch (e: ClosedReceiveChannelException) { - Log.debug("The WebSocket channel was unexpectedly closed: $e") - } - } - /** * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) * @@ -224,10 +201,16 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { Log.debug("Received FinishedTurn message from WebSocket connection") } WebSocketMessageType.UpdateGameData -> { - // TODO: The body of this message contains a whole game state, so we need to unpack and use it here + // TODO + /* + @Suppress("CAST_NEVER_SUCCEEDS") + val gameInfo = UncivFiles.gameInfoFromString((msg as UpdateGameData).gameData) + Log.debug("Saving new game info for name '${gameInfo.gameId}'") + UncivGame.Current.files.saveGame(gameInfo, gameInfo.gameId) withGLContext { - EventBus.send(MultiplayerGameAdded(game.name)) + EventBus.send(MultiplayerGameUpdated(gameInfo.gameId, gameInfo.asPreview())) } + */ } WebSocketMessageType.ClientDisconnected -> { Log.debug("Received ClientDisconnected message from WebSocket connection") diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 13cf136ee3f17..0379f0998652b 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -18,11 +18,8 @@ import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.* import io.ktor.serialization.kotlinx.json.* -import io.ktor.websocket.* import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentLinkedQueue @@ -44,7 +41,7 @@ internal const val LOBBY_MAX_PLAYERS = 34 open class ApiV2Wrapper(private val baseUrl: String) { // HTTP client to handle the server connections, logging, content parsing and cookies - private val client = HttpClient(CIO) { + internal val client = HttpClient(CIO) { // Do not add install(HttpCookies) because it will break Cookie handling install(ContentNegotiation) { json(Json { @@ -61,9 +58,6 @@ open class ApiV2Wrapper(private val baseUrl: String) { } } - // Cache the result of the last server API compatibility check - private var compatibilityCheck: Boolean? = null - // Helper that replaces library cookie storages to fix cookie serialization problems private val authCookieHelper = AuthCookieHelper() @@ -121,33 +115,6 @@ open class ApiV2Wrapper(private val baseUrl: String) { */ internal val lobby = LobbyApi(client, authCookieHelper) - /** - * Handle existing WebSocket connections - * - * This method should be dispatched to a daemon thread pool executor. - */ - private suspend fun handleWebSocketSession(session: ClientWebSocketSession) { - try { - val incomingMessage = session.incoming.receive() - - Log.debug("Incoming WebSocket message: $incomingMessage") - if (incomingMessage.frameType == FrameType.PING) { - session.send( - Frame.byType( - false, - FrameType.PONG, - byteArrayOf(), - rsv1 = true, - rsv2 = true, - rsv3 = true - ) - ) - } - } catch (e: ClosedReceiveChannelException) { - Log.error("The WebSocket channel was unexpectedly closed: $e") - } - } - /** * Start a new WebSocket connection * @@ -156,7 +123,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { * method does instantly return, detaching the creation of the WebSocket. * The [handler] coroutine might not get called, if opening the WS fails. */ - suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit): Boolean { + internal suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit): Boolean { Log.debug("Starting a new WebSocket connection ...") coroutineScope { @@ -171,9 +138,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { } } val job = Concurrency.runOnNonDaemonThreadPool { - launch { - handler(session) - } + handler(session) } websocketJobs.add(job) Log.debug("A new WebSocket has been created, running in job $job") @@ -189,70 +154,10 @@ open class ApiV2Wrapper(private val baseUrl: String) { /** * Retrieve the currently available API version of the connected server */ - suspend fun version(): VersionResponse { + internal suspend fun version(): VersionResponse { return client.get("/api/version").body() } - /** - * Determine if the remote server is compatible with this API implementation - * - * 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 - * responses are retrieved in both cases, the API is surely incompatible. - * - * This method won't raise any exception other than network-related. - * It should be used to verify server URLs to determine the further handling. - */ - suspend fun isServerCompatible(): Boolean { - val versionInfo = try { - val r = client.get("/api/version") - if (!r.status.isSuccess()) { - false - } else { - val b: VersionResponse = r.body() - b.version == 2 - } - } catch (e: IllegalArgumentException) { - false - } catch (e: Throwable) { - Log.error("Unexpected exception calling version endpoint for '$baseUrl': $e") - false - } - - if (!versionInfo) { - compatibilityCheck = false - return false - } - - val websocketSupport = try { - val r = client.get("/api/v2/ws") - if (r.status.isSuccess()) { - Log.error("Websocket endpoint from '$baseUrl' accepted unauthenticated request") - false - } else { - val b: ApiErrorResponse = r.body() - b.statusCode == ApiStatusCode.Unauthenticated - } - } catch (e: IllegalArgumentException) { - false - } catch (e: Throwable) { - Log.error("Unexpected exception calling WebSocket endpoint for '$baseUrl': $e") - false - } - - compatibilityCheck = websocketSupport - return websocketSupport - } - - /** - * Getter for [compatibilityCheck] - */ - fun getCompatibilityCheck(): Boolean? { - return compatibilityCheck - } - } /** diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index 80fe843769203..216d438d42c3e 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -5,7 +5,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles -import com.unciv.utils.Log +import com.unciv.logic.multiplayer.ApiVersion import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException @@ -36,17 +36,13 @@ class OnlineMultiplayerFiles( return if (identifier == Constants.dropboxMultiplayerServer) { DropBox } else { - if (ApiV2FileStorageWrapper.api != null) { - if (ApiV2FileStorageWrapper.api!!.getCompatibilityCheck() == null) { - runBlocking { - ApiV2FileStorageWrapper.api!!.isServerCompatible() - } - } - if (ApiV2FileStorageWrapper.api!!.getCompatibilityCheck()!!) { + runBlocking { UncivGame.Current.onlineMultiplayer.awaitInitialized() } + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + if (UncivGame.Current.onlineMultiplayer.api.isAuthenticated()) { return ApiV2FileStorageWrapper.storage!! + } else { + throw MultiplayerAuthException(null) } - } else { - Log.error("API v2 file storage wrapper was null, it may be uninitialized due to race condition") } UncivServerFileStorage.apply { serverUrl = identifier!! diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index bc88f6625f73e..e50e11facd1fb 100644 --- a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt @@ -303,10 +303,10 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { // Authentication is handled before the multiplayer screen is shown val popup = Popup(stage) popup.addGoodSizedLabel("Loading...") - if (!game.onlineMultiplayer.isAuthenticated()) { + if (!game.onlineMultiplayer.api.isAuthenticated()) { popup.open() Concurrency.run { - if (game.onlineMultiplayer.refreshSession()) { + if (game.onlineMultiplayer.api.refreshSession()) { Concurrency.runOnGLThread { popup.close() game.pushScreen(MultiplayerScreenV2()) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 1a4338a07fcd7..9ab0b24454ad7 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -49,7 +49,7 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { Concurrency.run { // TODO: Remove this workaround fix by implementing a serious API handler - game.onlineMultiplayer.user = game.onlineMultiplayer.api.account.get() + game.onlineMultiplayer.api.user = game.onlineMultiplayer.api.account.get() updateMessages() } } @@ -100,7 +100,7 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { val label = "${it.sender.displayName} [${it.sender.username}] (${it.createdAt}):\n${msgList.joinToString("\n")}".toLabel() // TODO: Maybe add a border around each label to differentiate between messages visually clearer - if (it.sender.uuid == game.onlineMultiplayer.user.uuid) { + if (it.sender.uuid == game.onlineMultiplayer.api.user!!.uuid) { messageTable.add(label).maxWidth(label.width).prefWidth(label.width).right().row() } else { messageTable.add(label).maxWidth(label.width).prefWidth(label.width).left().row() From 27ce25bb9ea1afb6db1d67652d9493d08e90fd51 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 27 Mar 2023 05:12:39 +0200 Subject: [PATCH 045/152] Improved chat handling, added server game detail caching --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 69 ++++++++++++++++++- .../storage/ApiV2FileStorageEmulator.kt | 6 +- .../storage/OnlineMultiplayerFiles.kt | 3 + .../screens/worldscreen/WorldScreenTopBar.kt | 21 +++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index e6a175ff4fa0c..220938b357322 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -3,7 +3,9 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper +import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency import io.ktor.client.call.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* @@ -14,11 +16,15 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.serialization.json.Json import java.time.Duration import java.time.Instant +import java.util.* import java.util.concurrent.atomic.AtomicReference /** Default session timeout expected from multiplayer servers (unreliable) */ private val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) +/** Default cache expiry timeout to indicate that certain data needs to be re-fetched */ +private val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) + /** * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] */ @@ -40,7 +46,10 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** Timestamp of the last successful login */ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() - /** User identification on the server */ + /** Cache for the game details to make certain lookups faster */ + private val gameDetails: MutableMap = mutableMapOf() + + /** User identification on the server, may be null if unset or not logged in */ var user: AccountResponse? = null /** @@ -63,6 +72,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { lastSuccessfulAuthentication.set(Instant.now()) lastSuccessfulCredentials = credentials user = account.get() + Concurrency.run { + refreshGameDetails() + } websocket(::handleWebSocket) } } @@ -150,6 +162,46 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { return websocketSupport } + // ---------------- GAME-RELATED FUNCTIONALITY ---------------- + + /** + * Fetch server's details about a game based on its game ID + * + * @throws MultiplayerFileNotFoundException: if the gameId can't be resolved on the server + */ + suspend fun getGameDetails(gameId: UUID): GameDetails { + val result = gameDetails[gameId] + if (result != null && result.refreshed + DEFAULT_CACHE_EXPIRY > Instant.now()) { + return result.to() + } + refreshGameDetails() + return gameDetails[gameId]?.to() ?: throw MultiplayerFileNotFoundException(null) + } + + /** + * Fetch server's details about a game based on its game ID + * + * @throws MultiplayerFileNotFoundException: if the gameId can't be resolved on the server + */ + suspend fun getGameDetails(gameId: String): GameDetails { + return getGameDetails(UUID.fromString(gameId)) + } + + /** + * Refresh the cache of known multiplayer games, [gameDetails] + */ + private suspend fun refreshGameDetails() { + val currentGames = game.list() + for (entry in gameDetails.keys) { + if (entry !in currentGames.map { it.gameUUID }) { + gameDetails.remove(entry) + } + } + for (g in currentGames) { + gameDetails[g.gameUUID] = TimedGameDetails(Instant.now(), g.gameUUID, g.chatRoomID, g.gameDataID, g.name) + } + } + // ---------------- WEBSOCKET FUNCTIONALITY ---------------- /** @@ -293,3 +345,18 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { } } + +/** + * Small struct to store the most relevant details about a game, useful for caching + * + * Note that those values may become invalid (especially the [dataId]), so use it only for + * caching for short durations. The [chatRoomId] may be valid longer (up to the game's lifetime). + */ +data class GameDetails(val gameId: UUID, val chatRoomId: Long, val dataId: Long, val name: String) + +/** + * Holding the same values as [GameDetails], but with an instant determining the last refresh + */ +private data class TimedGameDetails(val refreshed: Instant, val gameId: UUID, val chatRoomId: Long, val dataId: Long, val name: String) { + fun to() = GameDetails(gameId, chatRoomId, dataId, name) +} diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 3ee00c0cfa9e7..c08668813d931 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -1,14 +1,14 @@ package com.unciv.logic.multiplayer.storage import com.unciv.logic.files.UncivFiles -import com.unciv.logic.multiplayer.apiv2.ApiV2Wrapper +import com.unciv.logic.multiplayer.apiv2.ApiV2 import com.unciv.utils.Log import java.util.* /** * Transition helper that emulates file storage behavior using the API v2 */ -class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { +class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { override suspend fun saveGameData(gameId: String, data: String) { val uuid = UUID.fromString(gameId.lowercase()) @@ -66,6 +66,6 @@ class ApiV2FileStorageEmulator(private val api: ApiV2Wrapper): FileStorage { * This object keeps references which are populated during program startup at runtime. */ object ApiV2FileStorageWrapper { - var api: ApiV2Wrapper? = null + var api: ApiV2? = null var storage: ApiV2FileStorageEmulator? = null } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index 216d438d42c3e..93a4387af0f61 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -41,6 +41,9 @@ class OnlineMultiplayerFiles( if (UncivGame.Current.onlineMultiplayer.api.isAuthenticated()) { return ApiV2FileStorageWrapper.storage!! } else { + if (runBlocking { ApiV2FileStorageWrapper.api!!.refreshSession() }) { + return ApiV2FileStorageWrapper.storage!! + } throw MultiplayerAuthException(null) } } diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index f4811f6a77d12..7b0b2e1143851 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -10,6 +10,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.logic.civilization.Civilization import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.tile.TileResource import com.unciv.models.ruleset.unique.UniqueType @@ -33,12 +34,15 @@ import com.unciv.ui.popups.popups import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen +import com.unciv.ui.screens.multiplayerscreens.ChatRoomScreen import com.unciv.ui.screens.overviewscreen.EmpireOverviewCategories import com.unciv.ui.screens.overviewscreen.EmpireOverviewScreen import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen import com.unciv.ui.screens.pickerscreens.TechPickerScreen import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency import kotlin.math.ceil import kotlin.math.max import kotlin.math.roundToInt @@ -175,11 +179,22 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { private class OpenGameChatTable(worldScreen: WorldScreen) : Table(BaseScreen.skin) { init { // The chat feature will only be enabled if the multiplayer server has support for it - if (worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + if (worldScreen.gameInfo.gameParameters.isOnlineMultiplayer && worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { val openChatButton = "Chat".toTextButton() openChatButton.onClick { - // TODO: Implement this - ToastPopup("In-game chat has not been implemented yet.", worldScreen.stage) + Concurrency.run { + try { + val details = worldScreen.game.onlineMultiplayer.api.getGameDetails(worldScreen.gameInfo.gameId) + Concurrency.runOnGLThread { + worldScreen.game.pushScreen(ChatRoomScreen(details.chatRoomId)) + } + } catch (e: MultiplayerFileNotFoundException) { + Concurrency.runOnGLThread { + Log.error("No game details associated with game '%s' found", worldScreen.gameInfo.gameId) + ToastPopup("No chat associated with this game found.", worldScreen.stage) + } + } + } } add(openChatButton).pad(10f) pack() From 29abeb4200fc3a608122e59b7bf2b21e532396f2 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 27 Mar 2023 22:59:43 +0200 Subject: [PATCH 046/152] Align the right control buttons of the NewGameScreen horizontally This provides more space in the screen above the control button group --- .../com/unciv/ui/screens/newgamescreen/NewGameScreen.kt | 4 ++-- .../ui/screens/pickerscreens/HorizontalPickerScreen.kt | 6 ++++++ core/src/com/unciv/ui/screens/pickerscreens/PickerPane.kt | 7 ++++--- .../src/com/unciv/ui/screens/pickerscreens/PickerScreen.kt | 4 ++-- 4 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/pickerscreens/HorizontalPickerScreen.kt diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt index 1596669d34673..5f3341c526a33 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt @@ -37,7 +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.PickerScreen +import com.unciv.ui.screens.pickerscreens.HorizontalPickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread @@ -48,7 +48,7 @@ import com.unciv.ui.components.AutoScrollPane as ScrollPane class NewGameScreen( _gameSetupInfo: GameSetupInfo? = null -): IPreviousScreen, PickerScreen(), RecreateOnResize { +): IPreviousScreen, HorizontalPickerScreen() /* to get more space */, RecreateOnResize { override val gameSetupInfo = _gameSetupInfo ?: GameSetupInfo.fromSettings() override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) // needs to be set because the GameOptionsTable etc. depend on this 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 e4ba146d12ed1..c0990383581bb 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,13 +19,14 @@ 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 [setDefaultCloseAction] */ 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 ed38709e582d5..93cdc7458602f 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.KeyCharAndCode import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.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 From 0fd7227ce8ea831163dfa429ba25f2c0bc0b46d1 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 27 Mar 2023 23:00:07 +0200 Subject: [PATCH 047/152] Avoid crashing during server checks (e.g. due to network issues) --- .../logic/multiplayer/OnlineMultiplayer.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 7666f48653380..a347e8f91534d 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -450,16 +450,21 @@ class OnlineMultiplayer { */ private fun isAliveAPIv1(): Boolean { var statusOk = false - 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() + 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 } From 99708e7adfafb0d0a1917ba88f13b00bf5f0f00a Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 28 Mar 2023 04:07:50 +0200 Subject: [PATCH 048/152] Added a logout button in the multiplayer options tab --- .../unciv/ui/popups/options/MultiplayerTab.kt | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 66c169f7f25d7..a7d7af55b44cb 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -5,13 +5,16 @@ 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 @@ -153,12 +156,12 @@ private fun addMultiplayerServerOptions( multiplayerUsernameTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" } serverIpTable.add("Multiplayer username".toLabel()).colspan(2).row() serverIpTable.add(multiplayerUsernameTextField) - .minWidth(optionsPopup.stageToShowOn.width / 2) - .colspan(2).growX().padBottom(8f).row() + .minWidth(optionsPopup.stageToShowOn.width / 2.5f) + .growX().padBottom(8f) serverIpTable.add("Save username".toTextButton().onClick { settings.multiplayer.userName = multiplayerUsernameTextField.text settings.save() - }).colspan(2).padBottom(8f).row() + }).padBottom(8f).row() serverIpTable.add("Server address".toLabel().onClick { multiplayerServerTextField.text = Gdx.app.clipboard.contents @@ -230,6 +233,30 @@ private fun addMultiplayerServerOptions( serverIpTable.add(passwordStatusTable).colspan(2).row() } + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2 && UncivGame.Current.onlineMultiplayer.hasAuthentication()) { + val logoutButton = "Logout".toTextButton() + serverIpTable.add(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) + } + } + } + }).colspan(2).padBottom(8f).row() + } + tab.add(serverIpTable).colspan(2).fillX().row() } From 69ef74bda6ae598a6a531afbe54376b0e9fea801 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 28 Mar 2023 05:57:19 +0200 Subject: [PATCH 049/152] Added a generic HTTP request wrapper that can retry requests easily --- .../apiv2/EndpointImplementations.kt | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 7db658b0d0dd4..a75703d2764ff 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -11,9 +11,124 @@ import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* +import kotlinx.coroutines.delay +import java.io.IOException import java.util.* +/** + * List of HTTP status codes which are considered to [ApiErrorResponse]s by the specification + */ +internal val ERROR_CODES = listOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError) + +/** + * List of API status codes that should be re-executed after session refresh, if possible + */ +private val RETRY_CODES = listOf(ApiStatusCode.Unauthenticated) + +/** + * Perform a HTTP request via [method] to [endpoint] + * + * Use [refine] to change the [HttpRequestBuilder] after it has been prepared with the method + * and path. Do not edit the cookie header or the request URL, since they might be overwritten. + * This function retries failed requests after executing coroutine [onRetry], if it + * is set and the request failed due to network or defined API errors, see [RETRY_CODES]. + * Therefore, to enable plain re-trying without anything else, set [onRetry] to (() -> Unit). + * If [suppress] is set, it will return null instead of throwing any exceptions. + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ +private suspend fun request( + method: HttpMethod, + endpoint: String, + client: HttpClient, + authHelper: AuthHelper, + refine: ((HttpRequestBuilder) -> Unit)? = null, + suppress: Boolean = false, + onRetry: (/* suspending block */ () -> Unit)? = null +): HttpResponse? { + val builder = HttpRequestBuilder() + builder.method = method + if (refine != null) { + refine(builder) + } + builder.url { path(endpoint) } + authHelper.add(builder) + + // Perform the request, but handle network issues gracefully according to the specified exceptions + val response = try { + client.request(builder) + } catch (e: IOException) { + return if (onRetry != null) { + Log.debug("Retrying after network error %s: %s (cause: %s)", e, e.message, e.cause) + onRetry() + request(method, endpoint, client, authHelper, + refine = refine, + suppress = suppress, + onRetry = null + ) + } else if (suppress) { + Log.debug("Suppressed network error %s: %s (cause: %s)", e, e.message, e.cause) + null + } else { + Log.debug("Throwing network error %s: %s (cause: %s)", e, e.message, e.cause) + throw UncivNetworkException(e) + } + } + + // For HTTP errors defined in the API, throwing an ApiException would be the correct handling. + // Therefore, try to de-serialize the response as ApiErrorResponse first. If it happens to be + // an authentication failure, the request could be retried as well. Otherwise, throw the error. + if (response.status in ERROR_CODES) { + try { + val error: ApiErrorResponse = response.body() + // Now the API response can be checked for retry-able failures + if (error.statusCode in RETRY_CODES && onRetry != null) { + onRetry() + return request(method, endpoint, client, authHelper, + refine = refine, + suppress = suppress, + onRetry = null + ) + } + if (suppress) { + Log.debug("Suppressing %s for call to '%s'", error, response.request.url) + return null + } + throw error.to() + } catch (e: IllegalArgumentException) { // de-serialization failed + Log.error("Invalid body for '%s %s' -> %s: %s: '%s'", method, response.request.url, response.status, e.message, response.bodyAsText()) + return if (onRetry != null) { + onRetry() + request(method, endpoint, client, authHelper, + refine = refine, + suppress = suppress, + onRetry = null + ) + } else if (suppress) { + Log.debug("Suppressed invalid API error response %s: %s (cause: %s)", e, e.message, e.cause) + null + } else { + Log.debug("Throwing network error instead of API error due to serialization failure %s: %s (cause: %s)", e, e.message, e.cause) + throw UncivNetworkException(e) + } + } + } else if (response.status.isSuccess()) { + return response + } else { + // Here, the server returned a non-success code which is not recognized, + // therefore it is considered a network error (even if was something like 404) + if (suppress) { + Log.debug("Suppressed unknown HTTP status code %s for '%s %s'", response.status, method, response.request.url) + return null + } + // If the server does not conform to the API, re-trying requests is useless + throw UncivNetworkException(IllegalArgumentException(response.status.toString())) + } +} + /** * API wrapper for account handling (do not use directly; use the Api class instead) */ From d5423d39c858dab63c9af4133de415be29b5ede5 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 28 Mar 2023 23:07:36 +0200 Subject: [PATCH 050/152] Added a default handler to retry requests after session refreshing --- .../apiv2/EndpointImplementations.kt | 82 +++++++++++++++---- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index a75703d2764ff..b005478d70afe 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -13,7 +13,6 @@ import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* -import kotlinx.coroutines.delay import java.io.IOException import java.util.* @@ -32,10 +31,12 @@ private val RETRY_CODES = listOf(ApiStatusCode.Unauthenticated) * * Use [refine] to change the [HttpRequestBuilder] after it has been prepared with the method * and path. Do not edit the cookie header or the request URL, since they might be overwritten. - * This function retries failed requests after executing coroutine [onRetry], if it - * is set and the request failed due to network or defined API errors, see [RETRY_CODES]. - * Therefore, to enable plain re-trying without anything else, set [onRetry] to (() -> Unit). * If [suppress] is set, it will return null instead of throwing any exceptions. + * This function retries failed requests after executing coroutine [retry] which will be passed + * the same arguments as the [request] coroutine, if it is set and the request failed due to + * network or defined API errors, see [RETRY_CODES]. It should return a [Boolean] which determines + * if the original request should be retried after finishing [retry]. For example, to silently + * repeat a request on such failure, use such coroutine: suspend { true } * * @throws ApiException: thrown for defined and recognized API problems * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems @@ -47,7 +48,7 @@ private suspend fun request( authHelper: AuthHelper, refine: ((HttpRequestBuilder) -> Unit)? = null, suppress: Boolean = false, - onRetry: (/* suspending block */ () -> Unit)? = null + retry: (suspend () -> Boolean)? = null ): HttpResponse? { val builder = HttpRequestBuilder() builder.method = method @@ -61,13 +62,18 @@ private suspend fun request( val response = try { client.request(builder) } catch (e: IOException) { - return if (onRetry != null) { + val shouldRetry = if (retry != null) { + Log.debug("Calling retry coroutine %s for network error %s in '%s %s'", retry, e, method, endpoint) + retry() + } else { + false + } + return if (shouldRetry) { Log.debug("Retrying after network error %s: %s (cause: %s)", e, e.message, e.cause) - onRetry() request(method, endpoint, client, authHelper, refine = refine, suppress = suppress, - onRetry = null + retry = null ) } else if (suppress) { Log.debug("Suppressed network error %s: %s (cause: %s)", e, e.message, e.cause) @@ -85,13 +91,15 @@ private suspend fun request( try { val error: ApiErrorResponse = response.body() // Now the API response can be checked for retry-able failures - if (error.statusCode in RETRY_CODES && onRetry != null) { - onRetry() - return request(method, endpoint, client, authHelper, - refine = refine, - suppress = suppress, - onRetry = null - ) + if (error.statusCode in RETRY_CODES && retry != null) { + Log.debug("Calling retry coroutine %s for API response error %s in '%s %s'", retry, error, method, endpoint) + if (retry()) { + return request(method, endpoint, client, authHelper, + refine = refine, + suppress = suppress, + retry = null + ) + } } if (suppress) { Log.debug("Suppressing %s for call to '%s'", error, response.request.url) @@ -100,12 +108,17 @@ private suspend fun request( throw error.to() } catch (e: IllegalArgumentException) { // de-serialization failed Log.error("Invalid body for '%s %s' -> %s: %s: '%s'", method, response.request.url, response.status, e.message, response.bodyAsText()) - return if (onRetry != null) { - onRetry() + val shouldRetry = if (retry != null) { + Log.debug("Calling retry coroutine %s for serialization error %s in '%s %s'", retry, e, method, endpoint) + retry() + } else { + false + } + return if (shouldRetry) { request(method, endpoint, client, authHelper, refine = refine, suppress = suppress, - onRetry = null + retry = null ) } else if (suppress) { Log.debug("Suppressed invalid API error response %s: %s (cause: %s)", e, e.message, e.cause) @@ -129,6 +142,39 @@ private suspend fun request( } } +/** + * Get the default retry mechanism which tries to refresh the current session, if credentials are available + */ +private fun getDefaultRetry(client: HttpClient, authHelper: AuthHelper): (suspend () -> Boolean) { + val lastCredentials = authHelper.lastSuccessfulCredentials.get() + if (lastCredentials != null) { + return suspend { + val response = request(HttpMethod.Post, "/api/v2/auth/login", client, authHelper, suppress = true, retry = null, refine = {b -> + b.contentType(ContentType.Application.Json) + b.setBody(LoginRequest(lastCredentials.first, lastCredentials.second)) + }) + if (response != null && response.status.isSuccess()) { + val authCookie = response.setCookie()[SESSION_COOKIE_NAME] + Log.debug("Received new session cookie in retry handler: $authCookie") + if (authCookie != null) { + authHelper.setCookie( + authCookie.value, + authCookie.maxAge, + Pair(lastCredentials.first, lastCredentials.second) + ) + true + } else { + false + } + } else { + false + } + } + } else { + return suspend { false } + } +} + /** * API wrapper for account handling (do not use directly; use the Api class instead) */ From b6873d777d43974de27f2152f70f4dafeb571b2f Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 29 Mar 2023 02:44:06 +0200 Subject: [PATCH 051/152] Updated the API structs based on the new OpenAPI specifications --- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 3 +- .../apiv2/EndpointImplementations.kt | 14 +-- .../multiplayer/apiv2/JsonSerializers.kt | 1 - .../logic/multiplayer/apiv2/RequestStructs.kt | 8 +- .../multiplayer/apiv2/ResponseStructs.kt | 95 +++++++++++++------ .../multiplayer/apiv2/WebSocketStructs.kt | 26 +---- 6 files changed, 82 insertions(+), 65 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 0379f0998652b..81682ab67dc7c 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -24,7 +24,8 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentLinkedQueue -internal const val LOBBY_MAX_PLAYERS = 34 +/** Default value for max number of players in a lobby if no other value is set */ +internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32 /** * API wrapper around the newly implemented REST API for multiplayer game handling diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index b005478d70afe..191fe48e4739b 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -663,8 +663,8 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au * The executing user must be in the specified open lobby. * The invited friend must not be in a friend request state. */ - suspend fun new(friend: UUID, lobbyID: Long): Boolean { - return new(CreateInviteRequest(friend, lobbyID)) + suspend fun new(friend: UUID, lobbyUUID: UUID): Boolean { + return new(CreateInviteRequest(friend, lobbyUUID)) } /** @@ -680,7 +680,7 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au authCookieHelper.add(this) } if (response.status.isSuccess()) { - Log.debug("The friend ${r.friend} has been invited to lobby ${r.lobbyID}") + Log.debug("The friend ${r.friendUUID} has been invited to lobby ${r.lobbyUUID}") return true } else { val err: ApiErrorResponse = response.body() @@ -719,7 +719,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut * If you are already in another lobby, an error is returned. * ``max_players`` must be between 2 and 34 (inclusive). */ - suspend fun open(name: String, maxPlayers: Int = LOBBY_MAX_PLAYERS): Long { + suspend fun open(name: String, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS): UUID { return open(CreateLobbyRequest(name, null, maxPlayers)) } @@ -730,7 +730,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut * ``max_players`` must be between 2 and 34 (inclusive). * If password is an empty string, an error is returned. */ - suspend fun open(name: String, password: String?, maxPlayers: Int = LOBBY_MAX_PLAYERS): Long { + suspend fun open(name: String, password: String?, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS): UUID { return open(CreateLobbyRequest(name, password, maxPlayers)) } @@ -741,7 +741,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut * ``max_players`` must be between 2 and 34 (inclusive). * If password is an empty string, an error is returned. */ - suspend fun open(r: CreateLobbyRequest): Long { + suspend fun open(r: CreateLobbyRequest): UUID { val response = client.post("/api/v2/lobbies") { contentType(ContentType.Application.Json) setBody(r) @@ -749,7 +749,7 @@ class LobbyApi(private val client: HttpClient, private val authCookieHelper: Aut } if (response.status.isSuccess()) { val responseBody: CreateLobbyResponse = response.body() - return responseBody.lobbyID + return responseBody.lobbyUUID } else { val err: ApiErrorResponse = response.body() throw err.to() diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index a52dde80e272a..b75e5f37950f6 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -69,7 +69,6 @@ internal class WebSocketMessageSerializer : JsonContentPolymorphicSerializer InvalidMessage.serializer() - WebSocketMessageType.FinishedTurn -> FinishedTurnMessage.serializer() WebSocketMessageType.UpdateGameData -> UpdateGameDataMessage.serializer() WebSocketMessageType.ClientDisconnected -> ClientDisconnectedMessage.serializer() WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer() diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt index 67623a9de4132..e045208015551 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -33,10 +33,12 @@ data class CreateFriendRequest( */ @Serializable data class CreateInviteRequest( + @SerialName("friend_uuid") @Serializable(with = UUIDSerializer::class) - val friend: UUID, - @SerialName("lobby_id") - val lobbyID: Long + val friendUUID: UUID, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID ) /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 6b08d70c19cde..441c0338fa1f7 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -7,7 +7,7 @@ package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.Instant -import java.util.UUID +import java.util.* /** * The account data @@ -63,10 +63,13 @@ enum class ApiStatusCode(val value: Int) { InvalidDisplayName(1010), FriendshipAlreadyRequested(1011), AlreadyFriends(1012), - InvalidId(1013), - MissingPrivileges(1014), - InvalidMaxPlayersCount(1017), - AlreadyInALobby(1018), + MissingPrivileges(1013), + InvalidMaxPlayersCount(1014), + AlreadyInALobby(1015), + InvalidUuid(1016), + InvalidLobbyUuid(1017), + InvalidFriendUuid(1018), + GameNotFound(1019), InternalServerError(2000), DatabaseError(2001), @@ -96,11 +99,12 @@ data class ChatMember( /** * The message of a chatroom * - * The parameter [id] should be used to uniquely identify a message. + * The parameter [uuid] should be used to uniquely identify a message. */ @Serializable data class ChatMessage( - val id: Long, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, val sender: AccountResponse, val message: String, @SerialName("created_at") @@ -109,32 +113,40 @@ data class ChatMessage( ) /** - * The response of a create lobby request, which contains the [lobbyID] of the created lobby + * The response of a create lobby request, which contains the [lobbyUUID] and [lobbyChatRoomUUID] */ @Serializable data class CreateLobbyResponse( - @SerialName("lobby_id") - val lobbyID: Long + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + @SerialName("lobby_chat_room_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyChatRoomUUID: UUID ) /** - * A single friend + * A single friend (the relationship is identified by the [uuid]) */ @Serializable data class FriendResponse( - @SerialName("chat_id") - val chatID: Long, - val id: Long, - val from: AccountResponse, - val to: OnlineAccountResponse + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + @SerialName("chat_uuid") + @Serializable(with = UUIDSerializer::class) + val chatUUID: UUID, + val friend: OnlineAccountResponse ) /** * A single friend request + * + * Use [from] and [to] comparing with "myself" to determine if it's incoming or outgoing. */ @Serializable data class FriendRequestResponse( - val id: Long, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, val from: AccountResponse, val to: AccountResponse ) @@ -149,7 +161,8 @@ data class FriendRequestResponse( @Serializable data class GameOverviewResponse( @SerialName("chat_room_id") - val chatRoomID: Long, + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, @SerialName("game_data_id") val gameDataID: Long, @SerialName("game_uuid") @@ -174,8 +187,9 @@ data class GameOverviewResponse( */ @Serializable data class GameStateResponse( - @SerialName("chat_room_id") - val chatRoomID: Long, + @SerialName("chat_room_uuid") + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, @SerialName("game_data") val gameData: String, @SerialName("game_data_id") @@ -200,14 +214,31 @@ data class GameUploadResponse( ) /** - * All chat rooms your user has access to + * Internal wrapper around [GetAllChatsResponse] that prevents serialization issues of lists of [UUID]s */ @Serializable -data class GetAllChatsResponse( +internal class GetAllChatsResponseImpl( @SerialName("friend_chat_rooms") - val friendChatRooms: List, + val friendChatRooms: List, + @SerialName("game_chat_rooms") + val gameChatRooms: List, @SerialName("lobby_chat_rooms") - val lobbyChatRooms: List + val lobbyChatRooms: List +) { + internal fun to() = GetAllChatsResponse( + friendChatRooms.map { UUID.fromString(it) }, + gameChatRooms.map { UUID.fromString(it) }, + lobbyChatRooms.map { UUID.fromString(it) } + ) +} + +/** + * All chat rooms your user has access to + */ +data class GetAllChatsResponse( + val friendChatRooms: List, + val gameChatRooms: List, + val lobbyChatRooms: List ) /** @@ -251,9 +282,11 @@ data class GetInvite( @Serializable(with = InstantSerializer::class) val createdAt: Instant, val from: AccountResponse, - val id: Long, - @SerialName("lobby_id") - val lobbyID: Long + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID ) /** @@ -277,14 +310,16 @@ data class GetLobbiesResponse( */ @Serializable data class LobbyResponse( - val id: Long, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, val name: String, @SerialName("max_players") val maxPlayers: Int, @SerialName("current_players") val currentPlayers: Int, - @SerialName("chat_room_id") - val chatRoomID: Long, + @SerialName("chat_room_uuid") + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, @SerialName("created_at") @Serializable(with = InstantSerializer::class) val createdAt: Instant, diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 71aadba74d1e1..f067bf3d795f6 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -4,16 +4,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.* -/** - * Upload a new game state from a client after finishing a turn - */ -@Serializable -data class FinishedTurn( - @SerialName("gameId") - val gameID: Long, - val gameData: String, // base64-encoded, gzipped game state -) - /** * An update of the game data * @@ -22,10 +12,10 @@ data class FinishedTurn( @Serializable data class UpdateGameData( @SerialName("gameId") - val gameID: Long, + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, val gameData: String, // base64-encoded, gzipped game state - /** A unique counter that is incremented every time a [FinishedTurn] - * is received from the same `game_id`. */ + /** A counter that is incremented every time a new game states has been uploaded for the same [gameUUID] via HTTP API. */ @SerialName("gameDataId") val gameDataID: Long ) @@ -77,15 +67,6 @@ data class InvalidMessage( override val type: WebSocketMessageType, ) : WebSocketMessage -/** - * Message to upload the game state after finishing the turn - */ -@Serializable -data class FinishedTurnMessage ( - override val type: WebSocketMessageType, - val content: FinishedTurn -) : WebSocketMessage - /** * Message to publish the new game state from the server to all clients */ @@ -128,7 +109,6 @@ data class IncomingChatMessageMessage ( @Serializable(with = WebSocketMessageTypeSerializer::class) enum class WebSocketMessageType(val type: String) { InvalidMessage("invalidMessage"), - FinishedTurn("finishedTurn"), UpdateGameData("updateGameData"), ClientDisconnected("clientDisconnected"), ClientReconnected("clientReconnected"), From c37d98b40ce2d4fdbda1b53da388140c32123f1e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 29 Mar 2023 04:34:26 +0200 Subject: [PATCH 052/152] Switched endpoint implementations to use the new 'request', updated WebSocket structs --- .../apiv2/EndpointImplementations.kt | 828 ++++++++++-------- .../multiplayer/apiv2/JsonSerializers.kt | 2 + .../logic/multiplayer/apiv2/RequestStructs.kt | 7 +- .../multiplayer/apiv2/WebSocketStructs.kt | 70 +- 4 files changed, 527 insertions(+), 380 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 191fe48e4739b..61c5a0e477853 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -26,6 +26,11 @@ internal val ERROR_CODES = listOf(HttpStatusCode.BadRequest, HttpStatusCode.Inte */ private val RETRY_CODES = listOf(ApiStatusCode.Unauthenticated) +/** + * Default value for randomly generated passwords + */ +private const val DEFAULT_RANDOM_PASSWORD_LENGTH = 32 + /** * Perform a HTTP request via [method] to [endpoint] * @@ -178,162 +183,191 @@ private fun getDefaultRetry(client: HttpClient, authHelper: AuthHelper): (suspen /** * API wrapper for account handling (do not use directly; use the Api class instead) */ -class AccountsApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class AccountsApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** * Retrieve information about the currently logged in user + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun get(): AccountResponse { - val response = client.get("/api/v2/accounts/me") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun get(suppress: Boolean = false): AccountResponse? { + return request( + HttpMethod.Get, "/api/v2/accounts/me", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() } /** - * Retrieve details for an account by its UUID (always preferred to using usernames) + * Retrieve details for an account by its [uuid] (always preferred to using usernames) + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun lookup(uuid: UUID): AccountResponse { - val response = client.get("/api/v2/accounts/$uuid") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun lookup(uuid: UUID, suppress: Boolean = false): AccountResponse? { + return request( + HttpMethod.Get, "/api/v2/accounts/$uuid", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() } /** - * Retrieve details for an account by its username + * Retrieve details for an account by its [username] * * Important note: Usernames can be changed, so don't assume they can be * cached to do lookups for their display names or UUIDs later. Always convert usernames * to UUIDs when handling any user interactions (e.g., inviting, sending messages, ...). - */ - suspend fun lookup(username: String): AccountResponse { - return lookup(LookupAccountUsernameRequest(username)) + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun lookup(username: String, suppress: Boolean = false): AccountResponse? { + return request( + HttpMethod.Post, "/api/v2/accounts/lookup", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper), + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(LookupAccountUsernameRequest(username)) + } + )?.body() } /** - * Retrieve details for an account by its username + * Set the [username] of the currently logged-in user * - * Important note: Usernames can be changed, so don't assume they can be - * cached to do lookups for their display names or UUIDs later. Always convert usernames - * to UUIDs when handling any user interactions (e.g., inviting, sending messages, ...). + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun lookup(r: LookupAccountUsernameRequest): AccountResponse { - val response = client.post("/api/v2/accounts/lookup") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun setUsername(username: String, suppress: Boolean = false): Boolean { + return update(UpdateAccountRequest(username, null), suppress) } /** - * Update the currently logged in user information + * Set the [displayName] of the currently logged-in user + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). * - * At least one value must be set to a non-null value. + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun update(username: String?, displayName: String?): Boolean { - return update(UpdateAccountRequest(username, displayName)) + suspend fun setDisplayName(displayName: String, suppress: Boolean = false): Boolean { + return update(UpdateAccountRequest(null, displayName), suppress) } /** * Update the currently logged in user information * - * At least one value must be set to a non-null value. - */ - suspend fun update(r: UpdateAccountRequest): Boolean { - val response = client.put("/api/v2/accounts/me") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } - } - - /** - * Deletes the currently logged-in account - */ - suspend fun delete(): Boolean { - val response = client.delete("/api/v2/accounts/me") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - Log.debug("The current user has been deleted") - authCookieHelper.unset() - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + private suspend fun update(r: UpdateAccountRequest, suppress: Boolean): Boolean { + val response = request( + HttpMethod.Put, "/api/v2/accounts/me", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper), + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(r) + } + ) + return response?.status?.isSuccess() == true } /** - * Set a new password for the currently logged-in account, provided the old password was accepted as valid + * Deletes the currently logged-in account (irreversible operation!) + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun setPassword(oldPassword: String, newPassword: String): Boolean { - return setPassword(SetPasswordRequest(oldPassword, newPassword)) + suspend fun delete(suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Delete, "/api/v2/accounts/me", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true } /** - * Set a new password for the currently logged-in account, provided the old password was accepted as valid - */ - suspend fun setPassword(r: SetPasswordRequest): Boolean { - val response = client.post("/api/v2/accounts/setPassword") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { + * Set [newPassword] for the currently logged-in account, provided the [oldPassword] was accepted as valid + * + * If not given, the [oldPassword] will be used from the login session cache, if available. + * However, if the [oldPassword] can't be determined, it will likely yield in a [ApiStatusCode.InvalidPassword]. + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun setPassword(newPassword: String, oldPassword: String? = null, suppress: Boolean = false): Boolean { + var oldLocalPassword = oldPassword + val lastKnownPassword = authHelper.lastSuccessfulCredentials.get()?.second + if (oldLocalPassword == null && lastKnownPassword != null) { + oldLocalPassword = lastKnownPassword + } + if (oldLocalPassword == null) { + oldLocalPassword = "" // empty passwords will yield InvalidPassword, so this is fine here + } + val response = request( + HttpMethod.Post, "/api/v2/accounts/setPassword", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper), + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(SetPasswordRequest(oldLocalPassword, newPassword)) + } + ) + return if (response?.status?.isSuccess() == true) { Log.debug("User's password has been changed successfully") - return true + true } else { - val err: ApiErrorResponse = response.body() - throw err.to() + false } } /** * Register a new user account - */ - suspend fun register(username: String, displayName: String, password: String): Boolean { - return register(AccountRegistrationRequest(username, displayName, password)) - } - - /** - * Register a new user account - */ - suspend fun register(r: AccountRegistrationRequest): Boolean { - val response = client.post("/api/v2/accounts/register") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - Log.debug("A new account for username ${r.username} has been created") - return true + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun register(username: String, displayName: String, password: String, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Post, "/api/v2/accounts/register", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(AccountRegistrationRequest(username, displayName, password)) + } + ) + return if (response?.status?.isSuccess() == true) { + Log.debug("A new account for username '%s' has been created", username) + true } else { - val err: ApiErrorResponse = response.body() - throw err.to() + false } } @@ -342,67 +376,98 @@ class AccountsApi(private val client: HttpClient, private val authCookieHelper: /** * API wrapper for authentication handling (do not use directly; use the Api class instead) */ -class AuthApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** - * Try logging in with username and password for testing purposes, don't set the session cookie + * Try logging in with [username] and [password] for testing purposes, don't set the session cookie * * This method won't raise *any* exception, just return the boolean value if login worked. + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ suspend fun loginOnly(username: String, password: String): Boolean { - val response = client.post("/api/v2/auth/login") { - contentType(ContentType.Application.Json) - setBody(LoginRequest(username, password)) - } - return response.status.isSuccess() + val response = request( + HttpMethod.Post, "/api/v2/auth/login", + client, authHelper, + suppress = true, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(LoginRequest(username, password)) + } + ) + return response?.status?.isSuccess() == true } /** - * Try logging in with username and password + * Try logging in with [username] and [password] to get a new session * - * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate further API calls - */ - suspend fun login(username: String, password: String): Boolean { - return login(LoginRequest(username, password)) - } - - /** - * Try logging in with username and password + * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate + * further API calls and cache the username and password to refresh expired sessions. + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). * - * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate further API calls - */ - suspend fun login(r: LoginRequest): Boolean { - val response = client.post("/api/v2/auth/login") { - contentType(ContentType.Application.Json) - setBody(r) - } - if (response.status.isSuccess()) { - val authCookie = response.setCookie()["id"] + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun login(username: String, password: String, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Post, "/api/v2/auth/login", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(LoginRequest(username, password)) + }, + retry = { Log.error("Failed to login. See previous debug logs for details."); false } + ) + return if (response?.status?.isSuccess() == true) { + val authCookie = response.setCookie()[SESSION_COOKIE_NAME] Log.debug("Received new session cookie: $authCookie") if (authCookie != null) { - authCookieHelper.set(authCookie.value) + authHelper.setCookie( + authCookie.value, + authCookie.maxAge, + Pair(username, password) + ) + } else { + Log.error("No recognized, valid session cookie found in login response!") } - return true + true } else { - val err: ApiErrorResponse = response.body() - throw err.to() + false } } /** * Logs out the currently logged in user * - * This method will also clear the cookie on success only to avoid further authenticated API calls - */ - suspend fun logout(): Boolean { - val response = client.post("/api/v2/auth/logout") - if (response.status.isSuccess()) { - Log.debug("Logged out successfully (dropping session cookie...)") - authCookieHelper.unset() - return true + * This method will also clear the cookie and credentials to avoid further authenticated API calls. + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun logout(suppress: Boolean = true): Boolean { + val response = try { + request( + HttpMethod.Get, "/api/v2/auth/logout", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + } catch (e: Throwable) { + authHelper.unset() + Log.debug("Logout failed due to %s (%s), dropped session anyways", e, e.message) + return false + } + return if (response?.status?.isSuccess() == true) { + authHelper.unset() + Log.debug("Logged out successfully, dropped session") + true } else { - val err: ApiErrorResponse = response.body() - throw err.to() + authHelper.unset() + Log.debug("Logout failed for some reason, dropped session anyways") + false } } @@ -411,40 +476,51 @@ class AuthApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for chat room handling (do not use directly; use the Api class instead) */ -class ChatApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** - * Retrieve all messages a user has access to + * Retrieve all chats a user has access to * - * In the response, you will find different categories, currently friend rooms and lobby rooms. + * In the response, you will find different room types / room categories. + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetAllChatsResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun list(): GetAllChatsResponse { - val response = client.get("/api/v2/chats") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() + suspend fun list(suppress: Boolean = false): GetAllChatsResponse? { + val response = request( + HttpMethod.Get, "/api/v2/chats", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + if (response != null) { + val body: GetAllChatsResponseImpl = response.body() + return body.to() } + return null } /** - * Retrieve the messages of a chatroom + * Retrieve the messages of a chatroom identified by [roomUUID] + * + * The [ChatMessage]s should be sorted by their timestamps, [ChatMessage.createdAt]. + * The [ChatMessage.uuid] should be used to uniquely identify chat messages. This is + * needed as new messages may be delivered via WebSocket as well. [GetChatResponse.members] + * holds information about all members that are currently in the chat room (including yourself). + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetChatResponse] or an error). * - * [GetChatResponse.members] holds information about all members that are currently in the chat room (including yourself) + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun get(roomID: Long): GetChatResponse { - val response = client.get("/api/v2/chats/$roomID") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun get(roomUUID: UUID, suppress: Boolean = false): GetChatResponse? { + return request( + HttpMethod.Get, "/api/v2/chats/$roomUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() } } @@ -452,99 +528,112 @@ class ChatApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for friend handling (do not use directly; use the Api class instead) */ -class FriendApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class FriendApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** * Retrieve a pair of the list of your established friendships and the list of your open friendship requests (incoming and outgoing) + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a pair of lists or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun listAll(): Pair, List> { - val response = client.get("/api/v2/friends") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: GetFriendResponse = response.body() - return Pair(responseBody.friends, responseBody.friendRequests) - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun list(suppress: Boolean = false): Pair, List>? { + val body: GetFriendResponse? = request( + HttpMethod.Get, "/api/v2/friends", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + return if (body != null) Pair(body.friends, body.friendRequests) else null } /** * Retrieve a list of your established friendships + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a list of [FriendResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun listFriends(): List { - return listAll().first + suspend fun listFriends(suppress: Boolean = false): List? { + return list(suppress = suppress)?.first } /** * Retrieve a list of your open friendship requests (incoming and outgoing) * - * If you have a request with ``from`` equal to your username, it means you - * have requested a friendship, but the destination hasn't accepted yet. - * In the other case, if your username is in ``to``, you have received a friend request. - */ - suspend fun listRequests(): List { - return listAll().second - } - - /** - * Request friendship with another user + * If you have a request with [FriendRequestResponse.from] equal to your username, it means + * you have requested a friendship, but the destination hasn't accepted yet. In the other + * case, if your username is in [FriendRequestResponse.to], you have received a friend request. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a list of [FriendRequestResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun request(other: UUID): Boolean { - return request(CreateFriendRequest(other)) + suspend fun listRequests(suppress: Boolean = false): List? { + return list(suppress = suppress)?.second } /** * Request friendship with another user - */ - suspend fun request(r: CreateFriendRequest): Boolean { - val response = client.post("/api/v2/friends") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun request(other: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Post, "/api/v2/friends", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(CreateFriendRequest(other)) + }, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true } /** - * Accept a friend request + * Accept a friend request identified by [friendRequestUUID] + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun accept(friendRequestID: Long): Boolean { - val response = client.delete("/api/v2/friends/$friendRequestID") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - Log.debug("Successfully accepted friendship request ID $friendRequestID") - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun accept(friendRequestUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Put, "/api/v2/friends/$friendRequestUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true } /** * Don't want your friends anymore? Just delete them! * - * This function accepts both friend IDs and friendship request IDs, - * since they are the same thing in the server's database anyways. + * This function accepts both friend UUIDs and friendship request UUIDs. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun delete(friendID: Long): Boolean { - val response = client.delete("/api/v2/friends/$friendID") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - Log.debug("Successfully rejected/dropped friendship ID $friendID") - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun delete(friendUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Delete, "/api/v2/friends/$friendUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true } } @@ -552,7 +641,7 @@ class FriendApi(private val client: HttpClient, private val authCookieHelper: Au /** * API wrapper for game handling (do not use directly; use the Api class instead) */ -class GameApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class GameApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** * Retrieves an overview of all open games of a player @@ -563,40 +652,43 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth * differs from the last known identifier, the server has a newer * state of the game. The [GameOverviewResponse.lastActivity] field * is a convenience attribute and shouldn't be used for update checks. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [GameOverviewResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun list(): List { - val response = client.get("/api/v2/games") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val body: GetGameOverviewResponse = response.body() - return body.games - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun list(suppress: Boolean = false): List? { + val body: GetGameOverviewResponse? = request( + HttpMethod.Get, "/api/v2/games", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + return body?.games } /** - * Retrieves a single game which is currently open (actively played) + * Retrieves a single game identified by [gameUUID] which is currently open (actively played) + * + * + * Other than [list], this method's return value contains a full game state (on success). + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GameStateResponse] or an error). * - * If the game has been completed or aborted, it will - * respond with a GameNotFound in [ApiErrorResponse]. + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun get(gameUUID: UUID): GameStateResponse { - val response = client.get("/api/v2/games/$gameUUID") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - return response.body() - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun get(gameUUID: UUID, suppress: Boolean = false): GameStateResponse? { + return request( + HttpMethod.Get, "/api/v2/games/$gameUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() } /** - * Upload a new game state for an existing game + * Upload a new game state for an existing game identified by [gameUUID] * * If the game can't be updated (maybe it has been already completed * or aborted), it will respond with a GameNotFound in [ApiErrorResponse]. @@ -604,34 +696,27 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth * * On success, returns the new game data ID that can be used to verify * that the client and server use the same state (prevents re-querying). - */ - suspend fun upload(gameUUID: UUID, gameData: String): Long { - return upload(GameUploadRequest(gameData, gameUUID)) - } - - /** - * Upload a new game state for an existing game * - * If the game can't be updated (maybe it has been already completed - * or aborted), it will respond with a GameNotFound in [ApiErrorResponse]. + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [Long] or an error). * - * On success, returns the new game data ID that can be used to verify - * that the client and server use the same state (prevents re-querying). - */ - suspend fun upload(r: GameUploadRequest): Long { - val response = client.put("/api/v2/games") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: GameUploadResponse = response.body() - Log.debug("The game with ID ${r.gameUUID} has been uploaded, the new data ID is ${responseBody.gameDataID}") - return responseBody.gameDataID - } else { - val err: ApiErrorResponse = response.body() - throw err.to() + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun upload(gameUUID: UUID, gameData: String, suppress: Boolean = false): Long? { + val body: GameUploadResponse? = request( + HttpMethod.Put, "/api/v2/games/$gameUUID", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(GameUploadRequest(gameData)) + }, + retry = getDefaultRetry(client, authHelper) + )?.body() + if (body != null) { + Log.debug("The game with UUID $gameUUID has been uploaded, the new data ID is ${body.gameDataID}") } + return body?.gameDataID } } @@ -639,53 +724,49 @@ class GameApi(private val client: HttpClient, private val authCookieHelper: Auth /** * API wrapper for invite handling (do not use directly; use the Api class instead) */ -class InviteApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class InviteApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** * Retrieve all invites for the executing user - */ - suspend fun list(): List { - val response = client.get("/api/v2/invites") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: GetInvitesResponse = response.body() - return responseBody.invites - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } - } - - /** - * Invite a friend to a lobby * - * The executing user must be in the specified open lobby. - * The invited friend must not be in a friend request state. + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [GetInvite] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun new(friend: UUID, lobbyUUID: UUID): Boolean { - return new(CreateInviteRequest(friend, lobbyUUID)) + suspend fun list(suppress: Boolean = false): List? { + val body: GetInvitesResponse? = request( + HttpMethod.Get, "/api/v2/invites", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + return body?.invites } /** * Invite a friend to a lobby * - * The executing user must be in the specified open lobby. - * The invited friend must not be in a friend request state. - */ - suspend fun new(r: CreateInviteRequest): Boolean { - val response = client.post("/api/v2/invites") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - Log.debug("The friend ${r.friendUUID} has been invited to lobby ${r.lobbyUUID}") - return true - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + * The executing user must be in the specified open lobby. The invited + * friend (identified by its [friendUUID]) must not be in a friend request state. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun new(friendUUID: UUID, lobbyUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Post, "/api/v2/friends", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(CreateInviteRequest(friendUUID, lobbyUUID)) + }, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true } } @@ -693,67 +774,78 @@ class InviteApi(private val client: HttpClient, private val authCookieHelper: Au /** * API wrapper for lobby handling (do not use directly; use the Api class instead) */ -class LobbyApi(private val client: HttpClient, private val authCookieHelper: AuthCookieHelper) { +class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelper) { /** * Retrieves all open lobbies * - * If hasPassword is true, the lobby is secured by a user-set password + * If [LobbyResponse.hasPassword] is true, the lobby is secured by a user-set password. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [LobbyResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun list(): List { - val response = client.get("/api/v2/lobbies") { - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: GetLobbiesResponse = response.body() - return responseBody.lobbies - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + suspend fun list(suppress: Boolean = false): List? { + val body: GetLobbiesResponse? = request( + HttpMethod.Get, "/api/v2/lobbies", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + return body?.lobbies } /** - * Create a new lobby and return the new lobby ID + * Create a new lobby and return the new lobby with some extra info as [CreateLobbyResponse] + * + * You can't be in more than one lobby at the same time. If [password] is set, the lobby + * will be considered closed. Users need the specified [password] to be able to join the + * lobby on their own behalf. Invites to the lobby are always possible as lobby creator. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [CreateLobbyResponse] or an error). * - * If you are already in another lobby, an error is returned. - * ``max_players`` must be between 2 and 34 (inclusive). + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun open(name: String, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS): UUID { - return open(CreateLobbyRequest(name, null, maxPlayers)) + suspend fun open(name: String, password: String? = null, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS, suppress: Boolean = false): CreateLobbyResponse? { + return open(CreateLobbyRequest(name, password, maxPlayers), suppress) } /** - * Create a new lobby and return the new lobby ID + * Create a new private lobby and return the new lobby with some extra info as [CreateLobbyResponse] + * + * You can't be in more than one lobby at the same time. *Important*: + * This lobby will be created with a random password which will *not* be stored. + * Other users can't join without invitation to this lobby, afterwards. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [CreateLobbyResponse] or an error). * - * If you are already in another lobby, an error is returned. - * ``max_players`` must be between 2 and 34 (inclusive). - * If password is an empty string, an error is returned. + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun open(name: String, password: String?, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS): UUID { - return open(CreateLobbyRequest(name, password, maxPlayers)) + suspend fun openPrivate(name: String, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS, suppress: Boolean = false): CreateLobbyResponse? { + val charset = ('a'..'z') + ('A'..'Z') + ('0'..'9') + val password = (1..DEFAULT_RANDOM_PASSWORD_LENGTH) + .map { charset.random() } + .joinToString("") + return open(CreateLobbyRequest(name, password, maxPlayers), suppress) } /** - * Create a new lobby and return the new lobby ID - * - * If you are already in another lobby, an error is returned. - * ``max_players`` must be between 2 and 34 (inclusive). - * If password is an empty string, an error is returned. + * Endpoint implementation to create a new lobby */ - suspend fun open(r: CreateLobbyRequest): UUID { - val response = client.post("/api/v2/lobbies") { - contentType(ContentType.Application.Json) - setBody(r) - authCookieHelper.add(this) - } - if (response.status.isSuccess()) { - val responseBody: CreateLobbyResponse = response.body() - return responseBody.lobbyUUID - } else { - val err: ApiErrorResponse = response.body() - throw err.to() - } + private suspend fun open(req: CreateLobbyRequest, suppress: Boolean): CreateLobbyResponse? { + return request( + HttpMethod.Post, "/api/v2/lobbies", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(req) + }, + retry = getDefaultRetry(client, authHelper) + )?.body() } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index b75e5f37950f6..eb9c0c7a958ff 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -73,6 +73,8 @@ internal class WebSocketMessageSerializer : JsonContentPolymorphicSerializer ClientDisconnectedMessage.serializer() WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer() WebSocketMessageType.IncomingChatMessage -> IncomingChatMessageMessage.serializer() + WebSocketMessageType.IncomingInvite -> IncomingInviteMessage.serializer() + WebSocketMessageType.GameStarted -> GameStartedMessage.serializer() } } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt index e045208015551..89c14b2b97ed0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -57,15 +57,12 @@ data class CreateLobbyRequest( /** * The request a user sends to the server to upload a new game state (non-WebSocket API) * - * The [gameUUID] was received by the server in a previous get or create API call. + * The game's UUID has to be set via the path argument of the endpoint. */ @Serializable data class GameUploadRequest( @SerialName("game_data") - val gameData: String, - @SerialName("game_uuid") - @Serializable(with = UUIDSerializer::class) - val gameUUID: UUID + val gameData: String ) /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index f067bf3d795f6..efca4a1cbd447 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -25,8 +25,9 @@ data class UpdateGameData( */ @Serializable data class ClientDisconnected( - @SerialName("gameId") - val gameID: Long, + @SerialName("gameUuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, @Serializable(with = UUIDSerializer::class) val uuid: UUID // client identifier ) @@ -36,8 +37,9 @@ data class ClientDisconnected( */ @Serializable data class ClientReconnected( - @SerialName("gameId") - val gameID: Long, + @SerialName("gameUuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, @Serializable(with = UUIDSerializer::class) val uuid: UUID // client identifier ) @@ -47,11 +49,45 @@ data class ClientReconnected( */ @Serializable data class IncomingChatMessage( - @SerialName("chatId") - val chatID: Long, + @SerialName("chatUuid") + @Serializable(with = UUIDSerializer::class) + val chatUUID: UUID, val message: ChatMessage ) +/** + * An invite to a lobby is sent to the client + */ +@Serializable +data class IncomingInvite( + @SerialName("inviteUuid") + @Serializable(with = UUIDSerializer::class) + val inviteUUID: UUID, + val from: AccountResponse, + @SerialName("lobbyUuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID +) + +/** + * The notification for the clients that a new game has started + */ +@Serializable +data class GameStarted( + @SerialName("gameUuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, + @SerialName("gameChatUuid") + @Serializable(with = UUIDSerializer::class) + val gameChatUUID: UUID, + @SerialName("lobbyUuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + @SerialName("lobbyChatUuid") + @Serializable(with = UUIDSerializer::class) + val lobbyChatUUID: UUID, +) + /** * The base WebSocket message, encapsulating only the type of the message */ @@ -103,6 +139,24 @@ data class IncomingChatMessageMessage ( val content: IncomingChatMessage ) : WebSocketMessage +/** + * Message to indicate that a client gets invited to a lobby + */ +@Serializable +data class IncomingInviteMessage ( + override val type: WebSocketMessageType, + val content: IncomingInvite +) : WebSocketMessage + +/** + * Message to indicate that a game started + */ +@Serializable +data class GameStartedMessage ( + override val type: WebSocketMessageType, + val content: GameStarted +) : WebSocketMessage + /** * Type enum of all known WebSocket messages */ @@ -112,7 +166,9 @@ enum class WebSocketMessageType(val type: String) { UpdateGameData("updateGameData"), ClientDisconnected("clientDisconnected"), ClientReconnected("clientReconnected"), - IncomingChatMessage("incomingChatMessage"); + IncomingChatMessage("incomingChatMessage"), + IncomingInvite("incomingInvite"), + GameStarted("gameStarted"); companion object { private val VALUES = values() From 0e5953b11060e28f68cd1032675ab24e3d5bef3f Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 29 Mar 2023 04:38:22 +0200 Subject: [PATCH 053/152] Updated the auth helper, added the UncivNetworkException --- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 25 +++---- .../multiplayer/apiv2/AuthCookieHelper.kt | 37 ---------- .../logic/multiplayer/apiv2/AuthHelper.kt | 69 +++++++++++++++++++ .../apiv2/UncivNetworkException.kt | 11 +++ 4 files changed, 91 insertions(+), 51 deletions(-) delete mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 81682ab67dc7c..4afdfdbd650d0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -1,7 +1,3 @@ -/** - * TODO: Comment this file - */ - package com.unciv.logic.multiplayer.apiv2 import com.unciv.UncivGame @@ -59,8 +55,9 @@ open class ApiV2Wrapper(private val baseUrl: String) { } } - // Helper that replaces library cookie storages to fix cookie serialization problems - private val authCookieHelper = AuthCookieHelper() + /** Helper that replaces library cookie storages to fix cookie serialization problems and keeps + * track of user-supplied credentials to be able to refresh expired sessions on the fly */ + private val authHelper = AuthHelper() // Queue to keep references to all opened WebSocket handler jobs private var websocketJobs = ConcurrentLinkedQueue() @@ -84,37 +81,37 @@ open class ApiV2Wrapper(private val baseUrl: String) { /** * API for account management */ - internal val account = AccountsApi(client, authCookieHelper) + internal val account = AccountsApi(client, authHelper) /** * API for authentication management */ - internal val auth = AuthApi(client, authCookieHelper) + internal val auth = AuthApi(client, authHelper) /** * API for chat management */ - internal val chat = ChatApi(client, authCookieHelper) + internal val chat = ChatApi(client, authHelper) /** * API for friendship management */ - internal val friend = FriendApi(client, authCookieHelper) + internal val friend = FriendApi(client, authHelper) /** * API for game management */ - internal val game = GameApi(client, authCookieHelper) + internal val game = GameApi(client, authHelper) /** * API for invite management */ - internal val invite = InviteApi(client, authCookieHelper) + internal val invite = InviteApi(client, authHelper) /** * API for lobby management */ - internal val lobby = LobbyApi(client, authCookieHelper) + internal val lobby = LobbyApi(client, authHelper) /** * Start a new WebSocket connection @@ -131,7 +128,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { try { val session = client.webSocketSession { method = HttpMethod.Get - authCookieHelper.add(this) + authHelper.add(this) url { takeFrom(baseUrl) protocol = URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt deleted file mode 100644 index 50c50cd5378e3..0000000000000 --- a/core/src/com/unciv/logic/multiplayer/apiv2/AuthCookieHelper.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.unciv.logic.multiplayer.apiv2 - -import io.ktor.client.request.* -import io.ktor.http.* - -const val cookieName = "id" - -/** - * Simple authentication cookie helper which doesn't support multiple cookies, but just does the job correctly - * - * Do not use [HttpCookies] since the url-encoded cookie values break the authentication flow. - */ -class AuthCookieHelper { - private var cookieValue: String? = null - - fun set(value: String) { - cookieValue = value - } - - fun unset() { - cookieValue = null - } - - fun get(): String? { - return cookieValue - } - - fun add(request: HttpRequestBuilder) { - val currentValue = cookieValue - request.headers - if (currentValue != null) { - request.header(HttpHeaders.Cookie, encodeCookieValue( - "$cookieName=$currentValue", encoding = CookieEncoding.RAW - )) - } - } -} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt new file mode 100644 index 0000000000000..13914e8afdb2c --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt @@ -0,0 +1,69 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.utils.Log +import io.ktor.client.request.* +import io.ktor.http.* +import java.time.Instant +import java.util.* +import java.util.concurrent.atomic.AtomicReference + +/** + * Name of the session cookie returned and expected by the server + */ +internal const val SESSION_COOKIE_NAME = "id" + +/** + * Authentication helper which doesn't support multiple cookies, but just does the job correctly + * + * It also stores the username and password as well as the timestamp of the last successful login. + * Do not use HttpCookies since the url-encoded cookie values break the authentication flow. + */ +class AuthHelper { + + /** Value of the last received session cookie (pair of cookie value and max age) */ + private var cookie: AtomicReference?> = AtomicReference() + + /** Credentials used during the last successful login */ + internal var lastSuccessfulCredentials: AtomicReference?> = AtomicReference() + + /** Timestamp of the last successful login */ + private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() + + /** User identification on the server, may be null if unset or not logged in */ + var user: UUID? = null + + /** + * Set the session cookie, update the last refresh timestamp and the last successful credentials + */ + internal fun setCookie(value: String, maxAge: Int? = null, credentials: Pair? = null) { + cookie.set(Pair(value, maxAge)) + lastSuccessfulAuthentication.set(Instant.now()) + lastSuccessfulCredentials.set(credentials) + } + + /** + * Drop the session cookie and credentials, so that authenticating won't be possible until re-login + */ + internal fun unset() { + cookie.set(null) + lastSuccessfulCredentials.set(null) + } + + /** + * Add authentication to the request builder by adding the Cookie header + */ + fun add(request: HttpRequestBuilder) { + val value = cookie.get() + if (value != null) { + if ((lastSuccessfulAuthentication.get()?.plusSeconds((value.second ?: 0).toLong()) ?: Instant.MIN) < Instant.now()) { + Log.debug("Session cookie might have already expired") + } + // Using the raw cookie encoding ensures that valid base64 characters are not re-url-encoded + request.header(HttpHeaders.Cookie, encodeCookieValue( + "$SESSION_COOKIE_NAME=${value.first}", encoding = CookieEncoding.RAW + )) + } else { + Log.debug("Session cookie is not available") + } + } +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt b/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt new file mode 100644 index 0000000000000..01019d6f323e1 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt @@ -0,0 +1,11 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.logic.UncivShowableException + +/** + * Subclass of [UncivShowableException] indicating network errors (timeout, connection refused and so on) + */ +class UncivNetworkException : UncivShowableException { + constructor(cause: Throwable) : super("An unexpected network error occurred.", cause) + constructor(text: String, cause: Throwable?) : super(text, cause) +} From 93d9327ef4c5e033f271663e5f205f8693bfd421 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Mar 2023 00:28:38 +0200 Subject: [PATCH 054/152] Fixed some more issues due to refactoring APIv2 handler --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 30 +++++++++++-------- .../storage/ApiV2FileStorageEmulator.kt | 4 +-- .../storage/OnlineMultiplayerFiles.kt | 17 +++++------ .../multiplayerscreens/MultiplayerScreenV2.kt | 28 ++++++++++------- 4 files changed, 45 insertions(+), 34 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 220938b357322..48a00a0eb3059 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -167,7 +167,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** * Fetch server's details about a game based on its game ID * - * @throws MultiplayerFileNotFoundException: if the gameId can't be resolved on the server + * @throws MultiplayerFileNotFoundException: if the [gameId] can't be resolved on the server */ suspend fun getGameDetails(gameId: UUID): GameDetails { val result = gameDetails[gameId] @@ -181,7 +181,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** * Fetch server's details about a game based on its game ID * - * @throws MultiplayerFileNotFoundException: if the gameId can't be resolved on the server + * @throws MultiplayerFileNotFoundException: if the [gameId] can't be resolved on the server */ suspend fun getGameDetails(gameId: String): GameDetails { return getGameDetails(UUID.fromString(gameId)) @@ -191,14 +191,14 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { * Refresh the cache of known multiplayer games, [gameDetails] */ private suspend fun refreshGameDetails() { - val currentGames = game.list() + val currentGames = game.list()!! for (entry in gameDetails.keys) { if (entry !in currentGames.map { it.gameUUID }) { gameDetails.remove(entry) } } for (g in currentGames) { - gameDetails[g.gameUUID] = TimedGameDetails(Instant.now(), g.gameUUID, g.chatRoomID, g.gameDataID, g.name) + gameDetails[g.gameUUID] = TimedGameDetails(Instant.now(), g.gameUUID, g.chatRoomUUID, g.gameDataID, g.name) } } @@ -248,10 +248,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { WebSocketMessageType.InvalidMessage -> { Log.debug("Received invalid message from WebSocket connection") } - WebSocketMessageType.FinishedTurn -> { - // This message type is not meant to be received from the server - Log.debug("Received FinishedTurn message from WebSocket connection") - } WebSocketMessageType.UpdateGameData -> { // TODO /* @@ -276,6 +272,14 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { Log.debug("Received IncomingChatMessage message from WebSocket connection") // TODO: Implement chat message handling } + WebSocketMessageType.IncomingInvite -> { + Log.debug("Received IncomingInvite message from WebSocket connection") + // TODO: Implement invite handling + } + WebSocketMessageType.GameStarted -> { + Log.debug("Received GameStarted message from WebSocket connection") + // TODO: Implement game start handling + } } } @@ -349,14 +353,14 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** * Small struct to store the most relevant details about a game, useful for caching * - * Note that those values may become invalid (especially the [dataId]), so use it only for - * caching for short durations. The [chatRoomId] may be valid longer (up to the game's lifetime). + * Note that those values may become invalid (especially the [dataID]), so use it only for + * caching for short durations. The [chatRoomUUID] may be valid longer (up to the game's lifetime). */ -data class GameDetails(val gameId: UUID, val chatRoomId: Long, val dataId: Long, val name: String) +data class GameDetails(val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: Long, val name: String) /** * Holding the same values as [GameDetails], but with an instant determining the last refresh */ -private data class TimedGameDetails(val refreshed: Instant, val gameId: UUID, val chatRoomId: Long, val dataId: Long, val name: String) { - fun to() = GameDetails(gameId, chatRoomId, dataId, name) +private data class TimedGameDetails(val refreshed: Instant, val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: Long, val name: String) { + fun to() = GameDetails(gameUUID, chatRoomUUID, dataID, name) } diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index c08668813d931..526a95fbb39a4 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -22,7 +22,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { override suspend fun loadGameData(gameId: String): String { val uuid = UUID.fromString(gameId.lowercase()) - return api.game.get(uuid).gameData + return api.game.get(uuid)!!.gameData } override suspend fun loadPreviewData(gameId: String): String { @@ -51,7 +51,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { } override suspend fun setPassword(newPassword: String): Boolean { - api.account.setPassword("", newPassword) + api.account.setPassword(newPassword, suppress = true) // TODO: Not yet implemented return false } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index 93a4387af0f61..d74bd20517da2 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -6,7 +6,7 @@ import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.ApiVersion -import kotlinx.coroutines.runBlocking +import com.unciv.utils.Log import java.io.FileNotFoundException /** @@ -36,16 +36,15 @@ class OnlineMultiplayerFiles( return if (identifier == Constants.dropboxMultiplayerServer) { DropBox } else { - runBlocking { UncivGame.Current.onlineMultiplayer.awaitInitialized() } + 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.api.isAuthenticated()) { - return ApiV2FileStorageWrapper.storage!! - } else { - if (runBlocking { ApiV2FileStorageWrapper.api!!.refreshSession() }) { - return ApiV2FileStorageWrapper.storage!! - } - throw MultiplayerAuthException(null) + if (!UncivGame.Current.onlineMultiplayer.hasAuthentication() && !UncivGame.Current.onlineMultiplayer.api.isAuthenticated()) { + Log.error("User credentials not available, further execution may result in errors!") } + return ApiV2FileStorageWrapper.storage!! + } UncivServerFileStorage.apply { serverUrl = identifier!! diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index bfc41c11647b6..c42193d7b39bb 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -1,13 +1,14 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.UncivShowableException import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.MultiplayerGameDeleted import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ApiException +import com.unciv.logic.multiplayer.apiv2.FriendResponse import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse -import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse import com.unciv.models.translations.tr import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable @@ -20,14 +21,12 @@ import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency -import java.time.Instant -import java.util.* import com.unciv.ui.components.AutoScrollPane as ScrollPane class MultiplayerScreenV2 : PickerScreen() { private var selectedGame: Pair? = null // pair of game UUID to file handle private var cachedGames: Map = mutableMapOf() - private var cachedFriendResponse: Triple, List, List>? = null + private var cachedFriendResponse: Triple, List, List>? = null private val leftSideTable = Table() // list friend requests, then online friends, then offline friends, see recreateLeftSideTable() private val rightSideTable = Table() // list open games to re-join quickly @@ -95,11 +94,17 @@ class MultiplayerScreenV2 : PickerScreen() { */ private suspend fun reloadFriendList() { try { - cachedFriendResponse = game.onlineMultiplayer.getFriends() + val (friends, requests) = game.onlineMultiplayer.api.friend.list()!! + val myUUID = game.onlineMultiplayer.api.account.get()!!.uuid + cachedFriendResponse = Triple( + friends, + requests.filter { it.to.uuid == myUUID }.map{ it.from }, + requests.filter { it.from.uuid == myUUID }.map { it.to } + ) Concurrency.runOnGLThread { recreateLeftSideTable() } - } catch (e: ApiException) { + } catch (e: UncivShowableException) { Concurrency.runOnGLThread { InfoPopup(stage, e.localizedMessage) } @@ -112,7 +117,7 @@ class MultiplayerScreenV2 : PickerScreen() { private suspend fun reloadGameList() { try { // Map of game UUID to game overview - val newCachedGames = game.onlineMultiplayer.api.games.list().associateBy({ it.gameUUID.toString() }, { it }) + val newCachedGames = game.onlineMultiplayer.api.game.list()!!.associateBy({ it.gameUUID.toString() }, { it }) Concurrency.runOnGLThread { if (selectedGame != null && !newCachedGames.containsKey(selectedGame!!.first)) { unselectGame() @@ -150,6 +155,7 @@ class MultiplayerScreenV2 : PickerScreen() { leftSideTable.add("${it.displayName} wants to be your friend".toLabel()) val btn = "Options".toTextButton() btn.onClick { + // TODO: Implement friend request options ToastPopup("Options are not implemented yet", stage) } leftSideTable.add(btn) @@ -158,14 +164,16 @@ class MultiplayerScreenV2 : PickerScreen() { if (cachedFriendResponse!!.first.isNotEmpty()) { anything = true + // TODO: Verify that this sorting is stable, i.e. the first section is online, then sorted alphabetically cachedFriendResponse?.first!!.sortedBy { - it.displayName + it.friend.username }.sortedBy { - if (it.online) 0 else 1 + if (it.friend.online) 0 else 1 }.forEach {// alphabetically sorted friends - leftSideTable.add("${it.displayName} (${if (it.online) "online" else "offline"})".toLabel()) + leftSideTable.add("${it.friend.displayName} (${if (it.friend.online) "online" else "offline"})".toLabel()) val btn = "Options".toTextButton() btn.onClick { + // TODO: Implement friend options ToastPopup("Options are not implemented yet", stage) } leftSideTable.add(btn) From f83a77a91a6b79b4d0dd60e396af1825f306ca15 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Mar 2023 02:54:12 +0200 Subject: [PATCH 055/152] Fixed some issues and some minor incompatibilities with the new API --- .../com/unciv/logic/multiplayer/FriendList.kt | 2 + .../logic/multiplayer/OnlineMultiplayer.kt | 23 +-------- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 4 ++ .../apiv2/EndpointImplementations.kt | 48 +++++++++++-------- .../multiplayerscreens/ChatRoomScreen.kt | 4 +- .../screens/worldscreen/WorldScreenTopBar.kt | 2 +- 6 files changed, 39 insertions(+), 44 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/FriendList.kt b/core/src/com/unciv/logic/multiplayer/FriendList.kt index e50f5deb48cc8..c01f17d10e0db 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.* 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 a347e8f91534d..e2a830a195153 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -10,11 +10,7 @@ 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.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ApiV2 -import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse -import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator -import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException @@ -26,8 +22,6 @@ import com.unciv.utils.concurrency.Dispatcher import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug -import io.ktor.client.plugins.websocket.* -import io.ktor.websocket.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -95,7 +89,8 @@ class OnlineMultiplayer { Log.debug("Server at '$baseUrl' detected API version: $apiVersion") checkServerStatus() startPollChecker() - isAliveAPIv1() // this is called for any API version since it sets the featureSet implicitly + featureSet = ServerFeatureSet() // setting this here to fix problems for non-network games + isAliveAPIv1() // this is called for any API version since it updates the featureSet implicitly if (apiVersion == ApiVersion.APIv2) { if (hasAuthentication()) { apiImpl.initialize(Pair(settings.multiplayer.userName, settings.multiplayer.passwords[baseUrl]?:"")) @@ -294,20 +289,6 @@ class OnlineMultiplayer { return true } - /** - * Load all friends and friend requests (split by incoming and outgoing) of the currently logged-in user - */ - suspend fun getFriends(): Triple, List, List> { - val (friends, requests) = apiImpl.friend.listAll() - // TODO: The user's UUID should be cached, when this class is extended to a game manager class - val myUUID = apiImpl.account.get().uuid - return Triple( - friends.map { it.to }, - requests.filter { it.to.uuid == myUUID }.map{ it.from }, - requests.filter { it.from.uuid == myUUID }.map { it.to } - ) - } - /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerFileNotFoundException if the file can't be found diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 4afdfdbd650d0..ecb35899c32b0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -46,6 +46,10 @@ open class ApiV2Wrapper(private val baseUrl: String) { isLenient = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 5000 + connectTimeoutMillis = 3000 + } install(WebSockets) { pingInterval = 90_000 contentConverter = KotlinxWebsocketSerializationConverter(Json) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 61c5a0e477853..c96b8d24a49db 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -13,6 +13,7 @@ import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* +import io.ktor.util.network.* import java.io.IOException import java.util.* @@ -66,26 +67,33 @@ private suspend fun request( // Perform the request, but handle network issues gracefully according to the specified exceptions val response = try { client.request(builder) - } catch (e: IOException) { - val shouldRetry = if (retry != null) { - Log.debug("Calling retry coroutine %s for network error %s in '%s %s'", retry, e, method, endpoint) - retry() - } else { - false - } - return if (shouldRetry) { - Log.debug("Retrying after network error %s: %s (cause: %s)", e, e.message, e.cause) - request(method, endpoint, client, authHelper, - refine = refine, - suppress = suppress, - retry = null - ) - } else if (suppress) { - Log.debug("Suppressed network error %s: %s (cause: %s)", e, e.message, e.cause) - null - } else { - Log.debug("Throwing network error %s: %s (cause: %s)", e, e.message, e.cause) - throw UncivNetworkException(e) + } catch (e: Throwable) { + when (e) { + // This workaround allows to catch multiple exception types at the same time + // See https://youtrack.jetbrains.com/issue/KT-7128 if you want this feature in Kotlin :) + is IOException, is UnresolvedAddressException -> { + val shouldRetry = if (retry != null) { + Log.debug("Calling retry coroutine %s for network error %s in '%s %s'", retry, e, method, endpoint) + retry() + } else { + false + } + return if (shouldRetry) { + Log.debug("Retrying after network error %s: %s (cause: %s)", e, e.message, e.cause) + request(method, endpoint, client, authHelper, + refine = refine, + suppress = suppress, + retry = null + ) + } else if (suppress) { + Log.debug("Suppressed network error %s: %s (cause: %s)", e, e.message, e.cause) + null + } else { + Log.debug("Throwing network error %s: %s (cause: %s)", e, e.message, e.cause) + throw UncivNetworkException(e) + } + } + else -> throw e } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 9ab0b24454ad7..1ff2c430fff85 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -23,7 +23,7 @@ import kotlin.collections.ArrayList import kotlin.math.max import com.unciv.ui.components.AutoScrollPane as ScrollPane -class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { +class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { private val messageTable = Table() @@ -42,7 +42,7 @@ class ChatRoomScreen(private val chatRoomID: Long) : PickerScreen() { rightSideButton.enable() rightSideButton.onClick { val ask = AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { - Log.debug("Sending '$it' to room $chatRoomID") // TODO: Implement this + Log.debug("Sending '$it' to room $chatRoomUUID") // TODO: Implement this }) ask.open() } diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 7b0b2e1143851..10af68487c760 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -186,7 +186,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { try { val details = worldScreen.game.onlineMultiplayer.api.getGameDetails(worldScreen.gameInfo.gameId) Concurrency.runOnGLThread { - worldScreen.game.pushScreen(ChatRoomScreen(details.chatRoomId)) + worldScreen.game.pushScreen(ChatRoomScreen(details.chatRoomUUID)) } } catch (e: MultiplayerFileNotFoundException) { Concurrency.runOnGLThread { From 148eee6d26f9e1a58a7cf87c194e47b4558b483a Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Mar 2023 02:54:57 +0200 Subject: [PATCH 056/152] Allow storing the result of the register/auth popup persistently --- core/src/com/unciv/ui/popups/RegisterLoginPopup.kt | 8 ++++++++ core/src/com/unciv/ui/popups/options/MultiplayerTab.kt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index 788a99e2cc430..d793e00d8f3c7 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -49,6 +49,10 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> 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() authSuccessful?.invoke(success) @@ -76,6 +80,10 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> 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(stage, "Successfully registered new account".tr()) { diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index a7d7af55b44cb..a58f1acfa0715 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -165,7 +165,7 @@ private fun addMultiplayerServerOptions( 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 From feed93d1d94acaf80dee2ebbcf1b9cb2edbc9750 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Mar 2023 03:23:32 +0200 Subject: [PATCH 057/152] Improved lobby handling, added a InfoPopup wrapper --- core/src/com/unciv/ui/popups/InfoPopup.kt | 20 +++++++++++ .../unciv/ui/popups/options/MultiplayerTab.kt | 2 ++ .../multiplayerscreens/ChatRoomScreen.kt | 1 + .../multiplayerscreens/LobbyBrowserScreen.kt | 35 +++++++++++-------- .../multiplayerscreens/MultiplayerScreenV2.kt | 17 ++++++--- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 89adfeeeee20c..8ce667047fae4 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -2,7 +2,10 @@ package com.unciv.ui.popups import com.badlogic.gdx.scenes.scene2d.Stage import com.badlogic.gdx.utils.Align +import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException import com.unciv.ui.components.extensions.toLabel +import com.unciv.utils.concurrency.Concurrency /** Variant of [Popup] with one label and a cancel button * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] @@ -25,4 +28,21 @@ open class InfoPopup( open() } + companion object { + + /** + * Wrap the execution of a coroutine to display an [InfoPopup] when a [UncivShowableException] occurs + */ + suspend fun wrap(stage: Stage, function: suspend () -> T): T? { + try { + return function() + } catch (e: UncivShowableException) { + Concurrency.runOnGLThread { + InfoPopup(stage, e.localizedMessage) + } + } + return null + } + + } } diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index a58f1acfa0715..c71ab677f3dc3 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -295,6 +295,8 @@ private fun addTurnCheckerOptions( private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { Concurrency.run("TestIsAlive") { try { + // TODO: Detecting API version changes doesn't work without game restart yet, + // therefore this server check will almost certainly fail when the server changes val connectionSuccess = UncivGame.Current.onlineMultiplayer.checkServerStatus() var authSuccess = false if (connectionSuccess) { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 1ff2c430fff85..8436bf1f580e1 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -44,6 +44,7 @@ class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { val ask = AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { Log.debug("Sending '$it' to room $chatRoomUUID") // TODO: Implement this }) + ask.width = max(ask.width, stage.width / 1.5f) ask.open() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index c187d6c8e659e..816557f8dfac3 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -2,6 +2,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.* import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException import com.unciv.logic.multiplayer.apiv2.LobbyResponse import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.popups.Popup @@ -10,9 +11,9 @@ import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.InfoPopup import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency -import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.ui.components.AutoScrollPane as ScrollPane /** @@ -37,19 +38,9 @@ class LobbyBrowserScreen : PickerScreen() { leftSideTable.add(noLobbies.toLabel()).row() leftSideTable.add(updateListButton).padTop(30f).row() - Concurrency.run("Update lobby list") { - val listOfOpenLobbies = UncivGame.Current.onlineMultiplayer.api.lobby.list() - launchOnGLThread { - refreshLobbyList(listOfOpenLobbies) - } - } + triggerUpdateLobbyList() updateListButton.onClick { - Concurrency.run("Update lobby list") { - val listOfOpenLobbies = UncivGame.Current.onlineMultiplayer.api.lobby.list() - launchOnGLThread { - refreshLobbyList(listOfOpenLobbies) - } - } + triggerUpdateLobbyList() } // The functionality of joining a lobby will be added on-demand in [refreshLobbyList] @@ -80,6 +71,22 @@ class LobbyBrowserScreen : PickerScreen() { rightSideTable.add(noLobbySelected.toLabel()).padBottom(10f).row() } + /** + * Detach updating the list of lobbies in another coroutine + */ + private fun triggerUpdateLobbyList() { + Concurrency.run("Update lobby list") { + val listOfOpenLobbies = InfoPopup.wrap(stage) { + UncivGame.Current.onlineMultiplayer.api.lobby.list()!! + } + if (listOfOpenLobbies != null) { + Concurrency.runOnGLThread { + refreshLobbyList(listOfOpenLobbies) + } + } + } + } + /** * Update the right side table with details about a specific lobby */ @@ -113,7 +120,7 @@ class LobbyBrowserScreen : PickerScreen() { updateRightSideTable(lobby) // TODO: Un-selecting a lobby is not implemented yet rightSideButton.onClick { - Log.debug("Joining lobby '${lobby.name}' (ID ${lobby.id})") + Log.debug("Joining lobby '${lobby.name}' (UUID ${lobby.uuid})") } rightSideButton.enable() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index c42193d7b39bb..1c81d3ad33230 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -10,6 +10,8 @@ import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.logic.multiplayer.apiv2.FriendResponse import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse import com.unciv.models.translations.tr +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.brighten import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick @@ -143,12 +145,12 @@ class MultiplayerScreenV2 : PickerScreen() { leftSideTable.defaults().pad(10.0f) if (cachedFriendResponse == null) { - leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).row() + leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).center().row() } else { var anything = false if (cachedFriendResponse!!.second.isNotEmpty()) { anything = true - leftSideTable.add("Friend requests".toLabel()).colspan(2).row() + leftSideTable.add("Friend requests".toLabel()).colspan(2).center().row() cachedFriendResponse?.second!!.sortedBy { it.displayName }.forEach { // incoming friend requests @@ -158,12 +160,16 @@ class MultiplayerScreenV2 : PickerScreen() { // TODO: Implement friend request options ToastPopup("Options are not implemented yet", stage) } - leftSideTable.add(btn) + leftSideTable.add(btn).row() } } if (cachedFriendResponse!!.first.isNotEmpty()) { + if (anything) { + leftSideTable.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f)) + } anything = true + leftSideTable.add("Friends".toLabel()).colspan(2).center().row() // TODO: Verify that this sorting is stable, i.e. the first section is online, then sorted alphabetically cachedFriendResponse?.first!!.sortedBy { it.friend.username @@ -176,15 +182,16 @@ class MultiplayerScreenV2 : PickerScreen() { // TODO: Implement friend options ToastPopup("Options are not implemented yet", stage) } - leftSideTable.add(btn) + leftSideTable.add(btn).row() } } if (!anything) { - leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).row() + leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).center().row() } } + leftSideTable.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f)) leftSideTable.add(updateFriendListButton) leftSideTable.add(requestFriendshipButton).row() } From 4d921fdaf4544fbf019ae5a94c8696f97108d57b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 01:53:21 +0200 Subject: [PATCH 058/152] Updated the InfoPopup, added a common interface for map options table --- core/src/com/unciv/ui/popups/InfoPopup.kt | 22 ++++++++----------- .../multiplayerscreens/MultiplayerScreenV2.kt | 4 ++-- .../newgamescreen/MapFileSelectTable.kt | 4 ++-- .../newgamescreen/MapOptionsInterface.kt | 14 ++++++++++++ .../screens/newgamescreen/MapOptionsTable.kt | 2 +- .../ui/screens/newgamescreen/NewGameScreen.kt | 12 +++++----- 6 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/newgamescreen/MapOptionsInterface.kt diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 8ce667047fae4..4cecea95a1350 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -1,30 +1,25 @@ package com.unciv.ui.popups import com.badlogic.gdx.scenes.scene2d.Stage -import com.badlogic.gdx.utils.Align -import com.unciv.UncivGame import com.unciv.logic.UncivShowableException -import com.unciv.ui.components.extensions.toLabel import com.unciv.utils.concurrency.Concurrency /** Variant of [Popup] with one label and a cancel button * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] - * @param text The text for the label + * @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 */ open class InfoPopup( stageToShowOn: Stage, - text: String, + vararg texts: String, action: (() -> Unit)? = null ) : Popup(stageToShowOn) { - /** The [Label][com.badlogic.gdx.scenes.scene2d.ui.Label] created for parameter `text` for optional layout tweaking */ - private val label = text.toLabel() - init { - label.setAlignment(Align.center) - add(label).colspan(2).row() - addCloseButton(action = action) + for (element in texts) { + addGoodSizedLabel(element).row() + } + addCloseButton(action = action).row() open() } @@ -33,16 +28,17 @@ open class InfoPopup( /** * Wrap the execution of a coroutine to display an [InfoPopup] when a [UncivShowableException] occurs */ - suspend fun wrap(stage: Stage, function: suspend () -> T): T? { + suspend fun wrap(stage: Stage, vararg texts: String, function: suspend () -> T): T? { try { return function() } catch (e: UncivShowableException) { Concurrency.runOnGLThread { - InfoPopup(stage, e.localizedMessage) + InfoPopup(stage, *texts, e.localizedMessage) } } return null } } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt index 1c81d3ad33230..4e62ead37ee42 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt @@ -20,12 +20,12 @@ import com.unciv.ui.components.extensions.toTextButton 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.pickerscreens.HorizontalPickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.ui.components.AutoScrollPane as ScrollPane -class MultiplayerScreenV2 : PickerScreen() { +class MultiplayerScreenV2 : HorizontalPickerScreen() { private var selectedGame: Pair? = null // pair of game UUID to file handle private var cachedGames: Map = mutableMapOf() private var cachedFriendResponse: Triple, List, List>? = null diff --git a/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt index 87bd3cf708664..901cf3fd5728b 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapFileSelectTable.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.isActive class MapFileSelectTable( - private val newGameScreen: NewGameScreen, + private val newGameScreen: MapOptionsInterface, private val mapParameters: MapParameters ) : Table() { @@ -98,7 +98,7 @@ class MapFileSelectTable( MapSaver.loadMapParameters(mapFile) } catch (ex:Exception){ ex.printStackTrace() - Popup(newGameScreen).apply { + Popup(stage).apply { addGoodSizedLabel("Could not load map!").row() if (ex is UncivShowableException) addGoodSizedLabel(ex.message).row() 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..2f2fc4b55e8ac --- /dev/null +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsInterface.kt @@ -0,0 +1,14 @@ +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 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 7b5f81975c21a..976b4d2597f3b 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapOptionsTable.kt @@ -6,7 +6,7 @@ import com.unciv.ui.components.extensions.onChange import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.screens.basescreen.BaseScreen -class MapOptionsTable(private val newGameScreen: NewGameScreen): Table() { +class MapOptionsTable(private val newGameScreen: MapOptionsInterface): Table() { private val mapParameters = newGameScreen.gameSetupInfo.mapParameters private var mapTypeSpecificTable = Table() diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt index 5f3341c526a33..0e81aec22416c 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt @@ -48,7 +48,7 @@ import com.unciv.ui.components.AutoScrollPane as ScrollPane class NewGameScreen( _gameSetupInfo: GameSetupInfo? = null -): IPreviousScreen, HorizontalPickerScreen() /* to get more space */, RecreateOnResize { +): MapOptionsInterface, HorizontalPickerScreen() /* to get more space */, RecreateOnResize { override val gameSetupInfo = _gameSetupInfo ?: GameSetupInfo.fromSettings() override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) // needs to be set because the GameOptionsTable etc. depend on this @@ -216,7 +216,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() = stage.width / (if (isNarrowerThan4to3()) 1 else 3) + override fun getColumnWidth() = stage.width / (if (isNarrowerThan4to3()) 1 else 3) private fun initLandscape() { scrollPane.setScrollingDisabled(true,true) @@ -342,24 +342,24 @@ class NewGameScreen( } } - fun updateRuleset() { + override fun updateRuleset() { ruleset.clear() ruleset.add(RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters)) ImageGetter.setNewRuleset(ruleset) game.musicController.setModList(gameSetupInfo.gameParameters.getModsAndBaseRuleset()) } - fun lockTables() { + 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 From f6882de3a1c9e406887452a1afdce864cf35d209 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 05:28:57 +0200 Subject: [PATCH 059/152] Added a ChatMessageList --- .../multiplayerscreens/ChatMessageList.kt | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt 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..ec0fbab727c56 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -0,0 +1,75 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.ChatMessage +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import java.time.Instant +import java.util.* + +/** + * 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) + */ +class ChatMessageList(private val chatRoomUUID: UUID): Table() { + init { + pad(10f).defaults().expandX().space(4f) + recreate(listOf()) + } + + /** + * 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(suppress: Boolean = false) { + // TODO + } + + /** + * Recreate the message list from strings for testing purposes using random fill data + */ + internal fun recreate(messages: List) { + recreate(messages.map { ChatMessage(UUID.randomUUID(), AccountResponse("user", "User", UUID.randomUUID()), it, Instant.now()) }) + } + + /** + * Recreate the table of messages using the given list of chat messages + */ + fun recreate(messages: List) { + clearChildren() + if (messages.isEmpty()) { + val label = "No messages here yet".toLabel() + label.setAlignment(Align.center) + addActor(Container(label).pad(5f).center().fillY()) + return + } + + for (message in messages) { + row() + val label = Label("${message.sender.displayName} [${message.sender.username}] (${message.createdAt}):\n${message.message}", BaseScreen.skin) + label.setAlignment(Align.left) + label.wrap = true + val cell = add(label) + cell.fillX() + } + } + +} From 0681df8f69d2f0b1e2b87d35a0f56de7f6c63853 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 20:01:14 +0200 Subject: [PATCH 060/152] Switch to full JSON for saving APIv2 games instead of minimal --- core/src/com/unciv/logic/files/UncivFiles.kt | 3 ++- .../logic/multiplayer/storage/OnlineMultiplayerFiles.kt | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/com/unciv/logic/files/UncivFiles.kt b/core/src/com/unciv/logic/files/UncivFiles.kt index 24938582d0b6b..e2625b95db605 100644 --- a/core/src/com/unciv/logic/files/UncivFiles.kt +++ b/core/src/com/unciv/logic/files/UncivFiles.kt @@ -4,6 +4,7 @@ import com.badlogic.gdx.Files import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle 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 @@ -365,7 +366,7 @@ class UncivFiles( * Returns pretty-printed (= manually readable) serialization of [game], optionally gzipped */ fun gameInfoToPrettyString(game: GameInfo, useZip: Boolean = false): String { - val prettyJson = json().prettyPrint(game) + val prettyJson = json().apply { setOutputType(JsonWriter.OutputType.json) }.prettyPrint(game) return if (useZip) Gzip.zip(prettyJson) else prettyJson } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index d74bd20517da2..d5c8cbb2b9428 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -58,7 +58,12 @@ class OnlineMultiplayerFiles( * @throws MultiplayerAuthException if the authentication failed */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { - val zippedGameInfo = UncivFiles.gameInfoToPrettyString(gameInfo, useZip = true) + // 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) + } fileStorage().saveGameData(gameInfo.gameId, zippedGameInfo) // We upload the preview after the game because otherwise the following race condition will happen: From 437cc70a5377a0a9b8672e4c11ced1d144ca1467 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 20:16:17 +0200 Subject: [PATCH 061/152] Use the ChatMessageList in the ChatRoomScreen --- .../multiplayerscreens/ChatRoomScreen.kt | 75 +------------------ 1 file changed, 3 insertions(+), 72 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 8436bf1f580e1..eea1016c81c77 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -1,31 +1,23 @@ package com.unciv.ui.screens.multiplayerscreens -import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.ChatMessage import com.unciv.models.translations.tr +import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick -import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.popups.AskTextPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log -import com.unciv.utils.concurrency.Concurrency -import kotlinx.coroutines.delay -import java.time.Instant import java.util.* -import kotlin.collections.ArrayList import kotlin.math.max -import com.unciv.ui.components.AutoScrollPane as ScrollPane class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { - private val messageTable = Table() + private val messageTable = ChatMessageList(chatRoomUUID) private val events = EventBus.EventReceiver() // listen for incoming chat messages in the current chat @@ -33,10 +25,9 @@ class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { setDefaultCloseAction() scrollPane.setScrollingDisabled(false, true) - topTable.add(ScrollPane(messageTable).apply { setScrollingDisabled(true, false) }).center() + topTable.add(AutoScrollPane(messageTable, skin).apply { setScrollingDisabled(true, false) }).center() setupTopButtons() - recreateMessageTable(listOf()) rightSideButton.setText("New message".tr()) rightSideButton.enable() @@ -47,66 +38,6 @@ class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { ask.width = max(ask.width, stage.width / 1.5f) ask.open() } - - Concurrency.run { - // TODO: Remove this workaround fix by implementing a serious API handler - game.onlineMultiplayer.api.user = game.onlineMultiplayer.api.account.get() - updateMessages() - } - } - - /** - * Update the messages of the chat room by querying the server, then recreate the message table - */ - private suspend fun updateMessages() { - // TODO: Implement querying the server - Concurrency.runOnGLThread { - recreateMessageTable(listOf()) - } - } - - /** - * Recreate the central table of all available messages - */ - private fun recreateMessageTable(messages: List) { - messageTable.clear() - if (messages.isEmpty()) { - messageTable.add("No messages here yet".toLabel()).center().row() - return - } - - messageTable.add().minWidth(0.96f * stage.width).expandX().fillX().row() // empty cell to make the table span the whole X screen - messageTable.add("Messages".toLabel()).center().row() - messageTable.defaults().uniformX() - messageTable.defaults().pad(10.0f) - - messages.forEach { - // This block splits the message by spaces to make it fit on the screen. - // It might be inefficient but at least it works reliably for any amount of text. - val msgList = ArrayList() - var currentLine = "" - for (word in it.message.split(" ")) { - currentLine = if (Label("$currentLine $word", skin).width < 0.7f * stage.width) { - if (currentLine == "") { - word - } else { - "$currentLine $word" - } - } else { - msgList.add(currentLine) - word - } - } - msgList.add(currentLine) - - val label = "${it.sender.displayName} [${it.sender.username}] (${it.createdAt}):\n${msgList.joinToString("\n")}".toLabel() - // TODO: Maybe add a border around each label to differentiate between messages visually clearer - if (it.sender.uuid == game.onlineMultiplayer.api.user!!.uuid) { - messageTable.add(label).maxWidth(label.width).prefWidth(label.width).right().row() - } else { - messageTable.add(label).maxWidth(label.width).prefWidth(label.width).left().row() - } - } } /** From b6c2a82c50dba0ac127aa45467ba21f7f4e1d1bd Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 20:40:29 +0200 Subject: [PATCH 062/152] Added the first version of the LobbyScreen --- .../screens/multiplayerscreens/LobbyScreen.kt | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt 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..677fc664f06e7 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -0,0 +1,164 @@ +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.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.ChatMessage +import com.unciv.models.metadata.GameSetupInfo +import com.unciv.models.metadata.Player +import com.unciv.models.ruleset.RulesetCache +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.KeyCharAndCode +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.keyShortcuts +import com.unciv.ui.components.extensions.onActivation +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +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.Log +import java.util.* + + +/** + * 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 three menu buttons for a) game settings, + * b) map settings and c) 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, override val gameSetupInfo: GameSetupInfo): BaseScreen(), MapOptionsInterface { + + constructor(lobbyUUID: UUID, lobbyChatUUID: UUID) : this(lobbyUUID, lobbyChatUUID, GameSetupInfo.fromSettings()) + + override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) + + private val gameOptionsTable = GameOptionsTable(this, updatePlayerPickerTable = { x -> + Log.error("Updating player picker table with '%s' is not implemented yet.", x) + }) + private val mapOptionsTable = MapOptionsTable(this) + + private val lobbyName: String = "My new lobby" // TODO: Get name by looking up the UUID + private val chatMessages: MutableList = mutableListOf() + private val players: MutableList = mutableListOf() + + private val screenTitle = "Lobby: $lobbyName".toLabel(fontSize = Constants.headingFontSize) + private val chatMessageList = ChatMessageList(lobbyChatUUID) + private val menuButtonGameOptions = "Game options".toTextButton() + private val menuButtonMapOptions = "Map options".toTextButton() + private val menuButtonStartGame = "Start game".toTextButton() + private val bottomButtonLeave = "Leave".toTextButton() + private val bottomButtonSocial = "Social".toTextButton() + private val bottomButtonHelp = "Help".toTextButton() + + init { + menuButtonGameOptions.onClick { + WrapPopup(stage, gameOptionsTable) + } + menuButtonMapOptions.onClick { + WrapPopup(stage, mapOptionsTable) + } + menuButtonStartGame.onActivation { + ToastPopup("The start game feature has not been implemented yet.", stage) + } + + bottomButtonLeave.keyShortcuts.add(KeyCharAndCode.ESC) + bottomButtonLeave.keyShortcuts.add(KeyCharAndCode.BACK) + bottomButtonLeave.onActivation { + game.popScreen() + } + bottomButtonSocial.onActivation { + ToastPopup("The social feature has not been implemented yet.", stage) + } + bottomButtonHelp.keyShortcuts.add(Input.Keys.F1) + bottomButtonHelp.onActivation { + ToastPopup("The help feature has not been implemented yet.", stage) + } + + recreate() + } + + private class WrapPopup(stage: Stage, other: Actor, action: (() -> Unit)? = null) : Popup(stage) { + init { + innerTable.add(other).center().expandX().row() + addCloseButton(action = action) + open() + } + } + + fun recreate(): BaseScreen { + val table = Table() + table.setFillParent(true) + stage.addActor(table) + + val players = VerticalGroup() + val playerScroll = AutoScrollPane(players) + playerScroll.setScrollingDisabled(true, false) + + val optionsTable = VerticalGroup().apply { + align(Align.center) + space(10f) + } + optionsTable.addActor(menuButtonGameOptions) + optionsTable.addActor(menuButtonMapOptions) + optionsTable.addActor(menuButtonStartGame) + + val chatScroll = AutoScrollPane(chatMessageList, skin) + chatScroll.setScrollingDisabled(true, false) + + val menuBar = Table() + menuBar.align(Align.bottom) + menuBar.add(bottomButtonLeave).pad(10f) + menuBar.add().fillX().expandX() + menuBar.add(bottomButtonSocial).pad(5f) // half padding since the help button has padding as well + menuBar.add(bottomButtonHelp).pad(10f) + + // Construct the table which makes up the whole lobby screen + table.row() + table.add(Container(screenTitle).pad(10f)).colspan(4).fillX() + table.row().expandX().expandY() + table.add(playerScroll).fillX().expandY().prefWidth(stage.width * 0.475f).padLeft(5f) + table.add(optionsTable).prefWidth(0.05f * stage.width) + table.addSeparatorVertical(skinStrings.skinConfig.baseColor.brighten(0.1f), width = 0.5f).height(0.5f * stage.height) + table.add(chatScroll).fillX().expandY().prefWidth(stage.width * 0.475f).padRight(5f) + table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 0.5f).width(stage.width * 0.85f).padTop(10f).row() + table.row().bottom().fillX().maxHeight(stage.height / 8) + table.add(menuBar).colspan(4).fillX() + table.pack() + table.invalidate() + return this + } + + override fun lockTables() { + Log.error("Not yet implemented") + } + + override fun unlockTables() { + Log.error("Not yet implemented") + } + + override fun updateTables() { + Log.error("Not yet implemented") + } + + override fun updateRuleset() { + Log.error("Not yet implemented") + } + +} From 739e4cfe7d3ac8fd2ee7b5ab478d8c72afe59d57 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 31 Mar 2023 23:10:29 +0200 Subject: [PATCH 063/152] Opened the NationPickerPopup to use it from other screens as well This allows to use this picker popup in the APIv2 lobby screen as well. --- .../newgamescreen/PlayerPickerTable.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt index 7fe74f6b57f6d..23451489f40e6 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt @@ -303,7 +303,7 @@ class PlayerPickerTable( * @param player current player */ private fun popupNationPicker(player: Player, noRandom: Boolean) { - NationPickerPopup(this, player, noRandom).open() + NationPickerPopup(player, civBlocksWidth, { update() }, previousScreen as BaseScreen, previousScreen, noRandom, getAvailablePlayerCivs(player.chosenCiv)).open() update() } @@ -380,11 +380,18 @@ class FriendSelectionPopup( } -private class NationPickerPopup( - private val playerPicker: PlayerPickerTable, +/** + * Popup that lets the user choose a nation for a player (human or AI) + */ +class NationPickerPopup( private val player: Player, - noRandom: Boolean -) : Popup(playerPicker.previousScreen as BaseScreen) { + private val civBlocksWidth: Float, + private val update: () -> Unit, + baseScreen: BaseScreen, + previousScreen: IPreviousScreen, + noRandom: Boolean, + availablePlayerCivs: Sequence +) : Popup(baseScreen) { companion object { // These are used for the Close/OK buttons in the lower left/right corners: const val buttonsCircleSize = 70f @@ -393,12 +400,10 @@ private class NationPickerPopup( val buttonsBackColor: Color = Color.BLACK.cpy().apply { a = 0.67f } } - private val previousScreen = playerPicker.previousScreen private val ruleset = previousScreen.ruleset // 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 = ScrollPane(nationListTable) private val nationDetailsTable = Table() @@ -423,8 +428,7 @@ private class NationPickerPopup( val spectator = previousScreen.ruleset.nations[Constants.spectator] if (spectator != null && player.playerType != PlayerType.AI) // only humans can spectate, sorry robots yield(spectator) - } + playerPicker.getAvailablePlayerCivs(player.chosenCiv) - .sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.name.tr() }) + } + availablePlayerCivs.sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.name.tr() }) val nations = nationSequence.toCollection(ArrayList(previousScreen.ruleset.nations.size)) var nationListScrollY = 0f @@ -488,6 +492,6 @@ private class NationPickerPopup( player.chosenCiv = selectedNation!!.name close() - playerPicker.update() + update() } } From 2a21a4ade31fddca3afe2e50f00c624a1ee7ec31 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 1 Apr 2023 00:32:17 +0200 Subject: [PATCH 064/152] Added a lobby player list and a lobby player --- .../screens/multiplayerscreens/LobbyPlayer.kt | 27 +++++ .../multiplayerscreens/LobbyPlayerList.kt | 108 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt 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..3e4b9e1df07c1 --- /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. + */ +class LobbyPlayer(internal val account: AccountResponse?, var chosenCiv: String = Constants.random) { + val isAI: Boolean + get() = account == null + + fun to() = Player().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..f2177d85aa309 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -0,0 +1,108 @@ +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.models.metadata.Player +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.surroundWithCircle +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.newgamescreen.IPreviousScreen +import com.unciv.ui.screens.newgamescreen.NationPickerPopup +import com.unciv.utils.Log +import java.util.* + +/** + * List of players in an APIv2 lobby screen + */ +class LobbyPlayerList( + private val lobbyUUID: UUID, + internal val players: MutableList = mutableListOf(), + private val base: IPreviousScreen, + private val update: () -> Unit +) : Table() { + init { + defaults().expandX() + recreate() + } + + /** + * Recreate the table of players based on the list of internal player representations + */ + fun recreate() { + clearChildren() + if (players.isEmpty()) { + val label = "No players here yet".toLabel() + label.setAlignment(Align.center) + add(label).fillX().fillY().center() + return + } + + for (i in 0 until players.size) { + row() + val movements = VerticalGroup() + movements.space(5f) + movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { Log.error("Click up not implemented yet") }) + movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { Log.error("Click down not implemented yet") }) + add(movements) + + val player = players[i] + add(getNationTable(player.to())) + if (player.isAI) { + add("AI".toLabel()) + } else { + add(player.account!!.username.toLabel()) + } + add(player.chosenCiv.toLabel()) + + val kickButton = "❌".toLabel(Color.SCARLET, Constants.headingFontSize).apply { this.setAlignment(Align.center) } + // kickButton.surroundWithCircle(Constants.headingFontSize.toFloat(), color = color) + kickButton.onClick { ToastPopup("Kicking players has not been implemented yet", stage) } + add(kickButton) + + if (i < players.size - 1) { + row() + addSeparator(color = Color.DARK_GRAY).width(0.8f * width).pad(5f) + } + } + + row() + val addPlayerButton = "+".toLabel(Color.LIGHT_GRAY, 30) + .apply { this.setAlignment(Align.center) } + .surroundWithCircle(50f, color = Color.GRAY) + .onClick { + ToastPopup("Adding AI players has not been implemented yet", stage) + } + add(addPlayerButton).colspan(columns).fillX().center() + } + + /** + * Create clickable icon and nation name for some [Player], where clicking creates [NationPickerPopup] + */ + private fun getNationTable(player: Player): Table { + 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 || base.gameSetupInfo.gameParameters.players.none { player -> player.chosenCiv == it.name } } + nationTable.onClick { + NationPickerPopup(player, 0.45f * stage.width, { update() }, base as BaseScreen, base, false, availableCivilisations).open() + update() + } + return nationTable + } + +} From 331d881dd24aa042d38f685b628d0a45cc64933e Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 1 Apr 2023 02:01:40 +0200 Subject: [PATCH 065/152] Added button collections, make use of player lists, fixed various bugs --- .../unciv/ui/components/ButtonCollection.kt | 44 +++++++++++++++++++ core/src/com/unciv/ui/popups/InfoPopup.kt | 29 ++++++++++-- .../multiplayerscreens/ChatMessageList.kt | 5 +-- .../multiplayerscreens/LobbyBrowserScreen.kt | 7 +-- .../multiplayerscreens/LobbyBrowserTable.kt | 4 ++ .../multiplayerscreens/LobbyPlayerList.kt | 1 - .../screens/multiplayerscreens/LobbyScreen.kt | 42 +++++++++++------- 7 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 core/src/com/unciv/ui/components/ButtonCollection.kt create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt 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..697f87f8d1c2e --- /dev/null +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -0,0 +1,44 @@ +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 + +class RefreshButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/Loading").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} + +class SearchButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/Search").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} + +class ChatButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/DiplomacyW").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} + +class MultiplayerButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/Multiplayer").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 4cecea95a1350..83cd868b6411d 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -3,6 +3,7 @@ package com.unciv.ui.popups import com.badlogic.gdx.scenes.scene2d.Stage import com.unciv.logic.UncivShowableException import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.runBlocking /** Variant of [Popup] with one label and a cancel button * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] @@ -26,11 +27,13 @@ open class InfoPopup( companion object { /** - * Wrap the execution of a coroutine to display an [InfoPopup] when a [UncivShowableException] occurs + * Wrap the execution of a [coroutine] to display an [InfoPopup] when a [UncivShowableException] occurs + * + * This function should be called from the GL thread to avoid race conditions. */ - suspend fun wrap(stage: Stage, vararg texts: String, function: suspend () -> T): T? { + suspend fun wrap(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { try { - return function() + return coroutine() } catch (e: UncivShowableException) { Concurrency.runOnGLThread { InfoPopup(stage, *texts, e.localizedMessage) @@ -39,6 +42,26 @@ open class InfoPopup( 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. + * This function should be called from the GL thread to avoid race conditions. + */ + fun load(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { + val popup = InfoPopup(stage, "Loading") + return runBlocking { + try { + val result = coroutine() + popup.close() + result + } catch (e: UncivShowableException) { + popup.close() + InfoPopup(stage, *texts, e.localizedMessage) + null + } + } + } } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index ec0fbab727c56..66465d2429835 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -1,6 +1,5 @@ package com.unciv.ui.screens.multiplayerscreens -import com.badlogic.gdx.scenes.scene2d.ui.Container import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align @@ -28,7 +27,7 @@ import java.util.* */ class ChatMessageList(private val chatRoomUUID: UUID): Table() { init { - pad(10f).defaults().expandX().space(4f) + defaults().expandX().space(5f) recreate(listOf()) } @@ -58,7 +57,7 @@ class ChatMessageList(private val chatRoomUUID: UUID): Table() { if (messages.isEmpty()) { val label = "No messages here yet".toLabel() label.setAlignment(Align.center) - addActor(Container(label).pad(5f).center().fillY()) + add(label).fillX().fillY().center() return } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 816557f8dfac3..c6ce6565f3241 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -2,9 +2,8 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.* import com.unciv.UncivGame -import com.unciv.logic.UncivShowableException import com.unciv.logic.multiplayer.apiv2.LobbyResponse -import com.unciv.ui.screens.pickerscreens.PickerScreen +import com.unciv.ui.components.RefreshButton import com.unciv.ui.popups.Popup import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable @@ -12,6 +11,8 @@ import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.ui.components.AutoScrollPane as ScrollPane @@ -26,7 +27,7 @@ class LobbyBrowserScreen : PickerScreen() { private val leftSideTable = Table() // use to list all lobbies in a scrollable way private val rightSideTable = Table() // use for details about a lobby - private val updateListButton = "Update".toTextButton() + private val updateListButton = RefreshButton() private val noLobbies = "Sorry, no open lobbies at the moment!" private val noLobbySelected = "Select a lobby to show details" 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..116c1f2eaf1d3 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -0,0 +1,4 @@ +package com.unciv.ui.screens.multiplayerscreens + +class LobbyBrowserTable { +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index f2177d85aa309..6232a1d568da3 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -60,7 +60,6 @@ class LobbyPlayerList( } else { add(player.account!!.username.toLabel()) } - add(player.chosenCiv.toLabel()) val kickButton = "❌".toLabel(Color.SCARLET, Constants.headingFontSize).apply { this.setAlignment(Align.center) } // kickButton.surroundWithCircle(Constants.headingFontSize.toFloat(), color = color) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 677fc664f06e7..691f767000eff 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -8,6 +8,7 @@ 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.ChatMessage import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.metadata.Player @@ -15,7 +16,6 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode 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.keyShortcuts import com.unciv.ui.components.extensions.onActivation @@ -59,9 +59,11 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, private val players: MutableList = mutableListOf() private val screenTitle = "Lobby: $lobbyName".toLabel(fontSize = Constants.headingFontSize) + private val lobbyPlayerList = LobbyPlayerList(lobbyUUID, mutableListOf(), this) { update() } private val chatMessageList = ChatMessageList(lobbyChatUUID) private val menuButtonGameOptions = "Game options".toTextButton() private val menuButtonMapOptions = "Map options".toTextButton() + private val menuButtonInvite = "Invite player".toTextButton() private val menuButtonStartGame = "Start game".toTextButton() private val bottomButtonLeave = "Leave".toTextButton() private val bottomButtonSocial = "Social".toTextButton() @@ -74,6 +76,9 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, menuButtonMapOptions.onClick { WrapPopup(stage, mapOptionsTable) } + menuButtonInvite.onClick { + ToastPopup("The invitation feature has not been implemented yet.", stage) + } menuButtonStartGame.onActivation { ToastPopup("The start game feature has not been implemented yet.", stage) } @@ -108,16 +113,17 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, stage.addActor(table) val players = VerticalGroup() - val playerScroll = AutoScrollPane(players) + val playerScroll = AutoScrollPane(lobbyPlayerList, skin) playerScroll.setScrollingDisabled(true, false) - val optionsTable = VerticalGroup().apply { + val optionsTable = Table().apply { align(Align.center) - space(10f) } - optionsTable.addActor(menuButtonGameOptions) - optionsTable.addActor(menuButtonMapOptions) - optionsTable.addActor(menuButtonStartGame) + 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 chatScroll = AutoScrollPane(chatMessageList, skin) chatScroll.setScrollingDisabled(true, false) @@ -131,17 +137,17 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, // Construct the table which makes up the whole lobby screen table.row() - table.add(Container(screenTitle).pad(10f)).colspan(4).fillX() + table.add(Container(screenTitle).pad(10f)).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.475f).padLeft(5f) - table.add(optionsTable).prefWidth(0.05f * stage.width) - table.addSeparatorVertical(skinStrings.skinConfig.baseColor.brighten(0.1f), width = 0.5f).height(0.5f * stage.height) - table.add(chatScroll).fillX().expandY().prefWidth(stage.width * 0.475f).padRight(5f) - table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 0.5f).width(stage.width * 0.85f).padTop(10f).row() + table.add(playerScroll).fillX().expandY().prefWidth(stage.width * 0.6f).padLeft(5f) + table.add(optionsTable).prefWidth(0f) + // TODO: A 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(chatScroll).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(4).fillX() - table.pack() - table.invalidate() + table.add(menuBar).colspan(3).fillX() return this } @@ -161,4 +167,8 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, Log.error("Not yet implemented") } + private fun update() { + Log.error("Not yet implemented") + } + } From bd0df401c5c49862f38b403f7f56071f05f4b71b Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 1 Apr 2023 23:55:59 +0200 Subject: [PATCH 066/152] Implemented the LobbyBrowserTable, added missing API endpoint --- .../apiv2/EndpointImplementations.kt | 23 ++++ .../multiplayer/apiv2/ResponseStructs.kt | 13 +++ .../multiplayerscreens/LobbyBrowserScreen.kt | 61 +---------- .../multiplayerscreens/LobbyBrowserTable.kt | 103 +++++++++++++++++- .../screens/multiplayerscreens/LobbyScreen.kt | 2 + 5 files changed, 143 insertions(+), 59 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index c96b8d24a49db..b61ffb3551663 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -856,4 +856,27 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe )?.body() } + /** + * Start a game from an existing lobby + * + * The executing user must be the owner of the lobby. The lobby is deleted in the + * process, a new chatroom is created and all messages from the lobby chatroom are + * attached to the game chatroom. This will invoke a [GameStartedMessage] that is sent + * to all members of the lobby to inform them which lobby was started. It also contains + * the the new and old chatroom [UUID]s to make mapping for the clients easier. Afterwards, + * the lobby owner must use the [GameApi.upload] to upload the initial game state. + * + * Note: This behaviour is subject to change. The server should be set the order in + * which players are allowed to make their turns. This allows the server to detect + * malicious players trying to update the game state before its their turn. + */ + suspend fun startGame(lobbyUUID: UUID, suppress: Boolean = false): StartGameResponse? { + return request( + HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/start", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + } + } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 441c0338fa1f7..0a05c9b71cc67 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -343,6 +343,19 @@ data class OnlineAccountResponse( val displayName: String ) +/** + * The response when starting a game + */ +@Serializable +data class StartGameResponse( + @SerialName("game_chat_uuid") + @Serializable(with = UUIDSerializer::class) + val gameChatUUID: UUID, + @SerialName("game_uuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID +) + /** * The version data for clients */ diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index c6ce6565f3241..54f0b0dba08b1 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -24,26 +24,15 @@ import com.unciv.ui.components.AutoScrollPane as ScrollPane * A lobby might be password-protected (=private), in that case a pop-up should ask for the password. */ class LobbyBrowserScreen : PickerScreen() { - private val leftSideTable = Table() // use to list all lobbies in a scrollable way + private val lobbyBrowserTable = LobbyBrowserTable(this) private val rightSideTable = Table() // use for details about a lobby - private val updateListButton = RefreshButton() - - private val noLobbies = "Sorry, no open lobbies at the moment!" + private val newLobbyButton = "Open lobby".toTextButton() private val noLobbySelected = "Select a lobby to show details" init { setDefaultCloseAction() - // This will be updated concurrently, but it shows some text to fix the layout - leftSideTable.add(noLobbies.toLabel()).row() - leftSideTable.add(updateListButton).padTop(30f).row() - - triggerUpdateLobbyList() - updateListButton.onClick { - triggerUpdateLobbyList() - } - // The functionality of joining a lobby will be added on-demand in [refreshLobbyList] rightSideButton.setText("Join lobby") rightSideButton.disable() @@ -62,7 +51,7 @@ class LobbyBrowserScreen : PickerScreen() { stage.addActor(tab) val mainTable = Table() - mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).height(stage.height * 2 / 3) + mainTable.add(ScrollPane(lobbyBrowserTable).apply { setScrollingDisabled(true, false) }).height(stage.height * 2 / 3) mainTable.add(rightSideTable) topTable.add(mainTable).row() scrollPane.setScrollingDisabled(false, true) @@ -72,22 +61,6 @@ class LobbyBrowserScreen : PickerScreen() { rightSideTable.add(noLobbySelected.toLabel()).padBottom(10f).row() } - /** - * Detach updating the list of lobbies in another coroutine - */ - private fun triggerUpdateLobbyList() { - Concurrency.run("Update lobby list") { - val listOfOpenLobbies = InfoPopup.wrap(stage) { - UncivGame.Current.onlineMultiplayer.api.lobby.list()!! - } - if (listOfOpenLobbies != null) { - Concurrency.runOnGLThread { - refreshLobbyList(listOfOpenLobbies) - } - } - } - } - /** * Update the right side table with details about a specific lobby */ @@ -101,32 +74,4 @@ class LobbyBrowserScreen : PickerScreen() { rightSideTable.add("Created: ${selectedLobby.createdAt}.".toLabel()).row() rightSideTable.add("Owner: ${selectedLobby.owner.displayName}".toLabel()).row() } - - /** - * Refresh the list of lobbies (called after finishing the coroutine of the update button) - */ - private fun refreshLobbyList(lobbies: List) { - leftSideTable.clear() - if (lobbies.isEmpty()) { - leftSideTable.add(noLobbies.toLabel()).row() - leftSideTable.add(updateListButton).padTop(30f).row() - return - } - - lobbies.sortedBy { it.createdAt } - for (lobby in lobbies.reversed()) { - // TODO: The button may be styled with icons and the texts may be translated - val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() - btn.onClick { - updateRightSideTable(lobby) - // TODO: Un-selecting a lobby is not implemented yet - rightSideButton.onClick { - Log.debug("Joining lobby '${lobby.name}' (UUID ${lobby.uuid})") - } - rightSideButton.enable() - } - leftSideTable.add(btn).row() - } - leftSideTable.add(updateListButton).padTop(30f).row() - } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index 116c1f2eaf1d3..5e2fec247f72a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -1,4 +1,105 @@ package com.unciv.ui.screens.multiplayerscreens -class LobbyBrowserTable { +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.unciv.ui.components.RefreshButton +import com.unciv.ui.components.extensions.onClick +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.images.ImageGetter +import com.unciv.ui.popups.AskTextPopup +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency + +/** + * Table listing all available open lobbies and allow joining them by clicking on them + */ +class LobbyBrowserTable(private val screen: BaseScreen): Table() { + + private val updateButton = RefreshButton().onClick { + triggerUpdate() + } + 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() + add(updateButton).padTop(30f).row() + triggerUpdate() + } + + /** + * Open a lobby by joining it (may ask for a passphrase for protected lobbies) + */ + private fun openLobby(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) { + // TODO: screen.game.onlineMultiplayer.api.lobby.join + Concurrency.runOnGLThread { + screen.game.pushScreen(LobbyScreen(lobby)) + } + } + } + popup.open() + } else { + InfoPopup.load(stage) { + // TODO: screen.game.onlineMultiplayer.api.lobby.join + Concurrency.runOnGLThread { + screen.game.pushScreen(LobbyScreen(lobby)) + } + } + } + } + + /** + * Recreate the table of this lobby browser using the supplied list of lobbies + */ + fun recreate(lobbies: List) { + clearChildren() + if (lobbies.isEmpty()) { + add(noLobbies).row() + add(updateButton).padTop(30f).row() + return + } + + lobbies.sortedBy { it.createdAt } + for (lobby in lobbies.reversed()) { + // TODO: The button may be styled with icons and the texts may be translated + val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() + btn.onClick { + openLobby(lobby) + } + add(btn).row() + } + add(updateButton).padTop(30f).row() + } + + /** + * Detach updating the list of lobbies in another coroutine + */ + fun triggerUpdate() { + Concurrency.run("Update lobby list") { + 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/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 691f767000eff..5715ec0b96ceb 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -10,6 +10,7 @@ import com.badlogic.gdx.utils.Align import com.unciv.Constants import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ChatMessage +import com.unciv.logic.multiplayer.apiv2.LobbyResponse import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.metadata.Player import com.unciv.models.ruleset.RulesetCache @@ -46,6 +47,7 @@ import java.util.* class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, override val gameSetupInfo: GameSetupInfo): BaseScreen(), MapOptionsInterface { constructor(lobbyUUID: UUID, lobbyChatUUID: UUID) : this(lobbyUUID, lobbyChatUUID, GameSetupInfo.fromSettings()) + constructor(lobby: LobbyResponse): this(lobby.uuid, lobby.chatRoomUUID) override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) From ebf5c8c6f97d039d1be05f03ad538ac5385b87b1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 2 Apr 2023 01:23:55 +0200 Subject: [PATCH 067/152] Added choosing nations for players, fixed kick and add buttons --- .../multiplayerscreens/LobbyPlayerList.kt | 50 ++++++++++++------- .../screens/multiplayerscreens/LobbyScreen.kt | 13 +++-- .../newgamescreen/PlayerPickerTable.kt | 13 +++-- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index 6232a1d568da3..90c7cd7df6603 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -24,10 +24,20 @@ import java.util.* */ class LobbyPlayerList( private val lobbyUUID: UUID, - internal val players: MutableList = mutableListOf(), + private val mutablePlayers: MutableList = mutableListOf(), private val base: IPreviousScreen, private val update: () -> Unit ) : Table() { + internal val players: List = mutablePlayers + + private val addBotButton = "+".toLabel(Color.LIGHT_GRAY, 30) + .apply { this.setAlignment(Align.center) } + .surroundWithCircle(50f, color = Color.GRAY) + .onClick { + mutablePlayers.add(LobbyPlayer(null, Constants.random)) + recreate() + } + init { defaults().expandX() recreate() @@ -41,11 +51,12 @@ class LobbyPlayerList( if (players.isEmpty()) { val label = "No players here yet".toLabel() label.setAlignment(Align.center) - add(label).fillX().fillY().center() + add(label).fillX().fillY().center().padBottom(15f).row() + add(addBotButton) return } - for (i in 0 until players.size) { + for (i in players.indices) { row() val movements = VerticalGroup() movements.space(5f) @@ -54,7 +65,7 @@ class LobbyPlayerList( add(movements) val player = players[i] - add(getNationTable(player.to())) + add(getNationTable(player)) if (player.isAI) { add("AI".toLabel()) } else { @@ -63,7 +74,14 @@ class LobbyPlayerList( val kickButton = "❌".toLabel(Color.SCARLET, Constants.headingFontSize).apply { this.setAlignment(Align.center) } // kickButton.surroundWithCircle(Constants.headingFontSize.toFloat(), color = color) - kickButton.onClick { ToastPopup("Kicking players has not been implemented yet", stage) } + kickButton.onClick { + if (!player.isAI) { + ToastPopup("Kicking human players has not been implemented yet.", stage) // TODO: Implement this + } + val success = mutablePlayers.remove(player) + Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") + recreate() + } add(kickButton) if (i < players.size - 1) { @@ -73,19 +91,13 @@ class LobbyPlayerList( } row() - val addPlayerButton = "+".toLabel(Color.LIGHT_GRAY, 30) - .apply { this.setAlignment(Align.center) } - .surroundWithCircle(50f, color = Color.GRAY) - .onClick { - ToastPopup("Adding AI players has not been implemented yet", stage) - } - add(addPlayerButton).colspan(columns).fillX().center() + add(addBotButton).colspan(columns).fillX().center() } /** - * Create clickable icon and nation name for some [Player], where clicking creates [NationPickerPopup] + * Create clickable icon and nation name for some [LobbyPlayer], where clicking creates [NationPickerPopup] */ - private fun getNationTable(player: Player): Table { + private fun getNationTable(player: LobbyPlayer): Table { val nationTable = Table() val nationImage = if (player.chosenCiv == Constants.random) @@ -96,10 +108,14 @@ class LobbyPlayerList( nationTable.touchable = Touchable.enabled val availableCivilisations = base.ruleset.nations.values.asSequence() .filter { it.isMajorCiv() } - .filter { it.name == player.chosenCiv || base.gameSetupInfo.gameParameters.players.none { player -> player.chosenCiv == it.name } } + .filter { it.name == player.chosenCiv || players.none { player -> player.chosenCiv == it.name } } nationTable.onClick { - NationPickerPopup(player, 0.45f * stage.width, { update() }, base as BaseScreen, base, false, availableCivilisations).open() - update() + val p = player.to() + NationPickerPopup(p, 0.45f * stage.width, base as BaseScreen, base, false, availableCivilisations) { + player.chosenCiv = p.chosenCiv + recreate() + update() + }.open() } return nationTable } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 5715ec0b96ceb..6972d9dfdff2b 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -58,7 +58,6 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, private val lobbyName: String = "My new lobby" // TODO: Get name by looking up the UUID private val chatMessages: MutableList = mutableListOf() - private val players: MutableList = mutableListOf() private val screenTitle = "Lobby: $lobbyName".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList = LobbyPlayerList(lobbyUUID, mutableListOf(), this) { update() } @@ -82,7 +81,12 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, ToastPopup("The invitation feature has not been implemented yet.", stage) } menuButtonStartGame.onActivation { - ToastPopup("The start game feature has not been implemented yet.", stage) + val lobbyStartResponse = InfoPopup.load(stage) { + game.onlineMultiplayer.api.lobby.startGame(lobbyUUID) + } + if (lobbyStartResponse != null) { + ToastPopup("The start game feature has not been implemented yet.", stage) + } } bottomButtonLeave.keyShortcuts.add(KeyCharAndCode.ESC) @@ -143,8 +147,9 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, 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.6f).padLeft(5f) - table.add(optionsTable).prefWidth(0f) - // TODO: A vertical horizontal bar like a left border for the chat screen + // 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(chatScroll).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() diff --git a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt index 23451489f40e6..565d8dddbecc6 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/PlayerPickerTable.kt @@ -303,7 +303,14 @@ class PlayerPickerTable( * @param player current player */ private fun popupNationPicker(player: Player, noRandom: Boolean) { - NationPickerPopup(player, civBlocksWidth, { update() }, previousScreen as BaseScreen, previousScreen, noRandom, getAvailablePlayerCivs(player.chosenCiv)).open() + NationPickerPopup( + player, + civBlocksWidth, + previousScreen as BaseScreen, + previousScreen, + noRandom, + getAvailablePlayerCivs(player.chosenCiv) + ) { update() }.open() update() } @@ -386,11 +393,11 @@ class FriendSelectionPopup( class NationPickerPopup( private val player: Player, private val civBlocksWidth: Float, - private val update: () -> Unit, baseScreen: BaseScreen, previousScreen: IPreviousScreen, noRandom: Boolean, - availablePlayerCivs: Sequence + availablePlayerCivs: Sequence, + private val update: () -> Unit ) : Popup(baseScreen) { companion object { // These are used for the Close/OK buttons in the lower left/right corners: From 13975b37b9ccac5ac532dee7109c1950efc233ad Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 2 Apr 2023 02:08:17 +0200 Subject: [PATCH 068/152] Added a popup that allows creating new APIv2 lobbies (optional password) --- .../multiplayerscreens/CreateLobbyPopup.kt | 64 +++++++++++++++++++ .../multiplayerscreens/LobbyBrowserScreen.kt | 1 + .../screens/multiplayerscreens/LobbyScreen.kt | 7 ++ 3 files changed, 72 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt new file mode 100644 index 0000000000000..a361d92f079ea --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt @@ -0,0 +1,64 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.logic.multiplayer.ApiVersion +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 APIv2 multiplayer lobby + */ +class CreateLobbyPopup(private val base: BaseScreen) : Popup(base.stage) { + private var requirePassword: Boolean = false + private val nameField = UncivTextField.create("Lobby name", "New lobby").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() + open() + } + + 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() + open() + } + + private fun onClose() { + Log.debug("Creating a new lobby '%s'", nameField.text) + val response = InfoPopup.load(base.stage) { + base.game.onlineMultiplayer.api.lobby.open(nameField.text, if (requirePassword) passwordField.text else null) + } + if (response != null) { + base.game.pushScreen(LobbyScreen(response)) + } + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 54f0b0dba08b1..b2e18f1459b80 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -10,6 +10,7 @@ import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.CreateLobbyPopup import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.pickerscreens.PickerScreen diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 6972d9dfdff2b..62e07eb623cb6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -10,19 +10,23 @@ import com.badlogic.gdx.utils.Align import com.unciv.Constants import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.ChatMessage +import com.unciv.logic.multiplayer.apiv2.CreateLobbyResponse import com.unciv.logic.multiplayer.apiv2.LobbyResponse import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.metadata.Player import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode +import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.brighten import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.onClick +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.popups.InfoPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.BaseScreen @@ -31,6 +35,8 @@ 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.Log +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay import java.util.* @@ -47,6 +53,7 @@ import java.util.* class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, override val gameSetupInfo: GameSetupInfo): BaseScreen(), MapOptionsInterface { constructor(lobbyUUID: UUID, lobbyChatUUID: UUID) : this(lobbyUUID, lobbyChatUUID, GameSetupInfo.fromSettings()) + constructor(newLobby: CreateLobbyResponse): this(newLobby.lobbyUUID, newLobby.lobbyChatRoomUUID) constructor(lobby: LobbyResponse): this(lobby.uuid, lobby.chatRoomUUID) override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) From ba9e2d3101fbbd9651c0169e33db4d917718cbd3 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 3 Apr 2023 00:50:46 +0200 Subject: [PATCH 069/152] Created GameDisplayBase for common functionality, introduced GameDisplayV2 --- .../ui/screens/multiplayerscreens/GameList.kt | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt index 8fff953eeced7..6a40ee911d105 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt @@ -23,12 +23,14 @@ import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.components.extensions.toLabel class GameList( - onSelected: (String) -> Unit + private val onSelected: (String) -> Unit, + private val useV2: Boolean = false // set for APIv2 to change GameDisplay to GameDisplayV2 ) : VerticalGroup() { - private val gameDisplays = mutableMapOf() + private val gameDisplays = mutableMapOf() private val events = EventBus.EventReceiver() @@ -60,21 +62,47 @@ class GameList( } private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) { - val gameDisplay = GameDisplay(name, preview, error, onSelected) + val gameDisplay = if (useV2) GameDisplayV2(name, preview, onSelected) else GameDisplay(name, preview, error, onSelected) gameDisplays[name] = gameDisplay addActor(gameDisplay) children.sort() } } +/** + * Common abstract base class for [GameDisplay] and [GameDisplayV2] allowing more code reuse + */ +private abstract class GameDisplayBase: Table(), Comparable { + protected abstract var preview: GameInfoPreview? + protected abstract var gameName: String + + abstract fun changeName(newName: String) + + protected fun createIndicator(imagePath: String): Actor { + val image = ImageGetter.getImage(imagePath) + image.setSize(50f) + val container = Container(image) + container.padRight(5f) + return container + } + + fun isPlayersTurn() = preview?.isUsersTurn() == true + + override fun compareTo(other: GameDisplayBase): Int = + if (isPlayersTurn() != other.isPlayersTurn()) // games where it's the player's turn are displayed first, thus must get the lower number + other.isPlayersTurn().compareTo(isPlayersTurn()) + else gameName.compareTo(other.gameName) + override fun equals(other: Any?): Boolean = (other is GameDisplayBase) && (gameName == other.gameName) + override fun hashCode(): Int = gameName.hashCode() +} + private class GameDisplay( - multiplayerGameName: String, - var preview: GameInfoPreview?, + override var gameName: String, + override var preview: GameInfoPreview?, error: Exception?, private val onSelected: (String) -> Unit -) : Table(), Comparable { - var gameName: String = multiplayerGameName - private set +) : GameDisplayBase() { + val gameButton = TextButton(gameName, BaseScreen.skin) val turnIndicator = createIndicator("OtherIcons/ExclamationMark") val errorIndicator = createIndicator("StatIcons/Malcontent") @@ -111,7 +139,7 @@ private class GameDisplay( } } - fun changeName(newName: String) { + override fun changeName(newName: String) { gameName = newName gameButton.setText(newName) } @@ -125,21 +153,25 @@ private class GameDisplay( if (hasError) statusIndicators.addActor(errorIndicator) else errorIndicator.remove() } +} - private fun createIndicator(imagePath: String): Actor { - val image = ImageGetter.getImage(imagePath) - image.setSize(50f) - val container = Container(image) - container.padRight(5f) - return container - } +private class GameDisplayV2( + override var gameName: String, + override var preview: GameInfoPreview?, + private val onSelected: (String) -> Unit +): GameDisplayBase() { - fun isPlayersTurn() = preview?.isUsersTurn() == true + private val gameButton = TextButton(gameName, BaseScreen.skin) + private val statusIndicators = HorizontalGroup() - override fun compareTo(other: GameDisplay): Int = - if (isPlayersTurn() != other.isPlayersTurn()) // games where it's the player's turn are displayed first, thus must get the lower number - other.isPlayersTurn().compareTo(isPlayersTurn()) - else gameName.compareTo(other.gameName) - override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName) - override fun hashCode(): Int = gameName.hashCode() + init { + add(gameName.toLabel()) + add(statusIndicators) + add(gameButton) + onClick { onSelected(gameName) } + } + + override fun changeName(newName: String) { + // TODO + } } From b93a49d9e51aa8fe7c03835c14403762d636fb04 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 5 Apr 2023 22:42:16 +0200 Subject: [PATCH 070/152] Revert "Created GameDisplayBase for common functionality, introduced GameDisplayV2" This reverts commit 792d218daf1b5df2105fc6d77b48faa8f1d4765b. --- .../ui/screens/multiplayerscreens/GameList.kt | 78 ++++++------------- 1 file changed, 23 insertions(+), 55 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt index 6a40ee911d105..8fff953eeced7 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameList.kt @@ -23,14 +23,12 @@ import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.setSize -import com.unciv.ui.components.extensions.toLabel class GameList( - private val onSelected: (String) -> Unit, - private val useV2: Boolean = false // set for APIv2 to change GameDisplay to GameDisplayV2 + onSelected: (String) -> Unit ) : VerticalGroup() { - private val gameDisplays = mutableMapOf() + private val gameDisplays = mutableMapOf() private val events = EventBus.EventReceiver() @@ -62,47 +60,21 @@ class GameList( } private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) { - val gameDisplay = if (useV2) GameDisplayV2(name, preview, onSelected) else GameDisplay(name, preview, error, onSelected) + val gameDisplay = GameDisplay(name, preview, error, onSelected) gameDisplays[name] = gameDisplay addActor(gameDisplay) children.sort() } } -/** - * Common abstract base class for [GameDisplay] and [GameDisplayV2] allowing more code reuse - */ -private abstract class GameDisplayBase: Table(), Comparable { - protected abstract var preview: GameInfoPreview? - protected abstract var gameName: String - - abstract fun changeName(newName: String) - - protected fun createIndicator(imagePath: String): Actor { - val image = ImageGetter.getImage(imagePath) - image.setSize(50f) - val container = Container(image) - container.padRight(5f) - return container - } - - fun isPlayersTurn() = preview?.isUsersTurn() == true - - override fun compareTo(other: GameDisplayBase): Int = - if (isPlayersTurn() != other.isPlayersTurn()) // games where it's the player's turn are displayed first, thus must get the lower number - other.isPlayersTurn().compareTo(isPlayersTurn()) - else gameName.compareTo(other.gameName) - override fun equals(other: Any?): Boolean = (other is GameDisplayBase) && (gameName == other.gameName) - override fun hashCode(): Int = gameName.hashCode() -} - private class GameDisplay( - override var gameName: String, - override var preview: GameInfoPreview?, + multiplayerGameName: String, + var preview: GameInfoPreview?, error: Exception?, private val onSelected: (String) -> Unit -) : GameDisplayBase() { - +) : Table(), Comparable { + var gameName: String = multiplayerGameName + private set val gameButton = TextButton(gameName, BaseScreen.skin) val turnIndicator = createIndicator("OtherIcons/ExclamationMark") val errorIndicator = createIndicator("StatIcons/Malcontent") @@ -139,7 +111,7 @@ private class GameDisplay( } } - override fun changeName(newName: String) { + fun changeName(newName: String) { gameName = newName gameButton.setText(newName) } @@ -153,25 +125,21 @@ private class GameDisplay( if (hasError) statusIndicators.addActor(errorIndicator) else errorIndicator.remove() } -} -private class GameDisplayV2( - override var gameName: String, - override var preview: GameInfoPreview?, - private val onSelected: (String) -> Unit -): GameDisplayBase() { - - private val gameButton = TextButton(gameName, BaseScreen.skin) - private val statusIndicators = HorizontalGroup() - - init { - add(gameName.toLabel()) - add(statusIndicators) - add(gameButton) - onClick { onSelected(gameName) } + private fun createIndicator(imagePath: String): Actor { + val image = ImageGetter.getImage(imagePath) + image.setSize(50f) + val container = Container(image) + container.padRight(5f) + return container } - override fun changeName(newName: String) { - // TODO - } + fun isPlayersTurn() = preview?.isUsersTurn() == true + + override fun compareTo(other: GameDisplay): Int = + if (isPlayersTurn() != other.isPlayersTurn()) // games where it's the player's turn are displayed first, thus must get the lower number + other.isPlayersTurn().compareTo(isPlayersTurn()) + else gameName.compareTo(other.gameName) + override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName) + override fun hashCode(): Int = gameName.hashCode() } From 1ee8dfb4102add2001a066d9d5c953a4b817d106 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 5 Apr 2023 23:06:32 +0200 Subject: [PATCH 071/152] Added new button and various fixes, moved CreateLobbyPopup --- .../com/unciv/ui/components/ButtonCollection.kt | 9 +++++++++ .../CreateLobbyPopup.kt | 2 +- core/src/com/unciv/ui/popups/InfoPopup.kt | 13 +++++++------ .../multiplayerscreens/LobbyBrowserTable.kt | 15 ++++++--------- 4 files changed, 23 insertions(+), 16 deletions(-) rename core/src/com/unciv/ui/{screens/multiplayerscreens => popups}/CreateLobbyPopup.kt (98%) diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt index 697f87f8d1c2e..24fd86ef37aec 100644 --- a/core/src/com/unciv/ui/components/ButtonCollection.kt +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -42,3 +42,12 @@ class MultiplayerButton(size: Float = Constants.headingFontSize.toFloat()): Butt }) } } + +class NewButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/New").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt similarity index 98% rename from core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt rename to core/src/com/unciv/ui/popups/CreateLobbyPopup.kt index a361d92f079ea..0781c5c05e1ac 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/CreateLobbyPopup.kt +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -10,7 +10,7 @@ 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 APIv2 multiplayer lobby + * Variant of [Popup] used to ask the questions related to opening a new [ApiVersion.APIv2] multiplayer lobby */ class CreateLobbyPopup(private val base: BaseScreen) : Popup(base.stage) { private var requirePassword: Boolean = false diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 83cd868b6411d..166547bc3feef 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -28,8 +28,6 @@ open class InfoPopup( /** * Wrap the execution of a [coroutine] to display an [InfoPopup] when a [UncivShowableException] occurs - * - * This function should be called from the GL thread to avoid race conditions. */ suspend fun wrap(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { try { @@ -46,18 +44,21 @@ open class InfoPopup( * Show a loading popup while running a [coroutine] and return its optional result * * This function will display an [InfoPopup] when a [UncivShowableException] occurs. - * This function should be called from the GL thread to avoid race conditions. */ fun load(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { val popup = InfoPopup(stage, "Loading") return runBlocking { try { val result = coroutine() - popup.close() + Concurrency.runOnGLThread { + popup.close() + } result } catch (e: UncivShowableException) { - popup.close() - InfoPopup(stage, *texts, e.localizedMessage) + Concurrency.runOnGLThread { + popup.close() + InfoPopup(stage, *texts, e.localizedMessage) + } null } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index 5e2fec247f72a..a14fd3a8fd77f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -3,7 +3,6 @@ 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.LobbyResponse -import com.unciv.ui.components.RefreshButton import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.surroundWithCircle import com.unciv.ui.components.extensions.toLabel @@ -14,28 +13,25 @@ import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay /** * Table listing all available open lobbies and allow joining them by clicking on them */ class LobbyBrowserTable(private val screen: BaseScreen): Table() { - private val updateButton = RefreshButton().onClick { - triggerUpdate() - } 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() - add(updateButton).padTop(30f).row() triggerUpdate() } /** * Open a lobby by joining it (may ask for a passphrase for protected lobbies) */ - private fun openLobby(lobby: LobbyResponse) { + private fun joinLobby(lobby: LobbyResponse) { Log.debug("Trying to join lobby '${lobby.name}' (UUID ${lobby.uuid}) ...") if (lobby.hasPassword) { val popup = AskTextPopup( @@ -70,7 +66,6 @@ class LobbyBrowserTable(private val screen: BaseScreen): Table() { clearChildren() if (lobbies.isEmpty()) { add(noLobbies).row() - add(updateButton).padTop(30f).row() return } @@ -79,11 +74,10 @@ class LobbyBrowserTable(private val screen: BaseScreen): Table() { // TODO: The button may be styled with icons and the texts may be translated val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() btn.onClick { - openLobby(lobby) + joinLobby(lobby) } add(btn).row() } - add(updateButton).padTop(30f).row() } /** @@ -91,6 +85,9 @@ class LobbyBrowserTable(private val screen: BaseScreen): Table() { */ 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() } From 0b62347dbef79a17ca37335a0321ca197dba7298 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 6 Apr 2023 00:30:26 +0200 Subject: [PATCH 072/152] Fixed login and auth issues in the main menu screen --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 8 +++++--- .../apiv2/EndpointImplementations.kt | 3 ++- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 2 ++ .../screens/mainmenuscreen/MainMenuScreen.kt | 20 ++++++++++++------- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 48a00a0eb3059..19139444728ca 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -66,7 +66,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { } if (credentials != null) { - if (!auth.login(credentials.first, credentials.second)) { + if (!auth.login(credentials.first, credentials.second, suppress = true)) { Log.debug("Login failed using provided credentials (username '${credentials.first}')") } else { lastSuccessfulAuthentication.set(Instant.now()) @@ -331,9 +331,11 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { * Refresh the currently used session by logging in with username and password stored in the game settings * * Any errors are suppressed. Differentiating invalid logins from network issues is therefore impossible. + * + * Set [ignoreLastCredentials] to refresh the session even if there was no last successful credentials. */ - suspend fun refreshSession(): Boolean { - if (lastSuccessfulCredentials == null) { + suspend fun refreshSession(ignoreLastCredentials: Boolean = false): Boolean { + if (lastSuccessfulCredentials == null && !ignoreLastCredentials) { return false } val success = try { diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index b61ffb3551663..77ca778bb91dd 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -437,10 +437,11 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper authCookie.maxAge, Pair(username, password) ) + true } else { Log.error("No recognized, valid session cookie found in login response!") + false } - true } else { false } diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index d793e00d8f3c7..a7d8482040357 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -48,6 +48,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> val success = UncivGame.Current.onlineMultiplayer.api.auth.login( usernameField.text, passwordField.text ) + UncivGame.Current.onlineMultiplayer.api.refreshSession(ignoreLastCredentials = true) launchOnGLThread { Log.debug("Updating username and password after successfully authenticating") UncivGame.Current.settings.multiplayer.userName = usernameField.text @@ -79,6 +80,7 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> UncivGame.Current.onlineMultiplayer.api.auth.login( usernameField.text, passwordField.text ) + UncivGame.Current.onlineMultiplayer.api.refreshSession(ignoreLastCredentials = true) launchOnGLThread { Log.debug("Updating username and password after successfully authenticating") UncivGame.Current.settings.multiplayer.userName = usernameField.text diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index e50e11facd1fb..6d95df52eb9d9 100644 --- a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt @@ -45,8 +45,8 @@ 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.multiplayerscreens.MultiplayerScreenV2 import com.unciv.ui.screens.newgamescreen.NewGameScreen import com.unciv.ui.screens.pickerscreens.ModManagementScreen import com.unciv.ui.screens.savescreens.LoadGameScreen @@ -296,30 +296,36 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { RegisterLoginPopup(this.stage) { Log.debug("Register popup success state: %s", it) if (it) { - game.pushScreen(MultiplayerScreenV2()) + game.pushScreen(LobbyBrowserScreen()) } }.open() } else { // Authentication is handled before the multiplayer screen is shown - val popup = Popup(stage) - popup.addGoodSizedLabel("Loading...") 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(MultiplayerScreenV2()) + game.pushScreen(LobbyBrowserScreen()) } } else { + game.onlineMultiplayer.api.auth.logout(true) Concurrency.runOnGLThread { popup.close() - ToastPopup("Please login again.", this@MainMenuScreen).isVisible = true + RegisterLoginPopup(this@MainMenuScreen.stage) { + Log.debug("Register popup success state: %s", it) + if (it) { + game.pushScreen(LobbyBrowserScreen()) + } + }.open() } } } } else { - game.pushScreen(MultiplayerScreenV2()) + game.pushScreen(LobbyBrowserScreen()) } } } else { From f411e7f4f55825a21dd6056d65ee1852c4a86d95 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 6 Apr 2023 00:35:55 +0200 Subject: [PATCH 073/152] Added GameListV2, improved the lobby screens --- .../unciv/ui/components/ButtonCollection.kt | 9 ++ .../screens/multiplayerscreens/GameListV2.kt | 86 ++++++++++++++ .../multiplayerscreens/LobbyBrowserScreen.kt | 109 ++++++++++-------- .../screens/multiplayerscreens/LobbyScreen.kt | 1 - 4 files changed, 157 insertions(+), 48 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt index 24fd86ef37aec..54a47efaa8c27 100644 --- a/core/src/com/unciv/ui/components/ButtonCollection.kt +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -51,3 +51,12 @@ class NewButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseS }) } } + +class PencilButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { + init { + add(ImageGetter.getImage("OtherIcons/Pencil").apply { + setOrigin(Align.center) + setSize(size) + }) + } +} 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..58d34198be98c --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -0,0 +1,86 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse +import com.unciv.ui.components.ChatButton +import com.unciv.ui.components.PencilButton +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.ToastPopup +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay + +/** + * Table listing the recently played open games for APIv2 multiplayer games + */ +class GameListV2(private val screen: BaseScreen, private val onSelected: (String) -> Unit) : Table() { + + private val noGames = "No recently played games here".toLabel() + private val games = mutableListOf() + private val events = EventBus.EventReceiver() + + init { + // TODO: Add event handling + add(noGames).row() + triggerUpdate() + } + + private fun addGame(game: GameOverviewResponse) { + // TODO: Determine if it's the current turn, then add an indicator for that + + add(game.name) + add(game.lastActivity.toString()) + add(game.lastPlayer.username) + add(game.gameDataID.toString()) + add(game.gameUUID.toString()) + add(game.gameDataID.toString()) + + add(PencilButton().apply { onClick { + ToastPopup("Renaming game ${game.gameUUID} not implemented yet", screen.stage) + } }) + add(ChatButton().apply { onClick { + ToastPopup("Opening chat room ${game.chatRoomUUID} not implemented yet", screen.stage) + } }) + } + + /** + * Recreate the table of this game list using the supplied list of open games + */ + fun recreate() { + clearChildren() + if (games.isEmpty()) { + add(noGames).row() + return + } + games.sortedBy { it.lastActivity } + for (game in games.reversed()) { + addGame(game) + row() + } + } + + /** + * Detach updating the list of games in another coroutine + */ + 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.forEach { games.add(it) } + recreate() + } + } + } + } + +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index b2e18f1459b80..457f8fe7b3c18 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -1,78 +1,93 @@ package com.unciv.ui.screens.multiplayerscreens -import com.badlogic.gdx.scenes.scene2d.ui.* -import com.unciv.UncivGame -import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.Constants +import com.unciv.ui.components.KeyCharAndCode +import com.unciv.ui.components.NewButton import com.unciv.ui.components.RefreshButton -import com.unciv.ui.popups.Popup -import com.unciv.ui.components.extensions.disable -import com.unciv.ui.components.extensions.enable +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.keyShortcuts +import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton 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.pickerscreens.PickerScreen +import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log -import com.unciv.utils.concurrency.Concurrency import com.unciv.ui.components.AutoScrollPane as ScrollPane /** - * Screen that should list all open lobbies in the left side and details about a selected lobby in the right side - * - * The right side is not fully implemented yet. The right side button should join a lobby. - * A lobby might be password-protected (=private), in that case a pop-up should ask for the password. + * 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 : PickerScreen() { +class LobbyBrowserScreen : BaseScreen() { private val lobbyBrowserTable = LobbyBrowserTable(this) - private val rightSideTable = Table() // use for details about a lobby + private val gameList = GameListV2(this, ::onSelect) + + 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 = "Open lobby".toTextButton() - private val noLobbySelected = "Select a lobby to show details" + private val newLobbyButton = NewButton() + private val helpButton = "Help".toTextButton() + private val updateButton = RefreshButton() + private val closeButton = Constants.close.toTextButton() init { - setDefaultCloseAction() + 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() - // The functionality of joining a lobby will be added on-demand in [refreshLobbyList] - rightSideButton.setText("Join lobby") - rightSideButton.disable() + val lobbyButtons = Table() + newLobbyButton.onClick { + CreateLobbyPopup(this as BaseScreen) + // TODO: Testing with random UUID, need a pop-up to determine private/public lobby type + //game.pushScreen(LobbyScreen(UUID.randomUUID(), UUID.randomUUID())) + } + updateButton.onClick { + lobbyBrowserTable.triggerUpdate() + } + lobbyButtons.add(newLobbyButton).padBottom(5f).row() + lobbyButtons.add("F".toTextButton().apply { + onClick { ToastPopup("Filtering is not implemented yet", stage) } + }).padBottom(5f).row() + lobbyButtons.add(updateButton).row() - val tab = Table() - val helpButton = "Help".toTextButton() + 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() + } helpButton.onClick { val helpPopup = Popup(this) helpPopup.addGoodSizedLabel("This should become a lobby browser.").row() // TODO helpPopup.addCloseButton() helpPopup.open() } - tab.add(helpButton) - tab.x = (stage.width - helpButton.width) - tab.y = (stage.height - helpButton.height) - stage.addActor(tab) + bottomTable.add(closeButton).pad(20f) + bottomTable.add().colspan(2).growX() // layout purposes only + bottomTable.add(helpButton).pad(20f) - val mainTable = Table() - mainTable.add(ScrollPane(lobbyBrowserTable).apply { setScrollingDisabled(true, false) }).height(stage.height * 2 / 3) - mainTable.add(rightSideTable) - topTable.add(mainTable).row() - scrollPane.setScrollingDisabled(false, true) + 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() - rightSideTable.defaults().fillX() - rightSideTable.defaults().pad(20.0f) - rightSideTable.add(noLobbySelected.toLabel()).padBottom(10f).row() + table.setFillParent(true) + stage.addActor(table) } - /** - * Update the right side table with details about a specific lobby - */ - private fun updateRightSideTable(selectedLobby: LobbyResponse) { - rightSideTable.clear() - // TODO: This texts need translation - rightSideTable.add("${selectedLobby.name} (${selectedLobby.currentPlayers}/${selectedLobby.maxPlayers} players)".toLabel()).padBottom(10f).row() - if (selectedLobby.hasPassword) { - rightSideTable.add("This lobby requires a password to join.".toLabel()).row() - } - rightSideTable.add("Created: ${selectedLobby.createdAt}.".toLabel()).row() - rightSideTable.add("Owner: ${selectedLobby.owner.displayName}".toLabel()).row() + private fun onSelect(gameName: String) { + Log.debug("Selecting game '%s'", gameName) // TODO: Implement handling } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 62e07eb623cb6..5bd9a1e7ec634 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -39,7 +39,6 @@ import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.delay import java.util.* - /** * Lobby screen for open lobbies * From 5f80fface9cd985fe85dd8df128695c2f77f2fdd Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 8 Apr 2023 00:29:31 +0200 Subject: [PATCH 074/152] Added a ChatTable and various fixes to the chat handling --- .../multiplayerscreens/ChatMessageList.kt | 34 ++++++++++++- .../multiplayerscreens/ChatRoomScreen.kt | 2 +- .../screens/multiplayerscreens/ChatTable.kt | 51 +++++++++++++++++++ .../multiplayerscreens/LobbyBrowserScreen.kt | 5 +- .../screens/multiplayerscreens/LobbyScreen.kt | 15 +++--- 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 66465d2429835..feb54a97d9f75 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -3,12 +3,15 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align +import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.unciv.logic.multiplayer.apiv2.ApiV2 import com.unciv.logic.multiplayer.apiv2.ChatMessage import com.unciv.ui.components.AutoScrollPane 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.Concurrency import java.time.Instant import java.util.* @@ -24,13 +27,22 @@ import java.util.* * val chatMessages = ChatMessageList(UUID.randomUUID()) * val chatScroll = AutoScrollPane(chatMessages, skin) * chatScroll.setScrollingDisabled(true, false) + * + * Another good way is to use the [ChatTable] directly. */ -class ChatMessageList(private val chatRoomUUID: UUID): Table() { +class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table() { init { defaults().expandX().space(5f) recreate(listOf()) } + /** + * Send a [message] to the chat room by dispatching a coroutine which handles it + */ + fun sendMessage(message: String) { + // TODO + } + /** * Trigger a background refresh of the chat messages * @@ -39,7 +51,25 @@ class ChatMessageList(private val chatRoomUUID: UUID): Table() { * Use [suppress] to avoid showing an [InfoPopup] for any failures. */ fun triggerRefresh(suppress: Boolean = false) { - // TODO + Concurrency.run { + if (suppress) { + val chatInfo = mp.api.chat.get(chatRoomUUID, true) + if (chatInfo != null) { + Concurrency.runOnGLThread { + recreate(chatInfo.messages) + } + } + } else { + InfoPopup.wrap(stage) { + val chatInfo = mp.api.chat.get(chatRoomUUID, false) + if (chatInfo != null) { + Concurrency.runOnGLThread { + recreate(chatInfo.messages) + } + } + } + } + } } /** diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index eea1016c81c77..bb394325b2b3f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -17,7 +17,7 @@ import kotlin.math.max class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { - private val messageTable = ChatMessageList(chatRoomUUID) + private val messageTable = ChatMessageList(chatRoomUUID, game.onlineMultiplayer) private val events = EventBus.EventReceiver() // listen for incoming chat messages in the current chat 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..9a35a667c8e31 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -0,0 +1,51 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.AutoScrollPane +import com.unciv.ui.components.KeyCharAndCode +import com.unciv.ui.components.RefreshButton +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.keyShortcuts +import com.unciv.ui.components.extensions.onActivation +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen + +/** + * A [Table] which combines [ChatMessageList] with a text input and send button to write a new message + * + * Optionally, it can display a [RefreshButton] to the right of the send button. + */ +class ChatTable(chatMessageList: ChatMessageList, showRefreshButton: Boolean, maxLength: Int? = null): Table() { + init { + val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) + chatScroll.setScrollingDisabled(true, false) + add(chatScroll).fillX().expandY().padBottom(10f) + row() + + val nameField = UncivTextField.create("New message") + if (maxLength != null) { + nameField.maxLength = maxLength + } + val sendButton = ArrowButton() + sendButton.onActivation { + ToastPopup("Sending your message '${nameField.text}' is not implemented yet.", stage) + chatMessageList.sendMessage(nameField.text) + nameField.text = "" + } + sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) + + add(nameField).padLeft(5f).growX() + if (showRefreshButton) { + add(sendButton).padLeft(10f).padRight(10f) + val refreshButton = RefreshButton() + refreshButton.onActivation { + chatMessageList.triggerRefresh(false) + } + add(refreshButton).padRight(5f) + } else { + add(sendButton).padLeft(10f).padRight(5f) + } + row() + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 457f8fe7b3c18..d3760213cf1f6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -3,6 +3,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.Constants +import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.NewButton import com.unciv.ui.components.RefreshButton @@ -86,8 +87,8 @@ class LobbyBrowserScreen : BaseScreen() { stage.addActor(table) } - private fun onSelect(gameName: String) { - Log.debug("Selecting game '%s'", gameName) // TODO: Implement handling + private fun onSelect(game: GameOverviewResponse) { + Log.debug("Selecting game '%s' (%s)", game.name, game.gameUUID) // TODO: Implement handling } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 5bd9a1e7ec634..446a8d17a9a2e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -4,6 +4,7 @@ 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.scenes.scene2d.ui.VerticalGroup import com.badlogic.gdx.utils.Align @@ -18,6 +19,7 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode 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.keyShortcuts @@ -67,7 +69,7 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, private val screenTitle = "Lobby: $lobbyName".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList = LobbyPlayerList(lobbyUUID, mutableListOf(), this) { update() } - private val chatMessageList = ChatMessageList(lobbyChatUUID) + private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) private val menuButtonGameOptions = "Game options".toTextButton() private val menuButtonMapOptions = "Map options".toTextButton() private val menuButtonInvite = "Invite player".toTextButton() @@ -137,9 +139,7 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, optionsTable.add(menuButtonInvite).padBottom(10f).row() optionsTable.add(menuButtonStartGame).row() - val chatScroll = AutoScrollPane(chatMessageList, skin) - chatScroll.setScrollingDisabled(true, false) - + val chatTable = ChatTable(chatMessageList, true) val menuBar = Table() menuBar.align(Align.bottom) menuBar.add(bottomButtonLeave).pad(10f) @@ -149,7 +149,10 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, // Construct the table which makes up the whole lobby screen table.row() - table.add(Container(screenTitle).pad(10f)).colspan(3).fillX() + val topLine = HorizontalGroup() + topLine.addActor(Container(screenTitle).padRight(10f)) + topLine.addActor(PencilButton().apply { onClick { ToastPopup("Renaming a lobby is not implemented.", stage) } }) + 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.6f).padLeft(5f) @@ -157,7 +160,7 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, 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(chatScroll).fillX().expandY().prefWidth(stage.width * 0.5f).padRight(5f) + 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() From 286b719f7feffaf280d72ad1d0b096560a2a51e7 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 10 Apr 2023 20:31:15 +0200 Subject: [PATCH 075/152] Added new WebSocket structs for handling invites and friends --- .../multiplayer/apiv2/WebSocketStructs.kt | 166 ++++++++++++++++-- 1 file changed, 155 insertions(+), 11 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index efca4a1cbd447..7522195ebaa9e 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -4,6 +4,34 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.* +/** + * Enum of all events that can happen in a friendship + */ +enum class FriendshipEvent(val type: String) { + Accepted("accepted"), + Rejected("rejected"), + Deleted("deleted"); +} + +/** + * The notification for the clients that a new game has started + */ +@Serializable +data class GameStarted( + @SerialName("gameUuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, + @SerialName("gameChatUuid") + @Serializable(with = UUIDSerializer::class) + val gameChatUUID: UUID, + @SerialName("lobbyUuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + @SerialName("lobbyChatUuid") + @Serializable(with = UUIDSerializer::class) + val lobbyChatUUID: UUID, +) + /** * An update of the game data * @@ -70,22 +98,75 @@ data class IncomingInvite( ) /** - * The notification for the clients that a new game has started + * A friend request is sent to a client */ @Serializable -data class GameStarted( - @SerialName("gameUuid") +data class IncomingFriendRequest( + val from: AccountResponse +) + +/** + * A friendship was modified + */ +@Serializable +data class FriendshipChanged( + val friend: AccountResponse, + val event: FriendshipEvent +) + +/** + * A new player joined the lobby + */ +@Serializable +data class LobbyJoin( + @SerialName("lobbyUuid") @Serializable(with = UUIDSerializer::class) - val gameUUID: UUID, - @SerialName("gameChatUuid") + val lobbyUUID: UUID, + val player: AccountResponse +) + +/** + * A lobby closed in which the client was part of + */ +@Serializable +data class LobbyClosed( + @SerialName("lobbyUuid") @Serializable(with = UUIDSerializer::class) - val gameChatUUID: UUID, + val lobbyUUID: UUID +) + +/** + * A player has left the lobby + */ +@Serializable +data class LobbyLeave( @SerialName("lobbyUuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID, - @SerialName("lobbyChatUuid") + val player: AccountResponse +) + +/** + * A player was kicked out of the lobby. + * + * Make sure to check the player if you were kicked ^^ + */ +@Serializable +data class LobbyKick( + @SerialName("lobbyUuid") @Serializable(with = UUIDSerializer::class) - val lobbyChatUUID: UUID, + val lobbyUUID: UUID, + val player: AccountResponse +) + +/** + * The user account was updated + * + * This might be especially useful for reflecting changes in the username, etc. in the frontend + */ +@Serializable +data class AccountUpdated( + val account: AccountResponse ) /** @@ -103,6 +184,15 @@ data class InvalidMessage( override val type: WebSocketMessageType, ) : WebSocketMessage +/** + * Message to indicate that a game started + */ +@Serializable +data class GameStartedMessage ( + override val type: WebSocketMessageType, + val content: GameStarted +) : WebSocketMessage + /** * Message to publish the new game state from the server to all clients */ @@ -149,12 +239,66 @@ data class IncomingInviteMessage ( ) : WebSocketMessage /** - * Message to indicate that a game started + * Message to indicate that a client received a friend request */ @Serializable -data class GameStartedMessage ( +data class IncomingFriendRequestMessage ( override val type: WebSocketMessageType, - val content: GameStarted + val content: IncomingFriendRequest +) : WebSocketMessage + +/** + * Message to indicate that a friendship has changed + */ +@Serializable +data class FriendshipChangedMessage ( + override val type: WebSocketMessageType, + val content: FriendshipChanged +) : WebSocketMessage + +/** + * Message to indicate that a client joined the lobby + */ +@Serializable +data class LobbyJoinMessage ( + override val type: WebSocketMessageType, + val content: LobbyJoin +) : WebSocketMessage + +/** + * Message to indicate that the current lobby got closed + */ +@Serializable +data class LobbyClosedMessage ( + override val type: WebSocketMessageType, + val content: LobbyClosed +) : WebSocketMessage + +/** + * Message to indicate that a client left the lobby + */ +@Serializable +data class LobbyLeaveMessage ( + override val type: WebSocketMessageType, + val content: LobbyLeave +) : WebSocketMessage + +/** + * Message to indicate that a client got kicked out of the lobby + */ +@Serializable +data class LobbyKickMessage ( + override val type: WebSocketMessageType, + val content: LobbyKick +) : WebSocketMessage + +/** + * Message to indicate that the current user account's data have been changed + */ +@Serializable +data class AccountUpdatedMessage ( + override val type: WebSocketMessageType, + val content: AccountUpdated ) : WebSocketMessage /** From f7a366230b9b8b7ebfb3ee4d36d5cfe0425d120a Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 10 Apr 2023 23:08:18 +0200 Subject: [PATCH 076/152] Updated the API reference implementation --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 38 ++++-- .../apiv2/EndpointImplementations.kt | 117 +++++++++++++++++- .../multiplayer/apiv2/JsonSerializers.kt | 9 +- .../logic/multiplayer/apiv2/RequestStructs.kt | 8 ++ .../multiplayer/apiv2/ResponseStructs.kt | 4 + .../multiplayer/apiv2/WebSocketStructs.kt | 9 +- 6 files changed, 174 insertions(+), 11 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 19139444728ca..b8e34fa95f525 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -49,9 +49,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** Cache for the game details to make certain lookups faster */ private val gameDetails: MutableMap = mutableMapOf() - /** User identification on the server, may be null if unset or not logged in */ - var user: AccountResponse? = null - /** * Initialize this class (performing actual networking connectivity) * @@ -71,7 +68,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { } else { lastSuccessfulAuthentication.set(Instant.now()) lastSuccessfulCredentials = credentials - user = account.get() Concurrency.run { refreshGameDetails() } @@ -248,6 +244,10 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { WebSocketMessageType.InvalidMessage -> { Log.debug("Received invalid message from WebSocket connection") } + WebSocketMessageType.GameStarted -> { + Log.debug("Received GameStarted message from WebSocket connection") + // TODO: Implement game start handling + } WebSocketMessageType.UpdateGameData -> { // TODO /* @@ -276,9 +276,33 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { Log.debug("Received IncomingInvite message from WebSocket connection") // TODO: Implement invite handling } - WebSocketMessageType.GameStarted -> { - Log.debug("Received GameStarted message from WebSocket connection") - // TODO: Implement game start handling + WebSocketMessageType.IncomingFriendRequest -> { + Log.debug("Received IncomingFriendRequest message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.FriendshipChanged -> { + Log.debug("Received FriendshipChanged message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.LobbyJoin -> { + Log.debug("Received LobbyJoin message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.LobbyClosed -> { + Log.debug("Received LobbyClosed message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.LobbyLeave -> { + Log.debug("Received LobbyLeave message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.LobbyKick -> { + Log.debug("Received LobbyKick message from WebSocket connection") + // TODO: Implement this branch + } + WebSocketMessageType.AccountUpdated -> { + Log.debug("Received AccountUpdated message from WebSocket connection") + // TODO: Implement this branch } } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 77ca778bb91dd..ce8af842adf3a 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -757,7 +757,7 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp * Invite a friend to a lobby * * The executing user must be in the specified open lobby. The invited - * friend (identified by its [friendUUID]) must not be in a friend request state. + * player (identified by its [friendUUID]) must not be in a friend request state. * * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). * @@ -766,7 +766,7 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun new(friendUUID: UUID, lobbyUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Post, "/api/v2/friends", + HttpMethod.Post, "/api/v2/invites", client, authHelper, suppress = suppress, refine = { b -> @@ -778,6 +778,27 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp return response?.status?.isSuccess() == true } + /** + * Reject or retract an invite to a lobby + * + * This endpoint can be used either by the sender of the invite + * to retract the invite or by the receiver to reject the invite. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun reject(inviteUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Delete, "/api/v2/invites/$inviteUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true + } + } /** @@ -857,6 +878,93 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe )?.body() } + /** + * Kick a player from an open lobby (as the lobby owner) + * + * All players in the lobby as well as the kicked player will receive a [LobbyKickMessage] on success. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun kick(lobbyUUID: UUID, playerUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Delete, "/api/v2/lobbies/$lobbyUUID/$playerUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true + } + + /** + * Close an open lobby (as the lobby owner) + * + * On success, all joined players will receive a [LobbyClosedMessage] via WebSocket. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun close(lobbyUUID: UUID, suppress: Boolean = false): Boolean { + val response = request( + HttpMethod.Delete, "/api/v2/lobbies/$lobbyUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + ) + return response?.status?.isSuccess() == true + } + + /** + * Join an existing lobby + * + * The executing user must not be the owner of a lobby or member of a lobby. + * To be placed in a lobby, an active WebSocket connection is required. + * As a lobby might be protected by password, the optional parameter password + * may be specified. On success, all players that were in the lobby before, + * are notified about the new player with a [LobbyJoinMessage]. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun join(lobbyUUID: UUID, password: String? = null, suppress: Boolean = false): Boolean { + return request( + HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/join", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(JoinLobbyRequest(password = password)) + }, + retry = getDefaultRetry(client, authHelper) + )?.status?.isSuccess() == true + } + + /** + * Leave an open lobby + * + * This endpoint can only be used by joined users. + * All players in the lobby will receive a [LobbyLeaveMessage] on success. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun leave(lobbyUUID: UUID, suppress: Boolean = false): Boolean { + return request( + HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/leave", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.status?.isSuccess() == true + } + /** * Start a game from an existing lobby * @@ -870,6 +978,11 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe * Note: This behaviour is subject to change. The server should be set the order in * which players are allowed to make their turns. This allows the server to detect * malicious players trying to update the game state before its their turn. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [StartGameResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ suspend fun startGame(lobbyUUID: UUID, suppress: Boolean = false): StartGameResponse? { return request( diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index eb9c0c7a958ff..d4b55afac4210 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -69,12 +69,19 @@ internal class WebSocketMessageSerializer : JsonContentPolymorphicSerializer InvalidMessage.serializer() + WebSocketMessageType.GameStarted -> GameStartedMessage.serializer() WebSocketMessageType.UpdateGameData -> UpdateGameDataMessage.serializer() WebSocketMessageType.ClientDisconnected -> ClientDisconnectedMessage.serializer() WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer() WebSocketMessageType.IncomingChatMessage -> IncomingChatMessageMessage.serializer() WebSocketMessageType.IncomingInvite -> IncomingInviteMessage.serializer() - WebSocketMessageType.GameStarted -> GameStartedMessage.serializer() + WebSocketMessageType.IncomingFriendRequest -> IncomingFriendRequestMessage.serializer() + WebSocketMessageType.FriendshipChanged -> FriendshipChangedMessage.serializer() + WebSocketMessageType.LobbyJoin -> LobbyJoinMessage.serializer() + WebSocketMessageType.LobbyClosed -> LobbyClosedMessage.serializer() + WebSocketMessageType.LobbyLeave -> LobbyLeaveMessage.serializer() + WebSocketMessageType.LobbyKick -> LobbyKickMessage.serializer() + WebSocketMessageType.AccountUpdated -> AccountUpdatedMessage.serializer() } } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt index 89c14b2b97ed0..a290870a7bee9 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -65,6 +65,14 @@ data class GameUploadRequest( val gameData: String ) +/** + * The request to join a lobby + */ +@Serializable +data class JoinLobbyRequest( + val password: String? = null +) + /** * The request data of a login request */ diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 0a05c9b71cc67..1a3035f502023 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -70,6 +70,10 @@ enum class ApiStatusCode(val value: Int) { InvalidLobbyUuid(1017), InvalidFriendUuid(1018), GameNotFound(1019), + InvalidMessage(1020), + WsNotConnected(1021), + LobbyFull(1022), + InvalidPlayerUUID(1023), InternalServerError(2000), DatabaseError(2001), diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 7522195ebaa9e..83d4174a52eb0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -307,12 +307,19 @@ data class AccountUpdatedMessage ( @Serializable(with = WebSocketMessageTypeSerializer::class) enum class WebSocketMessageType(val type: String) { InvalidMessage("invalidMessage"), + GameStarted("gameStarted"), UpdateGameData("updateGameData"), ClientDisconnected("clientDisconnected"), ClientReconnected("clientReconnected"), IncomingChatMessage("incomingChatMessage"), IncomingInvite("incomingInvite"), - GameStarted("gameStarted"); + IncomingFriendRequest("incomingFriendRequest"), + FriendshipChanged("friendshipChanged"), + LobbyJoin("lobbyJoin"), + LobbyClosed("lobbyClosed"), + LobbyLeave("lobbyLeave"), + LobbyKick("lobbyKick"), + AccountUpdated("accountUpdated"); companion object { private val VALUES = values() From 37ae9d5f4a26b8cd2ecc84ddb79dd76922eba126 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 10 Apr 2023 23:31:25 +0200 Subject: [PATCH 077/152] Always display InfoPopups forcefully, refreshed the button collection --- .../unciv/ui/components/ButtonCollection.kt | 60 +++++-------------- core/src/com/unciv/ui/popups/InfoPopup.kt | 2 +- 2 files changed, 15 insertions(+), 47 deletions(-) diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt index 54a47efaa8c27..e56799cb479ea 100644 --- a/core/src/com/unciv/ui/components/ButtonCollection.kt +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -7,56 +7,24 @@ import com.unciv.ui.components.extensions.setSize import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen -class RefreshButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/Loading").apply { - setOrigin(Align.center) - setSize(size) - }) - } -} - -class SearchButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/Search").apply { - setOrigin(Align.center) - setSize(size) - }) - } -} - -class ChatButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/DiplomacyW").apply { - setOrigin(Align.center) - setSize(size) - }) - } -} - -class MultiplayerButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/Multiplayer").apply { - setOrigin(Align.center) - setSize(size) - }) - } -} +open class SpecificButton(private val size: Float, private val path: String): Button(BaseScreen.skin) { + init { create() } -class NewButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/New").apply { + private fun create() { + add(ImageGetter.getImage(path).apply { setOrigin(Align.center) setSize(size) }) } } -class PencilButton(size: Float = Constants.headingFontSize.toFloat()): Button(BaseScreen.skin) { - init { - add(ImageGetter.getImage("OtherIcons/Pencil").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") diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 166547bc3feef..b208048496306 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -21,7 +21,7 @@ open class InfoPopup( addGoodSizedLabel(element).row() } addCloseButton(action = action).row() - open() + open(force = true) } companion object { From 4e7bdc8be2f49329ea0837d6e910d6c3e5ae4e20 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 10 Apr 2023 23:37:02 +0200 Subject: [PATCH 078/152] Added a friend table to handle friends and requests --- .../multiplayerscreens/FriendListV2.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt 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..733e3b157e395 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -0,0 +1,144 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.Constants +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.OptionsButton +import com.unciv.ui.components.extensions.addSeparatorVertical +import com.unciv.ui.components.extensions.onActivation +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.concurrency.Concurrency +import java.util.* + +/** + * 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 show a button that opens a chat dialog with a single friend. 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. + * A sane default for this functionality is the [showEditPopup] function. + * This table should be encapsulated into a [base]screen or pop-up containing one. + */ +class FriendListV2( + private val base: BaseScreen, + private val me: UUID, + friends: List = listOf(), + friendRequests: List = listOf(), + private val requests: Boolean = false, + private val chat: Boolean = true, + private val select: ((UUID) -> Unit)? = null, + private val edit: ((UUID) -> 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 && !friendRequests.isEmpty()) { + body.add(getRequestTable(friendRequests)).padBottom(10f).row() + body.addSeparatorVertical(Color.DARK_GRAY, 1f).padBottom(10f).row() + } + body.add(getFriendTable(friends)) + + 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) 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) { + table.add(ChatButton().apply { onActivation { ToastPopup("Chatting is not implemented yet", stage).open(force = true) } }).padLeft(5f).padBottom(5f) + } + if (edit != null) { + table.add(OptionsButton().apply { onActivation { (edit)(friend.friend.uuid ) } }).padLeft(5f).padBottom(5f) + } + if (select != null) { + table.add(ArrowButton().apply { onActivation { (select)(friend.friend.uuid ) } }).padLeft(5f).padBottom(5f) + } + table.row() + } + return table + } + + private fun getRequestTable(friendRequests: List): Table { + val table = Table(BaseScreen.skin) + table.add("Friend requests".toLabel(fontSize = Constants.headingFontSize)).colspan(3).padBottom(10f).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() + } + } }) + table.row() + } + return table + } + + companion object { + fun showEditPopup(friend: UUID) { + // TODO: Add a pop-up that allows to edit a friend + } + } + +} From 22e3de6f7946998d5d0b5ff73fdc32ccc72b622f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 11 Apr 2023 01:54:25 +0200 Subject: [PATCH 079/152] Added GET cache, allowed all WS messages to be Events, added missing endpoints --- .../apiv2/EndpointImplementations.kt | 104 ++++++++++++++- .../logic/multiplayer/apiv2/RequestStructs.kt | 8 ++ .../multiplayer/apiv2/ResponseStructs.kt | 25 +++- .../multiplayer/apiv2/WebSocketStructs.kt | 118 ++++++++++-------- 4 files changed, 194 insertions(+), 61 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index ce8af842adf3a..df86e21e4b2f7 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -15,6 +15,7 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.network.* import java.io.IOException +import java.time.Instant import java.util.* /** @@ -32,6 +33,11 @@ private val RETRY_CODES = listOf(ApiStatusCode.Unauthenticated) */ private const val DEFAULT_RANDOM_PASSWORD_LENGTH = 32 +/** + * Max age of a cached entry before it will be re-queried + */ +private const val MAX_CACHE_AGE_SECONDS = 60L + /** * Perform a HTTP request via [method] to [endpoint] * @@ -188,6 +194,43 @@ private fun getDefaultRetry(client: HttpClient, authHelper: AuthHelper): (suspen } } +/** + * Simple cache for GET queries to the API + */ +private object Cache { + private var responseCache: MutableMap> = mutableMapOf() + + /** + * Clear the response cache + */ + fun clear() { + responseCache.clear() + } + + /** + * Wrapper around [request] to cache responses to GET queries up to [MAX_CACHE_AGE_SECONDS] + */ + suspend fun get( + endpoint: String, + client: HttpClient, + authHelper: AuthHelper, + refine: ((HttpRequestBuilder) -> Unit)? = null, + suppress: Boolean = false, + cache: Boolean = true, + retry: (suspend () -> Boolean)? = null + ): HttpResponse? { + val result = responseCache[endpoint] + if (cache && result != null && result.first.plusSeconds(MAX_CACHE_AGE_SECONDS).isAfter(Instant.now())) { + return result.second + } + val response = request(HttpMethod.Get, endpoint, client, authHelper, refine, suppress, retry) + if (cache && response != null) { + responseCache[endpoint] = Pair(Instant.now(), response) + } + return response + } +} + /** * API wrapper for account handling (do not use directly; use the Api class instead) */ @@ -196,16 +239,18 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe /** * Retrieve information about the currently logged in user * + * Unset [cache] to avoid using the cache and update the data from the server. * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error). * * @throws ApiException: thrown for defined and recognized API problems * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun get(suppress: Boolean = false): AccountResponse? { - return request( - HttpMethod.Get, "/api/v2/accounts/me", + suspend fun get(cache: Boolean = true, suppress: Boolean = false): AccountResponse? { + return Cache.get( + "/api/v2/accounts/me", client, authHelper, suppress = suppress, + cache = cache, retry = getDefaultRetry(client, authHelper) )?.body() } @@ -213,16 +258,18 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe /** * Retrieve details for an account by its [uuid] (always preferred to using usernames) * + * Unset [cache] to avoid using the cache and update the data from the server. * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error). * * @throws ApiException: thrown for defined and recognized API problems * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun lookup(uuid: UUID, suppress: Boolean = false): AccountResponse? { - return request( - HttpMethod.Get, "/api/v2/accounts/$uuid", + suspend fun lookup(uuid: UUID, cache: Boolean = true, suppress: Boolean = false): AccountResponse? { + return Cache.get( + "/api/v2/accounts/$uuid", client, authHelper, suppress = suppress, + cache = cache, retry = getDefaultRetry(client, authHelper) )?.body() } @@ -466,9 +513,11 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper ) } catch (e: Throwable) { authHelper.unset() + Cache.clear() Log.debug("Logout failed due to %s (%s), dropped session anyways", e, e.message) return false } + Cache.clear() return if (response?.status?.isSuccess() == true) { authHelper.unset() Log.debug("Logged out successfully, dropped session") @@ -532,6 +581,30 @@ class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper )?.body() } + /** + * Send a message to a chat room + * + * The executing user must be a member of the chatroom and the message must not be empty. + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun send(message: String, chatRoomUUID: UUID, suppress: Boolean = false): ChatMessage? { + val response = request( + HttpMethod.Post, "/api/v2/chats/$chatRoomUUID", + client, authHelper, + suppress = suppress, + refine = { b -> + b.contentType(ContentType.Application.Json) + b.setBody(SendMessageRequest(message)) + }, + retry = getDefaultRetry(client, authHelper) + ) + return response?.body() + } + } /** @@ -826,6 +899,25 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe return body?.lobbies } + /** + * Fetch a single open lobby + * + * If [LobbyResponse.hasPassword] is true, the lobby is secured by a user-set password. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetLobbyResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun get(lobbyUUID: UUID, suppress: Boolean = false): GetLobbyResponse? { + return request( + HttpMethod.Get, "/api/v2/lobbies/$lobbyUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + } + /** * Create a new lobby and return the new lobby with some extra info as [CreateLobbyResponse] * diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt index a290870a7bee9..24154d80d41d2 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -90,6 +90,14 @@ data class LookupAccountUsernameRequest( val username: String ) +/** + * The request for sending a message to a chatroom + */ +@Serializable +data class SendMessageRequest( + val message: String +) + /** * The set password request data * diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 1a3035f502023..fc655ddc9befe 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -164,7 +164,7 @@ data class FriendRequestResponse( */ @Serializable data class GameOverviewResponse( - @SerialName("chat_room_id") + @SerialName("chat_room_uuid") @Serializable(with = UUIDSerializer::class) val chatRoomUUID: UUID, @SerialName("game_data_id") @@ -309,6 +309,29 @@ data class GetLobbiesResponse( val lobbies: List ) +/** + * A single lobby (in contrast to [LobbyResponse], this is fetched by its own) + */ +@Serializable +data class GetLobbyResponse( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val name: String, + @SerialName("max_players") + val maxPlayers: Int, + @SerialName("current_players") + val currentPlayers: List, + @SerialName("chat_room_uuid") + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, + @SerialName("created_at") + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @SerialName("password") + val hasPassword: Boolean, + val owner: AccountResponse +) + /** * A single lobby */ diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 83d4174a52eb0..4535c123c3f41 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -1,5 +1,6 @@ package com.unciv.logic.multiplayer.apiv2 +import com.unciv.logic.event.Event import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.* @@ -18,19 +19,19 @@ enum class FriendshipEvent(val type: String) { */ @Serializable data class GameStarted( - @SerialName("gameUuid") + @SerialName("game_uuid") @Serializable(with = UUIDSerializer::class) val gameUUID: UUID, - @SerialName("gameChatUuid") + @SerialName("game_chat_uuid") @Serializable(with = UUIDSerializer::class) val gameChatUUID: UUID, - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID, - @SerialName("lobbyChatUuid") + @SerialName("lobby_chat_uuid") @Serializable(with = UUIDSerializer::class) val lobbyChatUUID: UUID, -) +) : Event /** * An update of the game data @@ -39,63 +40,64 @@ data class GameStarted( */ @Serializable data class UpdateGameData( - @SerialName("gameId") + @SerialName("game_uuid") @Serializable(with = UUIDSerializer::class) val gameUUID: UUID, + @SerialName("game_data") val gameData: String, // base64-encoded, gzipped game state /** A counter that is incremented every time a new game states has been uploaded for the same [gameUUID] via HTTP API. */ - @SerialName("gameDataId") + @SerialName("game_data_id") val gameDataID: Long -) +) : Event /** * Notification for clients if a client in their game disconnected */ @Serializable data class ClientDisconnected( - @SerialName("gameUuid") + @SerialName("game_uuid") @Serializable(with = UUIDSerializer::class) val gameUUID: UUID, @Serializable(with = UUIDSerializer::class) val uuid: UUID // client identifier -) +) : Event /** * Notification for clients if a client in their game reconnected */ @Serializable data class ClientReconnected( - @SerialName("gameUuid") + @SerialName("game_uuid") @Serializable(with = UUIDSerializer::class) val gameUUID: UUID, @Serializable(with = UUIDSerializer::class) val uuid: UUID // client identifier -) +) : Event /** * A new chat message is sent to the client */ @Serializable data class IncomingChatMessage( - @SerialName("chatUuid") + @SerialName("chat_uuid") @Serializable(with = UUIDSerializer::class) val chatUUID: UUID, val message: ChatMessage -) +) : Event /** * An invite to a lobby is sent to the client */ @Serializable data class IncomingInvite( - @SerialName("inviteUuid") + @SerialName("invite_uuid") @Serializable(with = UUIDSerializer::class) val inviteUUID: UUID, val from: AccountResponse, - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID -) +) : Event /** * A friend request is sent to a client @@ -103,7 +105,7 @@ data class IncomingInvite( @Serializable data class IncomingFriendRequest( val from: AccountResponse -) +) : Event /** * A friendship was modified @@ -112,39 +114,39 @@ data class IncomingFriendRequest( data class FriendshipChanged( val friend: AccountResponse, val event: FriendshipEvent -) +) : Event /** * A new player joined the lobby */ @Serializable data class LobbyJoin( - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID, val player: AccountResponse -) +) : Event /** * A lobby closed in which the client was part of */ @Serializable data class LobbyClosed( - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID -) +) : Event /** * A player has left the lobby */ @Serializable data class LobbyLeave( - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID, val player: AccountResponse -) +) : Event /** * A player was kicked out of the lobby. @@ -153,11 +155,11 @@ data class LobbyLeave( */ @Serializable data class LobbyKick( - @SerialName("lobbyUuid") + @SerialName("lobby_uuid") @Serializable(with = UUIDSerializer::class) val lobbyUUID: UUID, val player: AccountResponse -) +) : Event /** * The user account was updated @@ -167,7 +169,7 @@ data class LobbyKick( @Serializable data class AccountUpdated( val account: AccountResponse -) +) : Event /** * The base WebSocket message, encapsulating only the type of the message @@ -176,6 +178,14 @@ interface WebSocketMessage { val type: WebSocketMessageType } +/** + * The useful base WebSocket message, encapsulating only the type of the message and the content + */ +interface WebSocketMessageWithContent: WebSocketMessage { + override val type: WebSocketMessageType + val content: Event +} + /** * Message when a previously sent WebSocket frame a received frame is invalid */ @@ -190,8 +200,8 @@ data class InvalidMessage( @Serializable data class GameStartedMessage ( override val type: WebSocketMessageType, - val content: GameStarted -) : WebSocketMessage + override val content: GameStarted +) : WebSocketMessageWithContent /** * Message to publish the new game state from the server to all clients @@ -199,8 +209,8 @@ data class GameStartedMessage ( @Serializable data class UpdateGameDataMessage ( override val type: WebSocketMessageType, - val content: UpdateGameData -) : WebSocketMessage + override val content: UpdateGameData +) : WebSocketMessageWithContent /** * Message to indicate that a client disconnected @@ -208,8 +218,8 @@ data class UpdateGameDataMessage ( @Serializable data class ClientDisconnectedMessage ( override val type: WebSocketMessageType, - val content: ClientDisconnected -) : WebSocketMessage + override val content: ClientDisconnected +) : WebSocketMessageWithContent /** * Message to indicate that a client, who previously disconnected, reconnected @@ -217,8 +227,8 @@ data class ClientDisconnectedMessage ( @Serializable data class ClientReconnectedMessage ( override val type: WebSocketMessageType, - val content: ClientReconnected -) : WebSocketMessage + override val content: ClientReconnected +) : WebSocketMessageWithContent /** * Message to indicate that a user received a new text message via the chat feature @@ -226,8 +236,8 @@ data class ClientReconnectedMessage ( @Serializable data class IncomingChatMessageMessage ( override val type: WebSocketMessageType, - val content: IncomingChatMessage -) : WebSocketMessage + override val content: IncomingChatMessage +) : WebSocketMessageWithContent /** * Message to indicate that a client gets invited to a lobby @@ -235,8 +245,8 @@ data class IncomingChatMessageMessage ( @Serializable data class IncomingInviteMessage ( override val type: WebSocketMessageType, - val content: IncomingInvite -) : WebSocketMessage + override val content: IncomingInvite +) : WebSocketMessageWithContent /** * Message to indicate that a client received a friend request @@ -244,8 +254,8 @@ data class IncomingInviteMessage ( @Serializable data class IncomingFriendRequestMessage ( override val type: WebSocketMessageType, - val content: IncomingFriendRequest -) : WebSocketMessage + override val content: IncomingFriendRequest +) : WebSocketMessageWithContent /** * Message to indicate that a friendship has changed @@ -253,8 +263,8 @@ data class IncomingFriendRequestMessage ( @Serializable data class FriendshipChangedMessage ( override val type: WebSocketMessageType, - val content: FriendshipChanged -) : WebSocketMessage + override val content: FriendshipChanged +) : WebSocketMessageWithContent /** * Message to indicate that a client joined the lobby @@ -262,8 +272,8 @@ data class FriendshipChangedMessage ( @Serializable data class LobbyJoinMessage ( override val type: WebSocketMessageType, - val content: LobbyJoin -) : WebSocketMessage + override val content: LobbyJoin +) : WebSocketMessageWithContent /** * Message to indicate that the current lobby got closed @@ -271,8 +281,8 @@ data class LobbyJoinMessage ( @Serializable data class LobbyClosedMessage ( override val type: WebSocketMessageType, - val content: LobbyClosed -) : WebSocketMessage + override val content: LobbyClosed +) : WebSocketMessageWithContent /** * Message to indicate that a client left the lobby @@ -280,8 +290,8 @@ data class LobbyClosedMessage ( @Serializable data class LobbyLeaveMessage ( override val type: WebSocketMessageType, - val content: LobbyLeave -) : WebSocketMessage + override val content: LobbyLeave +) : WebSocketMessageWithContent /** * Message to indicate that a client got kicked out of the lobby @@ -289,8 +299,8 @@ data class LobbyLeaveMessage ( @Serializable data class LobbyKickMessage ( override val type: WebSocketMessageType, - val content: LobbyKick -) : WebSocketMessage + override val content: LobbyKick +) : WebSocketMessageWithContent /** * Message to indicate that the current user account's data have been changed @@ -298,8 +308,8 @@ data class LobbyKickMessage ( @Serializable data class AccountUpdatedMessage ( override val type: WebSocketMessageType, - val content: AccountUpdated -) : WebSocketMessage + override val content: AccountUpdated +) : WebSocketMessageWithContent /** * Type enum of all known WebSocket messages From 36694c0fc81e34213fe72846477779d807c36b00 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 11 Apr 2023 01:57:44 +0200 Subject: [PATCH 080/152] Added lobby invite table --- .../multiplayerscreens/LobbyInviteTable.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt new file mode 100644 index 0000000000000..15728b6239d5a --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt @@ -0,0 +1,55 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.Constants +import com.unciv.ui.components.KeyCharAndCode +import com.unciv.ui.components.SearchButton +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.addSeparatorVertical +import com.unciv.ui.components.extensions.keyShortcuts +import com.unciv.ui.components.extensions.onActivation +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import java.util.UUID + +class LobbyInviteTable(private val lobbyUUID: UUID, private val base: BaseScreen): Table() { + init { + add("Invite player".toLabel(fontSize = Constants.headingFontSize)).colspan(2).pad(5f).padBottom(10f) + row() + + val nameField = UncivTextField.create("Search player") + val searchButton = SearchButton() + searchButton.onActivation { + Log.debug("Searching for player '%s'", nameField.text) + Concurrency.run { + val response = InfoPopup.wrap(base.stage) { + base.game.onlineMultiplayer.api.account.lookup(nameField.text) + } + if (response != null) { + Concurrency.runOnGLThread { + Log.debug("Looked up '%s' as '%s'", response.uuid, response.username) + invite(response.uuid) + } + } + } + } + + searchButton.keyShortcuts.add(KeyCharAndCode.RETURN) + add(nameField).padLeft(5f).padRight(5f) + add(searchButton).padRight(5f) + row() + + addSeparatorVertical(Color.DARK_GRAY).colspan(2).pad(5f).row() + + } + + private fun invite(friendUUID: UUID) { + InfoPopup.load(base.stage) { + base.game.onlineMultiplayer.api.invite.new(friendUUID, lobbyUUID) + } + } +} From 6c5ec58bb3e8071e48f8e40bb227a94f47784b41 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 11 Apr 2023 17:31:51 +0200 Subject: [PATCH 081/152] Added func to dispose and refresh OnlineMultiplayer, only show set username for APIv2 --- core/src/com/unciv/UncivGame.kt | 32 +++++++++++++++++++ .../logic/multiplayer/OnlineMultiplayer.kt | 14 +++++++- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 14 +++++++- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 2 +- .../unciv/ui/popups/options/MultiplayerTab.kt | 26 ++++++++------- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 7e6783349dd9d..508661b713bdf 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -540,6 +540,38 @@ 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. This is a blocking operation. + */ + fun refreshOnlineMultiplayer() { + val mp = Concurrency.runBlocking { + val newMultiplayer = OnlineMultiplayer() + newMultiplayer.initialize() + + // Check if the server is available in case the feature set has changed + try { + newMultiplayer.checkServerStatus() + } catch (ex: Exception) { + debug("Couldn't connect to server: " + ex.message) + } + newMultiplayer + } + if (mp != null) { + Concurrency.runOnGLThread { + val oldMultiplayer = Current.onlineMultiplayer + Current.onlineMultiplayer = mp + Concurrency.run { + oldMultiplayer.dispose() + } + } + } else { + Log.error("Failed to refresh online multiplayer successfully") + } + } } data class Version( diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index e2a830a195153..e2440743ce8a8 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -23,6 +23,7 @@ import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow @@ -66,6 +67,7 @@ class OnlineMultiplayer { private val files = UncivGame.Current.files val multiplayerFiles = OnlineMultiplayerFiles() private lateinit var featureSet: ServerFeatureSet + private var pollChecker: Job? = null private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -509,7 +511,7 @@ class OnlineMultiplayer { private fun startPollChecker() { if (apiVersion in listOf(ApiVersion.APIv0, ApiVersion.APIv1)) { Log.debug("Starting poll service for remote games ...") - flow { + pollChecker = flow { while (true) { delay(500) @@ -527,6 +529,16 @@ class OnlineMultiplayer { } } + /** + * Dispose this [OnlineMultiplayer] instance by closing its background jobs and connections + */ + fun dispose() { + pollChecker?.cancel() + if (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/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index b8e34fa95f525..f1d72858781b0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -11,6 +11,7 @@ import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.websocket.* +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.SendChannel import kotlinx.serialization.json.Json @@ -79,7 +80,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { initialized = true } - // ---------------- SIMPLE GETTER ---------------- + // ---------------- LIFECYCLE FUNCTIONALITY ---------------- /** * Determine if the user is authenticated by comparing timestamps @@ -97,6 +98,17 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { return initialized } + /** + * Dispose this class and its children and jobs + */ + fun dispose() { + sendChannel?.close() + for (job in websocketJobs) { + job.cancel() + } + client.cancel() + } + // ---------------- COMPATIBILITY FUNCTIONALITY ---------------- /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index ecb35899c32b0..3e6733fe16ea7 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -64,7 +64,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { private val authHelper = AuthHelper() // Queue to keep references to all opened WebSocket handler jobs - private var websocketJobs = ConcurrentLinkedQueue() + protected var websocketJobs = ConcurrentLinkedQueue() init { client.plugin(HttpSend).intercept { request -> diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index c71ab677f3dc3..51c3c7438acb4 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -150,18 +150,19 @@ private fun addMultiplayerServerOptions( multiplayerServerTextField.programmaticChangeEvents = true val serverIpTable = Table() - // TODO: This is a quick workaround to allow setting the username and should be extended and improved - 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() + 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 @@ -190,6 +191,7 @@ private fun addMultiplayerServerOptions( addGoodSizedLabel("Awaiting response...").row() open(true) } + UncivGame.refreshOnlineMultiplayer() successfullyConnectedToServer { connectionSuccess, authSuccess -> if (connectionSuccess && authSuccess) { From dbb8b0edf957e83d8d0c89fb2207833fd3de80e4 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 11 Apr 2023 21:20:25 +0200 Subject: [PATCH 082/152] Updated the lobby screen --- .../multiplayerscreens/LobbyBrowserTable.kt | 4 +- .../screens/multiplayerscreens/LobbyScreen.kt | 99 ++++++++++++++----- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index a14fd3a8fd77f..804abedea8ac9 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -42,7 +42,7 @@ class LobbyBrowserTable(private val screen: BaseScreen): Table() { maxLength = 120 ) { InfoPopup.load(stage) { - // TODO: screen.game.onlineMultiplayer.api.lobby.join + screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid) Concurrency.runOnGLThread { screen.game.pushScreen(LobbyScreen(lobby)) } @@ -51,7 +51,7 @@ class LobbyBrowserTable(private val screen: BaseScreen): Table() { popup.open() } else { InfoPopup.load(stage) { - // TODO: screen.game.onlineMultiplayer.api.lobby.join + screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid) Concurrency.runOnGLThread { screen.game.pushScreen(LobbyScreen(lobby)) } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 446a8d17a9a2e..4fccde26f1426 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -10,22 +10,24 @@ 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.ChatMessage -import com.unciv.logic.multiplayer.apiv2.CreateLobbyResponse +import com.unciv.logic.multiplayer.apiv2.FriendRequestResponse +import com.unciv.logic.multiplayer.apiv2.FriendResponse +import com.unciv.logic.multiplayer.apiv2.GetLobbyResponse import com.unciv.logic.multiplayer.apiv2.LobbyResponse +import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse import com.unciv.models.metadata.GameSetupInfo -import com.unciv.models.metadata.Player import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.PencilButton +import com.unciv.ui.components.RefreshButton +import com.unciv.ui.components.SearchButton import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.brighten import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.onClick -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.popups.InfoPopup @@ -38,7 +40,7 @@ import com.unciv.ui.screens.newgamescreen.MapOptionsTable import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency -import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import java.util.* /** @@ -46,16 +48,24 @@ import java.util.* * * 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 three menu buttons for a) game settings, - * b) map settings and c) to start the game. It also has a footer section + * 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, override val gameSetupInfo: GameSetupInfo): BaseScreen(), MapOptionsInterface { - - constructor(lobbyUUID: UUID, lobbyChatUUID: UUID) : this(lobbyUUID, lobbyChatUUID, GameSetupInfo.fromSettings()) - constructor(newLobby: CreateLobbyResponse): this(newLobby.lobbyUUID, newLobby.lobbyChatRoomUUID) - constructor(lobby: LobbyResponse): this(lobby.uuid, lobby.chatRoomUUID) +class LobbyScreen( + private val lobbyUUID: UUID, + private val lobbyChatUUID: UUID, + private var lobbyName: String, + private val maxPlayers: Int, + private var currentPlayers: List, + 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, lobby.hasPassword, lobby.owner, GameSetupInfo.fromSettings()) override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) @@ -64,21 +74,27 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, }) private val mapOptionsTable = MapOptionsTable(this) - private val lobbyName: String = "My new lobby" // TODO: Get name by looking up the UUID - private val chatMessages: MutableList = mutableListOf() + private val me + get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! + private val screenTitle + get() = "Lobby: [$lobbyName] [${currentPlayers.size + 1}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) - private val screenTitle = "Lobby: $lobbyName".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList = LobbyPlayerList(lobbyUUID, mutableListOf(), this) { update() } private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) + private val changeLobbyNameButton = PencilButton() private val menuButtonGameOptions = "Game options".toTextButton() private val menuButtonMapOptions = "Map options".toTextButton() private val menuButtonInvite = "Invite player".toTextButton() private val menuButtonStartGame = "Start game".toTextButton() - private val bottomButtonLeave = "Leave".toTextButton() - private val bottomButtonSocial = "Social".toTextButton() + private val bottomButtonLeave = if (owner.uuid == me.uuid) "Close lobby".toTextButton() else "Leave".toTextButton() + private val bottomButtonSocial = MultiplayerButton() private val bottomButtonHelp = "Help".toTextButton() init { + changeLobbyNameButton.onActivation { + ToastPopup("Renaming a lobby is not implemented.", stage) + } + menuButtonGameOptions.onClick { WrapPopup(stage, gameOptionsTable) } @@ -86,7 +102,7 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, WrapPopup(stage, mapOptionsTable) } menuButtonInvite.onClick { - ToastPopup("The invitation feature has not been implemented yet.", stage) + WrapPopup(stage, LobbyInviteTable(lobbyUUID, this as BaseScreen)) } menuButtonStartGame.onActivation { val lobbyStartResponse = InfoPopup.load(stage) { @@ -100,6 +116,13 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, 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 { @@ -111,6 +134,9 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, } recreate() + Concurrency.run { + refresh() + } } private class WrapPopup(stage: Stage, other: Actor, action: (() -> Unit)? = null) : Popup(stage) { @@ -121,10 +147,32 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, } } + /** + * Refresh the cached data for this lobby and its chat room and recreate the screen + */ + private suspend fun refresh() { + chatMessageList.triggerRefresh() + + 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) { + currentPlayers = lobby.currentPlayers + lobbyName = lobby.name + Concurrency.runOnGLThread { + recreate() + } + } + } + + /** + * Recreate the screen including some of its elements + */ fun recreate(): BaseScreen { val table = Table() - table.setFillParent(true) - stage.addActor(table) val players = VerticalGroup() val playerScroll = AutoScrollPane(lobbyPlayerList, skin) @@ -142,16 +190,16 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, val chatTable = ChatTable(chatMessageList, true) val menuBar = Table() menuBar.align(Align.bottom) - menuBar.add(bottomButtonLeave).pad(10f) + menuBar.add(bottomButtonLeave).pad(20f) menuBar.add().fillX().expandX() - menuBar.add(bottomButtonSocial).pad(5f) // half padding since the help button has padding as well - menuBar.add(bottomButtonHelp).pad(10f) + 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() topLine.addActor(Container(screenTitle).padRight(10f)) - topLine.addActor(PencilButton().apply { onClick { ToastPopup("Renaming a lobby is not implemented.", stage) } }) + 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() @@ -164,6 +212,9 @@ class LobbyScreen(private val lobbyUUID: UUID, private val lobbyChatUUID: UUID, 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) return this } From 811cb8a99c026bf3c0be03963409bd4d9ce0c41b Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 01:53:04 +0200 Subject: [PATCH 083/152] Added a handler to start a game from a lobby --- .../screens/multiplayerscreens/LobbyScreen.kt | 55 ++++++++++++++++++- .../ui/screens/newgamescreen/NewGameScreen.kt | 5 +- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 4fccde26f1426..0abc3962df462 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -9,12 +9,15 @@ 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.GameInfo +import com.unciv.logic.GameStarter import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.logic.multiplayer.apiv2.FriendRequestResponse import com.unciv.logic.multiplayer.apiv2.FriendResponse import com.unciv.logic.multiplayer.apiv2.GetLobbyResponse import com.unciv.logic.multiplayer.apiv2.LobbyResponse import com.unciv.logic.multiplayer.apiv2.OnlineAccountResponse +import com.unciv.logic.multiplayer.apiv2.StartGameResponse import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.components.AutoScrollPane @@ -109,7 +112,7 @@ class LobbyScreen( game.onlineMultiplayer.api.lobby.startGame(lobbyUUID) } if (lobbyStartResponse != null) { - ToastPopup("The start game feature has not been implemented yet.", stage) + startGame(lobbyStartResponse) } } @@ -218,6 +221,56 @@ class LobbyScreen( return this } + /** + * Build a new [GameInfo], upload it to the server and start the game + */ + 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", + lobbyStart.gameUUID, + exception + ) + exception.printStackTrace() + 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...") + } + runBlocking { + InfoPopup.wrap(stage) { + game.onlineMultiplayer.createGame(gameInfo) + true + } + Log.debug("Uploaded game %s", lobbyStart.gameUUID) + } + Concurrency.runOnGLThread { + popup.close() + game.loadGame(gameInfo) + } + } + } + override fun lockTables() { Log.error("Not yet implemented") } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt index 0e81aec22416c..2dcf70d1c5baf 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/NewGameScreen.kt @@ -283,12 +283,9 @@ class NewGameScreen( popup.open() } - // TODO: Introduce proper lobby handling, this is a temporary solution - val gameId = UncivGame.Current.onlineMultiplayer.allocateGameId() - val newGame: GameInfo try { - newGame = GameStarter.startNewGame(gameSetupInfo, gameId) + newGame = GameStarter.startNewGame(gameSetupInfo) } catch (exception: Exception) { exception.printStackTrace() launchOnGLThread { From 6af8718f2dd5e6282ee88488e155b00079864d0d Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 21:01:00 +0200 Subject: [PATCH 084/152] Updated the chat table and message list to react to incoming messages --- .../multiplayerscreens/ChatMessageList.kt | 72 ++++++++++++------- .../screens/multiplayerscreens/ChatTable.kt | 5 +- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index feb54a97d9f75..a419d873f474a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -3,15 +3,18 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align +import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.OnlineMultiplayer -import com.unciv.logic.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.ApiV2 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.toLabel import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.concurrency.Concurrency +import java.time.Duration import java.time.Instant import java.util.* @@ -31,16 +34,37 @@ import java.util.* * Another good way is to use the [ChatTable] directly. */ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table() { + private val events = EventBus.EventReceiver() + private var messageCache: MutableList = mutableListOf() + init { defaults().expandX().space(5f) - recreate(listOf()) + recreate(messageCache) + triggerRefresh() + + events.receive(IncomingChatMessage::class, { it.chatUUID == chatRoomUUID }) { + messageCache.add(it.message) + Concurrency.runOnGLThread { + recreate(messageCache) + } + } } /** * 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) { - // TODO + fun sendMessage(message: String, suppress: Boolean = false) { + Concurrency.run { + if (suppress) { + messageCache.add(mp.api.chat.send(message, chatRoomUUID)!!) + } else { + InfoPopup.wrap(stage) { + messageCache.add(mp.api.chat.send(message, chatRoomUUID)!!) + } + } + } } /** @@ -51,34 +75,32 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult * Use [suppress] to avoid showing an [InfoPopup] for any failures. */ fun triggerRefresh(suppress: Boolean = false) { - Concurrency.run { - if (suppress) { - val chatInfo = mp.api.chat.get(chatRoomUUID, true) - if (chatInfo != null) { - Concurrency.runOnGLThread { - recreate(chatInfo.messages) - } - } - } else { - InfoPopup.wrap(stage) { - val chatInfo = mp.api.chat.get(chatRoomUUID, false) + Concurrency.runOnGLThread { + val s = stage + 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(s) { + val chatInfo = mp.api.chat.get(chatRoomUUID, false) + if (chatInfo != null) { + Concurrency.runOnGLThread { + messageCache = chatInfo.messages.toMutableList() + recreate(chatInfo.messages) + } + } + } } } } } - /** - * Recreate the message list from strings for testing purposes using random fill data - */ - internal fun recreate(messages: List) { - recreate(messages.map { ChatMessage(UUID.randomUUID(), AccountResponse("user", "User", UUID.randomUUID()), it, Instant.now()) }) - } - /** * Recreate the table of messages using the given list of chat messages */ @@ -91,9 +113,11 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult return } + val now = Instant.now() for (message in messages) { row() - val label = Label("${message.sender.displayName} [${message.sender.username}] (${message.createdAt}):\n${message.message}", BaseScreen.skin) + val time = "[${Duration.between(message.createdAt, now).formatShort()}] ago".tr() + val label = Label("${message.sender.displayName} (${message.sender.username}) $time:\n${message.message}", BaseScreen.skin) label.setAlignment(Align.left) label.wrap = true val cell = add(label) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index 9a35a667c8e31..a944c36ee0d4b 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -8,7 +8,6 @@ import com.unciv.ui.components.RefreshButton import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation -import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.BaseScreen /** @@ -20,7 +19,8 @@ class ChatTable(chatMessageList: ChatMessageList, showRefreshButton: Boolean, ma init { val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) chatScroll.setScrollingDisabled(true, false) - add(chatScroll).fillX().expandY().padBottom(10f) + val width = if (showRefreshButton) 3 else 2 + add(chatScroll).colspan(width).fillX().expandY().padBottom(10f) row() val nameField = UncivTextField.create("New message") @@ -29,7 +29,6 @@ class ChatTable(chatMessageList: ChatMessageList, showRefreshButton: Boolean, ma } val sendButton = ArrowButton() sendButton.onActivation { - ToastPopup("Sending your message '${nameField.text}' is not implemented yet.", stage) chatMessageList.sendMessage(nameField.text) nameField.text = "" } From 659621ed8516c408f6fdd8be503770b6aa6f060c Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 22:34:25 +0200 Subject: [PATCH 085/152] Reworked the ApiV2 class to improve WebSocket handling for every login --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 177 +++++++----------- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 13 +- .../apiv2/EndpointImplementations.kt | 3 +- 3 files changed, 80 insertions(+), 113 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index f1d72858781b0..d45af18e6c5aa 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -1,5 +1,7 @@ package com.unciv.logic.multiplayer.apiv2 +import com.unciv.UncivGame +import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper @@ -41,9 +43,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** Info whether this class is fully initialized and ready to use */ private var initialized = false - /** Credentials used during the last successful login */ - private var lastSuccessfulCredentials: Pair? = null - /** Timestamp of the last successful login */ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() @@ -68,11 +67,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { Log.debug("Login failed using provided credentials (username '${credentials.first}')") } else { lastSuccessfulAuthentication.set(Instant.now()) - lastSuccessfulCredentials = credentials Concurrency.run { refreshGameDetails() } - websocket(::handleWebSocket) } } ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) @@ -186,15 +183,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { return gameDetails[gameId]?.to() ?: throw MultiplayerFileNotFoundException(null) } - /** - * Fetch server's details about a game based on its game ID - * - * @throws MultiplayerFileNotFoundException: if the [gameId] can't be resolved on the server - */ - suspend fun getGameDetails(gameId: String): GameDetails { - return getGameDetails(UUID.fromString(gameId)) - } - /** * Refresh the cache of known multiplayer games, [gameDetails] */ @@ -213,109 +201,67 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { // ---------------- WEBSOCKET FUNCTIONALITY ---------------- /** - * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * Send text as a [FrameType.TEXT] frame to the server via WebSocket (fire & forget) + * + * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error). * - * Returns [Unit] if no exception is thrown + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - internal suspend fun sendText(text: String): Unit { - if (sendChannel == null) { + internal suspend fun sendText(text: String, suppress: Boolean = false): Boolean { + val channel = sendChannel + if (channel == null) { Log.debug("No WebSocket connection, can't send text frame to server: '$text'") - return + if (suppress) { + return false + } else { + throw UncivNetworkException("WebSocket not connected", null) + } } try { - sendChannel?.send(Frame.Text(text)) + channel.send(Frame.Text(text)) } catch (e: Throwable) { - Log.debug("%s\n%s", e.localizedMessage, e.stackTraceToString()) - throw e + Log.debug("Sending text via WebSocket failed: %s\n%s", e.localizedMessage, e.stackTraceToString()) + if (!suppress) { + throw UncivNetworkException(e) + } else { + return false + } } + return true } /** - * Send text as a [FrameType.TEXT] frame to the remote side (fire & forget) + * Send a [FrameType.PING] frame to the server, without awaiting a response * - * Returns true on success, false otherwise. Any error is suppressed! + * This operation might fail with some exception, e.g. network exceptions. + * Internally, a random 8-byte array will be used for the ping. It returns + * true when sending worked as expected, false when there's no send channel + * available and an any exception otherwise. */ - internal suspend fun sendTextSuppressed(text: String): Boolean { - if (sendChannel == null) { - Log.debug("No WebSocket connection, can't send text frame to server: '$text'") - return false - } - try { - sendChannel!!.send(Frame.Text(text)) - } catch (e: Throwable) { - Log.debug("%s\n%s", e.localizedMessage, e.stackTraceToString()) + internal suspend fun sendPing(): Boolean { + val body = ByteArray(8) + Random().nextBytes(body) + val channel = sendChannel + return if (channel == null) { + false + } else { + channel.send(Frame.Ping(body)) + true } - return true } /** - * Handle incoming WebSocket messages + * Create a new WebSocket connection after logging in and if there's no current connection available */ - private suspend fun handleIncomingWSMessage(msg: WebSocketMessage) { - when (msg.type) { - WebSocketMessageType.InvalidMessage -> { - Log.debug("Received invalid message from WebSocket connection") - } - WebSocketMessageType.GameStarted -> { - Log.debug("Received GameStarted message from WebSocket connection") - // TODO: Implement game start handling - } - WebSocketMessageType.UpdateGameData -> { - // TODO - /* - @Suppress("CAST_NEVER_SUCCEEDS") - val gameInfo = UncivFiles.gameInfoFromString((msg as UpdateGameData).gameData) - Log.debug("Saving new game info for name '${gameInfo.gameId}'") - UncivGame.Current.files.saveGame(gameInfo, gameInfo.gameId) - withGLContext { - EventBus.send(MultiplayerGameUpdated(gameInfo.gameId, gameInfo.asPreview())) - } - */ - } - WebSocketMessageType.ClientDisconnected -> { - Log.debug("Received ClientDisconnected message from WebSocket connection") - // TODO: Implement client connectivity handling - } - WebSocketMessageType.ClientReconnected -> { - Log.debug("Received ClientReconnected message from WebSocket connection") - // TODO: Implement client connectivity handling - } - WebSocketMessageType.IncomingChatMessage -> { - Log.debug("Received IncomingChatMessage message from WebSocket connection") - // TODO: Implement chat message handling - } - WebSocketMessageType.IncomingInvite -> { - Log.debug("Received IncomingInvite message from WebSocket connection") - // TODO: Implement invite handling - } - WebSocketMessageType.IncomingFriendRequest -> { - Log.debug("Received IncomingFriendRequest message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.FriendshipChanged -> { - Log.debug("Received FriendshipChanged message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.LobbyJoin -> { - Log.debug("Received LobbyJoin message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.LobbyClosed -> { - Log.debug("Received LobbyClosed message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.LobbyLeave -> { - Log.debug("Received LobbyLeave message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.LobbyKick -> { - Log.debug("Received LobbyKick message from WebSocket connection") - // TODO: Implement this branch - } - WebSocketMessageType.AccountUpdated -> { - Log.debug("Received AccountUpdated message from WebSocket connection") - // TODO: Implement this branch - } + override suspend fun afterLogin() { + val pingSuccess = try { + sendPing() + } catch (e: Exception) { + Log.debug("Exception while sending WebSocket PING: %s", e.localizedMessage) + false + } + if (!pingSuccess) { + websocket(::handleWebSocket) } } @@ -331,7 +277,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { val incomingFrame = session.incoming.receive() when (incomingFrame.frameType) { FrameType.CLOSE, FrameType.PING, FrameType.PONG -> { - // This handler won't handle control frames + // This WebSocket handler won't handle control frames Log.debug("Received CLOSE, PING or PONG as message") } FrameType.BINARY -> { @@ -342,7 +288,19 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { val text = (incomingFrame as Frame.Text).readText() val msg = Json.decodeFromString(WebSocketMessageSerializer(), text) Log.debug("Incoming WebSocket message ${msg::class.java.canonicalName}: $msg") - handleIncomingWSMessage(msg) + when (msg.type) { + WebSocketMessageType.InvalidMessage -> { + Log.debug("Received 'InvalidMessage' from WebSocket connection") + } + else -> { + // Casting any message but InvalidMessage to WebSocketMessageWithContent should work, + // otherwise the class hierarchy has been messed up somehow; all messages should have content + Log.debug("Sending event %s with content %s", msg, (msg as WebSocketMessageWithContent).content) + Concurrency.runOnGLThread { + EventBus.send(msg.content) + } + } + } } catch (e: Throwable) { Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString()) } @@ -354,7 +312,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { sendChannel?.close() session.close() } catch (e: Throwable) { - Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString()) + Log.error("Error while handling a WebSocket connection: %s\n%s", e.localizedMessage, e.stackTraceToString()) sendChannel?.close() session.close() throw e @@ -371,15 +329,14 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { * Set [ignoreLastCredentials] to refresh the session even if there was no last successful credentials. */ suspend fun refreshSession(ignoreLastCredentials: Boolean = false): Boolean { - if (lastSuccessfulCredentials == null && !ignoreLastCredentials) { + if (!ignoreLastCredentials) { return false } - val success = try { - auth.login(lastSuccessfulCredentials!!.first, lastSuccessfulCredentials!!.second) - } catch (e: Throwable) { - Log.error("Suppressed error in 'refreshSession': $e") - false - } + val success = auth.login( + UncivGame.Current.settings.multiplayer.userName, + UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.onlineMultiplayer.baseUrl] ?: "", + suppress = true + ) if (success) { lastSuccessfulAuthentication.set(Instant.now()) } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 3e6733fe16ea7..cd0ce7e6d44f5 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -63,7 +63,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { * track of user-supplied credentials to be able to refresh expired sessions on the fly */ private val authHelper = AuthHelper() - // Queue to keep references to all opened WebSocket handler jobs + /** Queue to keep references to all opened WebSocket handler jobs */ protected var websocketJobs = ConcurrentLinkedQueue() init { @@ -82,6 +82,15 @@ open class ApiV2Wrapper(private val baseUrl: String) { } } + /** + * Coroutine directly executed after every successful login to the server, + * which also refreshed the session cookie (i.e., not [AuthApi.loginOnly]). + * This coroutine should not raise any unhandled exceptions, because otherwise + * the login function will fail as well. If it requires longer operations, + * those operations should be detached from the current thread. + */ + protected open suspend fun afterLogin() {} + /** * API for account management */ @@ -90,7 +99,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { /** * API for authentication management */ - internal val auth = AuthApi(client, authHelper) + internal val auth = AuthApi(client, authHelper, ::afterLogin) /** * API for chat management diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index df86e21e4b2f7..7701fcb5b815f 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -431,7 +431,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe /** * API wrapper for authentication handling (do not use directly; use the Api class instead) */ -class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper) { +class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper, private val afterLogin: suspend () -> Unit) { /** * Try logging in with [username] and [password] for testing purposes, don't set the session cookie @@ -484,6 +484,7 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper authCookie.maxAge, Pair(username, password) ) + afterLogin() true } else { Log.error("No recognized, valid session cookie found in login response!") From d3f6b91274a1666c2d1c3b3fc7ef3bbe033f800b Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 22:52:18 +0200 Subject: [PATCH 086/152] Added small game fetch, fixed lobby start, some smaller fixes --- .../logic/multiplayer/apiv2/AuthHelper.kt | 5 +-- .../apiv2/EndpointImplementations.kt | 31 ++++++++++++++++--- .../storage/ApiV2FileStorageEmulator.kt | 2 +- .../com/unciv/ui/popups/CreateLobbyPopup.kt | 7 ++++- core/src/com/unciv/ui/popups/InfoPopup.kt | 3 +- .../screens/multiplayerscreens/GameListV2.kt | 13 ++++---- .../multiplayerscreens/LobbyBrowserScreen.kt | 15 +++++++-- .../multiplayerscreens/LobbyBrowserTable.kt | 2 +- 8 files changed, 57 insertions(+), 21 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt index 13914e8afdb2c..9cd228e98fa2b 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt @@ -4,7 +4,6 @@ import com.unciv.utils.Log import io.ktor.client.request.* import io.ktor.http.* import java.time.Instant -import java.util.* import java.util.concurrent.atomic.AtomicReference /** @@ -29,9 +28,6 @@ class AuthHelper { /** Timestamp of the last successful login */ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() - /** User identification on the server, may be null if unset or not logged in */ - var user: UUID? = null - /** * Set the session cookie, update the last refresh timestamp and the last successful credentials */ @@ -66,4 +62,5 @@ class AuthHelper { Log.debug("Session cookie is not available") } } + } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 7701fcb5b815f..ca05ae2d9c5eb 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -754,22 +754,45 @@ class GameApi(private val client: HttpClient, private val authHelper: AuthHelper /** * Retrieves a single game identified by [gameUUID] which is currently open (actively played) * - * * Other than [list], this method's return value contains a full game state (on success). + * Set [cache] to false to avoid getting a cached result by this function. This + * is especially useful for receiving a new game on purpose / on request. + * * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GameStateResponse] or an error). * * @throws ApiException: thrown for defined and recognized API problems * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ - suspend fun get(gameUUID: UUID, suppress: Boolean = false): GameStateResponse? { - return request( - HttpMethod.Get, "/api/v2/games/$gameUUID", + suspend fun get(gameUUID: UUID, cache: Boolean = true, suppress: Boolean = false): GameStateResponse? { + return Cache.get( + "/api/v2/games/$gameUUID", client, authHelper, suppress = suppress, + cache = cache, retry = getDefaultRetry(client, authHelper) )?.body() } + /** + * Retrieves an overview of a single game of a player (or null if no such game is available) + * + * The response does not contain any full game state, but rather a + * shortened game state identified by its ID and state identifier. + * If the state ([GameOverviewResponse.gameDataID]) of a known game + * differs from the last known identifier, the server has a newer + * state of the game. The [GameOverviewResponse.lastActivity] field + * is a convenience attribute and shouldn't be used for update checks. + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GameOverviewResponse] or an error). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + suspend fun head(gameUUID: UUID, suppress: Boolean = false): GameOverviewResponse? { + val result = list(suppress = suppress) + return result?.filter { it.gameUUID == gameUUID }?.get(0) + } + /** * Upload a new game state for an existing game identified by [gameUUID] * diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 526a95fbb39a4..513d881187d42 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -22,7 +22,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { override suspend fun loadGameData(gameId: String): String { val uuid = UUID.fromString(gameId.lowercase()) - return api.game.get(uuid)!!.gameData + return api.game.get(uuid, cache = false)!!.gameData } override suspend fun loadPreviewData(gameId: String): String { diff --git a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt index 0781c5c05e1ac..1eb0db7cdc16e 100644 --- a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -54,7 +54,12 @@ class CreateLobbyPopup(private val base: BaseScreen) : Popup(base.stage) { private fun onClose() { Log.debug("Creating a new lobby '%s'", nameField.text) val response = InfoPopup.load(base.stage) { - base.game.onlineMultiplayer.api.lobby.open(nameField.text, if (requirePassword) passwordField.text else null) + 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 index b208048496306..4b1c912ea989e 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -46,7 +46,8 @@ open class InfoPopup( * This function will display an [InfoPopup] when a [UncivShowableException] occurs. */ fun load(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { - val popup = InfoPopup(stage, "Loading") + val popup = Popup(stage).apply { addGoodSizedLabel("Working...").row() } + popup.open(force = true) return runBlocking { try { val result = coroutine() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 58d34198be98c..5e14147667754 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.delay /** * Table listing the recently played open games for APIv2 multiplayer games */ -class GameListV2(private val screen: BaseScreen, private val onSelected: (String) -> Unit) : Table() { +class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOverviewResponse) -> Unit) : Table(BaseScreen.skin) { private val noGames = "No recently played games here".toLabel() private val games = mutableListOf() @@ -31,12 +31,11 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (String private fun addGame(game: GameOverviewResponse) { // TODO: Determine if it's the current turn, then add an indicator for that - add(game.name) - add(game.lastActivity.toString()) - add(game.lastPlayer.username) - add(game.gameDataID.toString()) - add(game.gameUUID.toString()) - add(game.gameDataID.toString()) + add(game.name).apply { onClick { onSelected(game) } } + add(game.lastActivity.toString()).apply { onClick { onSelected(game) } } + add(game.lastPlayer.username).apply { onClick { onSelected(game) } } + add(game.gameDataID.toString()).apply { onClick { onSelected(game) } } + add(game.gameDataID.toString()).apply { onClick { onSelected(game) } } add(PencilButton().apply { onClick { ToastPopup("Renaming game ${game.gameUUID} not implemented yet", screen.stage) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index d3760213cf1f6..be25b0200dff3 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -16,10 +16,13 @@ import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton 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.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.Concurrency.runBlocking import com.unciv.ui.components.AutoScrollPane as ScrollPane /** @@ -87,8 +90,16 @@ class LobbyBrowserScreen : BaseScreen() { stage.addActor(table) } - private fun onSelect(game: GameOverviewResponse) { - Log.debug("Selecting game '%s' (%s)", game.name, game.gameUUID) // TODO: Implement handling + 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) + } + } } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index 804abedea8ac9..d3b4d237850cb 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.delay /** * Table listing all available open lobbies and allow joining them by clicking on them */ -class LobbyBrowserTable(private val screen: BaseScreen): Table() { +internal class LobbyBrowserTable(private val screen: BaseScreen): Table() { 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:" From 0b00a36ffc32bffc390d7d12db0be25e3f97bd95 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 22:53:41 +0200 Subject: [PATCH 087/152] Added friend request canceling and search --- .../multiplayerscreens/FriendListV2.kt | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index 733e3b157e395..397dab6cd61f7 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -9,13 +9,21 @@ 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.KeyCharAndCode import com.unciv.ui.components.OptionsButton +import com.unciv.ui.components.SearchButton +import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.addSeparatorVertical +import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.toLabel +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.showEditPopup +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import java.util.* @@ -30,7 +38,7 @@ import java.util.* * A sane default for this functionality is the [showEditPopup] function. * This table should be encapsulated into a [base]screen or pop-up containing one. */ -class FriendListV2( +internal class FriendListV2( private val base: BaseScreen, private val me: UUID, friends: List = listOf(), @@ -76,7 +84,7 @@ class FriendListV2( */ fun recreate(friends: List, friendRequests: List = listOf()) { val body = Table() - if (requests && !friendRequests.isEmpty()) { + if (requests && friendRequests.isNotEmpty()) { body.add(getRequestTable(friendRequests)).padBottom(10f).row() body.addSeparatorVertical(Color.DARK_GRAY, 1f).padBottom(10f).row() } @@ -118,10 +126,47 @@ class FriendListV2( 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 { + Log.debug("Searching for player '%s'", nameField.text) + Concurrency.run { + val response = InfoPopup.wrap(base.stage) { + base.game.onlineMultiplayer.api.account.lookup(nameField.text) + } + if (response != null) { + Concurrency.runOnGLThread { + Log.debug("Looked up '%s' as '%s'", response.uuid, response.username) + 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 = "" + } + } + } + } + } + } + } + + searchButton.keyShortcuts.add(KeyCharAndCode.RETURN) + add(nameField).padLeft(5f).padRight(5f) + add(searchButton).padRight(5f) + 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 { @@ -130,14 +175,33 @@ class FriendListV2( triggerUpdate() } } }) + table.add(CloseButton().apply { onActivation { + InfoPopup.load(stage) { + base.game.onlineMultiplayer.api.friend.delete(request.uuid) + } + } }) table.row() } return table } companion object { - fun showEditPopup(friend: UUID) { - // TODO: Add a pop-up that allows to edit a friend + fun showEditPopup(friend: UUID, screen: BaseScreen) { + val popup = ConfirmPopup( + screen.stage, + "Do you really want to remove [$friend] as friend?", + "Yes", + false + ) { + Log.debug("Unfriending with %s", friend) + InfoPopup.load(screen.stage) { + screen.game.onlineMultiplayer.api.friend.delete(friend) + Concurrency.runOnGLThread { + ToastPopup("You removed [$friend] as friend", screen.stage) + } + } + } + popup.open(true) } } From 05f09c2d7e05516e4bb9d8099bca5c07e9188542 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 22:56:00 +0200 Subject: [PATCH 088/152] Fixed replacing the OnlineMultiplayer instance --- core/src/com/unciv/UncivGame.kt | 14 ++++---- .../logic/multiplayer/OnlineMultiplayer.kt | 16 --------- .../unciv/ui/popups/options/MultiplayerTab.kt | 35 ++++++++++--------- 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 508661b713bdf..ef991958d9ded 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -561,15 +561,17 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci newMultiplayer } if (mp != null) { - Concurrency.runOnGLThread { - val oldMultiplayer = Current.onlineMultiplayer - Current.onlineMultiplayer = mp - Concurrency.run { - oldMultiplayer.dispose() + Concurrency.runBlocking { + Concurrency.runOnGLThread { + val oldMultiplayer = Current.onlineMultiplayer + Current.onlineMultiplayer = mp + Concurrency.run { + oldMultiplayer.dispose() + } } } } else { - Log.error("Failed to refresh online multiplayer successfully") + Log.error("Failed to refresh online multiplayer") } } } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index e2440743ce8a8..22c733b33ac9e 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -133,22 +133,6 @@ class OnlineMultiplayer { } } - /** - * Allocate a new game ID on the server and return it - * - * IMPORTANT: This is a temporary solution until proper handling of lobbies is implemented. - * When this is done, this function should be changed to something like `startLobby`. - */ - fun allocateGameId(): String? { - // TODO: Make backward-compatible by ignoring remote backends which can't create game IDs - runBlocking { - // TODO: Implement the server endpoint for the function api.lobby.create() - return@runBlocking UUID.randomUUID().toString() - } - return null - } - - private fun getCurrentGame(): OnlineMultiplayerGame? { val gameInfo = UncivGame.Current.gameInfo return if (gameInfo != null) { diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 51c3c7438acb4..9ce63a00f357c 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -191,21 +191,26 @@ private fun addMultiplayerServerOptions( addGoodSizedLabel("Awaiting response...").row() open(true) } - UncivGame.refreshOnlineMultiplayer() - - 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 { + UncivGame.refreshOnlineMultiplayer() + 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) } - }.open(true) - } else { - popup.reuseWith("Failed!", true) + } else { + popup.reuseWith("Failed!", true) + } } } }).row() @@ -297,8 +302,6 @@ private fun addTurnCheckerOptions( private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { Concurrency.run("TestIsAlive") { try { - // TODO: Detecting API version changes doesn't work without game restart yet, - // therefore this server check will almost certainly fail when the server changes val connectionSuccess = UncivGame.Current.onlineMultiplayer.checkServerStatus() var authSuccess = false if (connectionSuccess) { From 599ac30d1871b761ea66ba951fc5a4652322cbab Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 23:39:03 +0200 Subject: [PATCH 089/152] Added lobby player sorting, fixed duplicate chat messages --- .../multiplayerscreens/ChatMessageList.kt | 24 +++++++++++------ .../screens/multiplayerscreens/LobbyPlayer.kt | 2 +- .../multiplayerscreens/LobbyPlayerList.kt | 27 ++++++++++++++----- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index a419d873f474a..007afcfbbe261 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -58,10 +58,10 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult fun sendMessage(message: String, suppress: Boolean = false) { Concurrency.run { if (suppress) { - messageCache.add(mp.api.chat.send(message, chatRoomUUID)!!) + mp.api.chat.send(message, chatRoomUUID) } else { InfoPopup.wrap(stage) { - messageCache.add(mp.api.chat.send(message, chatRoomUUID)!!) + mp.api.chat.send(message, chatRoomUUID) } } } @@ -116,13 +116,21 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult val now = Instant.now() for (message in messages) { row() - val time = "[${Duration.between(message.createdAt, now).formatShort()}] ago".tr() - val label = Label("${message.sender.displayName} (${message.sender.username}) $time:\n${message.message}", BaseScreen.skin) - label.setAlignment(Align.left) - label.wrap = true - val cell = add(label) - cell.fillX() + addMessage(message, now) } } + /** + * 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 label = Label("${message.sender.displayName} (${message.sender.username}) $time:\n${message.message}", BaseScreen.skin) + label.setAlignment(Align.left) + label.wrap = true + val cell = add(label) + cell.fillX() + row() + } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt index 3e4b9e1df07c1..e8211bac1ea92 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt @@ -13,7 +13,7 @@ import com.unciv.models.metadata.Player * easy backward compatibility without any further modifications. * Human players are identified by a valid [account], use null for AI players. */ -class LobbyPlayer(internal val account: AccountResponse?, var chosenCiv: String = Constants.random) { +internal class LobbyPlayer(internal val account: AccountResponse?, var chosenCiv: String = Constants.random) { val isAI: Boolean get() = account == null diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index 90c7cd7df6603..5e3461361e384 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -6,6 +6,7 @@ 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.models.metadata.Player import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.onClick @@ -24,17 +25,17 @@ import java.util.* */ class LobbyPlayerList( private val lobbyUUID: UUID, - private val mutablePlayers: MutableList = mutableListOf(), + startPlayers: List = listOf(), private val base: IPreviousScreen, private val update: () -> Unit ) : Table() { - internal val players: List = mutablePlayers + internal val players: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() private val addBotButton = "+".toLabel(Color.LIGHT_GRAY, 30) .apply { this.setAlignment(Align.center) } .surroundWithCircle(50f, color = Color.GRAY) .onClick { - mutablePlayers.add(LobbyPlayer(null, Constants.random)) + players.add(LobbyPlayer(null, Constants.random)) recreate() } @@ -60,8 +61,22 @@ class LobbyPlayerList( row() val movements = VerticalGroup() movements.space(5f) - movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { Log.error("Click up not implemented yet") }) - movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { Log.error("Click down not implemented yet") }) + movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { + if (i > 0) { + val above = players[i-1] + players[i-1] = players[i] + players[i] = above + recreate() + } + }) + movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { + if (i < players.size - 1) { + val below = players[i+1] + players[i+1] = players[i] + players[i] = below + recreate() + } + }) add(movements) val player = players[i] @@ -78,7 +93,7 @@ class LobbyPlayerList( if (!player.isAI) { ToastPopup("Kicking human players has not been implemented yet.", stage) // TODO: Implement this } - val success = mutablePlayers.remove(player) + val success = players.remove(player) Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") recreate() } From 78fe7241d45ec6f7c7e6e5c4ef47c1f6990be7f4 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Apr 2023 23:51:44 +0200 Subject: [PATCH 090/152] Enforce multiplayer games when the lobby for V2 games is used --- .../ui/screens/multiplayerscreens/LobbyScreen.kt | 9 ++++++--- .../ui/screens/newgamescreen/GameOptionsTable.kt | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 0abc3962df462..33fa1f8a5d1ab 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -72,9 +72,7 @@ class LobbyScreen( override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) - private val gameOptionsTable = GameOptionsTable(this, updatePlayerPickerTable = { x -> - Log.error("Updating player picker table with '%s' is not implemented yet.", x) - }) + private val gameOptionsTable: GameOptionsTable private val mapOptionsTable = MapOptionsTable(this) private val me @@ -94,6 +92,11 @@ class LobbyScreen( private val bottomButtonHelp = "Help".toTextButton() init { + gameSetupInfo.gameParameters.isOnlineMultiplayer = true + gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> + Log.error("Updating player picker table with '%s' is not implemented yet.", x) + }) + changeLobbyNameButton.onActivation { ToastPopup("Renaming a lobby is not implemented.", stage) } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt index a6fb15560883b..9f30eac7c3282 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt @@ -39,6 +39,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) { @@ -93,11 +94,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", From 7a1fb229a0c8e67e7b706a5a6eaa807444e702f9 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 13 Apr 2023 00:19:30 +0200 Subject: [PATCH 091/152] Improved the LobbyBrowserTable --- .../unciv/ui/components/ButtonCollection.kt | 1 + .../multiplayerscreens/LobbyBrowserTable.kt | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt index e56799cb479ea..0fe15168134d4 100644 --- a/core/src/com/unciv/ui/components/ButtonCollection.kt +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -28,3 +28,4 @@ class NewButton(size: Float = Constants.headingFontSize.toFloat()): SpecificButt 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") diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index d3b4d237850cb..a7ae4d90d9fa6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -3,10 +3,11 @@ 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.LobbyResponse +import com.unciv.ui.components.ArrowButton +import com.unciv.ui.components.LockButton import com.unciv.ui.components.extensions.onClick 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.images.ImageGetter import com.unciv.ui.popups.AskTextPopup import com.unciv.ui.popups.InfoPopup @@ -18,7 +19,7 @@ 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): Table() { +internal class LobbyBrowserTable(private val screen: BaseScreen): 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:" @@ -42,7 +43,7 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table() { maxLength = 120 ) { InfoPopup.load(stage) { - screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid) + screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid, it) Concurrency.runOnGLThread { screen.game.pushScreen(LobbyScreen(lobby)) } @@ -71,12 +72,13 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table() { lobbies.sortedBy { it.createdAt } for (lobby in lobbies.reversed()) { - // TODO: The button may be styled with icons and the texts may be translated - val btn = "${lobby.name} (${lobby.currentPlayers}/${lobby.maxPlayers} players) ${if (lobby.hasPassword) " LOCKED" else ""}".toTextButton() - btn.onClick { - joinLobby(lobby) + 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() } - add(btn).row() } } From 03f962dc25d132788939a864f9dccbff5e67912f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 13 Apr 2023 18:47:00 +0200 Subject: [PATCH 092/152] Allow to specify the player and AI civs for new games --- .../unciv/models/metadata/GameParameters.kt | 2 +- .../multiplayerscreens/LobbyPlayerList.kt | 44 +++++++++++++++---- .../screens/multiplayerscreens/LobbyScreen.kt | 13 ++++-- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/core/src/com/unciv/models/metadata/GameParameters.kt b/core/src/com/unciv/models/metadata/GameParameters.kt index aaed938ece961..2d8363b060bfe 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)) for (i in 1..3) add(Player()) } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index 5e3461361e384..c5699e4a0e41b 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -25,6 +25,7 @@ import java.util.* */ class LobbyPlayerList( private val lobbyUUID: UUID, + private var editable: Boolean, startPlayers: List = listOf(), private val base: IPreviousScreen, private val update: () -> Unit @@ -49,11 +50,14 @@ class LobbyPlayerList( */ 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() - add(addBotButton) + if (editable) { + add(addBotButton) + } return } @@ -77,10 +81,12 @@ class LobbyPlayerList( recreate() } }) - add(movements) + if (editable) { + add(movements) + } val player = players[i] - add(getNationTable(player)) + add(getNationTable(i)) if (player.isAI) { add("AI".toLabel()) } else { @@ -97,7 +103,9 @@ class LobbyPlayerList( Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") recreate() } - add(kickButton) + if (editable) { + add(kickButton) + } if (i < players.size - 1) { row() @@ -106,13 +114,31 @@ class LobbyPlayerList( } row() - add(addBotButton).colspan(columns).fillX().center() + if (editable) { + add(addBotButton).colspan(columns).fillX().center() + } + updateParameters() + } + + /** + * Update game parameters to reflect changes in the list of players + */ + internal fun updateParameters() { + base.gameSetupInfo.gameParameters.players = players.map { it.to() }.toMutableList() + } + + 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], where clicking creates [NationPickerPopup] + * Create clickable icon and nation name for some [LobbyPlayer] based on its index in [players], where clicking creates [NationPickerPopup] */ - private fun getNationTable(player: LobbyPlayer): Table { + private fun getNationTable(index: Int): Table { + val player = players[index] val nationTable = Table() val nationImage = if (player.chosenCiv == Constants.random) @@ -127,9 +153,9 @@ class LobbyPlayerList( nationTable.onClick { val p = player.to() NationPickerPopup(p, 0.45f * stage.width, base as BaseScreen, base, false, availableCivilisations) { - player.chosenCiv = p.chosenCiv + players[index].chosenCiv = p.chosenCiv + updateParameters() recreate() - update() }.open() } return nationTable diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 33fa1f8a5d1ab..7f4705c6559fb 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -61,14 +61,14 @@ class LobbyScreen( private val lobbyChatUUID: UUID, private var lobbyName: String, private val maxPlayers: Int, - private var currentPlayers: List, + private var 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, 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()) override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) @@ -80,7 +80,7 @@ class LobbyScreen( private val screenTitle get() = "Lobby: [$lobbyName] [${currentPlayers.size + 1}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) - private val lobbyPlayerList = LobbyPlayerList(lobbyUUID, mutableListOf(), this) { update() } + private val lobbyPlayerList: LobbyPlayerList private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) private val changeLobbyNameButton = PencilButton() private val menuButtonGameOptions = "Game options".toTextButton() @@ -92,9 +92,14 @@ class LobbyScreen( private val bottomButtonHelp = "Help".toTextButton() init { + if (owner !in currentPlayers) { + currentPlayers.add(owner) + } gameSetupInfo.gameParameters.isOnlineMultiplayer = true + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, currentPlayers, this) { update() } gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> Log.error("Updating player picker table with '%s' is not implemented yet.", x) + lobbyPlayerList.recreate() }) changeLobbyNameButton.onActivation { @@ -166,7 +171,7 @@ class LobbyScreen( null } if (lobby != null) { - currentPlayers = lobby.currentPlayers + currentPlayers = lobby.currentPlayers.toMutableList() lobbyName = lobby.name Concurrency.runOnGLThread { recreate() From 059de8bcfdda407bc6e96e1fce5238e4640d00aa Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 13 Apr 2023 18:49:01 +0200 Subject: [PATCH 093/152] Change the user ID after logging in to fix later in-game issues Attention: Afterwards, there is restoration of the previous player ID. Therefore, it won't be possible to revert back to APIv0 or APIv1 behavior easily (i.e., without saving the player ID before logging in the first time). --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index d45af18e6c5aa..a54583e561a7c 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -250,21 +250,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { } } - /** - * Create a new WebSocket connection after logging in and if there's no current connection available - */ - override suspend fun afterLogin() { - val pingSuccess = try { - sendPing() - } catch (e: Exception) { - Log.debug("Exception while sending WebSocket PING: %s", e.localizedMessage) - false - } - if (!pingSuccess) { - websocket(::handleWebSocket) - } - } - /** * Handle a newly established WebSocket connection */ @@ -295,9 +280,8 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { else -> { // Casting any message but InvalidMessage to WebSocketMessageWithContent should work, // otherwise the class hierarchy has been messed up somehow; all messages should have content - Log.debug("Sending event %s with content %s", msg, (msg as WebSocketMessageWithContent).content) Concurrency.runOnGLThread { - EventBus.send(msg.content) + EventBus.send((msg as WebSocketMessageWithContent).content) } } } @@ -321,6 +305,37 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { // ---------------- SESSION FUNCTIONALITY ---------------- + /** + * Perform post-login hooks and updates + * + * 1. Create a new WebSocket connection after logging in and + * if there's no current connection available + * 2. Update the [UncivGame.Current.settings.multiplayer.userId] + * (this makes using APIv0/APIv1 games impossible if the user ID is not preserved!) + */ + @Suppress("KDocUnresolvedReference") + override suspend fun afterLogin() { + val pingSuccess = try { + sendPing() + } catch (e: Exception) { + Log.debug("Exception while sending WebSocket PING: %s", e.localizedMessage) + false + } + if (!pingSuccess) { + websocket(::handleWebSocket) + } + val me = account.get() + if (me != null) { + Log.error( + "Updating user ID from %s to %s. This is no error. But you may need the old ID to be able to access your old multiplayer saves.", + UncivGame.Current.settings.multiplayer.userId, + me.uuid + ) + UncivGame.Current.settings.multiplayer.userId = me.uuid.toString() + UncivGame.Current.settings.save() + } + } + /** * Refresh the currently used session by logging in with username and password stored in the game settings * From 4f9312e462a9cbc81dfb5e21203ad787041b903c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 13 Apr 2023 22:39:27 +0200 Subject: [PATCH 094/152] Updated the WorldTopBar's social button, dropped update for LobbyPlayerList, fixed some bugs --- .../screens/multiplayerscreens/LobbyPlayer.kt | 2 +- .../multiplayerscreens/LobbyPlayerList.kt | 7 ++-- .../screens/multiplayerscreens/LobbyScreen.kt | 6 +-- .../screens/worldscreen/WorldScreenTopBar.kt | 38 +++++++++---------- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt index e8211bac1ea92..e71320e1343a3 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayer.kt @@ -17,7 +17,7 @@ internal class LobbyPlayer(internal val account: AccountResponse?, var chosenCiv val isAI: Boolean get() = account == null - fun to() = Player().apply { + fun to() = Player(chosenCiv = chosenCiv).apply { playerType = PlayerType.AI if (!isAI) { playerType = PlayerType.Human diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index c5699e4a0e41b..cc1874bc36fcf 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -27,8 +27,7 @@ class LobbyPlayerList( private val lobbyUUID: UUID, private var editable: Boolean, startPlayers: List = listOf(), - private val base: IPreviousScreen, - private val update: () -> Unit + private val base: IPreviousScreen ) : Table() { internal val players: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() @@ -129,7 +128,7 @@ class LobbyPlayerList( private fun reassignRemovedModReferences() { for (player in players) { - if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState()) + if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) player.chosenCiv = Constants.random } } @@ -148,7 +147,7 @@ class LobbyPlayerList( nationTable.add(player.chosenCiv.toLabel()).padRight(5f) nationTable.touchable = Touchable.enabled val availableCivilisations = base.ruleset.nations.values.asSequence() - .filter { it.isMajorCiv() } + .filter { it.isMajorCiv } .filter { it.name == player.chosenCiv || players.none { player -> player.chosenCiv == it.name } } nationTable.onClick { val p = player.to() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 7f4705c6559fb..6d7e97f857ea2 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -96,7 +96,7 @@ class LobbyScreen( currentPlayers.add(owner) } gameSetupInfo.gameParameters.isOnlineMultiplayer = true - lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, currentPlayers, this) { update() } + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, currentPlayers, this) gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> Log.error("Updating player picker table with '%s' is not implemented yet.", x) lobbyPlayerList.recreate() @@ -295,8 +295,4 @@ class LobbyScreen( Log.error("Not yet implemented") } - private fun update() { - Log.error("Not yet implemented") - } - } diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 10af68487c760..1c1b30ed28339 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -10,7 +10,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.logic.civilization.Civilization import com.unciv.logic.multiplayer.ApiVersion -import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.tile.TileResource import com.unciv.models.ruleset.unique.UniqueType @@ -18,6 +17,7 @@ import com.unciv.models.stats.Stats import com.unciv.models.translations.tr import com.unciv.ui.components.Fonts import com.unciv.ui.components.MayaCalendar +import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.YearTextUtil import com.unciv.ui.components.extensions.colorFromRGB @@ -29,7 +29,7 @@ import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toStringSigned import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.images.ImageGetter -import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.popups import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories @@ -41,8 +41,9 @@ import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen import com.unciv.ui.screens.pickerscreens.TechPickerScreen import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup -import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency +import java.util.* +import kotlin.collections.ArrayList import kotlin.math.ceil import kotlin.math.max import kotlin.math.roundToInt @@ -74,7 +75,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { private val resourcesWrapper = Table() private val resourceTable = getResourceTable() private val selectedCivTable = SelectedCivilizationTable(worldScreen) - private val openGameChatButton = OpenGameChatTable(worldScreen) + private val socialButton = SocialButtonWrapper(worldScreen) private val overviewButton = OverviewAndSupplyTable(worldScreen) private val leftFillerCell: Cell private val rightFillerCell: Cell @@ -176,27 +177,24 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { return resourceTable } - private class OpenGameChatTable(worldScreen: WorldScreen) : Table(BaseScreen.skin) { + private class SocialButtonWrapper(worldScreen: WorldScreen) : Table(BaseScreen.skin) { init { - // The chat feature will only be enabled if the multiplayer server has support for it + // The social features will only be enabled if the multiplayer server has support for it if (worldScreen.gameInfo.gameParameters.isOnlineMultiplayer && worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { - val openChatButton = "Chat".toTextButton() - openChatButton.onClick { + val socialButton = MultiplayerButton() + socialButton.onClick { Concurrency.run { - try { - val details = worldScreen.game.onlineMultiplayer.api.getGameDetails(worldScreen.gameInfo.gameId) + val details = InfoPopup.wrap(worldScreen.stage) { + worldScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(worldScreen.gameInfo.gameId)) + } + if (details != null) { Concurrency.runOnGLThread { worldScreen.game.pushScreen(ChatRoomScreen(details.chatRoomUUID)) } - } catch (e: MultiplayerFileNotFoundException) { - Concurrency.runOnGLThread { - Log.error("No game details associated with game '%s' found", worldScreen.gameInfo.gameId) - ToastPopup("No chat associated with this game found.", worldScreen.stage) - } } } } - add(openChatButton).pad(10f) + add(socialButton).pad(10f) pack() } } @@ -281,7 +279,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { val statsWidth = statsTable.minWidth val resourceWidth = resourceTable.minWidth - val chatWidth = openGameChatButton.minWidth + val socialWidth = socialButton.minWidth val overviewWidth = overviewButton.minWidth val selectedCivWidth = selectedCivTable.minWidth val leftRightNeeded = max(selectedCivWidth, overviewWidth) @@ -310,7 +308,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { } val leftFillerWidth = if (fillerHeight > 0f) selectedCivWidth else 0f - val rightFillerWidth = if (fillerHeight > 0f) (overviewWidth + chatWidth) else 0f + val rightFillerWidth = if (fillerHeight > 0f) (overviewWidth + socialWidth) else 0f if (leftFillerCell.minHeight != fillerHeight || leftFillerCell.minWidth != leftFillerWidth || rightFillerCell.minWidth != rightFillerWidth) { @@ -325,10 +323,10 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { setPosition(0f, stage.height, Align.topLeft) selectedCivTable.setPosition(1f, buttonY, Align.left) - openGameChatButton.setPosition(stage.width - overviewButton.width - 5f, buttonY, Align.right) + socialButton.setPosition(stage.width - overviewButton.width - 5f, buttonY, Align.right) overviewButton.setPosition(stage.width, buttonY, Align.right) addActor(selectedCivTable) // needs to be after pack - addActor(openGameChatButton) + addActor(socialButton) addActor(overviewButton) } From f0cc61265ad00cc30676db4719de255d21e83034 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 14 Apr 2023 00:03:20 +0200 Subject: [PATCH 095/152] Added periodic redrawing of chat messages, added dispose to chat list, dropped LobbyInviteTable --- .../multiplayerscreens/ChatMessageList.kt | 30 +++++++++- .../multiplayerscreens/ChatRoomScreen.kt | 4 ++ .../screens/multiplayerscreens/ChatTable.kt | 6 +- .../multiplayerscreens/FriendListV2.kt | 17 +++--- .../multiplayerscreens/LobbyInviteTable.kt | 55 ------------------- 5 files changed, 46 insertions(+), 66 deletions(-) delete mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 007afcfbbe261..c5a6342931f21 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -14,10 +14,15 @@ 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.Concurrency +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import java.time.Duration import java.time.Instant import java.util.* +/** Interval to redraw the chat message list in milliseconds */ +private const val REDRAW_INTERVAL = 5000L + /** * Simple list for messages from a multiplayer chat * @@ -31,11 +36,13 @@ import java.util.* * val chatScroll = AutoScrollPane(chatMessages, skin) * chatScroll.setScrollingDisabled(true, false) * - * Another good way is to use the [ChatTable] directly. + * 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 chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table() { private val events = EventBus.EventReceiver() private var messageCache: MutableList = mutableListOf() + private var redrawJob: Job = Concurrency.run { redrawPeriodically() } init { defaults().expandX().space(5f) @@ -133,4 +140,25 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult 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) + } + } + } + + /** + * Dispose this instance and cancel the [redrawJob] + */ + internal fun dispose() { + redrawJob.cancel() + } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index bb394325b2b3f..4a339e3261822 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -66,4 +66,8 @@ class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { stage.addActor(tab) } + override fun dispose() { + messageTable.dispose() + } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index a944c36ee0d4b..13404ef71acc6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -15,7 +15,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen * * Optionally, it can display a [RefreshButton] to the right of the send button. */ -class ChatTable(chatMessageList: ChatMessageList, showRefreshButton: Boolean, maxLength: Int? = null): Table() { +class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: Boolean, maxLength: Int? = null): Table() { init { val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) chatScroll.setScrollingDisabled(true, false) @@ -47,4 +47,8 @@ class ChatTable(chatMessageList: ChatMessageList, showRefreshButton: Boolean, ma } row() } + + 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 index 397dab6cd61f7..0594bd3130674 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -22,7 +22,6 @@ 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.showEditPopup import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import java.util.* @@ -32,7 +31,7 @@ import java.util.* * * 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 show a button that opens a chat dialog with a single friend. Use + * 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. * A sane default for this functionality is the [showEditPopup] function. @@ -43,10 +42,10 @@ internal class FriendListV2( private val me: UUID, friends: List = listOf(), friendRequests: List = listOf(), - private val requests: Boolean = false, - private val chat: Boolean = true, - private val select: ((UUID) -> Unit)? = null, - private val edit: ((UUID) -> Unit)? = null + val requests: Boolean = false, + val chat: ((UUID) -> Unit)? = null, + val select: ((UUID) -> Unit)? = null, + val edit: ((UUID) -> Unit)? = null ) : Table() { init { recreate(friends, friendRequests) @@ -107,13 +106,13 @@ internal class FriendListV2( return table } - val width = 2 + (if (chat) 1 else 0) + (if (edit != null) 1 else 0) + (if (select != null) 1 else 0) + 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) { - table.add(ChatButton().apply { onActivation { ToastPopup("Chatting is not implemented yet", stage).open(force = true) } }).padLeft(5f).padBottom(5f) + if (chat != null) { + table.add(ChatButton().apply { onActivation { (chat)(friend.friend.uuid) } }).padLeft(5f).padBottom(5f) } if (edit != null) { table.add(OptionsButton().apply { onActivation { (edit)(friend.friend.uuid ) } }).padLeft(5f).padBottom(5f) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt deleted file mode 100644 index 15728b6239d5a..0000000000000 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyInviteTable.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.unciv.ui.screens.multiplayerscreens - -import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.scenes.scene2d.ui.Table -import com.unciv.Constants -import com.unciv.ui.components.KeyCharAndCode -import com.unciv.ui.components.SearchButton -import com.unciv.ui.components.UncivTextField -import com.unciv.ui.components.extensions.addSeparatorVertical -import com.unciv.ui.components.extensions.keyShortcuts -import com.unciv.ui.components.extensions.onActivation -import com.unciv.ui.components.extensions.toLabel -import com.unciv.ui.popups.InfoPopup -import com.unciv.ui.screens.basescreen.BaseScreen -import com.unciv.utils.Log -import com.unciv.utils.concurrency.Concurrency -import java.util.UUID - -class LobbyInviteTable(private val lobbyUUID: UUID, private val base: BaseScreen): Table() { - init { - add("Invite player".toLabel(fontSize = Constants.headingFontSize)).colspan(2).pad(5f).padBottom(10f) - row() - - val nameField = UncivTextField.create("Search player") - val searchButton = SearchButton() - searchButton.onActivation { - Log.debug("Searching for player '%s'", nameField.text) - Concurrency.run { - val response = InfoPopup.wrap(base.stage) { - base.game.onlineMultiplayer.api.account.lookup(nameField.text) - } - if (response != null) { - Concurrency.runOnGLThread { - Log.debug("Looked up '%s' as '%s'", response.uuid, response.username) - invite(response.uuid) - } - } - } - } - - searchButton.keyShortcuts.add(KeyCharAndCode.RETURN) - add(nameField).padLeft(5f).padRight(5f) - add(searchButton).padRight(5f) - row() - - addSeparatorVertical(Color.DARK_GRAY).colspan(2).pad(5f).row() - - } - - private fun invite(friendUUID: UUID) { - InfoPopup.load(base.stage) { - base.game.onlineMultiplayer.api.invite.new(friendUUID, lobbyUUID) - } - } -} From be7c505ac1b88a50a310bbfd2e45c0e8296e82e6 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 14 Apr 2023 02:47:16 +0200 Subject: [PATCH 096/152] Corrected the friend list handling, implemented the social button --- .../multiplayerscreens/FriendListV2.kt | 43 ++++++++++++++----- .../multiplayerscreens/LobbyBrowserScreen.kt | 14 +++++- .../screens/multiplayerscreens/LobbyScreen.kt | 17 +++++++- .../multiplayerscreens/SocialMenuTable.kt | 38 ++++++++++++++++ 4 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index 0594bd3130674..670af2ab67045 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -3,6 +3,8 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.Constants +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 @@ -34,7 +36,7 @@ import java.util.* * 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. - * A sane default for this functionality is the [showEditPopup] function. + * A sane default for this functionality is the [showRemoveFriendshipPopup] function. * This table should be encapsulated into a [base]screen or pop-up containing one. */ internal class FriendListV2( @@ -83,7 +85,7 @@ internal class FriendListV2( */ fun recreate(friends: List, friendRequests: List = listOf()) { val body = Table() - if (requests && friendRequests.isNotEmpty()) { + if (requests) { body.add(getRequestTable(friendRequests)).padBottom(10f).row() body.addSeparatorVertical(Color.DARK_GRAY, 1f).padBottom(10f).row() } @@ -135,10 +137,25 @@ internal class FriendListV2( val nameField = UncivTextField.create("Search player") val searchButton = SearchButton() searchButton.onActivation { - Log.debug("Searching for player '%s'", nameField.text) + val searchString = nameField.text + if (searchString == "") { + return@onActivation + } + Log.debug("Searching for player '%s'", searchString) Concurrency.run { val response = InfoPopup.wrap(base.stage) { - base.game.onlineMultiplayer.api.account.lookup(nameField.text) + 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 { @@ -155,16 +172,19 @@ internal class FriendListV2( nameField.text = "" } } - } + }.open(force = true) } } } } searchButton.keyShortcuts.add(KeyCharAndCode.RETURN) - add(nameField).padLeft(5f).padRight(5f) - add(searchButton).padRight(5f) - row() + 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) @@ -173,19 +193,20 @@ internal class FriendListV2( 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() } return table } companion object { - fun showEditPopup(friend: UUID, screen: BaseScreen) { + fun showRemoveFriendshipPopup(friend: UUID, screen: BaseScreen) { val popup = ConfirmPopup( screen.stage, "Do you really want to remove [$friend] as friend?", diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index be25b0200dff3..6385a4a79f43f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -5,6 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.Constants import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse import com.unciv.ui.components.KeyCharAndCode +import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.NewButton import com.unciv.ui.components.RefreshButton import com.unciv.ui.components.extensions.addSeparator @@ -32,10 +33,14 @@ class LobbyBrowserScreen : BaseScreen() { private val lobbyBrowserTable = LobbyBrowserTable(this) private val gameList = GameListV2(this, ::onSelect) + private val me + get() = kotlinx.coroutines.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 helpButton = "Help".toTextButton() private val updateButton = RefreshButton() private val closeButton = Constants.close.toTextButton() @@ -72,6 +77,12 @@ class LobbyBrowserScreen : BaseScreen() { closeButton.onActivation { game.popScreen() } + socialButton.onClick { + val popup = Popup(stage) + popup.innerTable.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().fillX().fillY().row() + popup.addCloseButton() + popup.open() + } helpButton.onClick { val helpPopup = Popup(this) helpPopup.addGoodSizedLabel("This should become a lobby browser.").row() // TODO @@ -79,7 +90,8 @@ class LobbyBrowserScreen : BaseScreen() { helpPopup.open() } bottomTable.add(closeButton).pad(20f) - bottomTable.add().colspan(2).growX() // layout purposes only + bottomTable.add().growX() // layout purposes only + bottomTable.add(socialButton).pad(10f) bottomTable.add(helpButton).pad(20f) table.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f), height = 1f).width(stage.width * 0.85f).padTop(15f).row() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 6d7e97f857ea2..6fbc73b639f0e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -113,7 +113,17 @@ class LobbyScreen( WrapPopup(stage, mapOptionsTable) } menuButtonInvite.onClick { - WrapPopup(stage, LobbyInviteTable(lobbyUUID, this as BaseScreen)) + val friends = FriendListV2( + this as BaseScreen, + me.uuid, + select = { + InfoPopup.load(stage) { + game.onlineMultiplayer.api.invite.new(it, lobbyUUID) + } + } + ) + InfoPopup.load(stage) { friends.triggerUpdate() } + WrapPopup(stage, friends) } menuButtonStartGame.onActivation { val lobbyStartResponse = InfoPopup.load(stage) { @@ -150,6 +160,10 @@ class LobbyScreen( } } + override fun dispose() { + chatMessageList.dispose() + } + private class WrapPopup(stage: Stage, other: Actor, action: (() -> Unit)? = null) : Popup(stage) { init { innerTable.add(other).center().expandX().row() @@ -185,7 +199,6 @@ class LobbyScreen( fun recreate(): BaseScreen { val table = Table() - val players = VerticalGroup() val playerScroll = AutoScrollPane(lobbyPlayerList, skin) playerScroll.setScrollingDisabled(true, false) 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..0c11bffe0bb76 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -0,0 +1,38 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay +import java.util.* + +class SocialMenuTable( + private val base: BaseScreen, + private val me: UUID +): Table(BaseScreen.skin) { + + internal val friendList = FriendListV2(base, me, requests = true, chat = { startChatting(it) }, edit = { FriendListV2.showRemoveFriendshipPopup(it, base) }) + private val container = Container() + + init { + add(friendList) + add(container) + Concurrency.run { + while (stage == null) { + delay(10) + } + InfoPopup.wrap(stage) { friendList.triggerUpdate() } + } + } + + private fun startChatting(selectedFriend: UUID) { + Log.debug("Opening chat dialog with friend %s", selectedFriend) + // TODO: The UUID is the friend account, not the chat room! + container.actor?.dispose() + container.actor = ChatTable(ChatMessageList(selectedFriend, base.game.onlineMultiplayer), true) + } + +} From fe98ecfff211d8f010bf1ba434758890791eebcb Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 14 Apr 2023 02:32:54 +0200 Subject: [PATCH 097/152] Added serializer class for WebSocket's FriendshipEvent enum --- .../logic/multiplayer/apiv2/JsonSerializers.kt | 15 +++++++++++++++ .../logic/multiplayer/apiv2/WebSocketStructs.kt | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index d4b55afac4210..9da20a6e8f1ee 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -101,3 +101,18 @@ internal class WebSocketMessageTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FriendshipEventSerializer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: FriendshipEvent) { + encoder.encodeString(value.type) + } + + override fun deserialize(decoder: Decoder): FriendshipEvent { + return FriendshipEvent.getByValue(decoder.decodeString()) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 4535c123c3f41..5064332ba5de4 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -8,10 +8,16 @@ import java.util.* /** * Enum of all events that can happen in a friendship */ +@Serializable(with = FriendshipEventSerializer::class) enum class FriendshipEvent(val type: String) { Accepted("accepted"), Rejected("rejected"), Deleted("deleted"); + + companion object { + private val VALUES = FriendshipEvent.values() + fun getByValue(type: String) = VALUES.first { it.type == type } + } } /** From b1ea97227b71d1648e8e04ddbc654ceb10c7a981 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 15 Apr 2023 14:30:53 +0200 Subject: [PATCH 098/152] Fixed chat room access and cancelling friendships --- .../multiplayer/apiv2/ResponseStructs.kt | 6 ++-- .../multiplayerscreens/FriendListV2.kt | 28 +++++++++++-------- .../screens/multiplayerscreens/LobbyScreen.kt | 6 ++-- .../multiplayerscreens/SocialMenuTable.kt | 10 +++---- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index fc655ddc9befe..a15290ab98573 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -358,7 +358,7 @@ data class LobbyResponse( /** * The account data * - * It provides the extra field ``online`` indicating whether the account has any connected client. + * It provides the extra field [online] indicating whether the account has any connected client. */ @Serializable data class OnlineAccountResponse( @@ -368,7 +368,9 @@ data class OnlineAccountResponse( val username: String, @SerialName("display_name") val displayName: String -) +) { + fun to() = AccountResponse(uuid = uuid, username = username, displayName = displayName) +} /** * The response when starting a game diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index 670af2ab67045..41f0d56f9a2d2 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -3,6 +3,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.graphics.Color 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 @@ -36,7 +37,10 @@ import java.util.* * 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. - * A sane default for this functionality is the [showRemoveFriendshipPopup] function. + * [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( @@ -45,9 +49,9 @@ internal class FriendListV2( friends: List = listOf(), friendRequests: List = listOf(), val requests: Boolean = false, - val chat: ((UUID) -> Unit)? = null, - val select: ((UUID) -> Unit)? = null, - val edit: ((UUID) -> Unit)? = null + val chat: ((UUID, AccountResponse, UUID) -> Unit)? = null, + val select: ((UUID, AccountResponse) -> Unit)? = null, + val edit: ((UUID, AccountResponse) -> Unit)? = null ) : Table() { init { recreate(friends, friendRequests) @@ -114,13 +118,13 @@ internal class FriendListV2( for (friend in friends) { table.add("${friend.friend.displayName} (${friend.friend.username})").padBottom(5f) if (chat != null) { - table.add(ChatButton().apply { onActivation { (chat)(friend.friend.uuid) } }).padLeft(5f).padBottom(5f) + 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.friend.uuid ) } }).padLeft(5f).padBottom(5f) + 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.friend.uuid ) } }).padLeft(5f).padBottom(5f) + table.add(ArrowButton().apply { onActivation { (select)(friend.uuid, friend.friend.to()) } }).padLeft(5f).padBottom(5f) } table.row() } @@ -206,18 +210,18 @@ internal class FriendListV2( } companion object { - fun showRemoveFriendshipPopup(friend: UUID, screen: BaseScreen) { + fun showRemoveFriendshipPopup(friendship: UUID, friend: AccountResponse, screen: BaseScreen) { val popup = ConfirmPopup( screen.stage, - "Do you really want to remove [$friend] as friend?", + "Do you really want to remove [${friend.username}] as friend?", "Yes", false ) { - Log.debug("Unfriending with %s", friend) + Log.debug("Unfriending with %s (friendship UUID: %s)", friend.username, friendship) InfoPopup.load(screen.stage) { - screen.game.onlineMultiplayer.api.friend.delete(friend) + screen.game.onlineMultiplayer.api.friend.delete(friendship) Concurrency.runOnGLThread { - ToastPopup("You removed [$friend] as friend", screen.stage) + ToastPopup("You removed [${friend.username}] as friend", screen.stage) } } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 6fbc73b639f0e..85639067caefd 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -116,9 +116,9 @@ class LobbyScreen( val friends = FriendListV2( this as BaseScreen, me.uuid, - select = { + select = { _, friend -> InfoPopup.load(stage) { - game.onlineMultiplayer.api.invite.new(it, lobbyUUID) + game.onlineMultiplayer.api.invite.new(friend.uuid, lobbyUUID) } } ) @@ -227,7 +227,7 @@ class LobbyScreen( 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.6f).padLeft(5f) + 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 diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index 0c11bffe0bb76..aadc1143cf034 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -2,6 +2,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Container import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.logic.multiplayer.apiv2.AccountResponse import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log @@ -14,7 +15,7 @@ class SocialMenuTable( private val me: UUID ): Table(BaseScreen.skin) { - internal val friendList = FriendListV2(base, me, requests = true, chat = { startChatting(it) }, edit = { FriendListV2.showRemoveFriendshipPopup(it, base) }) + internal val friendList = FriendListV2(base, me, requests = true, chat = { _, a, c -> startChatting(a, c) }, edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, base) }) private val container = Container() init { @@ -28,11 +29,10 @@ class SocialMenuTable( } } - private fun startChatting(selectedFriend: UUID) { - Log.debug("Opening chat dialog with friend %s", selectedFriend) - // TODO: The UUID is the friend account, not the chat room! + private fun startChatting(friend: AccountResponse, chatRoom: UUID) { + Log.debug("Opening chat dialog with friend %s (room %s)", friend, chatRoom) container.actor?.dispose() - container.actor = ChatTable(ChatMessageList(selectedFriend, base.game.onlineMultiplayer), true) + container.actor = ChatTable(ChatMessageList(chatRoom, base.game.onlineMultiplayer), true) } } From a63cf837e4d55fcef859490a96fed8a819866b4e Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 15 Apr 2023 22:19:49 +0200 Subject: [PATCH 099/152] Added lobby player event handling --- .../multiplayerscreens/LobbyBrowserScreen.kt | 4 +- .../screens/multiplayerscreens/LobbyScreen.kt | 44 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 6385a4a79f43f..ec105e470d15e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -91,8 +91,8 @@ class LobbyBrowserScreen : BaseScreen() { } bottomTable.add(closeButton).pad(20f) bottomTable.add().growX() // layout purposes only - bottomTable.add(socialButton).pad(10f) - bottomTable.add(helpButton).pad(20f) + bottomTable.add(socialButton).pad(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) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 85639067caefd..1c0b9823f9dfa 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -6,17 +6,18 @@ 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.scenes.scene2d.ui.VerticalGroup import com.badlogic.gdx.utils.Align import com.unciv.Constants import com.unciv.logic.GameInfo import com.unciv.logic.GameStarter +import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.FriendRequestResponse -import com.unciv.logic.multiplayer.apiv2.FriendResponse 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.OnlineAccountResponse import com.unciv.logic.multiplayer.apiv2.StartGameResponse import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.RulesetCache @@ -24,15 +25,15 @@ import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.PencilButton -import com.unciv.ui.components.RefreshButton -import com.unciv.ui.components.SearchButton import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.brighten import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.onClick +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.images.ImageGetter import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup @@ -71,6 +72,7 @@ class LobbyScreen( constructor(lobby: GetLobbyResponse): this(lobby.uuid, lobby.chatRoomUUID, lobby.name, lobby.maxPlayers, lobby.currentPlayers.toMutableList(), lobby.hasPassword, lobby.owner, GameSetupInfo.fromSettings()) override var ruleset = RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters) + private val events = EventBus.EventReceiver() private val gameOptionsTable: GameOptionsTable private val mapOptionsTable = MapOptionsTable(this) @@ -154,6 +156,30 @@ class LobbyScreen( 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) + 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 }) { + val success = lobbyPlayerList.removePlayer(it.player.uuid) + Log.debug("Removing player %s from lobby %s", it.player, if (success) "succeeded" else "failed") + if (success) { + 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() + } + } + recreate() Concurrency.run { refresh() @@ -222,6 +248,12 @@ class LobbyScreen( // 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() From d089ccd5aa698bf6bfb83b04aae272dbf7c85288 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 15 Apr 2023 21:24:33 +0200 Subject: [PATCH 100/152] Added a mutex for all changes to the lobby player list --- .../multiplayerscreens/LobbyPlayerList.kt | 205 +++++++++++++----- .../screens/multiplayerscreens/LobbyScreen.kt | 14 +- 2 files changed, 162 insertions(+), 57 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index cc1874bc36fcf..fc085a3a08b75 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -7,18 +7,22 @@ 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.models.metadata.Player +import com.unciv.logic.multiplayer.apiv2.ApiV2 import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.surroundWithCircle import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.images.ImageGetter -import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.newgamescreen.IPreviousScreen import com.unciv.ui.screens.newgamescreen.NationPickerPopup import com.unciv.utils.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.* +import kotlin.math.roundToInt /** * List of players in an APIv2 lobby screen @@ -26,16 +30,22 @@ import java.util.* class LobbyPlayerList( private val lobbyUUID: UUID, private var editable: Boolean, + private var api: ApiV2, startPlayers: List = listOf(), private val base: IPreviousScreen ) : Table() { + private val mutex = Mutex() // used to synchronize changes to the players list internal val players: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() private val addBotButton = "+".toLabel(Color.LIGHT_GRAY, 30) .apply { this.setAlignment(Align.center) } .surroundWithCircle(50f, color = Color.GRAY) .onClick { - players.add(LobbyPlayer(null, Constants.random)) + runBlocking { + mutex.withLock { + players.add(LobbyPlayer(null, Constants.random)) + } + } recreate() } @@ -44,6 +54,33 @@ class LobbyPlayerList( recreate() } + /** + * Add the specified player to the player list and recreate the view + */ + internal fun addPlayer(player: AccountResponse): Boolean { + runBlocking { + mutex.withLock { + players.add(LobbyPlayer(player)) + } + } + recreate() + return true + } + + /** + * Remove the specified player from the player list and recreate the view + */ + internal fun removePlayer(player: UUID): Boolean { + var modified: Boolean + runBlocking { + mutex.withLock { + modified = players.removeAll { it.account?.uuid == player } + } + } + recreate() + return modified + } + /** * Recreate the table of players based on the list of internal player representations */ @@ -60,61 +97,95 @@ class LobbyPlayerList( 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] - players[i-1] = players[i] - players[i] = above - recreate() - } - }) - movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { - if (i < players.size - 1) { - val below = players[i+1] - players[i+1] = players[i] - players[i] = below - recreate() - } - }) - if (editable) { - add(movements) - } + runBlocking { + mutex.withLock { + for (i in players.indices) { + row() + val movements = VerticalGroup() + movements.space(5f) + movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { + if (runBlocking { + var changed = false + mutex.withLock { + if (i > 0) { + changed = true + val above = players[i - 1] + players[i - 1] = players[i] + players[i] = above + } + } + changed + }) { + recreate() + } + }) + movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { + if (runBlocking { + var changed = false + mutex.withLock { + if (i < players.size - 1) { + changed = true + val below = players[i + 1] + players[i + 1] = players[i] + players[i] = below - val player = players[i] - add(getNationTable(i)) - if (player.isAI) { - add("AI".toLabel()) - } else { - add(player.account!!.username.toLabel()) - } + } + } + changed + }) { + recreate() + } + }) + if (editable) { + add(movements) + } - val kickButton = "❌".toLabel(Color.SCARLET, Constants.headingFontSize).apply { this.setAlignment(Align.center) } - // kickButton.surroundWithCircle(Constants.headingFontSize.toFloat(), color = color) - kickButton.onClick { - if (!player.isAI) { - ToastPopup("Kicking human players has not been implemented yet.", stage) // TODO: Implement this - } - val success = players.remove(player) - Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") - recreate() - } - if (editable) { - add(kickButton) - } + 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 + if (!player.isAI) { + runBlocking { + success = true == InfoPopup.wrap(stage) { + api.lobby.kick(lobbyUUID, player.account!!.uuid) + } + } + } + if (success) { + runBlocking { + mutex.withLock { + success = players.remove(player) + } + } + } + Log.debug("Removing player %s [%s]: %s", player.account, i, if (success) "success" else "failure") + recreate() + } + if (editable) { + add(kickButton) + } - if (i < players.size - 1) { - row() - addSeparator(color = Color.DARK_GRAY).width(0.8f * width).pad(5f) + 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(fontSize = (Constants.defaultFontSize * 0.75).roundToInt())).colspan(columns).fillX().padTop(20f).center() } updateParameters() } @@ -127,9 +198,13 @@ class LobbyPlayerList( } private fun reassignRemovedModReferences() { - for (player in players) { - if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) - player.chosenCiv = Constants.random + runBlocking { + mutex.withLock { + for (player in players) { + if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) + player.chosenCiv = Constants.random + } + } } } @@ -160,4 +235,30 @@ class LobbyPlayerList( return nationTable } + /** + * Refresh the view of the human players based on the [currentPlayers] response from the server + */ + internal fun updateCurrentPlayers(currentPlayers: List) { + runBlocking { + mutex.withLock { + 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) { + players.remove(r) + } + for (newPlayer in currentPlayers) { + if (newPlayer !in humanPlayers) { + players.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 index 1c0b9823f9dfa..130aa475a4b9a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -59,10 +59,10 @@ import java.util.* */ class LobbyScreen( private val lobbyUUID: UUID, - private val lobbyChatUUID: UUID, + lobbyChatUUID: UUID, private var lobbyName: String, private val maxPlayers: Int, - private var currentPlayers: MutableList, + currentPlayers: MutableList, private val hasPassword: Boolean, private val owner: AccountResponse, override val gameSetupInfo: GameSetupInfo @@ -80,7 +80,7 @@ class LobbyScreen( private val me get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! private val screenTitle - get() = "Lobby: [$lobbyName] [${currentPlayers.size + 1}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) + get() = "Lobby: [$lobbyName] [${lobbyPlayerList.players.size + 1}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList: LobbyPlayerList private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) @@ -98,7 +98,7 @@ class LobbyScreen( currentPlayers.add(owner) } gameSetupInfo.gameParameters.isOnlineMultiplayer = true - lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, currentPlayers, this) + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, game.onlineMultiplayer.api, currentPlayers, this) gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> Log.error("Updating player picker table with '%s' is not implemented yet.", x) lobbyPlayerList.recreate() @@ -211,7 +211,11 @@ class LobbyScreen( null } if (lobby != null) { - currentPlayers = lobby.currentPlayers.toMutableList() + val refreshedLobbyPlayers = lobby.currentPlayers.toMutableList() + if (owner !in refreshedLobbyPlayers) { + refreshedLobbyPlayers.add(owner) + } + lobbyPlayerList.updateCurrentPlayers(refreshedLobbyPlayers) lobbyName = lobby.name Concurrency.runOnGLThread { recreate() From cb5834c8f00e2f94acd5d5544089fbd12d8df1df Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 16 Apr 2023 18:40:20 +0200 Subject: [PATCH 101/152] Dropped MultiplayerScreenV2, improved disposing functionality --- .../multiplayerscreens/ChatMessageList.kt | 5 +- .../multiplayerscreens/ChatRoomScreen.kt | 1 + .../screens/multiplayerscreens/GameListV2.kt | 46 ++- .../multiplayerscreens/LobbyBrowserScreen.kt | 5 + .../screens/multiplayerscreens/LobbyScreen.kt | 3 +- .../multiplayerscreens/MultiplayerScreenV2.kt | 295 ------------------ 6 files changed, 44 insertions(+), 311 deletions(-) delete mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index c5a6342931f21..c08e1f87f28d1 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -3,6 +3,7 @@ package com.unciv.ui.screens.multiplayerscreens 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.logic.event.EventBus import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.logic.multiplayer.apiv2.ChatMessage @@ -39,7 +40,7 @@ private const val REDRAW_INTERVAL = 5000L * 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 chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table() { +class ChatMessageList(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() } @@ -157,7 +158,7 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult /** * Dispose this instance and cancel the [redrawJob] */ - internal fun dispose() { + override fun dispose() { redrawJob.cancel() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt index 4a339e3261822..6a727a984a203 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt @@ -68,6 +68,7 @@ class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { override fun dispose() { messageTable.dispose() + super.dispose() } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 5e14147667754..03e1128abd5b6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -1,23 +1,31 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Disposable import com.unciv.logic.event.EventBus 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.screens.basescreen.BaseScreen import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant /** * 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) { - +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() @@ -31,18 +39,24 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv private fun addGame(game: GameOverviewResponse) { // TODO: Determine if it's the current turn, then add an indicator for that - add(game.name).apply { onClick { onSelected(game) } } - add(game.lastActivity.toString()).apply { onClick { onSelected(game) } } - add(game.lastPlayer.username).apply { onClick { onSelected(game) } } - add(game.gameDataID.toString()).apply { onClick { onSelected(game) } } - add(game.gameDataID.toString()).apply { onClick { onSelected(game) } } + add(game.name.toTextButton().onClick { onSelected(game) }).padRight(10f).padBottom(5f) + val time = "[${Duration.between(game.lastActivity, Instant.now()).formatShort()}] ago".tr() + add(time).padRight(10f).padBottom(5f) + add(game.lastPlayer.username).padRight(10f).padBottom(5f) + add(game.gameDataID.toString()).padRight(10f).padBottom(5f) add(PencilButton().apply { onClick { ToastPopup("Renaming game ${game.gameUUID} not implemented yet", screen.stage) - } }) + } }).padRight(5f).padBottom(5f) add(ChatButton().apply { onClick { - ToastPopup("Opening chat room ${game.chatRoomUUID} not implemented yet", screen.stage) - } }) + Log.debug("Opening chat room ${game.chatRoomUUID} from game list") + val popup = Popup(screen.stage) + val chatMessageList = ChatMessageList(game.chatRoomUUID, screen.game.onlineMultiplayer) + disposables.add(chatMessageList) + popup.innerTable.add(ChatTable(chatMessageList, false)).padBottom(10f).row() + popup.addCloseButton() + popup.open(force = true) + } }).padBottom(5f) } /** @@ -54,8 +68,7 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv add(noGames).row() return } - games.sortedBy { it.lastActivity } - for (game in games.reversed()) { + for (game in games.sortedBy { it.lastActivity }.reversed()) { addGame(game) row() } @@ -75,11 +88,18 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv if (listOfOpenGames != null) { Concurrency.runOnGLThread { games.clear() - listOfOpenGames.forEach { games.add(it) } + listOfOpenGames.sortedBy { it.lastActivity }.reversed().forEach { games.add(it) } recreate() } } } } + /** + * 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 index ec105e470d15e..84c9c69ab6f8e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -114,4 +114,9 @@ class LobbyBrowserScreen : BaseScreen() { } } + override fun dispose() { + gameList.dispose() + super.dispose() + } + } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 130aa475a4b9a..b3263de808ef9 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -80,7 +80,7 @@ class LobbyScreen( private val me get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! private val screenTitle - get() = "Lobby: [$lobbyName] [${lobbyPlayerList.players.size + 1}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) + get() = "Lobby: [$lobbyName] [${lobbyPlayerList.players.size}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList: LobbyPlayerList private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) @@ -188,6 +188,7 @@ class LobbyScreen( override fun dispose() { chatMessageList.dispose() + super.dispose() } private class WrapPopup(stage: Stage, other: Actor, action: (() -> Unit)? = null) : Popup(stage) { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt deleted file mode 100644 index 4e62ead37ee42..0000000000000 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreenV2.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.unciv.ui.screens.multiplayerscreens - -import com.badlogic.gdx.scenes.scene2d.ui.Table -import com.unciv.logic.UncivShowableException -import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.MultiplayerGameDeleted -import com.unciv.logic.multiplayer.OnlineMultiplayerGame -import com.unciv.logic.multiplayer.apiv2.AccountResponse -import com.unciv.logic.multiplayer.apiv2.ApiException -import com.unciv.logic.multiplayer.apiv2.FriendResponse -import com.unciv.logic.multiplayer.apiv2.GameOverviewResponse -import com.unciv.models.translations.tr -import com.unciv.ui.components.extensions.addSeparator -import com.unciv.ui.components.extensions.brighten -import com.unciv.ui.components.extensions.disable -import com.unciv.ui.components.extensions.enable -import com.unciv.ui.components.extensions.onClick -import com.unciv.ui.components.extensions.toLabel -import com.unciv.ui.components.extensions.toTextButton -import com.unciv.ui.popups.InfoPopup -import com.unciv.ui.popups.Popup -import com.unciv.ui.popups.ToastPopup -import com.unciv.ui.screens.pickerscreens.HorizontalPickerScreen -import com.unciv.utils.Log -import com.unciv.utils.concurrency.Concurrency -import com.unciv.ui.components.AutoScrollPane as ScrollPane - -class MultiplayerScreenV2 : HorizontalPickerScreen() { - private var selectedGame: Pair? = null // pair of game UUID to file handle - private var cachedGames: Map = mutableMapOf() - private var cachedFriendResponse: Triple, List, List>? = null - - private val leftSideTable = Table() // list friend requests, then online friends, then offline friends, see recreateLeftSideTable() - private val rightSideTable = Table() // list open games to re-join quickly - - private val updateFriendListButton = "Update friend list".toTextButton() - private val requestFriendshipButton = "Request friendship".toTextButton() - private val updateGameListButton = "Update games".toTextButton() - // TODO: Align lobby button horizontally to the join game button, if possible - private val lobbyBrowserButton = "Browse open lobbies".toTextButton() - - private val events = EventBus.EventReceiver() - - init { - lobbyBrowserButton.onClick { - game.pushScreen(LobbyBrowserScreen()) - } - requestFriendshipButton.onClick { - ToastPopup("Friend requests are not implemented yet", stage) - } - updateFriendListButton.onClick { - Concurrency.run { - reloadFriendList() - } - } - updateGameListButton.onClick { - Concurrency.run { - reloadGameList() - } - } - - setDefaultCloseAction() - recreateLeftSideTable() - - scrollPane.setScrollingDisabled(false, true) - topTable.add(createMainContent()).row() - - setupHelpButton() - - rightSideGroup.addActor(lobbyBrowserButton) - rightSideButton.setText("Join game".tr()) - rightSideButton.onClick { - if (selectedGame != null) { - Log.debug("Loading multiplayer game ${selectedGame!!.first}") - MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!.second) - } - } - - events.receive(MultiplayerGameDeleted::class, { it.name == selectedGame?.first }) { - unselectGame() - } - - pickerPane.bottomTable.background = skinStrings.getUiBackground("MultiplayerScreen/BottomTable", tintColor = skinStrings.skinConfig.clearColor) - pickerPane.topTable.background = skinStrings.getUiBackground("MultiplayerScreen/TopTable", tintColor = skinStrings.skinConfig.clearColor) - - Concurrency.run { - reloadGameList() - } - Concurrency.run { - reloadFriendList() - } - } - - /** - * Reload the list of friends and friend requests from the server - */ - private suspend fun reloadFriendList() { - try { - val (friends, requests) = game.onlineMultiplayer.api.friend.list()!! - val myUUID = game.onlineMultiplayer.api.account.get()!!.uuid - cachedFriendResponse = Triple( - friends, - requests.filter { it.to.uuid == myUUID }.map{ it.from }, - requests.filter { it.from.uuid == myUUID }.map { it.to } - ) - Concurrency.runOnGLThread { - recreateLeftSideTable() - } - } catch (e: UncivShowableException) { - Concurrency.runOnGLThread { - InfoPopup(stage, e.localizedMessage) - } - } - } - - /** - * Reload the list of open games from the server, disabling the button if it's not available anymore - */ - private suspend fun reloadGameList() { - try { - // Map of game UUID to game overview - val newCachedGames = game.onlineMultiplayer.api.game.list()!!.associateBy({ it.gameUUID.toString() }, { it }) - Concurrency.runOnGLThread { - if (selectedGame != null && !newCachedGames.containsKey(selectedGame!!.first)) { - unselectGame() - } - cachedGames = newCachedGames - recreateRightSideTable() - } - } catch (e: ApiException) { - Concurrency.runOnGLThread { - InfoPopup(stage, e.localizedMessage) - } - } - } - - /** - * Recreate a scrollable table of all friend requests and friends, sorted by their online status - */ - // TODO: This method is a stub at the moment and needs expansion - private fun recreateLeftSideTable() { - leftSideTable.clear() - leftSideTable.defaults().uniformX() - leftSideTable.defaults().fillX() - leftSideTable.defaults().pad(10.0f) - - if (cachedFriendResponse == null) { - leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).center().row() - } else { - var anything = false - if (cachedFriendResponse!!.second.isNotEmpty()) { - anything = true - leftSideTable.add("Friend requests".toLabel()).colspan(2).center().row() - cachedFriendResponse?.second!!.sortedBy { - it.displayName - }.forEach { // incoming friend requests - leftSideTable.add("${it.displayName} wants to be your friend".toLabel()) - val btn = "Options".toTextButton() - btn.onClick { - // TODO: Implement friend request options - ToastPopup("Options are not implemented yet", stage) - } - leftSideTable.add(btn).row() - } - } - - if (cachedFriendResponse!!.first.isNotEmpty()) { - if (anything) { - leftSideTable.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f)) - } - anything = true - leftSideTable.add("Friends".toLabel()).colspan(2).center().row() - // TODO: Verify that this sorting is stable, i.e. the first section is online, then sorted alphabetically - cachedFriendResponse?.first!!.sortedBy { - it.friend.username - }.sortedBy { - if (it.friend.online) 0 else 1 - }.forEach {// alphabetically sorted friends - leftSideTable.add("${it.friend.displayName} (${if (it.friend.online) "online" else "offline"})".toLabel()) - val btn = "Options".toTextButton() - btn.onClick { - // TODO: Implement friend options - ToastPopup("Options are not implemented yet", stage) - } - leftSideTable.add(btn).row() - } - } - - if (!anything) { - leftSideTable.add("You have no friends yet :/".toLabel()).colspan(2).center().row() - } - } - - leftSideTable.addSeparator(skinStrings.skinConfig.baseColor.brighten(0.1f)) - leftSideTable.add(updateFriendListButton) - leftSideTable.add(requestFriendshipButton).row() - } - - /** - * Recreate a list of all games stored on the server - */ - // TODO: This method is a stub at the moment and needs expansion - private fun recreateRightSideTable() { - rightSideTable.clear() - rightSideTable.add("Games".toLabel()).row() - - rightSideTable.defaults().uniformX() - rightSideTable.defaults().fillX() - rightSideTable.defaults().pad(10.0f) - - cachedGames.forEach { - val btn = "Game '${it.value.name}'".toTextButton() - btn.onClick { - selectGame(it.key) - } - rightSideTable.add(btn).row() - } - - rightSideTable.add(updateGameListButton).row() - } - - private fun createMainContent(): Table { - val mainTable = Table() - mainTable.add(ScrollPane(leftSideTable).apply { setScrollingDisabled(true, false) }).center() - mainTable.add(ScrollPane(rightSideTable).apply { setScrollingDisabled(true, false) }).center() - return mainTable - } - - /** - * Construct a help button - */ - private fun setupHelpButton() { - val tab = Table() - val helpButton = "Help".toTextButton() - helpButton.onClick { - val helpPopup = Popup(this) - helpPopup.addGoodSizedLabel("To create a multiplayer game, check the 'multiplayer' toggle in the New Game screen, and for each human player insert that player's user ID.") - .row() - helpPopup.addGoodSizedLabel("You can assign your own user ID there easily, and other players can copy their user IDs here and send them to you for you to include them in the game.") - .row() - helpPopup.addGoodSizedLabel("").row() - - helpPopup.addGoodSizedLabel("Once you've created your game, the Game ID gets automatically copied to your clipboard so you can send it to the other players.") - .row() - helpPopup.addGoodSizedLabel("Players can enter your game by copying the game ID to the clipboard, and clicking on the 'Add multiplayer game' button") - .row() - helpPopup.addGoodSizedLabel("").row() - - helpPopup.addGoodSizedLabel("The symbol of your nation will appear next to the game when it's your turn").row() - - helpPopup.addCloseButton() - helpPopup.open() - } - tab.add(helpButton) - tab.x = (stage.width - helpButton.width) - tab.y = (stage.height - helpButton.height) - - stage.addActor(tab) - } - - private fun unselectGame() { - selectedGame = null - rightSideButton.disable() - descriptionLabel.setText("") - } - - private fun selectGame(name: String) { - if (!cachedGames.containsKey(name)) { - Log.error("UI game cache key '$name' doesn't exist") - unselectGame() - return - } - - val storedMultiplayerGame = game.onlineMultiplayer.getGameByName(name) - if (storedMultiplayerGame == null) { - InfoPopup(stage, "The game $name was not downloaded yet.") // TODO - } else { - selectedGame = Pair(name, storedMultiplayerGame) - } - - rightSideButton.enable() - descriptionLabel.setText(describeGame(cachedGames[name]!!, storedMultiplayerGame)) - } - - private fun describeGame(cachedGame: GameOverviewResponse, storedMultiplayerGame: OnlineMultiplayerGame?): String { - var details = "More details are being loaded ..." - if (storedMultiplayerGame != null) { - val preview = storedMultiplayerGame.preview - if (preview != null) { - details = "Turns: ${preview.turns}\nDifficulty: ${preview.difficulty}\nCivilizations: ${preview.civilizations}" - } - } - return "${cachedGame.name}\nGame ID: ${cachedGame.gameUUID}\nData version: ${cachedGame.gameDataID}\nLast activity: ${cachedGame.lastActivity}\nLast player: ${cachedGame.lastPlayer.displayName}\n$details" - } -} From bc49caba164d87f7d2d68ae21e296897a0c73ff5 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 18 Apr 2023 00:19:09 +0200 Subject: [PATCH 102/152] Improved the social tab and the friend list with the chatting ability --- core/src/com/unciv/Constants.kt | 1 + .../multiplayerscreens/ChatMessageList.kt | 19 ++++++++--- .../screens/multiplayerscreens/ChatTable.kt | 13 +++++--- .../multiplayerscreens/FriendListV2.kt | 4 +-- .../multiplayerscreens/LobbyBrowserScreen.kt | 2 +- .../multiplayerscreens/LobbyPlayerList.kt | 7 +++- .../screens/multiplayerscreens/LobbyScreen.kt | 5 ++- .../multiplayerscreens/SocialMenuTable.kt | 32 +++++++++++++++---- 8 files changed, 62 insertions(+), 21 deletions(-) diff --git a/core/src/com/unciv/Constants.kt b/core/src/com/unciv/Constants.kt index ec59cabb09208..977c70e80eaa6 100644 --- a/core/src/com/unciv/Constants.kt +++ b/core/src/com/unciv/Constants.kt @@ -100,6 +100,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/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index c08e1f87f28d1..4040044d61031 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -1,9 +1,11 @@ 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.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 @@ -11,6 +13,8 @@ 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 @@ -133,11 +137,16 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult */ private fun addMessage(message: ChatMessage, now: Instant? = null) { val time = "[${Duration.between(message.createdAt, now ?: Instant.now()).formatShort()}] ago".tr() - val label = Label("${message.sender.displayName} (${message.sender.username}) $time:\n${message.message}", BaseScreen.skin) - label.setAlignment(Align.left) - label.wrap = true - val cell = add(label) - cell.fillX() + 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() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index 13404ef71acc6..47012a78d3ae1 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -15,17 +15,22 @@ import com.unciv.ui.screens.basescreen.BaseScreen * * Optionally, it can display a [RefreshButton] to the right of the send button. */ -class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: Boolean, maxLength: Int? = null): Table() { +class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: Boolean, actorHeight: Float? = null, maxMessageLength: Int? = null): Table() { init { val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) chatScroll.setScrollingDisabled(true, false) + + val chatCell = add(chatScroll) + if (actorHeight != null) { + chatCell.actorHeight = actorHeight + } val width = if (showRefreshButton) 3 else 2 - add(chatScroll).colspan(width).fillX().expandY().padBottom(10f) + chatCell.colspan(width).fillX().expandY().padBottom(10f) row() val nameField = UncivTextField.create("New message") - if (maxLength != null) { - nameField.maxLength = maxLength + if (maxMessageLength != null) { + nameField.maxLength = maxMessageLength } val sendButton = ArrowButton() sendButton.onActivation { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index 41f0d56f9a2d2..faae1c9ab290c 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -90,10 +90,10 @@ internal class FriendListV2( fun recreate(friends: List, friendRequests: List = listOf()) { val body = Table() if (requests) { - body.add(getRequestTable(friendRequests)).padBottom(10f).row() + body.add(getRequestTable(friendRequests)).padBottom(10f).growX().row() body.addSeparatorVertical(Color.DARK_GRAY, 1f).padBottom(10f).row() } - body.add(getFriendTable(friends)) + body.add(getFriendTable(friends)).growX() val scroll = AutoScrollPane(body, BaseScreen.skin) scroll.setScrollingDisabled(true, false) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 84c9c69ab6f8e..a751b262c5daa 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -79,7 +79,7 @@ class LobbyBrowserScreen : BaseScreen() { } socialButton.onClick { val popup = Popup(stage) - popup.innerTable.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().fillX().fillY().row() + popup.innerTable.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() popup.addCloseButton() popup.open() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index fc085a3a08b75..d152ed94b1957 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -185,7 +185,12 @@ class LobbyPlayerList( if (editable) { add(addBotButton).colspan(columns).fillX().center() } else { - add("Non-human players are not shown in this list.".toLabel(fontSize = (Constants.defaultFontSize * 0.75).roundToInt())).colspan(columns).fillX().padTop(20f).center() + add( + "Non-human players are not shown in this list.".toLabel( + alignment = Align.center, + fontSize = Constants.smallFontSize + ) + ).colspan(columns).growX().padTop(20f).center() } updateParameters() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index b3263de808ef9..c6f489c101e4b 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -149,7 +149,10 @@ class LobbyScreen( game.popScreen() } bottomButtonSocial.onActivation { - ToastPopup("The social feature has not been implemented yet.", stage) + val popup = Popup(stage) + popup.innerTable.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 { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index aadc1143cf034..3ff86373a0031 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -12,15 +12,23 @@ import java.util.* class SocialMenuTable( private val base: BaseScreen, - private val me: UUID + me: UUID, + maxChatHeight: Float = 0.8f * base.stage.height ): Table(BaseScreen.skin) { - internal val friendList = FriendListV2(base, me, requests = true, chat = { _, a, c -> startChatting(a, c) }, edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, base) }) - private val container = Container() + internal val friendList = FriendListV2( + base, + me, + requests = true, + chat = { _, a, c -> startChatting(a, c) }, + edit = { f, a -> FriendListV2.showRemoveFriendshipPopup(f, a, base) } + ) + private val chatContainer = Container() + private var lastSelectedFriendChat: UUID? = null init { - add(friendList) - add(container) + add(friendList).growX() + add(chatContainer).maxHeight(maxChatHeight) Concurrency.run { while (stage == null) { delay(10) @@ -30,9 +38,19 @@ class SocialMenuTable( } private fun startChatting(friend: AccountResponse, chatRoom: UUID) { + if (lastSelectedFriendChat == chatRoom) { + chatContainer.actor?.dispose() + chatContainer.actor = null + lastSelectedFriendChat = null + return + } + lastSelectedFriendChat = chatRoom Log.debug("Opening chat dialog with friend %s (room %s)", friend, chatRoom) - container.actor?.dispose() - container.actor = ChatTable(ChatMessageList(chatRoom, base.game.onlineMultiplayer), true) + chatContainer.actor?.dispose() + chatContainer.actor = ChatTable( + ChatMessageList(chatRoom, base.game.onlineMultiplayer), + false + ).apply { padLeft(15f) } } } From 6e1b7218ef7f65eec0c35029d8e1ec33434a6c6e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Apr 2023 00:47:20 +0200 Subject: [PATCH 103/152] Fixed WebSocket re-connecting, outsourced configs --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 35 ++++++++++++++----- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 14 ++++---- .../logic/multiplayer/apiv2/AuthHelper.kt | 5 --- .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 18 ++++++++++ 4 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index a54583e561a7c..b017b1de9ee75 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -1,5 +1,6 @@ package com.unciv.logic.multiplayer.apiv2 +import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator @@ -16,22 +17,16 @@ import io.ktor.websocket.* import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay import kotlinx.serialization.json.Json -import java.time.Duration import java.time.Instant import java.util.* import java.util.concurrent.atomic.AtomicReference -/** Default session timeout expected from multiplayer servers (unreliable) */ -private val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) - -/** Default cache expiry timeout to indicate that certain data needs to be re-fetched */ -private val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) - /** * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] */ -class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { +class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** Cache the result of the last server API compatibility check */ private var compatibilityCheck: Boolean? = null @@ -98,7 +93,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { /** * Dispose this class and its children and jobs */ - fun dispose() { + override fun dispose() { sendChannel?.close() for (job in websocketJobs) { job.cancel() @@ -257,6 +252,22 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { sendChannel?.close() sendChannel = session.outgoing + websocketJobs.add(Concurrency.run { + val currentChannel = session.outgoing + while (sendChannel != null && currentChannel == sendChannel) { + delay(DEFAULT_WEBSOCKET_PING_FREQUENCY) + try { + sendPing() + } catch (e: Exception) { + Log.debug("Failed to send WebSocket ping: %s", e.localizedMessage) + Concurrency.run { + websocket(::handleWebSocket) + } + } + } + Log.debug("It looks like the WebSocket channel has been replaced") + }) + try { while (true) { val incomingFrame = session.incoming.receive() @@ -295,10 +306,16 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl) { Log.debug("The WebSocket channel was closed: $e") sendChannel?.close() session.close() + Concurrency.run { + websocket(::handleWebSocket) + } } catch (e: Throwable) { Log.error("Error while handling a WebSocket connection: %s\n%s", e.localizedMessage, e.stackTraceToString()) sendChannel?.close() session.close() + Concurrency.run { + websocket(::handleWebSocket) + } throw e } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index cd0ce7e6d44f5..3c2876c4c05e7 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -20,9 +20,6 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentLinkedQueue -/** Default value for max number of players in a lobby if no other value is set */ -internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32 - /** * API wrapper around the newly implemented REST API for multiplayer game handling * @@ -51,7 +48,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { connectTimeoutMillis = 3000 } install(WebSockets) { - pingInterval = 90_000 + // Pings are configured manually to enable re-connecting automatically, don't use `pingInterval` contentConverter = KotlinxWebsocketSerializationConverter(Json) } defaultRequest { @@ -144,17 +141,20 @@ open class ApiV2Wrapper(private val baseUrl: String) { authHelper.add(this) url { takeFrom(baseUrl) - protocol = URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well + protocol = if (baseUrl.startsWith("https://")) URLProtocol.WSS else URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well path("/api/v2/ws") } } - val job = Concurrency.runOnNonDaemonThreadPool { + val job = Concurrency.run { handler(session) } websocketJobs.add(job) Log.debug("A new WebSocket has been created, running in job $job") } catch (e: SerializationException) { - Log.debug("Failed to create a WebSocket: $e") + Log.debug("Failed to create a WebSocket: %s", e.localizedMessage) + return@coroutineScope false + } catch (e: Exception) { + Log.debug("Failed to establish WebSocket connection: %s", e.localizedMessage) return@coroutineScope false } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt index 9cd228e98fa2b..0848bb193fb6c 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt @@ -6,11 +6,6 @@ import io.ktor.http.* import java.time.Instant import java.util.concurrent.atomic.AtomicReference -/** - * Name of the session cookie returned and expected by the server - */ -internal const val SESSION_COOKIE_NAME = "id" - /** * Authentication helper which doesn't support multiple cookies, but just does the job correctly * diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt new file mode 100644 index 0000000000000..9eca9a9d89acc --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -0,0 +1,18 @@ +package com.unciv.logic.multiplayer.apiv2 + +import java.time.Duration + +/** Name of the session cookie returned and expected by the server */ +internal const val SESSION_COOKIE_NAME = "id" + +/** Default value for max number of players in a lobby if no other value is set */ +internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32 + +/** Default ping frequency for outgoing WebSocket connection in seconds */ +internal const val DEFAULT_WEBSOCKET_PING_FREQUENCY = 5_000L + +/** Default session timeout expected from multiplayer servers (unreliable) */ +internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) + +/** Default cache expiry timeout to indicate that certain data needs to be re-fetched */ +internal val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) From 7b3d45b3ef012959b754882e2b914cb00b59c260 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Apr 2023 01:55:14 +0200 Subject: [PATCH 104/152] Updated the RegisterLoginPopup to ask if the user wants to use the new servers --- .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 2 +- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 183 +++++++++++------- .../screens/mainmenuscreen/MainMenuScreen.kt | 4 +- 3 files changed, 118 insertions(+), 71 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt index 9eca9a9d89acc..e0cae4810ac45 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -9,7 +9,7 @@ internal const val SESSION_COOKIE_NAME = "id" internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32 /** Default ping frequency for outgoing WebSocket connection in seconds */ -internal const val DEFAULT_WEBSOCKET_PING_FREQUENCY = 5_000L +internal const val DEFAULT_WEBSOCKET_PING_FREQUENCY = 15_000L /** Default session timeout expected from multiplayer servers (unreliable) */ internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index a7d8482040357..cd65363f06150 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -1,7 +1,8 @@ package com.unciv.ui.popups -import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.Gdx import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.apiv2.ApiException @@ -22,11 +23,26 @@ import com.unciv.utils.concurrency.launchOnGLThread * [UncivGame.Current.onlineMultiplayer] must be set to a [ApiVersion.APIv2] server, * otherwise this pop-up will not work. */ -class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) : Popup(stage) { +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") init { + if (confirmUsage) { + askConfirmUsage { + build() + } + } else { + build() + } + } + + /** + * Build the popup stage + */ + private fun build() { val negativeButtonStyle = BaseScreen.skin.get("negative", TextButton.TextButtonStyle::class.java) if (!multiplayer.isInitialized() || multiplayer.apiVersion != ApiVersion.APIv2) { @@ -34,75 +50,12 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> addGoodSizedLabel("Uninitialized online multiplayer instance or ${multiplayer.baseUrl} not APIv2 compatible").colspan(2).row() addCloseButton(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) } else { - val usernameField = UncivTextField.create("Username") - val passwordField = UncivTextField.create("Password") - val loginButton = "Login existing".toTextButton() loginButton.keyShortcuts.add(KeyCharAndCode.RETURN) val registerButton = "Register new".toTextButton() - loginButton.onClick { - val popup = createPopup(force = true) - Concurrency.run { - try { - val success = UncivGame.Current.onlineMultiplayer.api.auth.login( - usernameField.text, passwordField.text - ) - UncivGame.Current.onlineMultiplayer.api.refreshSession(ignoreLastCredentials = true) - 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() - authSuccessful?.invoke(success) - } - } catch (e: ApiException) { - launchOnGLThread { - popup.close() - close() - InfoPopup(stage, "Failed to login with existing account".tr() + ":\n${e.localizedMessage}") { - authSuccessful?.invoke(false) - } - } - } - } - } - - registerButton.onClick { - 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 - ) - UncivGame.Current.onlineMultiplayer.api.refreshSession(ignoreLastCredentials = true) - 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(stage, "Successfully registered new account".tr()) { - authSuccessful?.invoke(true) - } - } - } catch (e: ApiException) { - launchOnGLThread { - popup.close() - close() - InfoPopup(stage, "Failed to register new account".tr() + ":\n${e.localizedMessage}") { - authSuccessful?.invoke(false) - } - } - } - } - } + loginButton.onClick { login() } + registerButton.onClick { register() } 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() @@ -113,10 +66,104 @@ class RegisterLoginPopup(private val stage: Stage, authSuccessful: ((Boolean) -> } } + private fun askConfirmUsage(block: () -> Unit) { + val playerId = UncivGame.Current.settings.multiplayer.userId + 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) + row() + addGoodSizedLabel(playerId) + addButton("Copy user ID") { + Gdx.app.clipboard.contents = base.game.settings.multiplayer.userId + ToastPopup("UserID copied to clipboard", base).open(force = true) + } + row() + val cell = addButton(Constants.OK) { + innerTable.clear() + block.invoke() + } + cell.colspan(2) + cell.actor.keyShortcuts.add(KeyCharAndCode.ESC) + cell.actor.keyShortcuts.add(KeyCharAndCode.BACK) + cell.actor.keyShortcuts.add(KeyCharAndCode.RETURN) + } + private fun createPopup(msg: String? = null, force: Boolean = false): Popup { - val popup = Popup(stage) + 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 + ) + UncivGame.Current.onlineMultiplayer.api.refreshSession( + ignoreLastCredentials = true + ) + 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() + authSuccessful?.invoke(success) + } + } catch (e: ApiException) { + launchOnGLThread { + popup.close() + close() + InfoPopup( + base.stage, + "Failed to login with existing account".tr() + ":\n${e.localizedMessage}" + ) { + 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 + ) + UncivGame.Current.onlineMultiplayer.api.refreshSession( + ignoreLastCredentials = true + ) + 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()) { + authSuccessful?.invoke(true) + } + } + } catch (e: ApiException) { + launchOnGLThread { + popup.close() + close() + InfoPopup( + base.stage, + "Failed to register new account".tr() + ":\n${e.localizedMessage}" + ) { + authSuccessful?.invoke(false) + } + } + } + } + } } diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index 6d95df52eb9d9..a1a3d0d0c8398 100644 --- a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt @@ -293,7 +293,7 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { 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.stage) { + RegisterLoginPopup(this, confirmUsage = true) { Log.debug("Register popup success state: %s", it) if (it) { game.pushScreen(LobbyBrowserScreen()) @@ -315,7 +315,7 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { game.onlineMultiplayer.api.auth.logout(true) Concurrency.runOnGLThread { popup.close() - RegisterLoginPopup(this@MainMenuScreen.stage) { + RegisterLoginPopup(this@MainMenuScreen, confirmUsage = true) { Log.debug("Register popup success state: %s", it) if (it) { game.pushScreen(LobbyBrowserScreen()) From ddd06cefc07da1245ff07030ed60281f5b9d815b Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Apr 2023 22:39:15 +0200 Subject: [PATCH 105/152] Improved the lobby handling, fixed many bugs --- .../multiplayerscreens/ChatMessageList.kt | 20 ++++-- .../screens/multiplayerscreens/ChatTable.kt | 2 +- .../multiplayerscreens/LobbyBrowserTable.kt | 5 +- .../multiplayerscreens/LobbyPlayerList.kt | 10 ++- .../screens/multiplayerscreens/LobbyScreen.kt | 72 +++++++++++++++++-- 5 files changed, 93 insertions(+), 16 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 4040044d61031..ceef4acbe73be 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -1,6 +1,7 @@ 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 @@ -52,7 +53,16 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult init { defaults().expandX().space(5f) recreate(messageCache) - triggerRefresh() + 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) @@ -68,6 +78,9 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult * 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) @@ -86,9 +99,8 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult * and then recreate the message list in a separate coroutine. * Use [suppress] to avoid showing an [InfoPopup] for any failures. */ - fun triggerRefresh(suppress: Boolean = false) { + fun triggerRefresh(stage: Stage, suppress: Boolean = false) { Concurrency.runOnGLThread { - val s = stage Concurrency.run { if (suppress) { val chatInfo = mp.api.chat.get(chatRoomUUID, true) @@ -99,7 +111,7 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult } } } else { - InfoPopup.wrap(s) { + InfoPopup.wrap(stage) { val chatInfo = mp.api.chat.get(chatRoomUUID, false) if (chatInfo != null) { Concurrency.runOnGLThread { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index 47012a78d3ae1..ad392447a842e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -44,7 +44,7 @@ class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: add(sendButton).padLeft(10f).padRight(10f) val refreshButton = RefreshButton() refreshButton.onActivation { - chatMessageList.triggerRefresh(false) + chatMessageList.triggerRefresh(stage, false) } add(refreshButton).padRight(5f) } else { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index a7ae4d90d9fa6..563322aee47a0 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -63,15 +63,14 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table(BaseScre /** * Recreate the table of this lobby browser using the supplied list of lobbies */ - fun recreate(lobbies: List) { + internal fun recreate(lobbies: List) { clearChildren() if (lobbies.isEmpty()) { add(noLobbies).row() return } - lobbies.sortedBy { it.createdAt } - for (lobby in lobbies.reversed()) { + for (lobby in lobbies.sortedBy { it.createdAt }.reversed()) { add(lobby.name).padRight(15f) add("${lobby.currentPlayers}/${lobby.maxPlayers}").padRight(10f) if (lobby.hasPassword) { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index d152ed94b1957..a98b5e09e4c53 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -15,14 +15,13 @@ import com.unciv.ui.components.extensions.toLabel 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.IPreviousScreen +import com.unciv.ui.screens.newgamescreen.MapOptionsInterface import com.unciv.ui.screens.newgamescreen.NationPickerPopup import com.unciv.utils.Log import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.* -import kotlin.math.roundToInt /** * List of players in an APIv2 lobby screen @@ -31,8 +30,9 @@ class LobbyPlayerList( private val lobbyUUID: UUID, private var editable: Boolean, 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: IPreviousScreen + private val base: MapOptionsInterface ) : Table() { private val mutex = Mutex() // used to synchronize changes to the players list internal val players: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() @@ -47,6 +47,7 @@ class LobbyPlayerList( } } recreate() + update?.invoke() } init { @@ -165,9 +166,12 @@ class LobbyPlayerList( success = players.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) { add(kickButton) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index c6f489c101e4b..8afa4ccc5d9b8 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -7,11 +7,14 @@ 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 @@ -19,6 +22,7 @@ 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 @@ -44,6 +48,7 @@ import com.unciv.ui.screens.newgamescreen.MapOptionsTable import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.util.* @@ -71,6 +76,7 @@ class LobbyScreen( 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() @@ -84,6 +90,8 @@ class LobbyScreen( private val lobbyPlayerList: LobbyPlayerList private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) + private val disposables = mutableListOf() + private val changeLobbyNameButton = PencilButton() private val menuButtonGameOptions = "Game options".toTextButton() private val menuButtonMapOptions = "Map options".toTextButton() @@ -98,7 +106,7 @@ class LobbyScreen( currentPlayers.add(owner) } gameSetupInfo.gameParameters.isOnlineMultiplayer = true - lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, game.onlineMultiplayer.api, currentPlayers, this) + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, game.onlineMultiplayer.api, ::recreate, currentPlayers, this) gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> Log.error("Updating player picker table with '%s' is not implemented yet.", x) lobbyPlayerList.recreate() @@ -162,17 +170,26 @@ class LobbyScreen( 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) + recreate() 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) } } @@ -183,14 +200,46 @@ class LobbyScreen( } } + val startingGamePopup = Popup(stage) + events.receive(GameStarted::class, { it.lobbyUUID == lobbyUUID }) { + Log.debug("Game in lobby %s has been started", lobbyUUID) + gameUUID = it.gameUUID + startingGamePopup.clearChildren() + startingGamePopup.addGoodSizedLabel("The game is starting. Waiting for host...") + startingGamePopup.addGoodSizedLabel("Closing this popup will return you to the lobby browser.") + startingGamePopup.innerTable.add("Open game chat".toTextButton().onClick { + Log.debug("Opening game chat %s for game %s of lobby %s", it.gameChatUUID, it.gameUUID, lobbyName) + val gameChat = ChatMessageList(it.gameChatUUID, game.onlineMultiplayer) + disposables.add(gameChat) + val wrapper = WrapPopup(stage, ChatTable(gameChat, true)) + wrapper.open(force = true) + }) + startingGamePopup.addCloseButton { + game.popScreen() + } + startingGamePopup.open(force = true) + } + events.receive(UpdateGameData::class, { gameUUID != null && it.gameUUID == gameUUID }) { + val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + Log.debug("Successfully loaded game %s from WebSocket event", gameInfo.gameId) + startingGamePopup.reuseWith("Working...") + Concurrency.runOnNonDaemonThreadPool { + game.loadGame(gameInfo) + } + } + recreate() Concurrency.run { refresh() } + chatMessageList.triggerRefresh(stage) } override fun dispose() { chatMessageList.dispose() + for (disposable in disposables) { + disposable.dispose() + } super.dispose() } @@ -203,11 +252,9 @@ class LobbyScreen( } /** - * Refresh the cached data for this lobby and its chat room and recreate the screen + * Refresh the cached data for this lobby and recreate the screen */ private suspend fun refresh() { - chatMessageList.triggerRefresh() - val lobby = try { game.onlineMultiplayer.api.lobby.get(lobbyUUID) } catch (e: Exception) { @@ -219,6 +266,19 @@ class LobbyScreen( if (owner !in refreshedLobbyPlayers) { refreshedLobbyPlayers.add(owner) } + + // This construction prevents null pointer exceptions when `refresh` + // is executed concurrently to the constructor of this class, because + // `lobbyPlayerList` might be uninitialized when this function is executed + while (true) { + try { + lobbyPlayerList.removePlayer(owner.uuid) + break + } catch (_: NullPointerException) { + delay(1) + } + } + lobbyPlayerList.updateCurrentPlayers(refreshedLobbyPlayers) lobbyName = lobby.name Concurrency.runOnGLThread { @@ -341,7 +401,9 @@ class LobbyScreen( } override fun updateTables() { - Log.error("Not yet implemented") + Concurrency.run { + refresh() + } } override fun updateRuleset() { From 2a27ec011702c999d541be753786033051485837 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 22 Apr 2023 22:41:58 +0200 Subject: [PATCH 106/152] Implemented a self-contained API version check with side-effects --- .../com/unciv/logic/multiplayer/ApiVersion.kt | 120 +++++++++++++++++- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 4 +- .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 6 + 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt index 4b34d7f3064d5..b8b6fa31f026b 100644 --- a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -1,5 +1,25 @@ package com.unciv.logic.multiplayer +import com.unciv.Constants +import com.unciv.json.json +import com.unciv.logic.multiplayer.ApiVersion.APIv0 +import com.unciv.logic.multiplayer.ApiVersion.APIv1 +import com.unciv.logic.multiplayer.ApiVersion.APIv2 +import com.unciv.logic.multiplayer.apiv2.DEFAULT_CONNECT_TIMEOUT +import com.unciv.logic.multiplayer.apiv2.UncivNetworkException +import com.unciv.logic.multiplayer.apiv2.VersionResponse +import com.unciv.utils.Log +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + /** * Enum determining the version of a remote server API implementation * @@ -23,5 +43,103 @@ package com.unciv.logic.multiplayer * @see [OnlineMultiplayer.determineServerAPI] */ enum class ApiVersion { - APIv0, APIv1, APIv2 + APIv0, APIv1, APIv2; + + companion object { + /** + * Check the server version by connecting to [baseUrl] without side-effects + * + * This function doesn't make use of any currently used workers or high-level + * connection pools, but instead opens and closes the transports inside it. + * + * It will first check if the [baseUrl] equals the [Constants.dropboxMultiplayerServer] + * to check for [ApiVersion.APIv0]. Dropbox may be unavailable, but this is **not** + * checked here. It will then try to connect to ``/isalive`` of [baseUrl]. If a + * HTTP 200 response is received, it will try to decode the response body as JSON. + * On success (regardless of the content of the JSON), [ApiVersion.APIv1] has been + * detected. Otherwise, it will try ``/api/version`` to detect [ApiVersion.APIv2] + * and try to decode its response as JSON. If any of the network calls result in + * timeout, connection refused or any other networking error, [suppress] is checked. + * If set, throwing *any* errors is forbidden, so it returns null, otherwise the + * detected [ApiVersion] is returned or the exception is thrown. + * + * @throws UncivNetworkException: thrown for any kind of network error + * or de-serialization problems (ony when [suppress] is false) + */ + suspend fun detect(baseUrl: String, suppress: Boolean = true, timeout: Long? = null): ApiVersion? { + if (baseUrl == Constants.dropboxMultiplayerServer) { + return APIv0 + } + + // This client instance should be used during the API detection + val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + }) + } + install(HttpTimeout) { + connectTimeoutMillis = timeout ?: DEFAULT_CONNECT_TIMEOUT + } + defaultRequest { + url(baseUrl) + } + } + + // Try to connect to an APIv1 server at first + val response1 = try { + client.get("/isalive") + } catch (e: Exception) { + Log.debug("Failed to fetch '/isalive' at %s: %s", baseUrl, e.localizedMessage) + if (!suppress) { + client.close() + throw UncivNetworkException(e) + } + null + } + if (response1?.status?.isSuccess() == true) { + // Some API implementations just return the text "true" on the `isalive` endpoint + if (response1.bodyAsText().startsWith("true")) { + Log.debug("Detected APIv1 at %s (no feature set)", baseUrl) + client.close() + return APIv1 + } + try { + val serverFeatureSet: ServerFeatureSet = json().fromJson(ServerFeatureSet::class.java, response1.bodyAsText()) + // val serverFeatureSet: ServerFeatureSet = response1.body() + Log.debug("Detected APIv1 at %s: %s", baseUrl, serverFeatureSet) + client.close() + return APIv1 + } catch (e: Exception) { + Log.debug("Failed to de-serialize OK response body of '/isalive' at %s: %s", baseUrl, e.localizedMessage) + } + } + + // Then try to connect to an APIv2 server + val response2 = try { + client.get("/api/version") + } catch (e: Exception) { + Log.debug("Failed to fetch '/api/version' at %s: %s", baseUrl, e.localizedMessage) + if (!suppress) { + client.close() + throw UncivNetworkException(e) + } + null + } + if (response2?.status?.isSuccess() == true) { + try { + val serverVersion: VersionResponse = response2.body() + Log.debug("Detected APIv2 at %s: %s", baseUrl, serverVersion) + client.close() + return APIv2 + } catch (e: Exception) { + Log.debug("Failed to de-serialize OK response body of '/api/version' at %s: %s", baseUrl, e.localizedMessage) + } + } + + client.close() + return null + } + } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 3c2876c4c05e7..807202b0d11e2 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -44,8 +44,8 @@ open class ApiV2Wrapper(private val baseUrl: String) { }) } install(HttpTimeout) { - requestTimeoutMillis = 5000 - connectTimeoutMillis = 3000 + requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT + connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT } install(WebSockets) { // Pings are configured manually to enable re-connecting automatically, don't use `pingInterval` diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt index e0cae4810ac45..fcb94154775c5 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -16,3 +16,9 @@ internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) /** Default cache expiry timeout to indicate that certain data needs to be re-fetched */ internal val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) + +/** Default timeout for a single request (miliseconds) */ +internal const val DEFAULT_REQUEST_TIMEOUT = 5000L + +/** Default timeout for connecting to a remote server (miliseconds) */ +internal const val DEFAULT_CONNECT_TIMEOUT = 3000L From 8efbee570fdac3416ea24789056c4a56093b36c5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 23 Apr 2023 00:13:44 +0200 Subject: [PATCH 107/152] Reworked the multiplayer server check in the multiplayer settings tab --- .../unciv/ui/popups/options/MultiplayerTab.kt | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 9ce63a00f357c..cffbdd84aec4a 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -29,6 +29,7 @@ import com.unciv.ui.popups.AuthPopup 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.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import java.time.Duration @@ -192,7 +193,49 @@ private fun addMultiplayerServerOptions( open(true) } Concurrency.runOnNonDaemonThreadPool { - UncivGame.refreshOnlineMultiplayer() + 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 + } + if (!authSuccess) { + Concurrency.runOnGLThread { + popup.close() + AuthPopup(optionsPopup.stageToShowOn) { success -> + if (success) { + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + } + popup.reuseWith("Success!", true) + } else { + popup.reuseWith("Failed!", true) + } + popup.open(true) + }.open(true) + } + } else { + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + } + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } + } + } else { + Concurrency.runOnNonDaemonThreadPool { + UncivGame.refreshOnlineMultiplayer() + } + } + } catch (e: Exception) { + Concurrency.runOnGLThread { + popup.reuseWith("Failed!", true) + } + } + /* successfullyConnectedToServer { connectionSuccess, authSuccess -> if (connectionSuccess && authSuccess) { popup.reuseWith("Success!", true) @@ -212,6 +255,7 @@ private fun addMultiplayerServerOptions( popup.reuseWith("Failed!", true) } } + */ } }).row() From 8bea2542ddd76cd3dddbc0e5dcd8d288eedcb72b Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 23 Apr 2023 01:40:13 +0200 Subject: [PATCH 108/152] Added helper to set the user ID, fixed multiplayer reloading --- .../unciv/ui/popups/options/MultiplayerTab.kt | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index cffbdd84aec4a..d3943e69b7750 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -26,6 +26,7 @@ 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.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 @@ -34,6 +35,7 @@ import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import java.time.Duration import java.time.temporal.ChronoUnit +import java.util.* fun multiplayerTab( optionsPopup: OptionsPopup @@ -210,7 +212,7 @@ private fun addMultiplayerServerOptions( Concurrency.runOnNonDaemonThreadPool { UncivGame.refreshOnlineMultiplayer() } - popup.reuseWith("Success!", true) + popup.reuseWith("Success! Detected $apiVersion!", true) } else { popup.reuseWith("Failed!", true) } @@ -226,11 +228,15 @@ private fun addMultiplayerServerOptions( } } } else { + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } Concurrency.runOnNonDaemonThreadPool { UncivGame.refreshOnlineMultiplayer() } } } catch (e: Exception) { + Log.debug("Connectivity exception: %s", e.localizedMessage) Concurrency.runOnGLThread { popup.reuseWith("Failed!", true) } @@ -255,7 +261,7 @@ private fun addMultiplayerServerOptions( popup.reuseWith("Failed!", true) } } - */ + */ } }).row() @@ -284,9 +290,9 @@ private fun addMultiplayerServerOptions( serverIpTable.add(passwordStatusTable).colspan(2).row() } - if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2 && UncivGame.Current.onlineMultiplayer.hasAuthentication()) { + if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { val logoutButton = "Logout".toTextButton() - serverIpTable.add(logoutButton.onClick { + 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) @@ -305,7 +311,40 @@ private fun addMultiplayerServerOptions( } } } - }).colspan(2).padBottom(8f).row() + } + + 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) + } + + if (UncivGame.Current.onlineMultiplayer.hasAuthentication()) { + serverIpTable.add(logoutButton).padTop(8f) + serverIpTable.add(setUserIdButton).padTop(8f).row() + } else { + serverIpTable.add(setUserIdButton).padTop(8f).colspan(2).row() + } } tab.add(serverIpTable).colspan(2).fillX().row() @@ -343,6 +382,7 @@ private fun addTurnCheckerOptions( return turnCheckerSelect } +/* private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { Concurrency.run("TestIsAlive") { try { @@ -365,6 +405,7 @@ private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { } } } + */ private fun setPassword(password: String, optionsPopup: OptionsPopup) { if (password.isBlank()) From 4c58bc3f8262151814ca1f12bfa5846f4db0188f Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 23 Apr 2023 03:32:03 +0200 Subject: [PATCH 109/152] Added automatic loading of new games when receiving UpdateGameData events --- .../multiplayerscreens/ChatMessageList.kt | 1 + .../screens/multiplayerscreens/LobbyScreen.kt | 1 + .../status/MultiplayerStatusButton.kt | 24 +++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index ceef4acbe73be..325f3d4e462cf 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -180,6 +180,7 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult * Dispose this instance and cancel the [redrawJob] */ override fun dispose() { + events.stopReceiving() redrawJob.cancel() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 8afa4ccc5d9b8..1483f736774f8 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -236,6 +236,7 @@ class LobbyScreen( } override fun dispose() { + events.stopReceiving() chatMessageList.dispose() for (disposable in disposables) { disposable.dispose() 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 ac87257a395f9..5cca7efe4c33a 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -14,17 +14,20 @@ import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame import com.unciv.logic.event.EventBus +import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.HasMultiplayerGameName import com.unciv.logic.multiplayer.MultiplayerGameNameChanged import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.apiv2.UpdateGameData import com.unciv.logic.multiplayer.isUsersTurn -import com.unciv.ui.images.ImageGetter -import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.setSize +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.Job @@ -72,6 +75,23 @@ class MultiplayerStatusButton( events.receive(MultiplayerGameUpdateStarted::class, curGameFilter) { startLoading() } events.receive(MultiplayerGameUpdateEnded::class, curGameFilter) { stopLoading() } + // APIv2 games will just receive their updates via this WebSocket event + events.receive(UpdateGameData::class, null) { + Concurrency.runOnNonDaemonThreadPool { + val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + if (gameInfo.gameId == curGame?.preview?.gameId) { + try { + UncivGame.Current.loadGame(gameInfo) + } catch (e: Exception) { + Log.error("Failed to load game update from incoming event: %s", e.localizedMessage) + } + Log.debug("Game name: '%s'", curGameName) + EventBus.send(MultiplayerGameUpdated(curGameName ?: "", gameInfo.asPreview())) + UncivGame.Current.notifyTurnStarted() + } + } + } + onClick { MultiplayerStatusPopup(screen).open() } From 472bec341db3d060bed34f4adf8ef611f3478825 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 23 Apr 2023 17:01:24 +0200 Subject: [PATCH 110/152] Corrected loading of multiplayer games to make auto-loading work smoothly --- .../logic/multiplayer/OnlineMultiplayer.kt | 28 +++++++++++++++++-- .../multiplayer/OnlineMultiplayerEvents.kt | 10 +++++++ .../ui/screens/worldscreen/WorldScreen.kt | 10 +++++++ .../status/MultiplayerStatusButton.kt | 20 ------------- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 22c733b33ac9e..2ce5f0660dd33 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -1,6 +1,7 @@ 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 @@ -10,7 +11,10 @@ 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.UpdateGameData import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException @@ -49,7 +53,7 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) * * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. */ -class OnlineMultiplayer { +class OnlineMultiplayer: Disposable { private val settings get() = UncivGame.Current.settings @@ -69,6 +73,8 @@ class OnlineMultiplayer { private lateinit var featureSet: ServerFeatureSet private var pollChecker: Job? = null + private val events = EventBus.EventReceiver() + private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) private val lastFileUpdate: AtomicReference = AtomicReference() @@ -81,6 +87,23 @@ class OnlineMultiplayer { /** Server API auto-detection happens in the coroutine [initialize] */ lateinit var apiVersion: ApiVersion + init { + events.receive(UpdateGameData::class, null) { + Concurrency.runOnNonDaemonThreadPool { + try { + val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + addGame(gameInfo) + gameInfo.isUpToDate = true + Concurrency.runOnGLThread { + EventBus.send(MultiplayerGameCanBeLoaded(gameInfo, it.gameDataID)) + } + } catch (e: IncompatibleGameInfoVersionException) { + Log.debug("Failed to load GameInfo from incoming event: %s", e.localizedMessage) + } + } + } + } + /** * Initialize this instance and detect the API version of the server automatically * @@ -516,8 +539,9 @@ class OnlineMultiplayer { /** * Dispose this [OnlineMultiplayer] instance by closing its background jobs and connections */ - fun dispose() { + override fun dispose() { pollChecker?.cancel() + events.stopReceiving() if (apiVersion == ApiVersion.APIv2) { api.dispose() } diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt index cbfae1afe353f..a1b6c6467200f 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,11 @@ 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 gameDataID: Long // server data ID for the game state +): Event diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 325244a8e80ba..e8ae215841c5f 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -15,6 +15,7 @@ import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.diplomacy.DiplomaticStatus import com.unciv.logic.event.EventBus import com.unciv.logic.map.MapVisualization +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 @@ -202,6 +203,15 @@ 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) { + Concurrency.run { + UncivGame.Current.loadGame(gameInfo) + UncivGame.Current.notifyTurnStarted() + } + } + } } if (restoreState != null) restore(restoreState) 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 5cca7efe4c33a..ff9fbb3811c7e 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -14,20 +14,17 @@ import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame import com.unciv.logic.event.EventBus -import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.HasMultiplayerGameName import com.unciv.logic.multiplayer.MultiplayerGameNameChanged import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.OnlineMultiplayerGame -import com.unciv.logic.multiplayer.apiv2.UpdateGameData import com.unciv.logic.multiplayer.isUsersTurn import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.setSize import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen -import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.Job @@ -75,23 +72,6 @@ class MultiplayerStatusButton( events.receive(MultiplayerGameUpdateStarted::class, curGameFilter) { startLoading() } events.receive(MultiplayerGameUpdateEnded::class, curGameFilter) { stopLoading() } - // APIv2 games will just receive their updates via this WebSocket event - events.receive(UpdateGameData::class, null) { - Concurrency.runOnNonDaemonThreadPool { - val gameInfo = UncivFiles.gameInfoFromString(it.gameData) - if (gameInfo.gameId == curGame?.preview?.gameId) { - try { - UncivGame.Current.loadGame(gameInfo) - } catch (e: Exception) { - Log.error("Failed to load game update from incoming event: %s", e.localizedMessage) - } - Log.debug("Game name: '%s'", curGameName) - EventBus.send(MultiplayerGameUpdated(curGameName ?: "", gameInfo.asPreview())) - UncivGame.Current.notifyTurnStarted() - } - } - } - onClick { MultiplayerStatusPopup(screen).open() } From 075d9e846ad8ed7c823224cdc27f953bd9db0865 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 24 Apr 2023 22:28:59 +0200 Subject: [PATCH 111/152] Added an optional heading for chat message lists --- .../multiplayerscreens/ChatMessageList.kt | 6 +- .../multiplayerscreens/ChatRoomType.kt | 8 ++ .../screens/multiplayerscreens/ChatTable.kt | 78 +++++++++++++------ .../screens/multiplayerscreens/GameListV2.kt | 7 +- .../screens/multiplayerscreens/LobbyScreen.kt | 6 +- .../multiplayerscreens/SocialMenuTable.kt | 40 ++++++---- 6 files changed, 101 insertions(+), 44 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomType.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 325f3d4e462cf..86b8dfa7c7181 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -45,7 +45,7 @@ private const val REDRAW_INTERVAL = 5000L * 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 chatRoomUUID: UUID, private val mp: OnlineMultiplayer): Table(), Disposable { +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() } @@ -130,6 +130,10 @@ class ChatMessageList(private val chatRoomUUID: UUID, private val mp: OnlineMult */ 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) 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 index ad392447a842e..e1ff95a200884 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -1,6 +1,7 @@ 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.KeyCharAndCode @@ -8,14 +9,23 @@ import com.unciv.ui.components.RefreshButton import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.Popup import com.unciv.ui.screens.basescreen.BaseScreen /** * A [Table] which combines [ChatMessageList] with a text input and send button to write a new message * - * Optionally, it can display a [RefreshButton] to the right of the send button. + * Optionally, it can display a [RefreshButton] to the right of the send button + * or replace the send message text box with a popup that asks for a message. */ -class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: Boolean, actorHeight: Float? = null, maxMessageLength: Int? = null): Table() { +class ChatTable( + private val chatMessageList: ChatMessageList, + showRefreshButton: Boolean, + useInputPopup: Boolean = false, + actorHeight: Float? = null, + maxMessageLength: Int? = null +): Table(), Disposable { init { val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) chatScroll.setScrollingDisabled(true, false) @@ -28,32 +38,54 @@ class ChatTable(private val chatMessageList: ChatMessageList, showRefreshButton: chatCell.colspan(width).fillX().expandY().padBottom(10f) row() - val nameField = UncivTextField.create("New message") - if (maxMessageLength != null) { - nameField.maxLength = maxMessageLength - } - val sendButton = ArrowButton() - sendButton.onActivation { - chatMessageList.sendMessage(nameField.text) - nameField.text = "" - } - sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) - - add(nameField).padLeft(5f).growX() - if (showRefreshButton) { - add(sendButton).padLeft(10f).padRight(10f) - val refreshButton = RefreshButton() - refreshButton.onActivation { - chatMessageList.triggerRefresh(stage, false) + 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) } - add(refreshButton).padRight(5f) + newButton.keyShortcuts.add(KeyCharAndCode.RETURN) + add(newButton).growX().padRight(10f).padLeft(10f).row() + } else { - add(sendButton).padLeft(10f).padRight(5f) + val messageField = UncivTextField.create("New message") + if (maxMessageLength != null) { + messageField.maxLength = maxMessageLength + } + val sendButton = ArrowButton() + sendButton.onActivation { + chatMessageList.sendMessage(messageField.text) + messageField.text = "" + } + sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) + + add(messageField).padLeft(5f).growX() + if (showRefreshButton) { + add(sendButton).padLeft(10f).padRight(10f) + val refreshButton = RefreshButton() + refreshButton.onActivation { + chatMessageList.triggerRefresh(stage, false) + } + add(refreshButton).padRight(5f) + } else { + add(sendButton).padLeft(10f).padRight(5f) + } + row() } - row() } - fun dispose() { + override fun dispose() { chatMessageList.dispose() } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 03e1128abd5b6..260874ad735e5 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -51,7 +51,12 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv add(ChatButton().apply { onClick { Log.debug("Opening chat room ${game.chatRoomUUID} from game list") val popup = Popup(screen.stage) - val chatMessageList = ChatMessageList(game.chatRoomUUID, screen.game.onlineMultiplayer) + val chatMessageList = ChatMessageList( + true, + Pair(ChatRoomType.Game, game.name), + game.chatRoomUUID, + screen.game.onlineMultiplayer + ) disposables.add(chatMessageList) popup.innerTable.add(ChatTable(chatMessageList, false)).padBottom(10f).row() popup.addCloseButton() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 1483f736774f8..38c22adb7ab9a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -64,7 +64,7 @@ import java.util.* */ class LobbyScreen( private val lobbyUUID: UUID, - lobbyChatUUID: UUID, + private val lobbyChatUUID: UUID, private var lobbyName: String, private val maxPlayers: Int, currentPlayers: MutableList, @@ -89,7 +89,7 @@ class LobbyScreen( get() = "Lobby: [$lobbyName] [${lobbyPlayerList.players.size}]/[$maxPlayers]".toLabel(fontSize = Constants.headingFontSize) private val lobbyPlayerList: LobbyPlayerList - private val chatMessageList = ChatMessageList(lobbyChatUUID, game.onlineMultiplayer) + private val chatMessageList = ChatMessageList(false, Pair(ChatRoomType.Lobby, lobbyName), lobbyChatUUID, game.onlineMultiplayer) private val disposables = mutableListOf() private val changeLobbyNameButton = PencilButton() @@ -209,7 +209,7 @@ class LobbyScreen( startingGamePopup.addGoodSizedLabel("Closing this popup will return you to the lobby browser.") startingGamePopup.innerTable.add("Open game chat".toTextButton().onClick { Log.debug("Opening game chat %s for game %s of lobby %s", it.gameChatUUID, it.gameUUID, lobbyName) - val gameChat = ChatMessageList(it.gameChatUUID, game.onlineMultiplayer) + val gameChat = ChatMessageList(true, Pair(ChatRoomType.Game, lobbyName), it.gameChatUUID, game.onlineMultiplayer) disposables.add(gameChat) val wrapper = WrapPopup(stage, ChatTable(gameChat, true)) wrapper.open(force = true) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index 3ff86373a0031..a958e989277b7 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -2,10 +2,9 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Container import com.badlogic.gdx.scenes.scene2d.ui.Table -import com.unciv.logic.multiplayer.apiv2.AccountResponse +import com.badlogic.gdx.utils.Disposable import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen -import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.delay import java.util.* @@ -13,44 +12,53 @@ import java.util.* 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) { - - internal val friendList = FriendListV2( +): Table(BaseScreen.skin), Disposable { + private val friendList = FriendListV2( base, me, - requests = true, - chat = { _, a, c -> startChatting(a, c) }, + 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 lastSelectedFriendChat: UUID? = null + private var lastSelectedChat: UUID? = null init { - add(friendList).growX() - add(chatContainer).maxHeight(maxChatHeight) + debugAll() + add(friendList).growX().minWidth(base.stage.width * 0.45f).padRight(5f) + add(chatContainer).minWidth(base.stage.width * 0.45f).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(friend: AccountResponse, chatRoom: UUID) { - if (lastSelectedFriendChat == chatRoom) { + private fun startChatting(chatRoom: UUID, chatRoomType: ChatRoomType, name: String) { + if (lastSelectedChat == chatRoom) { chatContainer.actor?.dispose() chatContainer.actor = null - lastSelectedFriendChat = null + lastSelectedChat = null return } - lastSelectedFriendChat = chatRoom - Log.debug("Opening chat dialog with friend %s (room %s)", friend, chatRoom) + lastSelectedChat = chatRoom chatContainer.actor?.dispose() chatContainer.actor = ChatTable( - ChatMessageList(chatRoom, base.game.onlineMultiplayer), + ChatMessageList(chatRoomType in chatHeadingFilter, Pair(chatRoomType, name), chatRoom, base.game.onlineMultiplayer), false ).apply { padLeft(15f) } } + override fun dispose() { + chatContainer.actor?.dispose() + chatContainer.actor = null + } } From 72408a5e82b45bd8435a7dbfc185854afbb95573 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Apr 2023 02:03:52 +0200 Subject: [PATCH 112/152] Added the SocialMenuScreen, fixed some bugs, improved game start handling --- .../multiplayerscreens/ChatRoomScreen.kt | 74 ------------------- .../multiplayerscreens/LobbyBrowserScreen.kt | 7 +- .../screens/multiplayerscreens/LobbyScreen.kt | 32 +++++--- .../multiplayerscreens/SocialMenuScreen.kt | 45 +++++++++++ .../ui/screens/worldscreen/WorldScreen.kt | 3 +- .../screens/worldscreen/WorldScreenTopBar.kt | 30 ++++++-- 6 files changed, 98 insertions(+), 93 deletions(-) delete mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt deleted file mode 100644 index 6a727a984a203..0000000000000 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatRoomScreen.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.unciv.ui.screens.multiplayerscreens - -import com.badlogic.gdx.scenes.scene2d.ui.Table -import com.unciv.logic.event.EventBus -import com.unciv.models.translations.tr -import com.unciv.ui.components.AutoScrollPane -import com.unciv.ui.components.extensions.enable -import com.unciv.ui.components.extensions.onClick -import com.unciv.ui.components.extensions.toTextButton -import com.unciv.ui.popups.AskTextPopup -import com.unciv.ui.popups.Popup -import com.unciv.ui.popups.ToastPopup -import com.unciv.ui.screens.pickerscreens.PickerScreen -import com.unciv.utils.Log -import java.util.* -import kotlin.math.max - -class ChatRoomScreen(private val chatRoomUUID: UUID) : PickerScreen() { - - private val messageTable = ChatMessageList(chatRoomUUID, game.onlineMultiplayer) - - private val events = EventBus.EventReceiver() // listen for incoming chat messages in the current chat - - init { - setDefaultCloseAction() - - scrollPane.setScrollingDisabled(false, true) - topTable.add(AutoScrollPane(messageTable, skin).apply { setScrollingDisabled(true, false) }).center() - - setupTopButtons() - - rightSideButton.setText("New message".tr()) - rightSideButton.enable() - rightSideButton.onClick { - val ask = AskTextPopup(this, "Your new message", maxLength = 1024, actionOnOk = { - Log.debug("Sending '$it' to room $chatRoomUUID") // TODO: Implement this - }) - ask.width = max(ask.width, stage.width / 1.5f) - ask.open() - } - } - - /** - * Construct two buttons for chat members and help - */ - private fun setupTopButtons() { - val padding = 8.0f - val tab = Table() - val membersButton = "Chat members".toTextButton() - membersButton.onClick { - ToastPopup("Chat member list is not implemented yet.", stage) // TODO - } - membersButton.padRight(padding) - tab.add(membersButton) - - val helpButton = "Help".toTextButton() - helpButton.onClick { - val helpPopup = Popup(this) - helpPopup.addGoodSizedLabel("It would be nice if this screen was documented.").row() - helpPopup.addCloseButton() - helpPopup.open() - } - tab.add(helpButton) - tab.x = (stage.width - helpButton.width - membersButton.width) - tab.y = (stage.height - helpButton.height) - stage.addActor(tab) - } - - override fun dispose() { - messageTable.dispose() - super.dispose() - } - -} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index a751b262c5daa..da2c5e8e20228 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -2,6 +2,7 @@ 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.KeyCharAndCode @@ -34,7 +35,7 @@ class LobbyBrowserScreen : BaseScreen() { private val gameList = GameListV2(this, ::onSelect) private val me - get() = kotlinx.coroutines.runBlocking { game.onlineMultiplayer.api.account.get() }!! + 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 @@ -54,14 +55,14 @@ class LobbyBrowserScreen : BaseScreen() { val lobbyButtons = Table() newLobbyButton.onClick { CreateLobbyPopup(this as BaseScreen) - // TODO: Testing with random UUID, need a pop-up to determine private/public lobby type - //game.pushScreen(LobbyScreen(UUID.randomUUID(), UUID.randomUUID())) } 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() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 38c22adb7ab9a..b3c47acb4261d 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -204,27 +204,39 @@ class LobbyScreen( events.receive(GameStarted::class, { it.lobbyUUID == lobbyUUID }) { Log.debug("Game in lobby %s has been started", lobbyUUID) gameUUID = it.gameUUID - startingGamePopup.clearChildren() - startingGamePopup.addGoodSizedLabel("The game is starting. Waiting for host...") + startingGamePopup.reuseWith("The game is starting. Waiting for host...") + startingGamePopup.innerTable.cells.last().colspan(2) + startingGamePopup.row() startingGamePopup.addGoodSizedLabel("Closing this popup will return you to the lobby browser.") - startingGamePopup.innerTable.add("Open game chat".toTextButton().onClick { + startingGamePopup.innerTable.cells.last().colspan(2) + startingGamePopup.row() + startingGamePopup.innerTable.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, true)) wrapper.open(force = true) - }) - startingGamePopup.addCloseButton { - game.popScreen() } - startingGamePopup.open(force = true) + startingGamePopup.equalizeLastTwoButtonWidths() + startingGamePopup.open() } events.receive(UpdateGameData::class, { gameUUID != null && it.gameUUID == gameUUID }) { - val gameInfo = UncivFiles.gameInfoFromString(it.gameData) - Log.debug("Successfully loaded game %s from WebSocket event", gameInfo.gameId) - startingGamePopup.reuseWith("Working...") + 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() + } } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt new file mode 100644 index 0000000000000..55c964b8d1262 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt @@ -0,0 +1,45 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.utils.Disposable +import com.unciv.models.translations.tr +import com.unciv.ui.components.extensions.enable +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.InfoPopup +import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.pickerscreens.PickerScreen +import kotlinx.coroutines.runBlocking +import java.util.* + +class SocialMenuScreen(me: UUID? = null, initialChatRoom: Triple? = null) : PickerScreen(horizontally = true), Disposable { + private val socialTable = SocialMenuTable( + this, + me ?: runBlocking { game.onlineMultiplayer.api.account.get()!!.uuid }, + initialChatRoom, + listOf(ChatRoomType.Game) + ) + 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() + } + + init { + setDefaultCloseAction() + topTable.add(socialTable) + rightSideButton.setText("Members".tr()) + rightSideButton.padRight(5f) + rightSideButton.enable() + rightSideButton.onClick { + InfoPopup(stage, "It would be nice to show the members of this chat room").open() + } + rightSideGroup.addActor(Container(helpButton).padRight(5f)) + } + + override fun dispose() { + socialTable.dispose() + super.dispose() + } +} diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index e8ae215841c5f..68dadcf0943c2 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -15,6 +15,7 @@ import com.unciv.logic.civilization.Civilization 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 @@ -657,7 +658,7 @@ class WorldScreen( } private fun updateMultiplayerStatusButton() { - if (gameInfo.gameParameters.isOnlineMultiplayer || game.settings.multiplayer.statusButtonInSinglePlayer) { + if ((gameInfo.gameParameters.isOnlineMultiplayer && game.onlineMultiplayer.apiVersion != ApiVersion.APIv2) || game.settings.multiplayer.statusButtonInSinglePlayer) { if (statusButtons.multiplayerStatusButton != null) return statusButtons.multiplayerStatusButton = MultiplayerStatusButton(this, game.onlineMultiplayer.getGameByGameId(gameInfo.gameId)) } else { diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 1c1b30ed28339..6d35ab49691a8 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -34,7 +34,8 @@ import com.unciv.ui.popups.popups import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen -import com.unciv.ui.screens.multiplayerscreens.ChatRoomScreen +import com.unciv.ui.screens.multiplayerscreens.ChatRoomType +import com.unciv.ui.screens.multiplayerscreens.SocialMenuScreen import com.unciv.ui.screens.overviewscreen.EmpireOverviewCategories import com.unciv.ui.screens.overviewscreen.EmpireOverviewScreen import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen @@ -75,11 +76,14 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { private val resourcesWrapper = Table() private val resourceTable = getResourceTable() private val selectedCivTable = SelectedCivilizationTable(worldScreen) - private val socialButton = SocialButtonWrapper(worldScreen) + private val socialButton = SocialButtonWrapper(this, worldScreen) private val overviewButton = OverviewAndSupplyTable(worldScreen) private val leftFillerCell: Cell private val rightFillerCell: Cell + internal var me: UUID? = null + internal var gameChatRoom: UUID? = null + //endregion init { @@ -96,6 +100,22 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { val rightFillerBG = BaseScreen.skinStrings.getUiBackground("WorldScreen/TopBar/RightAttachment", BaseScreen.skinStrings.roundedEdgeRectangleShape, backColor) rightFillerCell = add(BackgroundActor(rightFillerBG, Align.topRight)) pack() + + // Caching the account and game data for APIv2 online games + Concurrency.run { + if (worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2 && worldScreen.gameInfo.gameParameters.isOnlineMultiplayer) { + InfoPopup.wrap(worldScreen.stage) { + val account = worldScreen.game.onlineMultiplayer.api.account.get() + if (account != null) { + me = account.uuid + } + val gameOverview = worldScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(worldScreen.gameInfo.gameId)) + if (gameOverview != null) { + gameChatRoom = gameOverview.chatRoomUUID + } + } + } + } } private fun getStatsTable(): Table { @@ -177,7 +197,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { return resourceTable } - private class SocialButtonWrapper(worldScreen: WorldScreen) : Table(BaseScreen.skin) { + private class SocialButtonWrapper(topBar: WorldScreenTopBar, worldScreen: WorldScreen) : Table(BaseScreen.skin) { init { // The social features will only be enabled if the multiplayer server has support for it if (worldScreen.gameInfo.gameParameters.isOnlineMultiplayer && worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { @@ -189,12 +209,12 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { } if (details != null) { Concurrency.runOnGLThread { - worldScreen.game.pushScreen(ChatRoomScreen(details.chatRoomUUID)) + worldScreen.game.pushScreen(SocialMenuScreen(topBar.me, Triple(details.chatRoomUUID, ChatRoomType.Game, details.name))) } } } } - add(socialButton).pad(10f) + add(socialButton).padTop(10f).padBottom(10f) pack() } } From 179c6a9234cdeb4852fb3f6762b24170037516f4 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 28 Apr 2023 00:31:52 +0200 Subject: [PATCH 113/152] Only show the new incoming game when the user is expecting it --- .../logic/multiplayer/OnlineMultiplayer.kt | 4 +-- .../multiplayer/OnlineMultiplayerEvents.kt | 1 + .../ui/screens/worldscreen/WorldScreen.kt | 35 +++++++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 2ce5f0660dd33..306bd99ee25aa 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -93,9 +93,9 @@ class OnlineMultiplayer: Disposable { try { val gameInfo = UncivFiles.gameInfoFromString(it.gameData) addGame(gameInfo) - gameInfo.isUpToDate = true + val gameDetails = api.game.head(it.gameUUID, suppress = true) Concurrency.runOnGLThread { - EventBus.send(MultiplayerGameCanBeLoaded(gameInfo, it.gameDataID)) + EventBus.send(MultiplayerGameCanBeLoaded(gameInfo, gameDetails?.name, it.gameDataID)) } } catch (e: IncompatibleGameInfoVersionException) { Log.debug("Failed to load GameInfo from incoming event: %s", e.localizedMessage) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt index a1b6c6467200f..e50b1e12e6051 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt @@ -70,5 +70,6 @@ class MultiplayerGameDeleted( */ 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/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 68dadcf0943c2..2f690986d3abb 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -207,9 +207,38 @@ class WorldScreen( // 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) { - Concurrency.run { - UncivGame.Current.loadGame(gameInfo) - UncivGame.Current.notifyTurnStarted() + 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() + 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) } } } From 7437208eb0f8016e2eab5de7427e3a7746702b51 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 29 Apr 2023 23:52:27 +0200 Subject: [PATCH 114/152] Added online multiplayer disposing on app exit --- core/src/com/unciv/UncivGame.kt | 4 ++++ core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index ef991958d9ded..581e9543c489e 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -458,9 +458,13 @@ 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 + if (::onlineMultiplayer.isInitialized) { + onlineMultiplayer.dispose() + } val curGameInfo = gameInfo if (curGameInfo != null) { diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 306bd99ee25aa..3f0e4b74214eb 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -542,7 +542,7 @@ class OnlineMultiplayer: Disposable { override fun dispose() { pollChecker?.cancel() events.stopReceiving() - if (apiVersion == ApiVersion.APIv2) { + if (isInitialized() && apiVersion == ApiVersion.APIv2) { api.dispose() } } From 680678989eb9a37b249066b19e45684aa9bf6ef0 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 30 Apr 2023 02:17:26 +0200 Subject: [PATCH 115/152] Fixed various problems with WebSocket connections --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 37 +++++++++++-------- .../screens/multiplayerscreens/GameListV2.kt | 5 +-- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index b017b1de9ee75..39e97429ec011 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -3,8 +3,8 @@ package com.unciv.logic.multiplayer.apiv2 import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator 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.storage.MultiplayerFileNotFoundException import com.unciv.utils.Log @@ -14,10 +14,12 @@ import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.websocket.* +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.ClosedReceiveChannelException 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.* @@ -98,6 +100,11 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { for (job in websocketJobs) { job.cancel() } + for (job in websocketJobs) { + runBlocking { + job.join() + } + } client.cancel() } @@ -202,6 +209,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ + @Suppress("Unused") internal suspend fun sendText(text: String, suppress: Boolean = false): Boolean { val channel = sendChannel if (channel == null) { @@ -234,8 +242,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * available and an any exception otherwise. */ internal suspend fun sendPing(): Boolean { - val body = ByteArray(8) - Random().nextBytes(body) + val body = ByteArray(0) val channel = sendChannel return if (channel == null) { false @@ -255,7 +262,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { websocketJobs.add(Concurrency.run { val currentChannel = session.outgoing while (sendChannel != null && currentChannel == sendChannel) { - delay(DEFAULT_WEBSOCKET_PING_FREQUENCY) try { sendPing() } catch (e: Exception) { @@ -264,6 +270,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { websocket(::handleWebSocket) } } + delay(DEFAULT_WEBSOCKET_PING_FREQUENCY) } Log.debug("It looks like the WebSocket channel has been replaced") }) @@ -306,13 +313,20 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { Log.debug("The WebSocket channel was closed: $e") sendChannel?.close() session.close() + session.flush() Concurrency.run { websocket(::handleWebSocket) } + } catch (e: CancellationException) { + Log.debug("WebSocket coroutine was cancelled, closing connection: $e") + sendChannel?.close() + session.close() + session.flush() } catch (e: Throwable) { Log.error("Error while handling a WebSocket connection: %s\n%s", e.localizedMessage, e.stackTraceToString()) sendChannel?.close() session.close() + session.flush() Concurrency.run { websocket(::handleWebSocket) } @@ -325,23 +339,13 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** * Perform post-login hooks and updates * - * 1. Create a new WebSocket connection after logging in and - * if there's no current connection available + * 1. Create a new WebSocket connection after logging in (ignoring existing sockets) * 2. Update the [UncivGame.Current.settings.multiplayer.userId] * (this makes using APIv0/APIv1 games impossible if the user ID is not preserved!) */ @Suppress("KDocUnresolvedReference") override suspend fun afterLogin() { - val pingSuccess = try { - sendPing() - } catch (e: Exception) { - Log.debug("Exception while sending WebSocket PING: %s", e.localizedMessage) - false - } - if (!pingSuccess) { - websocket(::handleWebSocket) - } - val me = account.get() + val me = account.get(cache = false, suppress = true) if (me != null) { Log.error( "Updating user ID from %s to %s. This is no error. But you may need the old ID to be able to access your old multiplayer saves.", @@ -350,6 +354,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { ) UncivGame.Current.settings.multiplayer.userId = me.uuid.toString() UncivGame.Current.settings.save() + websocket(::handleWebSocket) } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 260874ad735e5..30c084d1513ff 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -31,14 +31,11 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv private val events = EventBus.EventReceiver() init { - // TODO: Add event handling add(noGames).row() triggerUpdate() } private fun addGame(game: GameOverviewResponse) { - // TODO: Determine if it's the current turn, then add an indicator for that - add(game.name.toTextButton().onClick { onSelected(game) }).padRight(10f).padBottom(5f) val time = "[${Duration.between(game.lastActivity, Instant.now()).formatShort()}] ago".tr() add(time).padRight(10f).padBottom(5f) @@ -82,7 +79,7 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv /** * Detach updating the list of games in another coroutine */ - fun triggerUpdate() { + private fun triggerUpdate() { Concurrency.run("Update game list") { while (stage == null) { delay(20) // fixes race condition and null pointer exception in access to `stage` From b1758baa82f3ebeac990cf00d3e39ffd46927275 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 30 Apr 2023 03:02:40 +0200 Subject: [PATCH 116/152] Removed refresh button from chat, changed color of auth close button --- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 2 +- .../ui/screens/multiplayerscreens/ChatTable.kt | 17 +++-------------- .../screens/multiplayerscreens/FriendListV2.kt | 6 +++++- .../ui/screens/multiplayerscreens/GameListV2.kt | 2 +- .../screens/multiplayerscreens/LobbyScreen.kt | 4 ++-- .../multiplayerscreens/SocialMenuTable.kt | 3 +-- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index cd65363f06150..c5b0d3696de0e 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -60,7 +60,7 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) + addCloseButton { authSuccessful?.invoke(false) }.growX().padRight(8f) add(registerButton).growX().padLeft(8f) add(loginButton).growX().padLeft(8f) } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index e1ff95a200884..8946a1a024f71 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -16,12 +16,10 @@ import com.unciv.ui.screens.basescreen.BaseScreen /** * A [Table] which combines [ChatMessageList] with a text input and send button to write a new message * - * Optionally, it can display a [RefreshButton] to the right of the send button - * or replace the send message text box with a popup that asks for a message. + * Optionally, it can replace the send message text box with a popup that asks for a message. */ class ChatTable( private val chatMessageList: ChatMessageList, - showRefreshButton: Boolean, useInputPopup: Boolean = false, actorHeight: Float? = null, maxMessageLength: Int? = null @@ -34,7 +32,7 @@ class ChatTable( if (actorHeight != null) { chatCell.actorHeight = actorHeight } - val width = if (showRefreshButton) 3 else 2 + val width = 2 chatCell.colspan(width).fillX().expandY().padBottom(10f) row() @@ -71,16 +69,7 @@ class ChatTable( sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) add(messageField).padLeft(5f).growX() - if (showRefreshButton) { - add(sendButton).padLeft(10f).padRight(10f) - val refreshButton = RefreshButton() - refreshButton.onActivation { - chatMessageList.triggerRefresh(stage, false) - } - add(refreshButton).padRight(5f) - } else { - add(sendButton).padLeft(10f).padRight(5f) - } + add(sendButton).padLeft(10f).padRight(5f) row() } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index faae1c9ab290c..92c2f69dba467 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -163,7 +163,11 @@ internal class FriendListV2( } if (response != null) { Concurrency.runOnGLThread { - Log.debug("Looked up '%s' as '%s'", response.uuid, response.username) + 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?", diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 30c084d1513ff..f586393c7dfdd 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -55,7 +55,7 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv screen.game.onlineMultiplayer ) disposables.add(chatMessageList) - popup.innerTable.add(ChatTable(chatMessageList, false)).padBottom(10f).row() + popup.innerTable.add(ChatTable(chatMessageList)).padBottom(10f).row() popup.addCloseButton() popup.open(force = true) } }).padBottom(5f) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index b3c47acb4261d..dba392bb9f6c9 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -218,7 +218,7 @@ class LobbyScreen( 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, true)) + val wrapper = WrapPopup(stage, ChatTable(gameChat)) wrapper.open(force = true) } startingGamePopup.equalizeLastTwoButtonWidths() @@ -318,7 +318,7 @@ class LobbyScreen( optionsTable.add(menuButtonInvite).padBottom(10f).row() optionsTable.add(menuButtonStartGame).row() - val chatTable = ChatTable(chatMessageList, true) + val chatTable = ChatTable(chatMessageList) val menuBar = Table() menuBar.align(Align.bottom) menuBar.add(bottomButtonLeave).pad(20f) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index a958e989277b7..c1c23fee8dd7e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -52,8 +52,7 @@ class SocialMenuTable( lastSelectedChat = chatRoom chatContainer.actor?.dispose() chatContainer.actor = ChatTable( - ChatMessageList(chatRoomType in chatHeadingFilter, Pair(chatRoomType, name), chatRoom, base.game.onlineMultiplayer), - false + ChatMessageList(chatRoomType in chatHeadingFilter, Pair(chatRoomType, name), chatRoom, base.game.onlineMultiplayer) ).apply { padLeft(15f) } } From f96915bf5ce375f43304c144a8cd7b3a1d50bbe9 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 30 Apr 2023 04:00:37 +0200 Subject: [PATCH 117/152] Added BaseScreen handling for some incoming events --- .../unciv/ui/screens/basescreen/BaseScreen.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt index 0fbac3efa327d..fbc52208a388b 100644 --- a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt +++ b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt @@ -16,6 +16,11 @@ 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.models.TutorialTrigger import com.unciv.models.skins.SkinStrings import com.unciv.ui.components.Fonts @@ -25,6 +30,7 @@ import com.unciv.ui.components.extensions.DispatcherVetoer import com.unciv.ui.components.extensions.installShortcutDispatcher import com.unciv.ui.components.extensions.isNarrowerThan4to3 import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.activePopup import com.unciv.ui.popups.options.OptionsPopup import com.unciv.ui.tutorials.TutorialController @@ -56,6 +62,30 @@ abstract class BaseScreen : Screen { } stage.installShortcutDispatcher(globalShortcuts, this::createPopupBasedDispatcherVetoer) + + // 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) + } + } + } } private fun createPopupBasedDispatcherVetoer(): DispatcherVetoer? { From 8f148d662a1472596950809e92852b1b73ac2680 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 30 Apr 2023 21:52:23 +0200 Subject: [PATCH 118/152] Reworked the RegisterLoginPopup to address a bunch of UX issues --- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 77 +++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index c5b0d3696de0e..40013261a9cf1 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -1,6 +1,9 @@ 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 @@ -9,7 +12,9 @@ import com.unciv.logic.multiplayer.apiv2.ApiException import com.unciv.models.translations.tr import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.activate import com.unciv.ui.components.extensions.keyShortcuts +import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.screens.basescreen.BaseScreen @@ -28,8 +33,47 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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 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) { askConfirmUsage { build() @@ -39,6 +83,11 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f } } + override fun close() { + stage?.removeListener(listener) + super.close() + } + /** * Build the popup stage */ @@ -48,21 +97,22 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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) { authSuccessful?.invoke(false) }.growX().padRight(8f) + addCloseButton(style=negativeButtonStyle) { + stage?.removeListener(listener) + authSuccessful?.invoke(false) + }.growX().padRight(8f) } else { - val loginButton = "Login existing".toTextButton() - loginButton.keyShortcuts.add(KeyCharAndCode.RETURN) - val registerButton = "Register new".toTextButton() - - loginButton.onClick { login() } - registerButton.onClick { register() } + 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 { authSuccessful?.invoke(false) }.growX().padRight(8f) + addCloseButton { + stage?.removeListener(listener) + authSuccessful?.invoke(false) + }.growX().padRight(8f) add(registerButton).growX().padLeft(8f) - add(loginButton).growX().padLeft(8f) + add(loginButton).growX().padLeft(8f).apply { keyShortcuts.add(KeyCharAndCode.RETURN) } } } @@ -109,17 +159,18 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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: ApiException) { launchOnGLThread { popup.close() - close() InfoPopup( base.stage, "Failed to login with existing account".tr() + ":\n${e.localizedMessage}" ) { + stage?.addListener(listener) authSuccessful?.invoke(false) } } @@ -143,23 +194,23 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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.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: ApiException) { launchOnGLThread { popup.close() - close() InfoPopup( base.stage, "Failed to register new account".tr() + ":\n${e.localizedMessage}" ) { + stage?.addListener(listener) authSuccessful?.invoke(false) } } From 9247c4462fba7b793ca50246037e3c1160fa802e Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 1 May 2023 05:01:14 +0200 Subject: [PATCH 119/152] Don't show kick button for lobby owner, handle network issues during login --- core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt | 1 - core/src/com/unciv/ui/popups/RegisterLoginPopup.kt | 12 +++--------- .../ui/screens/multiplayerscreens/LobbyPlayerList.kt | 3 ++- .../ui/screens/multiplayerscreens/LobbyScreen.kt | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 39e97429ec011..8987a68800cf6 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -379,7 +379,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } return success } - } /** diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index 40013261a9cf1..5dc64cb7f6691 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -7,8 +7,8 @@ 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.logic.multiplayer.apiv2.ApiException import com.unciv.models.translations.tr import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.UncivTextField @@ -150,9 +150,6 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f val success = UncivGame.Current.onlineMultiplayer.api.auth.login( usernameField.text, passwordField.text ) - UncivGame.Current.onlineMultiplayer.api.refreshSession( - ignoreLastCredentials = true - ) launchOnGLThread { Log.debug("Updating username and password after successfully authenticating") UncivGame.Current.settings.multiplayer.userName = usernameField.text @@ -163,7 +160,7 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f close() authSuccessful?.invoke(success) } - } catch (e: ApiException) { + } catch (e: UncivShowableException) { launchOnGLThread { popup.close() InfoPopup( @@ -188,9 +185,6 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f UncivGame.Current.onlineMultiplayer.api.auth.login( usernameField.text, passwordField.text ) - UncivGame.Current.onlineMultiplayer.api.refreshSession( - ignoreLastCredentials = true - ) launchOnGLThread { Log.debug("Updating username and password after successfully authenticating") UncivGame.Current.settings.multiplayer.userName = usernameField.text @@ -203,7 +197,7 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f authSuccessful?.invoke(true) } } - } catch (e: ApiException) { + } catch (e: UncivShowableException) { launchOnGLThread { popup.close() InfoPopup( diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index a98b5e09e4c53..ab9926dbc628a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -29,6 +29,7 @@ import java.util.* 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(), @@ -173,7 +174,7 @@ class LobbyPlayerList( recreate() update?.invoke() } - if (editable) { + if (editable && me != player.account?.uuid) { add(kickButton) } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index dba392bb9f6c9..76fed22a12f15 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -106,7 +106,7 @@ class LobbyScreen( currentPlayers.add(owner) } gameSetupInfo.gameParameters.isOnlineMultiplayer = true - lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, game.onlineMultiplayer.api, ::recreate, currentPlayers, this) + lobbyPlayerList = LobbyPlayerList(lobbyUUID, owner == me, me.uuid, game.onlineMultiplayer.api, ::recreate, currentPlayers, this) gameOptionsTable = GameOptionsTable(this, multiplayerOnly = true, updatePlayerPickerRandomLabel = {}, updatePlayerPickerTable = { x -> Log.error("Updating player picker table with '%s' is not implemented yet.", x) lobbyPlayerList.recreate() From 9c1d7823e772a9ee1903d9177abea186024e4e95 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 May 2023 01:02:10 +0200 Subject: [PATCH 120/152] Added auto-reload to lobby browser, added lobby invitation popup --- core/src/com/unciv/ui/popups/InfoPopup.kt | 11 ++--- .../unciv/ui/popups/LobbyInvitationPopup.kt | 42 +++++++++++++++++++ core/src/com/unciv/ui/popups/Popup.kt | 2 +- .../unciv/ui/screens/basescreen/BaseScreen.kt | 12 ++++++ .../multiplayerscreens/LobbyBrowserScreen.kt | 8 ++++ .../multiplayerscreens/LobbyBrowserTable.kt | 3 +- 6 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt diff --git a/core/src/com/unciv/ui/popups/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 4b1c912ea989e..55c803509a3d7 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.runBlocking * @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 */ -open class InfoPopup( +class InfoPopup( stageToShowOn: Stage, vararg texts: String, action: (() -> Unit)? = null @@ -48,21 +48,22 @@ open class InfoPopup( fun load(stage: Stage, vararg texts: String, coroutine: suspend () -> T): T? { val popup = Popup(stage).apply { addGoodSizedLabel("Working...").row() } popup.open(force = true) - return runBlocking { + var result: T? = null + val job = Concurrency.run { try { - val result = coroutine() + result = coroutine() Concurrency.runOnGLThread { popup.close() } - result } catch (e: UncivShowableException) { Concurrency.runOnGLThread { popup.close() InfoPopup(stage, *texts, e.localizedMessage) } - null } } + 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..bc6f55c57f8fb --- /dev/null +++ b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt @@ -0,0 +1,42 @@ +package com.unciv.ui.popups + +import com.unciv.logic.multiplayer.apiv2.IncomingInvite +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +/** + * 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.").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) { + 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 ae48e067b016e..bd8879a11cd51 100644 --- a/core/src/com/unciv/ui/popups/Popup.kt +++ b/core/src/com/unciv/ui/popups/Popup.kt @@ -90,7 +90,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) innerTable.pack() pack() diff --git a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt index fbc52208a388b..62e437ae2563e 100644 --- a/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt +++ b/core/src/com/unciv/ui/screens/basescreen/BaseScreen.kt @@ -21,6 +21,7 @@ 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 @@ -30,10 +31,12 @@ import com.unciv.ui.components.extensions.DispatcherVetoer import com.unciv.ui.components.extensions.installShortcutDispatcher import com.unciv.ui.components.extensions.isNarrowerThan4to3 import com.unciv.ui.images.ImageGetter +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.ui.tutorials.TutorialController +import com.unciv.utils.concurrency.Concurrency abstract class BaseScreen : Screen { @@ -86,6 +89,15 @@ abstract class BaseScreen : Screen { } } } + events.receive(IncomingInvite::class, null) { + val lobbyInvitationPopup = LobbyInvitationPopup(this, it) + Concurrency.run { + lobbyInvitationPopup.await() + Concurrency.runOnGLThread { + lobbyInvitationPopup.open() + } + } + } } private fun createPopupBasedDispatcherVetoer(): DispatcherVetoer? { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index da2c5e8e20228..284bd6080de00 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -25,6 +25,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency.runBlocking +import kotlinx.coroutines.delay import com.unciv.ui.components.AutoScrollPane as ScrollPane /** @@ -33,6 +34,12 @@ import com.unciv.ui.components.AutoScrollPane as ScrollPane class LobbyBrowserScreen : BaseScreen() { private val lobbyBrowserTable = LobbyBrowserTable(this) private val gameList = GameListV2(this, ::onSelect) + private val updateJob = Concurrency.run { + while (true) { + delay(30 * 1000) + lobbyBrowserTable.triggerUpdate() + } + } private val me get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! @@ -116,6 +123,7 @@ class LobbyBrowserScreen : BaseScreen() { } 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 index 563322aee47a0..f645ac2553b57 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -38,7 +38,8 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table(BaseScre val popup = AskTextPopup( screen, enterLobbyPasswordText, - ImageGetter.getImage("OtherIcons/LockSmall").apply { this.color = Color.BLACK } + ImageGetter.getImage("OtherIcons/LockSmall") + .apply { this.color = Color.BLACK } .surroundWithCircle(80f), maxLength = 120 ) { From 7a6f23a8967c2ecd56e4d3bda49f6308a10bcbd2 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 5 May 2023 00:01:29 +0200 Subject: [PATCH 121/152] Fixed compilation errors due to rebasing regressions --- .../ui/screens/multiplayerscreens/GameListV2.kt | 2 +- .../multiplayerscreens/LobbyBrowserScreen.kt | 2 +- .../ui/screens/multiplayerscreens/LobbyScreen.kt | 16 +++++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index f586393c7dfdd..de67f58fbac47 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -55,7 +55,7 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv screen.game.onlineMultiplayer ) disposables.add(chatMessageList) - popup.innerTable.add(ChatTable(chatMessageList)).padBottom(10f).row() + popup.add(ChatTable(chatMessageList)).padBottom(10f).row() popup.addCloseButton() popup.open(force = true) } }).padBottom(5f) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 284bd6080de00..75f3f230da1cc 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -87,7 +87,7 @@ class LobbyBrowserScreen : BaseScreen() { } socialButton.onClick { val popup = Popup(stage) - popup.innerTable.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() + popup.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() popup.addCloseButton() popup.open() } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 76fed22a12f15..037bd43f98449 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -158,7 +158,7 @@ class LobbyScreen( } bottomButtonSocial.onActivation { val popup = Popup(stage) - popup.innerTable.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() + popup.add(SocialMenuTable(this as BaseScreen, me.uuid)).center().minWidth(0.5f * stage.width).fillX().fillY().row() popup.addCloseButton() popup.open() } @@ -200,17 +200,19 @@ class LobbyScreen( } } - val startingGamePopup = Popup(stage) + 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.innerTable.cells.last().colspan(2) + startingGamePopup.getTable().cells.last().colspan(2) startingGamePopup.row() startingGamePopup.addGoodSizedLabel("Closing this popup will return you to the lobby browser.") - startingGamePopup.innerTable.cells.last().colspan(2) + startingGamePopup.getTable().cells.last().colspan(2) startingGamePopup.row() - startingGamePopup.innerTable.columns.inc() + startingGamePopup.getTable().columns.inc() startingGamePopup.addCloseButton { game.popScreen() } @@ -423,4 +425,8 @@ class LobbyScreen( Log.error("Not yet implemented") } + override fun getColumnWidth(): Float { + return stage.width / (if (isNarrowerThan4to3()) 1 else 3) + } + } From 49af6b1764d93334f5a35316b665dad32129d138 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 7 May 2023 22:53:17 +0200 Subject: [PATCH 122/152] Added various UX fixes to the lobby screen and the browser --- .../com/unciv/ui/popups/CreateLobbyPopup.kt | 5 +-- .../multiplayerscreens/LobbyBrowserScreen.kt | 31 ++++++++++++++----- .../multiplayerscreens/LobbyBrowserTable.kt | 4 ++- .../screens/multiplayerscreens/LobbyScreen.kt | 2 +- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt index 1eb0db7cdc16e..18aa098bf1298 100644 --- a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -3,6 +3,7 @@ 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 @@ -12,9 +13,9 @@ 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) : Popup(base.stage) { +class CreateLobbyPopup(private val base: BaseScreen, me: AccountResponse) : Popup(base.stage) { private var requirePassword: Boolean = false - private val nameField = UncivTextField.create("Lobby name", "New lobby").apply { this.maxLength = 64 } + private val nameField = UncivTextField.create("Lobby name", "${me.displayName}'s lobby").apply { this.maxLength = 64 } private val passwordField = UncivTextField.create("Password", "").apply { this.maxLength = 64 } private val checkbox = "Require password".toCheckBox(false) { requirePassword = it diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 75f3f230da1cc..53757c45078a3 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -25,6 +25,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency.runBlocking +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import com.unciv.ui.components.AutoScrollPane as ScrollPane @@ -32,14 +33,9 @@ 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) + private val lobbyBrowserTable = LobbyBrowserTable(this) { updateJob.cancel() } private val gameList = GameListV2(this, ::onSelect) - private val updateJob = Concurrency.run { - while (true) { - delay(30 * 1000) - lobbyBrowserTable.triggerUpdate() - } - } + private var updateJob = startUpdateJob(false) private val me get() = runBlocking { game.onlineMultiplayer.api.account.get() }!! @@ -61,7 +57,7 @@ class LobbyBrowserScreen : BaseScreen() { val lobbyButtons = Table() newLobbyButton.onClick { - CreateLobbyPopup(this as BaseScreen) + CreateLobbyPopup(this as BaseScreen, me) } updateButton.onClick { lobbyBrowserTable.triggerUpdate() @@ -122,6 +118,25 @@ class LobbyBrowserScreen : BaseScreen() { } } + 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() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index f645ac2553b57..8e6030d45809f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -19,7 +19,7 @@ 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): Table(BaseScreen.skin) { +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:" @@ -46,6 +46,7 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table(BaseScre InfoPopup.load(stage) { screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid, it) Concurrency.runOnGLThread { + lobbyJoinCallback() screen.game.pushScreen(LobbyScreen(lobby)) } } @@ -55,6 +56,7 @@ internal class LobbyBrowserTable(private val screen: BaseScreen): Table(BaseScre InfoPopup.load(stage) { screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid) Concurrency.runOnGLThread { + lobbyJoinCallback() screen.game.pushScreen(LobbyScreen(lobby)) } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 037bd43f98449..cd1246cdfde60 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -95,7 +95,7 @@ class LobbyScreen( private val changeLobbyNameButton = PencilButton() private val menuButtonGameOptions = "Game options".toTextButton() private val menuButtonMapOptions = "Map options".toTextButton() - private val menuButtonInvite = "Invite player".toTextButton() + private val menuButtonInvite = "Invite friend".toTextButton() private val menuButtonStartGame = "Start game".toTextButton() private val bottomButtonLeave = if (owner.uuid == me.uuid) "Close lobby".toTextButton() else "Leave".toTextButton() private val bottomButtonSocial = MultiplayerButton() From f1d37d24a7ed4d4cb748ebd946cf98298175d6ce Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 8 May 2023 02:02:41 +0200 Subject: [PATCH 123/152] Added a first Multiplayer (M) tab for the overview field --- .../com/unciv/ui/popups/CreateLobbyPopup.kt | 2 +- .../EmpireOverviewCategories.kt | 9 ++ .../MultiplayerOverviewTable.kt | 102 ++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt diff --git a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt index 18aa098bf1298..29cc84463f50f 100644 --- a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -15,7 +15,7 @@ import com.unciv.utils.Log */ class CreateLobbyPopup(private val base: BaseScreen, me: AccountResponse) : Popup(base.stage) { private var requirePassword: Boolean = false - private val nameField = UncivTextField.create("Lobby name", "${me.displayName}'s lobby").apply { this.maxLength = 64 } + 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 diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt b/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewCategories.kt index 51e5241ea77b4..7b0eaf2f7d543 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.KeyCharAndCode @@ -68,6 +70,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..df33b55d7aba5 --- /dev/null +++ b/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt @@ -0,0 +1,102 @@ +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.Log +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.runBlocking +import java.util.* + +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) + override fun isEmpty() = false + } + + override val persistableData = (persistedData as? MultiplayerTabPersistableData) ?: MultiplayerTabPersistableData( + runBlocking { + // This operation shouldn't fail; however, this wraps into an InfoPopup before crashing the game + InfoPopup.wrap(overviewScreen.stage) { + overviewScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(gameInfo.gameId)) + }!! + } + ) + + 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 + ) + } +} From 1ec7429a657e8d8289bcf1c283592433a9e3d280 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 9 May 2023 18:31:09 +0200 Subject: [PATCH 124/152] Fixed auto-loading for single player, added GameListV2 updating, fixed MultiplayerTab layout --- .../com/unciv/ui/popups/options/MultiplayerTab.kt | 10 ++++++---- .../ui/screens/multiplayerscreens/GameListV2.kt | 15 +++++++++++++++ .../unciv/ui/screens/worldscreen/WorldScreen.kt | 6 ++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index d3943e69b7750..379f19aedb023 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -263,7 +263,7 @@ private fun addMultiplayerServerOptions( } */ } - }).row() + }).colspan(2).row() if (UncivGame.Current.onlineMultiplayer.serverFeatureSet.authVersion > 0) { val passwordTextField = UncivTextField.create( @@ -339,12 +339,14 @@ private fun addMultiplayerServerOptions( popup.open(force = true) } + val wrapper = Table() if (UncivGame.Current.onlineMultiplayer.hasAuthentication()) { - serverIpTable.add(logoutButton).padTop(8f) - serverIpTable.add(setUserIdButton).padTop(8f).row() + wrapper.add(logoutButton).padRight(8f) + wrapper.add(setUserIdButton) } else { - serverIpTable.add(setUserIdButton).padTop(8f).colspan(2).row() + wrapper.add(setUserIdButton) } + serverIpTable.add(wrapper).colspan(2).padTop(8f).row() } tab.add(serverIpTable).colspan(2).fillX().row() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index de67f58fbac47..2a340386bbbd8 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -3,6 +3,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Disposable 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 @@ -20,6 +21,7 @@ import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.delay import java.time.Duration import java.time.Instant +import java.util.* /** * Table listing the recently played open games for APIv2 multiplayer games @@ -33,6 +35,19 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv 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) { diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 2f690986d3abb..9d24285d921db 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -12,6 +12,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.UncivShowableException import com.unciv.logic.civilization.Civilization +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 @@ -204,6 +205,7 @@ 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) { @@ -641,6 +643,10 @@ class WorldScreen( debug("Next turn took %sms", System.currentTimeMillis() - startTime) + // Special case: when you are the only human player, the game will always be up to date + if (gameInfo.gameParameters.isOnlineMultiplayer && gameInfoClone.civilizations.filter { it.playerType == PlayerType.Human }.size == 1) { + gameInfoClone.isUpToDate = true + } startNewScreenJob(gameInfoClone) } } From 642aaf8c6d2ea6a64bc91e1641e4ca04dacdfdf0 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 10 May 2023 02:07:18 +0200 Subject: [PATCH 125/152] Suppress onlineMultiplayer checkbox clicks in game options (APIv2), fixed NPE in popup --- .../logic/multiplayer/OnlineMultiplayer.kt | 1 + .../com/unciv/ui/popups/RegisterLoginPopup.kt | 24 ++++++++++--------- .../multiplayerscreens/SocialMenuTable.kt | 1 - .../screens/newgamescreen/GameOptionsTable.kt | 24 +++++++++++++------ .../newgamescreen/MapParametersTable.kt | 2 +- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 3f0e4b74214eb..8f27229891543 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -92,6 +92,7 @@ class OnlineMultiplayer: Disposable { Concurrency.runOnNonDaemonThreadPool { try { val gameInfo = UncivFiles.gameInfoFromString(it.gameData) + gameInfo.setTransients() addGame(gameInfo) val gameDetails = api.game.head(it.gameUUID, suppress = true) Concurrency.runOnGLThread { diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index 5dc64cb7f6691..1892ceed2babc 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -37,6 +37,8 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f 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() { @@ -75,9 +77,10 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f } if (confirmUsage) { - askConfirmUsage { + confirmationPopup = askConfirmUsage { build() } + confirmationPopup?.open() } else { build() } @@ -116,24 +119,23 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f } } - private fun askConfirmUsage(block: () -> Unit) { + private fun askConfirmUsage(block: () -> Unit): Popup { val playerId = UncivGame.Current.settings.multiplayer.userId - 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) - row() - addGoodSizedLabel(playerId) - addButton("Copy user ID") { + 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) } - row() - val cell = addButton(Constants.OK) { - innerTable.clear() - block.invoke() - } + 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 { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index c1c23fee8dd7e..4b695c19814dc 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -28,7 +28,6 @@ class SocialMenuTable( private var lastSelectedChat: UUID? = null init { - debugAll() add(friendList).growX().minWidth(base.stage.width * 0.45f).padRight(5f) add(chatContainer).minWidth(base.stage.width * 0.45f).maxHeight(maxChatHeight).growX() Concurrency.run { diff --git a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt index 9f30eac7c3282..8991a4ebd8a11 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.GameParameters import com.unciv.models.metadata.Player import com.unciv.models.ruleset.RulesetCache @@ -165,14 +166,23 @@ 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 { + 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/MapParametersTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt index 25f4e14988e68..c516b20fb7feb 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/MapParametersTable.kt @@ -380,7 +380,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() } From 67028569ee838289ce41ce3c2989c10c2fb9331f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 May 2023 01:47:40 +0200 Subject: [PATCH 126/152] Added English translations for ApiStatusCode, fixed broken APIv1 games for uncivserver.xyz --- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 31 +++++++++++++++++-- .../storage/OnlineMultiplayerFiles.kt | 2 +- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 10 ++---- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 807202b0d11e2..7110bf575238a 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -180,6 +180,33 @@ class ApiException(val error: ApiErrorResponse) : UncivShowableException(lookupE * Convert an API status code to a string that can be translated and shown to users */ private fun lookupErrorMessage(statusCode: ApiStatusCode): String { - // TODO: Implement translations - return statusCode.name + return when (statusCode) { + ApiStatusCode.Unauthenticated -> "You are not logged in. Please login first." + ApiStatusCode.NotFound -> "The operation couldn't be completed, since the resource was not found." + ApiStatusCode.InvalidContentType -> "The media content type was invalid. Please report this as a bug." + ApiStatusCode.InvalidJson -> "The server didn't understand the sent data. Please report this as a bug." + ApiStatusCode.PayloadOverflow -> "The amount of data sent to the server was too large. Please report this as a bug." + ApiStatusCode.LoginFailed -> "The login failed. Is the username and password correct?" + ApiStatusCode.UsernameAlreadyOccupied -> "The selected username is already taken. Please choose another name." + ApiStatusCode.InvalidPassword -> "This password is not valid. Please choose another password." + ApiStatusCode.EmptyJson -> "The server encountered an empty JSON problem. Please report this as a bug." + ApiStatusCode.InvalidUsername -> "The username is not valid. Please choose another one." + ApiStatusCode.InvalidDisplayName -> "The display name is not valid. Please choose another one." + ApiStatusCode.FriendshipAlreadyRequested -> "You have already requested friendship with this player. Please wait until the request is accepted." + ApiStatusCode.AlreadyFriends -> "You are already friends, you can't request it again." + ApiStatusCode.MissingPrivileges -> "You don't have the required privileges to perform this operation." + ApiStatusCode.InvalidMaxPlayersCount -> "The maximum number of players for this lobby is out of the supported range for this server. Please adjust the number. Two players should always work." + ApiStatusCode.AlreadyInALobby -> "You are already in another lobby. You need to close or leave the other lobby before." + ApiStatusCode.InvalidUuid -> "The operation could not be completed, since an invalid UUID was given. Please retry later or restart the game. If the problem persists, please report this as a bug." + ApiStatusCode.InvalidLobbyUuid -> "The lobby was not found. Maybe it has already been closed?" + ApiStatusCode.InvalidFriendUuid -> "You must be friends with the other player before this action can be completed. Try again later." + ApiStatusCode.GameNotFound -> "The game was not found on the server. Try again later. If the problem persists, the game was probably already removed from the server, sorry." + ApiStatusCode.InvalidMessage -> "This message could not be sent, since it was invalid. Remove any invalid characters and try again." + ApiStatusCode.WsNotConnected -> "The WebSocket is not available. Please restart the game and try again. If the problem persists, please report this as a bug." + ApiStatusCode.LobbyFull -> "The lobby is currently full. You can't join right now." + ApiStatusCode.InvalidPlayerUUID -> "The ID of the player was invalid. Does the player exist? Please try again. If the problem persists, please report this as a bug." + ApiStatusCode.InternalServerError -> "Internal server error. Please report this as a bug." + ApiStatusCode.DatabaseError -> "Internal server database error. Please report this as a bug." + ApiStatusCode.SessionError -> "Internal session error. Please report this as a bug." + } } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index d5c8cbb2b9428..3ebca5c1acfc2 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -62,7 +62,7 @@ class OnlineMultiplayerFiles( val zippedGameInfo = if (UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { UncivFiles.gameInfoToPrettyString(gameInfo, useZip = true) } else { - UncivFiles.gameInfoToString(gameInfo) + UncivFiles.gameInfoToString(gameInfo, forceZip = true) } fileStorage().saveGameData(gameInfo.gameId, zippedGameInfo) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index 1892ceed2babc..0e2255452562b 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -165,10 +165,7 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f } catch (e: UncivShowableException) { launchOnGLThread { popup.close() - InfoPopup( - base.stage, - "Failed to login with existing account".tr() + ":\n${e.localizedMessage}" - ) { + InfoPopup(base.stage, e.localizedMessage) { stage?.addListener(listener) authSuccessful?.invoke(false) } @@ -202,10 +199,7 @@ class RegisterLoginPopup(private val base: BaseScreen, confirmUsage: Boolean = f } catch (e: UncivShowableException) { launchOnGLThread { popup.close() - InfoPopup( - base.stage, - "Failed to register new account".tr() + ":\n${e.localizedMessage}" - ) { + InfoPopup(base.stage, e.localizedMessage) { stage?.addListener(listener) authSuccessful?.invoke(false) } From 3c90e8fcc8e5cbeb58e544995c6edb556ac7e072 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 12 May 2023 02:26:11 +0200 Subject: [PATCH 127/152] Added auto-scroll to bottom for chat message lists, added some fixes for other things --- .../multiplayerscreens/ChatMessageList.kt | 23 +++++++++++++ .../screens/multiplayerscreens/ChatTable.kt | 33 ++++++++++++++++--- .../screens/multiplayerscreens/LobbyScreen.kt | 9 +++-- .../multiplayerscreens/SocialMenuScreen.kt | 3 +- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 86b8dfa7c7181..a6e3dfabef904 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -49,6 +49,7 @@ class ChatMessageList(private val showHeading: Boolean, private val type: Pair = mutableListOf() private var redrawJob: Job = Concurrency.run { redrawPeriodically() } + private val listeners = mutableListOf<(Boolean) -> Unit>() init { defaults().expandX().space(5f) @@ -68,6 +69,11 @@ class ChatMessageList(private val showHeading: Boolean, private val type: Pair Unit) { + listeners.add(callback) + } + /** * Dispose this instance and cancel the [redrawJob] */ diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt index 8946a1a024f71..45d48cb57fce6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatTable.kt @@ -5,13 +5,13 @@ import com.badlogic.gdx.utils.Disposable import com.unciv.ui.components.ArrowButton import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.KeyCharAndCode -import com.unciv.ui.components.RefreshButton import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.popups.Popup import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.utils.concurrency.Concurrency /** * A [Table] which combines [ChatMessageList] with a text input and send button to write a new message @@ -24,9 +24,26 @@ class ChatTable( actorHeight: Float? = null, maxMessageLength: Int? = null ): Table(), Disposable { + internal val messageField = UncivTextField.create("New message") + init { val chatScroll = AutoScrollPane(chatMessageList, BaseScreen.skin) - chatScroll.setScrollingDisabled(true, false) + + // 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) { @@ -57,14 +74,12 @@ class ChatTable( add(newButton).growX().padRight(10f).padLeft(10f).row() } else { - val messageField = UncivTextField.create("New message") if (maxMessageLength != null) { messageField.maxLength = maxMessageLength } val sendButton = ArrowButton() sendButton.onActivation { - chatMessageList.sendMessage(messageField.text) - messageField.text = "" + sendMessage() } sendButton.keyShortcuts.add(KeyCharAndCode.RETURN) @@ -74,6 +89,14 @@ class ChatTable( } } + /** + * 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/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index cd1246cdfde60..b6925f259d068 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -97,6 +97,7 @@ class LobbyScreen( 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() @@ -242,7 +243,7 @@ class LobbyScreen( } } - recreate() + recreate(true) Concurrency.run { refresh() } @@ -305,7 +306,7 @@ class LobbyScreen( /** * Recreate the screen including some of its elements */ - fun recreate(): BaseScreen { + fun recreate(initial: Boolean = false): BaseScreen { val table = Table() val playerScroll = AutoScrollPane(lobbyPlayerList, skin) @@ -320,7 +321,6 @@ class LobbyScreen( optionsTable.add(menuButtonInvite).padBottom(10f).row() optionsTable.add(menuButtonStartGame).row() - val chatTable = ChatTable(chatMessageList) val menuBar = Table() menuBar.align(Align.bottom) menuBar.add(bottomButtonLeave).pad(20f) @@ -354,6 +354,9 @@ class LobbyScreen( table.setFillParent(true) stage.clear() stage.addActor(table) + if (initial) { + stage.keyboardFocus = chatTable.messageField + } return this } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt index 55c964b8d1262..6ed7105945a1b 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt @@ -30,12 +30,11 @@ class SocialMenuScreen(me: UUID? = null, initialChatRoom: Triple Date: Fri, 12 May 2023 16:32:32 +0200 Subject: [PATCH 128/152] Fixed subpaths in baseUrl, added server settings button --- .../com/unciv/logic/multiplayer/ApiVersion.kt | 21 +++--- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 4 +- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 24 +++++-- .../apiv2/EndpointImplementations.kt | 64 +++++++++---------- .../unciv/ui/components/ButtonCollection.kt | 1 + .../unciv/ui/popups/options/MultiplayerTab.kt | 7 +- .../multiplayerscreens/LobbyBrowserScreen.kt | 6 ++ 7 files changed, 75 insertions(+), 52 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt index b8b6fa31f026b..8dd807bd52373 100644 --- a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -70,6 +70,7 @@ enum class ApiVersion { if (baseUrl == Constants.dropboxMultiplayerServer) { return APIv0 } + val fixedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" // This client instance should be used during the API detection val client = HttpClient(CIO) { @@ -83,15 +84,15 @@ enum class ApiVersion { connectTimeoutMillis = timeout ?: DEFAULT_CONNECT_TIMEOUT } defaultRequest { - url(baseUrl) + url(fixedBaseUrl) } } // Try to connect to an APIv1 server at first val response1 = try { - client.get("/isalive") + client.get("isalive") } catch (e: Exception) { - Log.debug("Failed to fetch '/isalive' at %s: %s", baseUrl, e.localizedMessage) + Log.debug("Failed to fetch '/isalive' at %s: %s", fixedBaseUrl, e.localizedMessage) if (!suppress) { client.close() throw UncivNetworkException(e) @@ -101,26 +102,26 @@ enum class ApiVersion { if (response1?.status?.isSuccess() == true) { // Some API implementations just return the text "true" on the `isalive` endpoint if (response1.bodyAsText().startsWith("true")) { - Log.debug("Detected APIv1 at %s (no feature set)", baseUrl) + Log.debug("Detected APIv1 at %s (no feature set)", fixedBaseUrl) client.close() return APIv1 } try { val serverFeatureSet: ServerFeatureSet = json().fromJson(ServerFeatureSet::class.java, response1.bodyAsText()) // val serverFeatureSet: ServerFeatureSet = response1.body() - Log.debug("Detected APIv1 at %s: %s", baseUrl, serverFeatureSet) + Log.debug("Detected APIv1 at %s: %s", fixedBaseUrl, serverFeatureSet) client.close() return APIv1 } catch (e: Exception) { - Log.debug("Failed to de-serialize OK response body of '/isalive' at %s: %s", baseUrl, e.localizedMessage) + Log.debug("Failed to de-serialize OK response body of '/isalive' at %s: %s", fixedBaseUrl, e.localizedMessage) } } // Then try to connect to an APIv2 server val response2 = try { - client.get("/api/version") + client.get("api/version") } catch (e: Exception) { - Log.debug("Failed to fetch '/api/version' at %s: %s", baseUrl, e.localizedMessage) + Log.debug("Failed to fetch '/api/version' at %s: %s", fixedBaseUrl, e.localizedMessage) if (!suppress) { client.close() throw UncivNetworkException(e) @@ -130,11 +131,11 @@ enum class ApiVersion { if (response2?.status?.isSuccess() == true) { try { val serverVersion: VersionResponse = response2.body() - Log.debug("Detected APIv2 at %s: %s", baseUrl, serverVersion) + Log.debug("Detected APIv2 at %s: %s", fixedBaseUrl, serverVersion) client.close() return APIv2 } catch (e: Exception) { - Log.debug("Failed to de-serialize OK response body of '/api/version' at %s: %s", baseUrl, e.localizedMessage) + Log.debug("Failed to de-serialize OK response body of '/api/version' at %s: %s", fixedBaseUrl, e.localizedMessage) } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 8987a68800cf6..7ed41b1cf506d 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -130,7 +130,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } val versionInfo = try { - val r = client.get("/api/version") + val r = client.get("api/version") if (!r.status.isSuccess()) { false } else { @@ -150,7 +150,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } val websocketSupport = try { - val r = client.get("/api/v2/ws") + val r = client.get("api/v2/ws") if (r.status.isSuccess()) { Log.error("Websocket endpoint from '$baseUrl' accepted unauthenticated request") false diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 7110bf575238a..fda6bcb19e4ef 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -32,7 +32,13 @@ import java.util.concurrent.ConcurrentLinkedQueue * abstracts HTTP endpoint names from other modules in this package. * Use the [ApiV2] class for public methods to interact with the server. */ -open class ApiV2Wrapper(private val baseUrl: String) { +open class ApiV2Wrapper(baseUrl: String) { + private val baseUrlImpl: String = if (baseUrl.endsWith("/")) baseUrl else ("$baseUrl/") + private val baseServer = URLBuilder(baseUrl).apply { + encodedPath = "" + encodedParameters = ParametersBuilder() + fragment = "" + }.toString() // HTTP client to handle the server connections, logging, content parsing and cookies internal val client = HttpClient(CIO) { @@ -52,7 +58,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { contentConverter = KotlinxWebsocketSerializationConverter(Json) } defaultRequest { - url(baseUrl) + url(baseUrlImpl) } } @@ -70,7 +76,7 @@ open class ApiV2Wrapper(private val baseUrl: String) { Log.debug( "'%s %s%s': %s (%d ms)", request.method.value, - if (baseUrl.endsWith("/")) baseUrl.subSequence(0, baseUrl.length - 2) else baseUrl, + baseServer, request.url.encodedPath, clientCall.response.status, clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp @@ -140,9 +146,8 @@ open class ApiV2Wrapper(private val baseUrl: String) { method = HttpMethod.Get authHelper.add(this) url { - takeFrom(baseUrl) - protocol = if (baseUrl.startsWith("https://")) URLProtocol.WSS else URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well - path("/api/v2/ws") + protocol = if (baseUrlImpl.startsWith("https://")) URLProtocol.WSS else URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well + appendPathSegments("api/v2/ws") } } val job = Concurrency.run { @@ -164,9 +169,14 @@ open class ApiV2Wrapper(private val baseUrl: String) { /** * Retrieve the currently available API version of the connected server + * + * Unlike other API endpoint implementations, this function does not handle + * any errors or retries on failure. You must wrap any call in a try-except + * clause expecting any type of error. The error may not be appropriate to + * be shown to end users, i.e. it's definitively no [UncivShowableException]. */ internal suspend fun version(): VersionResponse { - return client.get("/api/version").body() + return client.get("api/version").body() } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index ca05ae2d9c5eb..1c058236a8952 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -168,7 +168,7 @@ private fun getDefaultRetry(client: HttpClient, authHelper: AuthHelper): (suspen val lastCredentials = authHelper.lastSuccessfulCredentials.get() if (lastCredentials != null) { return suspend { - val response = request(HttpMethod.Post, "/api/v2/auth/login", client, authHelper, suppress = true, retry = null, refine = {b -> + val response = request(HttpMethod.Post, "api/v2/auth/login", client, authHelper, suppress = true, retry = null, refine = {b -> b.contentType(ContentType.Application.Json) b.setBody(LoginRequest(lastCredentials.first, lastCredentials.second)) }) @@ -247,7 +247,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ suspend fun get(cache: Boolean = true, suppress: Boolean = false): AccountResponse? { return Cache.get( - "/api/v2/accounts/me", + "api/v2/accounts/me", client, authHelper, suppress = suppress, cache = cache, @@ -266,7 +266,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ suspend fun lookup(uuid: UUID, cache: Boolean = true, suppress: Boolean = false): AccountResponse? { return Cache.get( - "/api/v2/accounts/$uuid", + "api/v2/accounts/$uuid", client, authHelper, suppress = suppress, cache = cache, @@ -288,7 +288,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ suspend fun lookup(username: String, suppress: Boolean = false): AccountResponse? { return request( - HttpMethod.Post, "/api/v2/accounts/lookup", + HttpMethod.Post, "api/v2/accounts/lookup", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper), @@ -333,7 +333,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ private suspend fun update(r: UpdateAccountRequest, suppress: Boolean): Boolean { val response = request( - HttpMethod.Put, "/api/v2/accounts/me", + HttpMethod.Put, "api/v2/accounts/me", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper), @@ -355,7 +355,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ suspend fun delete(suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Delete, "/api/v2/accounts/me", + HttpMethod.Delete, "api/v2/accounts/me", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -383,7 +383,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe oldLocalPassword = "" // empty passwords will yield InvalidPassword, so this is fine here } val response = request( - HttpMethod.Post, "/api/v2/accounts/setPassword", + HttpMethod.Post, "api/v2/accounts/setPassword", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper), @@ -410,7 +410,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe */ suspend fun register(username: String, displayName: String, password: String, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Post, "/api/v2/accounts/register", + HttpMethod.Post, "api/v2/accounts/register", client, authHelper, suppress = suppress, refine = { b -> @@ -443,7 +443,7 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun loginOnly(username: String, password: String): Boolean { val response = request( - HttpMethod.Post, "/api/v2/auth/login", + HttpMethod.Post, "api/v2/auth/login", client, authHelper, suppress = true, refine = { b -> @@ -466,7 +466,7 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun login(username: String, password: String, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Post, "/api/v2/auth/login", + HttpMethod.Post, "api/v2/auth/login", client, authHelper, suppress = suppress, refine = { b -> @@ -507,7 +507,7 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper suspend fun logout(suppress: Boolean = true): Boolean { val response = try { request( - HttpMethod.Get, "/api/v2/auth/logout", + HttpMethod.Get, "api/v2/auth/logout", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -548,7 +548,7 @@ class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun list(suppress: Boolean = false): GetAllChatsResponse? { val response = request( - HttpMethod.Get, "/api/v2/chats", + HttpMethod.Get, "api/v2/chats", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -575,7 +575,7 @@ class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun get(roomUUID: UUID, suppress: Boolean = false): GetChatResponse? { return request( - HttpMethod.Get, "/api/v2/chats/$roomUUID", + HttpMethod.Get, "api/v2/chats/$roomUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -594,7 +594,7 @@ class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun send(message: String, chatRoomUUID: UUID, suppress: Boolean = false): ChatMessage? { val response = request( - HttpMethod.Post, "/api/v2/chats/$chatRoomUUID", + HttpMethod.Post, "api/v2/chats/$chatRoomUUID", client, authHelper, suppress = suppress, refine = { b -> @@ -623,7 +623,7 @@ class FriendApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun list(suppress: Boolean = false): Pair, List>? { val body: GetFriendResponse? = request( - HttpMethod.Get, "/api/v2/friends", + HttpMethod.Get, "api/v2/friends", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -669,7 +669,7 @@ class FriendApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun request(other: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Post, "/api/v2/friends", + HttpMethod.Post, "api/v2/friends", client, authHelper, suppress = suppress, refine = { b -> @@ -691,7 +691,7 @@ class FriendApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun accept(friendRequestUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Put, "/api/v2/friends/$friendRequestUUID", + HttpMethod.Put, "api/v2/friends/$friendRequestUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -711,7 +711,7 @@ class FriendApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun delete(friendUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Delete, "/api/v2/friends/$friendUUID", + HttpMethod.Delete, "api/v2/friends/$friendUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -743,7 +743,7 @@ class GameApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun list(suppress: Boolean = false): List? { val body: GetGameOverviewResponse? = request( - HttpMethod.Get, "/api/v2/games", + HttpMethod.Get, "api/v2/games", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -765,7 +765,7 @@ class GameApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun get(gameUUID: UUID, cache: Boolean = true, suppress: Boolean = false): GameStateResponse? { return Cache.get( - "/api/v2/games/$gameUUID", + "api/v2/games/$gameUUID", client, authHelper, suppress = suppress, cache = cache, @@ -810,7 +810,7 @@ class GameApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun upload(gameUUID: UUID, gameData: String, suppress: Boolean = false): Long? { val body: GameUploadResponse? = request( - HttpMethod.Put, "/api/v2/games/$gameUUID", + HttpMethod.Put, "api/v2/games/$gameUUID", client, authHelper, suppress = suppress, refine = { b -> @@ -842,7 +842,7 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun list(suppress: Boolean = false): List? { val body: GetInvitesResponse? = request( - HttpMethod.Get, "/api/v2/invites", + HttpMethod.Get, "api/v2/invites", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -863,7 +863,7 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun new(friendUUID: UUID, lobbyUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Post, "/api/v2/invites", + HttpMethod.Post, "api/v2/invites", client, authHelper, suppress = suppress, refine = { b -> @@ -888,7 +888,7 @@ class InviteApi(private val client: HttpClient, private val authHelper: AuthHelp */ suspend fun reject(inviteUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Delete, "/api/v2/invites/$inviteUUID", + HttpMethod.Delete, "api/v2/invites/$inviteUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -915,7 +915,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun list(suppress: Boolean = false): List? { val body: GetLobbiesResponse? = request( - HttpMethod.Get, "/api/v2/lobbies", + HttpMethod.Get, "api/v2/lobbies", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -935,7 +935,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun get(lobbyUUID: UUID, suppress: Boolean = false): GetLobbyResponse? { return request( - HttpMethod.Get, "/api/v2/lobbies/$lobbyUUID", + HttpMethod.Get, "api/v2/lobbies/$lobbyUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -983,7 +983,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ private suspend fun open(req: CreateLobbyRequest, suppress: Boolean): CreateLobbyResponse? { return request( - HttpMethod.Post, "/api/v2/lobbies", + HttpMethod.Post, "api/v2/lobbies", client, authHelper, suppress = suppress, refine = { b -> @@ -1006,7 +1006,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun kick(lobbyUUID: UUID, playerUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Delete, "/api/v2/lobbies/$lobbyUUID/$playerUUID", + HttpMethod.Delete, "api/v2/lobbies/$lobbyUUID/$playerUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -1026,7 +1026,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun close(lobbyUUID: UUID, suppress: Boolean = false): Boolean { val response = request( - HttpMethod.Delete, "/api/v2/lobbies/$lobbyUUID", + HttpMethod.Delete, "api/v2/lobbies/$lobbyUUID", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -1050,7 +1050,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun join(lobbyUUID: UUID, password: String? = null, suppress: Boolean = false): Boolean { return request( - HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/join", + HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/join", client, authHelper, suppress = suppress, refine = { b -> @@ -1074,7 +1074,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun leave(lobbyUUID: UUID, suppress: Boolean = false): Boolean { return request( - HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/leave", + HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/leave", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) @@ -1102,7 +1102,7 @@ class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelpe */ suspend fun startGame(lobbyUUID: UUID, suppress: Boolean = false): StartGameResponse? { return request( - HttpMethod.Post, "/api/v2/lobbies/$lobbyUUID/start", + HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/start", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) diff --git a/core/src/com/unciv/ui/components/ButtonCollection.kt b/core/src/com/unciv/ui/components/ButtonCollection.kt index 0fe15168134d4..a9dfe23a4b022 100644 --- a/core/src/com/unciv/ui/components/ButtonCollection.kt +++ b/core/src/com/unciv/ui/components/ButtonCollection.kt @@ -29,3 +29,4 @@ class ArrowButton(size: Float = Constants.headingFontSize.toFloat()): SpecificBu 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/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 379f19aedb023..6c7ad79c7baae 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -227,13 +227,18 @@ private fun addMultiplayerServerOptions( popup.reuseWith("Success! Detected $apiVersion!", true) } } - } else { + } else if (apiVersion != null) { Concurrency.runOnGLThread { popup.reuseWith("Success! Detected $apiVersion!", true) } Concurrency.runOnNonDaemonThreadPool { UncivGame.refreshOnlineMultiplayer() } + } else { + Log.debug("Api version detection: null") + Concurrency.runOnGLThread { + popup.reuseWith("Failed!", true) + } } } catch (e: Exception) { Log.debug("Connectivity exception: %s", e.localizedMessage) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index 53757c45078a3..f4fad00b0daf4 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -9,6 +9,7 @@ import com.unciv.ui.components.KeyCharAndCode 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 @@ -45,6 +46,7 @@ class LobbyBrowserScreen : BaseScreen() { 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() @@ -87,6 +89,9 @@ class LobbyBrowserScreen : BaseScreen() { 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 @@ -96,6 +101,7 @@ class LobbyBrowserScreen : BaseScreen() { 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() From c6f01c0ad21477da037ae96862c8373b8d409f06 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 May 2023 00:10:09 +0200 Subject: [PATCH 129/152] Added WS-based Android turn checker, added a new event channel, fixed APIWrapper --- .../unciv/app/MultiplayerTurnCheckWorker.kt | 48 ++++++++++++++++++ .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 49 +++++++++++++++++++ .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 20 ++++---- .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 4 +- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt index cfc284a8c3dc9..9a7c93e4d25b0 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt @@ -22,9 +22,15 @@ import com.badlogic.gdx.backends.android.DefaultAndroidFiles import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.ApiVersion +import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage +import com.unciv.logic.multiplayer.apiv2.UpdateGameData import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.models.metadata.GameSettingsMultiplayer +import com.unciv.utils.concurrency.Concurrency +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.io.PrintWriter @@ -262,6 +268,8 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame */ private val notFoundRemotely = mutableMapOf() + private var worker: Job? = null + private val files: UncivFiles init { // We can't use Gdx.files since that is only initialized within a com.badlogic.gdx.backends.android.AndroidApplication. @@ -272,6 +280,36 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame files = UncivFiles(gdxFiles) } + private suspend fun checkTurnsForApiV2() { + UncivGame.Current.onlineMultiplayer.api.ensureConnectedWebSocket() + val channel = UncivGame.Current.onlineMultiplayer.api.getWebSocketEventChannel() + 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 -> { + 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 + notifyUserAboutTurn(applicationContext, Pair(name ?: event.gameUUID.toString(), event.gameUUID.toString())) + with(NotificationManagerCompat.from(applicationContext)) { + cancel(NOTIFICATION_ID_SERVICE) + } + } + } + } + } catch (t: Throwable) { + Log.e(LOG_TAG, "checkTurn APIv2 failure: $t / ${t.localizedMessage}") + Log.d(LOG_TAG, t.stackTraceToString()) + channel.cancel() + throw t + } + } + override fun doWork(): Result = runBlocking { Log.i(LOG_TAG, "doWork") val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true) @@ -279,6 +317,16 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame val fileStorage = inputData.getString(FILE_STORAGE) val authHeader = inputData.getString(AUTH_HEADER)!! + // In case of APIv2 games, doWork is supposed to do something entirely different + if (UncivGame.Current.onlineMultiplayer.isInitialized() && UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + Log.d(LOG_TAG, "Using APIv2 for turn checker doWork (confDelay = $configuredDelay, showNotificication = $showPersistNotific)") + val job = Concurrency.run { checkTurnsForApiV2() } + worker?.cancelAndJoin() + worker = job + Log.d(LOG_TAG, "Returning success, worker is $worker") + return@runBlocking Result.success() + } + try { val gameIds = inputData.getStringArray(GAME_ID)!! val gameNames = inputData.getStringArray(GAME_NAME)!! diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 7ed41b1cf506d..4147bf8f76772 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -2,6 +2,8 @@ package com.unciv.logic.multiplayer.apiv2 import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame +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 @@ -16,7 +18,11 @@ import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -46,6 +52,30 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** Cache for the game details to make certain lookups faster */ private val gameDetails: MutableMap = mutableMapOf() + /** List of channel that extend the usage of the [EventBus] system, see [getWebSocketEventChannel] */ + private val eventChannelList = mutableListOf>() + + /** + * Get a receiver channel for WebSocket [Event]s that is decoupled from the [EventBus] system + * + * All WebSocket events are sent to the [EventBus] as well as to all channels + * returned by this function, so it's possible to receive from any of these to + * get the event. It's better to cancel the [ReceiveChannel] after usage, but cleanup + * would also be carried out automatically asynchronously whenever events are sent. + * Note that only raw WebSocket messages are put here, i.e. no processed [GameInfo] + * or other large objects will be sent (the exception being [UpdateGameData], which + * may grow pretty big, as in up to 500 KiB as base64-encoded string data). + * + * Use the channel returned by this function if the GL render thread, which is used + * by the [EventBus] system, may not be available (e.g. in the Android turn checker). + */ + fun getWebSocketEventChannel(): ReceiveChannel { + // We're using CONFLATED channels here to avoid usage of possibly huge amounts of memory + val c = Channel(capacity = CONFLATED) + eventChannelList.add(c as SendChannel) + return c + } + /** * Initialize this class (performing actual networking connectivity) * @@ -301,6 +331,20 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { Concurrency.runOnGLThread { EventBus.send((msg as WebSocketMessageWithContent).content) } + for (c in eventChannelList) { + Concurrency.run { + try { + c.send((msg as WebSocketMessageWithContent).content) + } catch (closed: ClosedSendChannelException) { + delay(10) + eventChannelList.remove(c) + } catch (t: Throwable) { + Log.debug("Sending event %s to event channel %s failed: %s", (msg as WebSocketMessageWithContent).content, c, t) + delay(10) + eventChannelList.remove(c) + } + } + } } } } catch (e: Throwable) { @@ -334,6 +378,11 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } } + suspend fun ensureConnectedWebSocket() { + // TODO: Ensure that the WebSocket is connected (e.g. send a PING or build a new connection) + // This should be used from the MultiplayerTurnChecker (Android) + } + // ---------------- SESSION FUNCTIONALITY ---------------- /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index fda6bcb19e4ef..abb8856aa28b0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -74,9 +74,10 @@ open class ApiV2Wrapper(baseUrl: String) { request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") val clientCall = execute(request) Log.debug( - "'%s %s%s': %s (%d ms)", + "'%s %s%s%s': %s (%d ms)", request.method.value, baseServer, + if (baseServer.endsWith("/") or request.url.encodedPath.startsWith("/")) "" else "/", request.url.encodedPath, clientCall.response.status, clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp @@ -97,37 +98,37 @@ open class ApiV2Wrapper(baseUrl: String) { /** * API for account management */ - internal val account = AccountsApi(client, authHelper) + val account = AccountsApi(client, authHelper) /** * API for authentication management */ - internal val auth = AuthApi(client, authHelper, ::afterLogin) + val auth = AuthApi(client, authHelper, ::afterLogin) /** * API for chat management */ - internal val chat = ChatApi(client, authHelper) + val chat = ChatApi(client, authHelper) /** * API for friendship management */ - internal val friend = FriendApi(client, authHelper) + val friend = FriendApi(client, authHelper) /** * API for game management */ - internal val game = GameApi(client, authHelper) + val game = GameApi(client, authHelper) /** * API for invite management */ - internal val invite = InviteApi(client, authHelper) + val invite = InviteApi(client, authHelper) /** * API for lobby management */ - internal val lobby = LobbyApi(client, authHelper) + val lobby = LobbyApi(client, authHelper) /** * Start a new WebSocket connection @@ -146,7 +147,6 @@ open class ApiV2Wrapper(baseUrl: String) { method = HttpMethod.Get authHelper.add(this) url { - protocol = if (baseUrlImpl.startsWith("https://")) URLProtocol.WSS else URLProtocol.WS // TODO: Verify that secure WebSockets (WSS) work as well appendPathSegments("api/v2/ws") } } @@ -175,7 +175,7 @@ open class ApiV2Wrapper(baseUrl: String) { * clause expecting any type of error. The error may not be appropriate to * be shown to end users, i.e. it's definitively no [UncivShowableException]. */ - internal suspend fun version(): VersionResponse { + suspend fun version(): VersionResponse { return client.get("api/version").body() } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt index fcb94154775c5..1e6ce6e7808e4 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -18,7 +18,7 @@ internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) internal val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) /** Default timeout for a single request (miliseconds) */ -internal const val DEFAULT_REQUEST_TIMEOUT = 5000L +internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L /** Default timeout for connecting to a remote server (miliseconds) */ -internal const val DEFAULT_CONNECT_TIMEOUT = 3000L +internal const val DEFAULT_CONNECT_TIMEOUT = 5_000L From 1ecbfd4342882f7b7e3726d8fff2368f90eee10d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 16 May 2023 00:49:51 +0200 Subject: [PATCH 130/152] Refactored the multiplayer turn checker to use its own package --- android/src/com/unciv/app/AndroidLauncher.kt | 18 ++++++++----- .../com/unciv/app/CopyToClipboardReceiver.kt | 5 ++-- android/src/com/unciv/app/turncheck/Common.kt | 7 ++++++ .../WorkerV1.kt} | 25 ++++++++++++------- .../src/com/unciv/app/turncheck/WorkerV2.kt | 14 +++++++++++ 5 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 android/src/com/unciv/app/turncheck/Common.kt rename android/src/com/unciv/app/{MultiplayerTurnCheckWorker.kt => turncheck/WorkerV1.kt} (97%) create mode 100644 android/src/com/unciv/app/turncheck/WorkerV2.kt diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index e0c61d5b0b96b..3b8ce6398376b 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -6,7 +6,9 @@ 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.WorkerV1 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 +35,7 @@ open class AndroidLauncher : AndroidApplication() { UncivFiles.preferExternalStorage = true // Create notification channels for Multiplayer notificator - MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext) + WorkerV1.createNotificationChannels(applicationContext) copyMods() @@ -75,18 +77,22 @@ open class AndroidLauncher : AndroidApplication() { && game.settings.multiplayer.turnCheckerEnabled && game.files.getMultiplayerSaves().any() ) { - MultiplayerTurnCheckWorker.startTurnChecker( - applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) + if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { + // TODO + } else { + WorkerV1.startTurnChecker( + applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) + } } super.onPause() } override fun onResume() { try { - WorkManager.getInstance(applicationContext).cancelAllWorkByTag(MultiplayerTurnCheckWorker.WORK_TAG) + WorkManager.getInstance(applicationContext).cancelAllWorkByTag(WorkerV1.WORK_TAG) with(NotificationManagerCompat.from(this)) { - cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_INFO) - cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_SERVICE) + cancel(WorkerV1.NOTIFICATION_ID_INFO) + cancel(WorkerV1.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 e469732568f67..e199052d2ee77 100644 --- a/android/src/com/unciv/app/CopyToClipboardReceiver.kt +++ b/android/src/com/unciv/app/CopyToClipboardReceiver.kt @@ -3,6 +3,7 @@ package com.unciv.app import android.content.* import android.widget.Toast import com.badlogic.gdx.backends.android.AndroidApplication +import com.unciv.app.turncheck.WorkerV1 /** * This Receiver can be called from an Action on the error Notification shown by MultiplayerTurnCheckWorker. @@ -12,8 +13,8 @@ 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(WorkerV1.CLIPBOARD_EXTRA)) clipboard.setPrimaryClip(clip) Toast.makeText(context, context.resources.getString(R.string.Notify_Error_StackTrace_Toast), Toast.LENGTH_SHORT).show() } -} \ No newline at end of file +} 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..4f30b91f26456 --- /dev/null +++ b/android/src/com/unciv/app/turncheck/Common.kt @@ -0,0 +1,7 @@ +package com.unciv.app.turncheck + +/** + * Collection of common utilities for [WorkerV1] and [WorkerV2] + */ +class Common { +} diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/turncheck/WorkerV1.kt similarity index 97% rename from android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt rename to android/src/com/unciv/app/turncheck/WorkerV1.kt index 9a7c93e4d25b0..79063e69599e3 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV1.kt @@ -1,4 +1,4 @@ -package com.unciv.app +package com.unciv.app.turncheck import android.app.NotificationChannel import android.app.NotificationManager @@ -20,13 +20,15 @@ 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.logic.GameInfo import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.ApiVersion import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage import com.unciv.logic.multiplayer.apiv2.UpdateGameData import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached -import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.models.metadata.GameSettingsMultiplayer import com.unciv.utils.concurrency.Concurrency import kotlinx.coroutines.Job @@ -40,9 +42,10 @@ import java.time.Duration import java.util.* 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" @@ -75,7 +78,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val checkTurnWork = OneTimeWorkRequestBuilder() + val checkTurnWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInitialDelay(delay.seconds, TimeUnit.SECONDS) .addTag(WORK_TAG) @@ -218,7 +221,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), @@ -404,7 +408,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. @@ -444,7 +450,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..d9647326d9202 --- /dev/null +++ b/android/src/com/unciv/app/turncheck/WorkerV2.kt @@ -0,0 +1,14 @@ +package com.unciv.app.turncheck + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +/** + * Push-based multiplayer turn checker for APIv2 + */ +class WorkerV2(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + TODO("Not yet implemented") + } +} From cefc6cb0684f1879b0a489bb2ce0b6214ba8bb36 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 19 May 2023 01:29:35 +0200 Subject: [PATCH 131/152] Refactored the multiplayer turn checker to move companion code into a separate object --- android/src/com/unciv/app/AndroidLauncher.kt | 9 +- .../com/unciv/app/CopyToClipboardReceiver.kt | 4 +- android/src/com/unciv/app/turncheck/Common.kt | 164 +++++++++++++++++- .../src/com/unciv/app/turncheck/WorkerV1.kt | 152 +--------------- 4 files changed, 178 insertions(+), 151 deletions(-) diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 3b8ce6398376b..ee7869f791ca8 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -6,6 +6,7 @@ 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.logic.files.UncivFiles import com.unciv.logic.multiplayer.ApiVersion @@ -35,7 +36,7 @@ open class AndroidLauncher : AndroidApplication() { UncivFiles.preferExternalStorage = true // Create notification channels for Multiplayer notificator - WorkerV1.createNotificationChannels(applicationContext) + Common.createNotificationChannels(applicationContext) copyMods() @@ -89,10 +90,10 @@ open class AndroidLauncher : AndroidApplication() { override fun onResume() { try { - WorkManager.getInstance(applicationContext).cancelAllWorkByTag(WorkerV1.WORK_TAG) + WorkManager.getInstance(applicationContext).cancelAllWorkByTag(Common.WORK_TAG) with(NotificationManagerCompat.from(this)) { - cancel(WorkerV1.NOTIFICATION_ID_INFO) - cancel(WorkerV1.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 e199052d2ee77..923eee853935a 100644 --- a/android/src/com/unciv/app/CopyToClipboardReceiver.kt +++ b/android/src/com/unciv/app/CopyToClipboardReceiver.kt @@ -3,7 +3,7 @@ package com.unciv.app import android.content.* import android.widget.Toast import com.badlogic.gdx.backends.android.AndroidApplication -import com.unciv.app.turncheck.WorkerV1 +import com.unciv.app.turncheck.Common /** * This Receiver can be called from an Action on the error Notification shown by MultiplayerTurnCheckWorker. @@ -13,7 +13,7 @@ import com.unciv.app.turncheck.WorkerV1 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(WorkerV1.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 index 4f30b91f26456..8809731a11f99 100644 --- a/android/src/com/unciv/app/turncheck/Common.kt +++ b/android/src/com/unciv/app/turncheck/Common.kt @@ -1,7 +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] */ -class Common { +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/turncheck/WorkerV1.kt b/android/src/com/unciv/app/turncheck/WorkerV1.kt index 79063e69599e3..b213073c2b2ac 100644 --- a/android/src/com/unciv/app/turncheck/WorkerV1.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV1.kt @@ -1,7 +1,5 @@ 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 @@ -17,12 +14,19 @@ import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE import androidx.core.app.NotificationManagerCompat import androidx.work.* 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.ApiVersion @@ -48,20 +52,6 @@ import java.util.concurrent.TimeUnit 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" @@ -88,109 +78,6 @@ class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(app 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") val gameFiles = files.getMultiplayerSaves() @@ -237,29 +124,6 @@ class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(app } } - /** - * 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) From 30bb0a9aac902449f27600dde7b5efcc7f0a14ce Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 20 May 2023 23:33:11 +0200 Subject: [PATCH 132/152] Implemented the APIv2-based listener as TurnChecker V2 (Android) --- android/src/com/unciv/app/AndroidLauncher.kt | 18 +-- android/src/com/unciv/app/turncheck/Common.kt | 4 +- .../src/com/unciv/app/turncheck/WorkerV1.kt | 48 ------ .../src/com/unciv/app/turncheck/WorkerV2.kt | 146 +++++++++++++++++- 4 files changed, 155 insertions(+), 61 deletions(-) diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index ee7869f791ca8..0952d88c069cf 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -8,6 +8,7 @@ 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 @@ -73,16 +74,15 @@ open class AndroidLauncher : AndroidApplication() { override fun onPause() { val game = this.game!! - if (game.isInitialized - && game.gameInfo != null - && game.settings.multiplayer.turnCheckerEnabled - && game.files.getMultiplayerSaves().any() - ) { + if (game.isInitialized) { if (game.onlineMultiplayer.isInitialized() && game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { - // TODO - } else { - WorkerV1.startTurnChecker( - applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) + 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) } } super.onPause() diff --git a/android/src/com/unciv/app/turncheck/Common.kt b/android/src/com/unciv/app/turncheck/Common.kt index 8809731a11f99..050dda11ec5a2 100644 --- a/android/src/com/unciv/app/turncheck/Common.kt +++ b/android/src/com/unciv/app/turncheck/Common.kt @@ -154,8 +154,8 @@ object Common { } /** - * Notification Channels can't be modified after creation. - * Therefore Unciv needs to create new ones and delete legacy ones. + * 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 diff --git a/android/src/com/unciv/app/turncheck/WorkerV1.kt b/android/src/com/unciv/app/turncheck/WorkerV1.kt index b213073c2b2ac..37e0831e7f046 100644 --- a/android/src/com/unciv/app/turncheck/WorkerV1.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV1.kt @@ -29,14 +29,8 @@ 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.ApiVersion -import com.unciv.logic.multiplayer.apiv2.IncomingChatMessage -import com.unciv.logic.multiplayer.apiv2.UpdateGameData import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.models.metadata.GameSettingsMultiplayer -import com.unciv.utils.concurrency.Concurrency -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.io.PrintWriter @@ -136,8 +130,6 @@ class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(app */ private val notFoundRemotely = mutableMapOf() - private var worker: Job? = null - private val files: UncivFiles init { // We can't use Gdx.files since that is only initialized within a com.badlogic.gdx.backends.android.AndroidApplication. @@ -148,36 +140,6 @@ class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(app files = UncivFiles(gdxFiles) } - private suspend fun checkTurnsForApiV2() { - UncivGame.Current.onlineMultiplayer.api.ensureConnectedWebSocket() - val channel = UncivGame.Current.onlineMultiplayer.api.getWebSocketEventChannel() - 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 -> { - 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 - notifyUserAboutTurn(applicationContext, Pair(name ?: event.gameUUID.toString(), event.gameUUID.toString())) - with(NotificationManagerCompat.from(applicationContext)) { - cancel(NOTIFICATION_ID_SERVICE) - } - } - } - } - } catch (t: Throwable) { - Log.e(LOG_TAG, "checkTurn APIv2 failure: $t / ${t.localizedMessage}") - Log.d(LOG_TAG, t.stackTraceToString()) - channel.cancel() - throw t - } - } - override fun doWork(): Result = runBlocking { Log.i(LOG_TAG, "doWork") val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true) @@ -185,16 +147,6 @@ class WorkerV1(appContext: Context, workerParams: WorkerParameters) : Worker(app val fileStorage = inputData.getString(FILE_STORAGE) val authHeader = inputData.getString(AUTH_HEADER)!! - // In case of APIv2 games, doWork is supposed to do something entirely different - if (UncivGame.Current.onlineMultiplayer.isInitialized() && UncivGame.Current.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { - Log.d(LOG_TAG, "Using APIv2 for turn checker doWork (confDelay = $configuredDelay, showNotificication = $showPersistNotific)") - val job = Concurrency.run { checkTurnsForApiV2() } - worker?.cancelAndJoin() - worker = job - Log.d(LOG_TAG, "Returning success, worker is $worker") - return@runBlocking Result.success() - } - try { val gameIds = inputData.getStringArray(GAME_ID)!! val gameNames = inputData.getStringArray(GAME_NAME)!! diff --git a/android/src/com/unciv/app/turncheck/WorkerV2.kt b/android/src/com/unciv/app/turncheck/WorkerV2.kt index d9647326d9202..f0399dfc0d9cf 100644 --- a/android/src/com/unciv/app/turncheck/WorkerV2.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV2.kt @@ -1,14 +1,156 @@ 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.Concurrency +import com.unciv.utils.concurrency.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, params: WorkerParameters) : CoroutineWorker(appContext, params) { +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 { - TODO("Not yet implemented") + try { + Log.d(LOG_TAG, "Starting doWork for WorkerV2: $this") + enqueue(applicationContext, params.inputData, params.inputData.getLong(CONFIGURED_DELAY, 600L)) + + 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 (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() } } From c0abe0dd7f32b85599478c2e51381dff3401899f Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 20 May 2023 23:51:01 +0200 Subject: [PATCH 133/152] Added a logout hook, implemented ensureConnectedWebSocket --- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 52 +++++++++++++++---- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 21 ++++++-- .../apiv2/EndpointImplementations.kt | 5 +- .../unciv/ui/popups/LobbyInvitationPopup.kt | 2 +- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 4147bf8f76772..b9e8bf7507f27 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -17,6 +17,7 @@ import io.ktor.client.request.* import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED @@ -127,6 +128,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { */ override fun dispose() { sendChannel?.close() + for (channel in eventChannelList) { + channel.close() + } for (job in websocketJobs) { job.cancel() } @@ -267,12 +271,12 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * Send a [FrameType.PING] frame to the server, without awaiting a response * * This operation might fail with some exception, e.g. network exceptions. - * Internally, a random 8-byte array will be used for the ping. It returns - * true when sending worked as expected, false when there's no send channel - * available and an any exception otherwise. + * Internally, a random byte array of [size] will be used for the ping. It + * returns true when sending worked as expected, false when there's no + * send channel available and an exception otherwise. */ - internal suspend fun sendPing(): Boolean { - val body = ByteArray(0) + private suspend fun sendPing(size: Int = 0): Boolean { + val body = ByteArray(size) val channel = sendChannel return if (channel == null) { false @@ -378,9 +382,22 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } } - suspend fun ensureConnectedWebSocket() { - // TODO: Ensure that the WebSocket is connected (e.g. send a PING or build a new connection) - // This should be used from the MultiplayerTurnChecker (Android) + /** + * Ensure that the WebSocket is connected (send a PING and build a new connection on failure) + * + * Use [jobCallback] to receive the newly created job handling the WS connection. + * Note that this callback might not get called if no new WS connection was created. + */ + suspend fun ensureConnectedWebSocket(jobCallback: ((Job) -> Unit)? = null) { + val shouldRecreateConnection = try { + !sendPing() + } catch (e: Exception) { + Log.debug("Error %s while ensuring connected WebSocket: %s", e, e.localizedMessage) + true + } + if (shouldRecreateConnection) { + websocket(::handleWebSocket, jobCallback) + } } // ---------------- SESSION FUNCTIONALITY ---------------- @@ -403,8 +420,25 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { ) UncivGame.Current.settings.multiplayer.userId = me.uuid.toString() UncivGame.Current.settings.save() - websocket(::handleWebSocket) + ensureConnectedWebSocket() + } + super.afterLogin() + } + + /** + * Perform the post-logout hook, cancelling all WebSocket jobs and event channels + */ + override suspend fun afterLogout(success: Boolean) { + sendChannel?.close() + if (success) { + for (channel in eventChannelList) { + channel.close() + } + for (job in websocketJobs) { + job.cancel() + } } + super.afterLogout(success) } /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index abb8856aa28b0..53f0eaf2e3d93 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -95,6 +95,16 @@ open class ApiV2Wrapper(baseUrl: String) { */ protected open suspend fun afterLogin() {} + /** + * Coroutine directly executed after every attempt to logout from the server. + * The parameter [success] determines whether logging out completed successfully, + * i.e. this coroutine will also be called in the case of an error. + * This coroutine should not raise any unhandled exceptions, because otherwise + * the login function will fail as well. If it requires longer operations, + * those operations should be detached from the current thread. + */ + protected open suspend fun afterLogout(success: Boolean) {} + /** * API for account management */ @@ -103,7 +113,7 @@ open class ApiV2Wrapper(baseUrl: String) { /** * API for authentication management */ - val auth = AuthApi(client, authHelper, ::afterLogin) + val auth = AuthApi(client, authHelper, ::afterLogin, ::afterLogout) /** * API for chat management @@ -137,8 +147,9 @@ open class ApiV2Wrapper(baseUrl: String) { * [ClientWebSocketSession] on success at a later point. Note that this * method does instantly return, detaching the creation of the WebSocket. * The [handler] coroutine might not get called, if opening the WS fails. + * Use [jobCallback] to receive the newly created job handling the WS connection. */ - internal suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit): Boolean { + internal suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit, jobCallback: ((Job) -> Unit)? = null): Boolean { Log.debug("Starting a new WebSocket connection ...") coroutineScope { @@ -150,11 +161,15 @@ open class ApiV2Wrapper(baseUrl: String) { appendPathSegments("api/v2/ws") } } - val job = Concurrency.run { + val job = Concurrency.runOnNonDaemonThreadPool { handler(session) } websocketJobs.add(job) Log.debug("A new WebSocket has been created, running in job $job") + if (jobCallback != null) { + jobCallback(job) + } + true } catch (e: SerializationException) { Log.debug("Failed to create a WebSocket: %s", e.localizedMessage) return@coroutineScope false diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 1c058236a8952..e990a1b3b70c6 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -431,7 +431,7 @@ class AccountsApi(private val client: HttpClient, private val authHelper: AuthHe /** * API wrapper for authentication handling (do not use directly; use the Api class instead) */ -class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper, private val afterLogin: suspend () -> Unit) { +class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper, private val afterLogin: suspend () -> Unit, private val afterLogout: suspend (Boolean) -> Unit) { /** * Try logging in with [username] and [password] for testing purposes, don't set the session cookie @@ -516,16 +516,19 @@ class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper authHelper.unset() Cache.clear() Log.debug("Logout failed due to %s (%s), dropped session anyways", e, e.message) + afterLogout(false) return false } Cache.clear() return if (response?.status?.isSuccess() == true) { authHelper.unset() Log.debug("Logged out successfully, dropped session") + afterLogout(true) true } else { authHelper.unset() Log.debug("Logout failed for some reason, dropped session anyways") + afterLogout(false) false } } diff --git a/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt index bc6f55c57f8fb..0b15f0867c662 100644 --- a/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt +++ b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt @@ -20,7 +20,7 @@ class LobbyInvitationPopup( 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.").row() + 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 From ef3152ec3d17ffc5054400e1da1f2fdac4ff77cf Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 22 May 2023 03:14:59 +0200 Subject: [PATCH 134/152] Switched to use MultiplayerStatusButton for APIv2 games as well --- .../screens/newgamescreen/GameOptionsTable.kt | 1 + .../ui/screens/worldscreen/WorldScreen.kt | 14 ++- .../screens/worldscreen/WorldScreenTopBar.kt | 56 +---------- .../status/MultiplayerStatusButton.kt | 96 +++++++++++++++---- .../worldscreen/status/StatusButtons.kt | 1 + 5 files changed, 92 insertions(+), 76 deletions(-) diff --git a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt index 8991a4ebd8a11..482628caa15ef 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt @@ -177,6 +177,7 @@ class GameOptionsTable( update() } } else { + gameParameters.isOnlineMultiplayer = false val checkBox = addCheckbox("Online Multiplayer", initialState = false) {} checkBox.onChange { checkBox.isChecked = false diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index f5e9d3db1afb0..3357698f9149f 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -37,6 +37,7 @@ import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton 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,16 +58,17 @@ import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.bottombar.BattleTable import com.unciv.ui.screens.worldscreen.bottombar.TileInfoTable 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.StatusButtons import com.unciv.ui.screens.worldscreen.unit.UnitTable import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsTable import com.unciv.utils.Concurrency +import com.unciv.utils.debug import com.unciv.utils.launchOnGLThread import com.unciv.utils.launchOnThreadPool import com.unciv.utils.withGLContext -import com.unciv.utils.debug import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope @@ -693,9 +695,13 @@ class WorldScreen( } private fun updateMultiplayerStatusButton() { - if ((gameInfo.gameParameters.isOnlineMultiplayer && game.onlineMultiplayer.apiVersion != ApiVersion.APIv2) || game.settings.multiplayer.statusButtonInSinglePlayer) { + 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) + } 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/WorldScreenTopBar.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt index 5e28ca65be3c3..364f379b86ccd 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreenTopBar.kt @@ -9,7 +9,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.logic.civilization.Civilization -import com.unciv.logic.multiplayer.ApiVersion import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.tile.TileResource import com.unciv.models.ruleset.unique.UniqueType @@ -17,7 +16,6 @@ import com.unciv.models.stats.Stats import com.unciv.models.translations.tr import com.unciv.ui.components.Fonts import com.unciv.ui.components.MayaCalendar -import com.unciv.ui.components.MultiplayerButton import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.YearTextUtil import com.unciv.ui.components.extensions.colorFromRGB @@ -29,21 +27,16 @@ import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toStringSigned import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.images.ImageGetter -import com.unciv.ui.popups.InfoPopup import com.unciv.ui.popups.popups import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen -import com.unciv.ui.screens.multiplayerscreens.ChatRoomType -import com.unciv.ui.screens.multiplayerscreens.SocialMenuScreen import com.unciv.ui.screens.overviewscreen.EmpireOverviewCategories import com.unciv.ui.screens.overviewscreen.EmpireOverviewScreen import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen import com.unciv.ui.screens.pickerscreens.TechPickerScreen import com.unciv.ui.screens.victoryscreen.VictoryScreen import com.unciv.ui.screens.worldscreen.mainmenu.WorldScreenMenuPopup -import com.unciv.utils.Concurrency -import java.util.UUID import kotlin.math.ceil import kotlin.math.max import kotlin.math.roundToInt @@ -74,14 +67,9 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { private val resourcesWrapper = Table() private val resourceTable = getResourceTable() private val selectedCivTable = SelectedCivilizationTable(worldScreen) - private val socialButton = SocialButtonWrapper(this, worldScreen) private val overviewButton = OverviewAndSupplyTable(worldScreen) private val leftFillerCell: Cell private val rightFillerCell: Cell - - internal var me: UUID? = null - internal var gameChatRoom: UUID? = null - //endregion init { @@ -98,22 +86,6 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { val rightFillerBG = BaseScreen.skinStrings.getUiBackground("WorldScreen/TopBar/RightAttachment", BaseScreen.skinStrings.roundedEdgeRectangleShape, backColor) rightFillerCell = add(BackgroundActor(rightFillerBG, Align.topRight)) pack() - - // Caching the account and game data for APIv2 online games - Concurrency.run { - if (worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2 && worldScreen.gameInfo.gameParameters.isOnlineMultiplayer) { - InfoPopup.wrap(worldScreen.stage) { - val account = worldScreen.game.onlineMultiplayer.api.account.get() - if (account != null) { - me = account.uuid - } - val gameOverview = worldScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(worldScreen.gameInfo.gameId)) - if (gameOverview != null) { - gameChatRoom = gameOverview.chatRoomUUID - } - } - } - } } private fun getStatsTable(): Table { @@ -195,29 +167,6 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { return resourceTable } - private class SocialButtonWrapper(topBar: WorldScreenTopBar, worldScreen: WorldScreen) : Table(BaseScreen.skin) { - init { - // The social features will only be enabled if the multiplayer server has support for it - if (worldScreen.gameInfo.gameParameters.isOnlineMultiplayer && worldScreen.game.onlineMultiplayer.apiVersion == ApiVersion.APIv2) { - val socialButton = MultiplayerButton() - socialButton.onClick { - Concurrency.run { - val details = InfoPopup.wrap(worldScreen.stage) { - worldScreen.game.onlineMultiplayer.api.game.head(UUID.fromString(worldScreen.gameInfo.gameId)) - } - if (details != null) { - Concurrency.runOnGLThread { - worldScreen.game.pushScreen(SocialMenuScreen(topBar.me, Triple(details.chatRoomUUID, ChatRoomType.Game, details.name))) - } - } - } - } - add(socialButton).padTop(10f).padBottom(10f) - pack() - } - } - } - private class OverviewAndSupplyTable(worldScreen: WorldScreen) : Table(BaseScreen.skin) { val unitSupplyImage = ImageGetter.getImage("OtherIcons/ExclamationMark") .apply { color = Color.FIREBRICK } @@ -297,7 +246,6 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { val statsWidth = statsTable.minWidth val resourceWidth = resourceTable.minWidth - val socialWidth = socialButton.minWidth val overviewWidth = overviewButton.minWidth val selectedCivWidth = selectedCivTable.minWidth val leftRightNeeded = max(selectedCivWidth, overviewWidth) @@ -326,7 +274,7 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { } val leftFillerWidth = if (fillerHeight > 0f) selectedCivWidth else 0f - val rightFillerWidth = if (fillerHeight > 0f) (overviewWidth + socialWidth) else 0f + val rightFillerWidth = if (fillerHeight > 0f) overviewWidth else 0f if (leftFillerCell.minHeight != fillerHeight || leftFillerCell.minWidth != leftFillerWidth || rightFillerCell.minWidth != rightFillerWidth) { @@ -341,10 +289,8 @@ class WorldScreenTopBar(val worldScreen: WorldScreen) : Table() { setPosition(0f, stage.height, Align.topLeft) selectedCivTable.setPosition(1f, buttonY, Align.left) - socialButton.setPosition(stage.width - overviewButton.width - 5f, buttonY, Align.right) overviewButton.setPosition(stage.width, buttonY, Align.right) addActor(selectedCivTable) // needs to be after pack - addActor(socialButton) addActor(overviewButton) } 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 188a80a1b5fd2..265a994ea62c8 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -20,22 +20,46 @@ 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.onActivation import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.setSize import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.multiplayerscreens.ChatRoomType +import com.unciv.ui.screens.multiplayerscreens.SocialMenuScreen import com.unciv.utils.Concurrency import com.unciv.utils.launchOnGLThread import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import java.time.Duration import java.time.Instant +import java.util.UUID -class MultiplayerStatusButton( +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 MultiplayerStatusButtonV1( screen: BaseScreen, curGame: OnlineMultiplayerGame? -) : Button(BaseScreen.skin), Disposable { +) : MultiplayerStatusButton() { private var curGameName = curGame?.name private val multiplayerImage = createMultiplayerImage() private val loadingImage = createLoadingImage() @@ -122,21 +146,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() @@ -197,3 +206,56 @@ 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) : MultiplayerStatusButton() { + constructor(screen: BaseScreen, gameId: String) : this(screen, UUID.fromString(gameId)) + + 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) { + runBlocking { + details = InfoPopup.wrap(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(SocialMenuScreen(me?.uuid, null)) + } else { + gameDetails = details + screen.game.pushScreen(SocialMenuScreen(me?.uuid, Triple(details!!.chatRoomUUID, ChatRoomType.Game, details!!.name))) + } + } + } + + 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) From 8d4d7dc1309cec800713f81369bfadfea916e10a Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 22 May 2023 03:27:38 +0200 Subject: [PATCH 135/152] Throttle auto-reconnect for WS on Android in background, added reload notice for your turn popup --- android/src/com/unciv/app/AndroidLauncher.kt | 6 ++++ .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 34 ++++++++++++++++--- .../apiv2/EndpointImplementations.kt | 2 +- .../multiplayerscreens/SocialMenuTable.kt | 2 +- .../ui/screens/worldscreen/WorldScreen.kt | 28 ++++++++++++++- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 0952d88c069cf..5ad39c09c8c6a 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -85,10 +85,16 @@ open class AndroidLauncher : AndroidApplication() { 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?.onlineMultiplayer?.isInitialized() == true && game?.onlineMultiplayer?.apiVersion == ApiVersion.APIv2) { + game?.onlineMultiplayer?.api?.enableReconnecting() + } try { WorkManager.getInstance(applicationContext).cancelAllWorkByTag(Common.WORK_TAG) with(NotificationManagerCompat.from(this)) { diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 0b7d029680087..5720a55de046c 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -9,8 +9,8 @@ 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.storage.MultiplayerFileNotFoundException -import com.unciv.utils.Log import com.unciv.utils.Concurrency +import com.unciv.utils.Log import io.ktor.client.call.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* @@ -47,6 +47,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** Info whether this class is fully initialized and ready to use */ private var initialized = false + /** Switch to enable auto-reconnect attempts for the WebSocket connection */ + private var reconnectWebSocket = true + /** Timestamp of the last successful login */ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference() @@ -127,6 +130,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * Dispose this class and its children and jobs */ override fun dispose() { + disableReconnecting() sendChannel?.close() for (channel in eventChannelList) { channel.close() @@ -301,7 +305,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } catch (e: Exception) { Log.debug("Failed to send WebSocket ping: %s", e.localizedMessage) Concurrency.run { - websocket(::handleWebSocket) + if (reconnectWebSocket) { + websocket(::handleWebSocket) + } } } delay(DEFAULT_WEBSOCKET_PING_FREQUENCY) @@ -363,7 +369,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { session.close() session.flush() Concurrency.run { - websocket(::handleWebSocket) + if (reconnectWebSocket) { + websocket(::handleWebSocket) + } } } catch (e: CancellationException) { Log.debug("WebSocket coroutine was cancelled, closing connection: $e") @@ -376,7 +384,9 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { session.close() session.flush() Concurrency.run { - websocket(::handleWebSocket) + if (reconnectWebSocket) { + websocket(::handleWebSocket) + } } throw e } @@ -411,6 +421,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { */ @Suppress("KDocUnresolvedReference") override suspend fun afterLogin() { + enableReconnecting() val me = account.get(cache = false, suppress = true) if (me != null) { Log.error( @@ -429,6 +440,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * Perform the post-logout hook, cancelling all WebSocket jobs and event channels */ override suspend fun afterLogout(success: Boolean) { + disableReconnecting() sendChannel?.close() if (success) { for (channel in eventChannelList) { @@ -462,6 +474,20 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } return success } + + /** + * Enable auto re-connect attempts for the WebSocket connection + */ + fun enableReconnecting() { + reconnectWebSocket = true + } + + /** + * Disable auto re-connect attempts for the WebSocket connection + */ + fun disableReconnecting() { + reconnectWebSocket = false + } } /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index e990a1b3b70c6..6ce10ffb3e29f 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -793,7 +793,7 @@ class GameApi(private val client: HttpClient, private val authHelper: AuthHelper */ suspend fun head(gameUUID: UUID, suppress: Boolean = false): GameOverviewResponse? { val result = list(suppress = suppress) - return result?.filter { it.gameUUID == gameUUID }?.get(0) + return result?.filter { it.gameUUID == gameUUID }?.getOrNull(0) } /** diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt index 39884c7fecfa9..52c181decaf5a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuTable.kt @@ -29,7 +29,7 @@ class SocialMenuTable( init { add(friendList).growX().minWidth(base.stage.width * 0.45f).padRight(5f) - add(chatContainer).minWidth(base.stage.width * 0.45f).maxHeight(maxChatHeight).growX() + add(chatContainer).maxHeight(maxChatHeight).growX() Concurrency.run { while (stage == null) { delay(10) diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 3357698f9149f..2664210a92134 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -229,7 +229,28 @@ class WorldScreen( } else { popup.addGoodSizedLabel("It's your turn in game '${it.gameName}' now!").colspan(2).row() } - popup.addCloseButton() + 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) @@ -246,6 +267,11 @@ class WorldScreen( } } } + + // 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) From 118292f83fa84471d76f777bb311e108e0237fb2 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 29 May 2023 01:19:44 +0200 Subject: [PATCH 136/152] Implemented real pinging with awaiting responses, fixed ping-related problems --- .../src/com/unciv/app/turncheck/WorkerV2.kt | 6 +- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 128 ++++++++++++++++-- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 24 ++-- .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 3 + 4 files changed, 141 insertions(+), 20 deletions(-) diff --git a/android/src/com/unciv/app/turncheck/WorkerV2.kt b/android/src/com/unciv/app/turncheck/WorkerV2.kt index 929674d27ed23..9b356db370e07 100644 --- a/android/src/com/unciv/app/turncheck/WorkerV2.kt +++ b/android/src/com/unciv/app/turncheck/WorkerV2.kt @@ -137,10 +137,14 @@ class WorkerV2(appContext: Context, private val params: WorkerParameters) : Coro Log.d(LOG_TAG, "Starting doWork for WorkerV2: $this") enqueue(applicationContext, params.inputData, params.inputData.getLong(CONFIGURED_DELAY, 600L)) - onlineMultiplayer?.api?.ensureConnectedWebSocket { + 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)") diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 5720a55de046c..7a18c99867d52 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -59,6 +59,10 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { /** List of channel that extend the usage of the [EventBus] system, see [getWebSocketEventChannel] */ private val eventChannelList = mutableListOf>() + /** Map of waiting receivers of pongs (answers to pings) via a channel that gets null + * or any thrown exception; access is synchronized on the [ApiV2] instance */ + private val pongReceivers: MutableMap> = mutableMapOf() + /** * Get a receiver channel for WebSocket [Event]s that is decoupled from the [EventBus] system * @@ -281,15 +285,105 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { */ private suspend fun sendPing(size: Int = 0): Boolean { val body = ByteArray(size) + Random().nextBytes(body) + return sendPing(body) + } + + /** + * Send a [FrameType.PING] frame with the specified content to the server, without awaiting a response + * + * This operation might fail with some exception, e.g. network exceptions. + * It returns true when sending worked as expected, false when there's no + * send channel available and an exception otherwise. + */ + private suspend fun sendPing(content: ByteArray): Boolean { + val channel = sendChannel + return if (channel == null) { + false + } else { + channel.send(Frame.Ping(content)) + true + } + } + + /** + * Send a [FrameType.PONG] frame with the specified content to the server + * + * This operation might fail with some exception, e.g. network exceptions. + * It returns true when sending worked as expected, false when there's no + * send channel available and an exception otherwise. + */ + private suspend fun sendPong(content: ByteArray): Boolean { val channel = sendChannel return if (channel == null) { false } else { - channel.send(Frame.Ping(body)) + channel.send(Frame.Pong(content)) true } } + /** + * Send a [FrameType.PING] and await the response of a [FrameType.PONG] + * + * The function returns the delay between Ping and Pong in milliseconds. + * Note that the function may never return if the Ping or Pong packets are lost on + * the way, unless [timeout] is set. It will then return `null` if the [timeout] + * 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. + */ + suspend fun awaitPing(size: Int = 2, timeout: Long? = null): Double? { + if (size < 2) { + throw IllegalArgumentException("Size too small to identify ping responses uniquely") + } + val body = ByteArray(size) + Random().nextBytes(body) + + val key = body.toHex() + val channel = Channel(capacity = Channel.RENDEZVOUS) + synchronized(this) { + pongReceivers[key] = channel + } + + var job: Job? = null + if (timeout != null) { + job = Concurrency.run { + delay(timeout) + channel.close() + } + } + + try { + return kotlin.system.measureNanoTime { + if (!sendPing(body)) { + return null + } + val exception = runBlocking { channel.receive() } + job?.cancel() + channel.close() + if (exception != null) { + throw exception + } + }.toDouble() / 10e6 + } catch (c: ClosedReceiveChannelException) { + return null + } finally { + synchronized(this) { + pongReceivers.remove(key) + } + } + } + + /** + * Handler for incoming [FrameType.PONG] frames to make [awaitPing] work properly + */ + private suspend fun onPong(content: ByteArray) { + val receiver = synchronized(this) { pongReceivers[content.toHex()] } + receiver?.send(null) + } + /** * Handle a newly established WebSocket connection */ @@ -319,12 +413,17 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { while (true) { val incomingFrame = session.incoming.receive() when (incomingFrame.frameType) { - FrameType.CLOSE, FrameType.PING, FrameType.PONG -> { - // This WebSocket handler won't handle control frames - Log.debug("Received CLOSE, PING or PONG as message") + FrameType.PING -> { + sendPong(incomingFrame.data) + } + FrameType.PONG -> { + onPong(incomingFrame.data) + } + FrameType.CLOSE -> { + throw ClosedReceiveChannelException("Received CLOSE frame via WebSocket") } FrameType.BINARY -> { - Log.debug("Received binary packet which can't be parsed at the moment") + Log.debug("Received binary packet of size %s which can't be parsed at the moment", incomingFrame.data.size) } FrameType.TEXT -> { try { @@ -397,17 +496,19 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { * * Use [jobCallback] to receive the newly created job handling the WS connection. * Note that this callback might not get called if no new WS connection was created. + * It returns the measured round trip time in milliseconds if everything was fine. */ - suspend fun ensureConnectedWebSocket(jobCallback: ((Job) -> Unit)? = null) { - val shouldRecreateConnection = try { - !sendPing() + suspend fun ensureConnectedWebSocket(timeout: Long = DEFAULT_WEBSOCKET_PING_TIMEOUT, jobCallback: ((Job) -> Unit)? = null): Double? { + val pingMeasurement = try { + awaitPing(timeout = timeout) } catch (e: Exception) { Log.debug("Error %s while ensuring connected WebSocket: %s", e, e.localizedMessage) - true + null } - if (shouldRecreateConnection) { + if (pingMeasurement == null) { websocket(::handleWebSocket, jobCallback) } + return pingMeasurement } // ---------------- SESSION FUNCTIONALITY ---------------- @@ -504,3 +605,10 @@ data class GameDetails(val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: L private data class TimedGameDetails(val refreshed: Instant, val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: Long, val name: String) { fun to() = GameDetails(gameUUID, chatRoomUUID, dataID, name) } + +/** + * Convert a byte array to a hex string + */ +private fun ByteArray.toHex(): String { + return this.joinToString("") { it.toUByte().toString(16).padStart(2, '0') } +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index bb977403f04ec..c83b9c13e008e 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -2,14 +2,15 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.UncivGame import com.unciv.logic.UncivShowableException -import com.unciv.utils.Log import com.unciv.utils.Concurrency +import com.unciv.utils.Log import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.websocket.* +import io.ktor.client.plugins.websocket.cio.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.* @@ -72,15 +73,19 @@ open class ApiV2Wrapper(baseUrl: String) { init { client.plugin(HttpSend).intercept { request -> request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") - val clientCall = execute(request) + val clientCall = try { + execute(request) + } catch (t: Throwable) { + Log.error("Failed to query API: %s %s\nURL: %s\nError %s:\n%s", request.method.value, request.url.encodedPath, request.url, t.localizedMessage, t.stackTraceToString()) + throw t + } Log.debug( - "'%s %s%s%s': %s (%d ms)", + "'%s %s': %s (%d ms%s)", request.method.value, - baseServer, - if (baseServer.endsWith("/") or request.url.encodedPath.startsWith("/")) "" else "/", - request.url.encodedPath, + request.url.toString(), clientCall.response.status, - clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp + clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp, + if (!request.url.protocol.isSecure()) ", insecure!" else "" ) clientCall } @@ -154,10 +159,11 @@ open class ApiV2Wrapper(baseUrl: String) { coroutineScope { try { - val session = client.webSocketSession { - method = HttpMethod.Get + val session = client.webSocketRawSession { authHelper.add(this) url { + protocol = if (Url(baseServer).protocol.isSecure()) URLProtocol.WSS else URLProtocol.WS + port = Url(baseServer).specifiedPort.takeUnless { it == DEFAULT_PORT } ?: protocol.defaultPort appendPathSegments("api/v2/ws") } } diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt index 1e6ce6e7808e4..ddcab84794c7e 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -22,3 +22,6 @@ internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L /** Default timeout for connecting to a remote server (miliseconds) */ internal const val DEFAULT_CONNECT_TIMEOUT = 5_000L + +/** Default timeout for a single WebSocket PING-PONG roundtrip */ +internal const val DEFAULT_WEBSOCKET_PING_TIMEOUT = 10_000L From a41f7c6b7d50d46312a13589d9acd474f1c407f9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 31 May 2023 23:11:37 +0200 Subject: [PATCH 137/152] Reworked the SocialMenuScreen into a MultiplayerGameScreen --- .../MultiplayerGameScreen.kt | 167 ++++++++++++++++++ .../multiplayerscreens/SocialMenuScreen.kt | 44 ----- .../status/MultiplayerStatusButton.kt | 18 +- 3 files changed, 175 insertions(+), 54 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt delete mode 100644 core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt 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..3a3eb0a79f546 --- /dev/null +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt @@ -0,0 +1,167 @@ +package com.unciv.ui.screens.multiplayerscreens + +import com.badlogic.gdx.scenes.scene2d.Stage +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.logic.civilization.Civilization +import com.unciv.logic.civilization.PlayerType +import com.unciv.logic.multiplayer.apiv2.AccountResponse +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.onClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton +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.utils.Concurrency +import com.unciv.utils.Log +import java.util.UUID + +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() + } + + 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 { + val popup = Popup(this) + popup.add(friendList).growX().minWidth(this.stage.width * 0.5f) + popup.addCloseButton() + popup.open() + } + 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) + ) + ) + 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().colspan(4).padLeft(60f).padRight(60f).padTop(10f).padBottom(10f).row() + } + firstDone = true + + val identifiactionTable = Table(skin) + identifiactionTable.add(civ.civName).padBottom(5f).padLeft(15f).padRight(15f).colspan(2).row() + 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 { + identifiactionTable.add().padRight(15f) + } + + playerTable.add(ImageGetter.getNationPortrait(civ.nation, 50f)).padLeft(30f).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(30f).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(30f).row() + } else { + playerTable.add().padRight(30f).row() + } + } else { + playerTable.add() + playerTable.add().row() + } + } + } + } +} diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt deleted file mode 100644 index 6ed7105945a1b..0000000000000 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/SocialMenuScreen.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.unciv.ui.screens.multiplayerscreens - -import com.badlogic.gdx.scenes.scene2d.ui.Container -import com.badlogic.gdx.utils.Disposable -import com.unciv.models.translations.tr -import com.unciv.ui.components.extensions.enable -import com.unciv.ui.components.extensions.onClick -import com.unciv.ui.components.extensions.toTextButton -import com.unciv.ui.popups.InfoPopup -import com.unciv.ui.popups.Popup -import com.unciv.ui.screens.pickerscreens.PickerScreen -import kotlinx.coroutines.runBlocking -import java.util.* - -class SocialMenuScreen(me: UUID? = null, initialChatRoom: Triple? = null) : PickerScreen(horizontally = true), Disposable { - private val socialTable = SocialMenuTable( - this, - me ?: runBlocking { game.onlineMultiplayer.api.account.get()!!.uuid }, - initialChatRoom, - listOf(ChatRoomType.Game) - ) - 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() - } - - init { - setDefaultCloseAction() - topTable.add(socialTable) - rightSideButton.setText("Members".tr()) - rightSideButton.enable() - rightSideButton.onClick { - InfoPopup(stage, "It would be nice to show the members of this chat room").open() - } - rightSideGroup.addActor(Container(helpButton).padRight(5f).padLeft(5f)) - } - - override fun dispose() { - socialTable.dispose() - super.dispose() - } -} 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 265a994ea62c8..7ab31feac6d97 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -13,6 +13,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 @@ -30,12 +31,11 @@ import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.InfoPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.multiplayerscreens.ChatRoomType -import com.unciv.ui.screens.multiplayerscreens.SocialMenuScreen +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 kotlinx.coroutines.runBlocking import java.time.Duration import java.time.Instant import java.util.UUID @@ -212,8 +212,8 @@ private class TurnIndicator : HorizontalGroup(), Disposable { * * It shows a completely different user interfaces than the previous V1 buttons above. */ -class MultiplayerStatusButtonV2(screen: BaseScreen, private val gameUUID: UUID) : MultiplayerStatusButton() { - constructor(screen: BaseScreen, gameId: String) : this(screen, UUID.fromString(gameId)) +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 @@ -238,18 +238,16 @@ class MultiplayerStatusButtonV2(screen: BaseScreen, private val gameUUID: UUID) var details = gameDetails // Retrying in case the cache was broken somehow if (details == null) { - runBlocking { - details = InfoPopup.wrap(screen.stage) { - screen.game.onlineMultiplayer.api.game.head(gameUUID) - } + 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(SocialMenuScreen(me?.uuid, null)) + screen.game.pushScreen(MultiplayerGameScreen(me!!.uuid, null, civilizations)) } else { gameDetails = details - screen.game.pushScreen(SocialMenuScreen(me?.uuid, Triple(details!!.chatRoomUUID, ChatRoomType.Game, details!!.name))) + screen.game.pushScreen(MultiplayerGameScreen(me!!.uuid, Triple(details.chatRoomUUID, ChatRoomType.Game, details.name), civilizations)) } } } From 489b0fcdfbe8a3e47d645d27b37caaa5c1153684 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 1 Jun 2023 22:50:59 +0200 Subject: [PATCH 138/152] Fixed bugs in MultiplayerGameScreen, improved chat message list loading --- .../multiplayerscreens/ChatMessageList.kt | 26 +++++++-------- .../MultiplayerGameScreen.kt | 33 ++++++++++++++++--- .../ui/screens/worldscreen/WorldScreen.kt | 2 +- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 34ff4fc9b2072..4dcc0a15a1d2d 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -106,26 +106,24 @@ class ChatMessageList(private val showHeading: Boolean, private val type: Pair Date: Thu, 1 Jun 2023 23:28:54 +0200 Subject: [PATCH 139/152] Adapted new getAllChats API, added outstanding friend request list, improved styling --- .../apiv2/EndpointImplementations.kt | 11 ++---- .../multiplayer/apiv2/ResponseStructs.kt | 37 +++++++++---------- .../multiplayerscreens/ChatMessageList.kt | 2 +- .../multiplayerscreens/FriendListV2.kt | 16 ++++++++ .../MultiplayerGameScreen.kt | 14 ++++--- 5 files changed, 46 insertions(+), 34 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index 6ce10ffb3e29f..a7ba5ecb81a57 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -16,7 +16,7 @@ import io.ktor.http.* import io.ktor.util.network.* import java.io.IOException import java.time.Instant -import java.util.* +import java.util.UUID /** * List of HTTP status codes which are considered to [ApiErrorResponse]s by the specification @@ -550,17 +550,12 @@ class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems */ suspend fun list(suppress: Boolean = false): GetAllChatsResponse? { - val response = request( + return request( HttpMethod.Get, "api/v2/chats", client, authHelper, suppress = suppress, retry = getDefaultRetry(client, authHelper) - ) - if (response != null) { - val body: GetAllChatsResponseImpl = response.body() - return body.to() - } - return null + )?.body() } /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index a15290ab98573..696adb16095ec 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -116,6 +116,18 @@ data class ChatMessage( val createdAt: Instant ) +/** + * The small representation of a chatroom + */ +@Serializable +data class ChatSmall( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + @SerialName("last_message_uuid") + @Serializable(with = UUIDSerializer::class) + val lastMessageUUID: UUID? = null +) + /** * The response of a create lobby request, which contains the [lobbyUUID] and [lobbyChatRoomUUID] */ @@ -218,31 +230,16 @@ data class GameUploadResponse( ) /** - * Internal wrapper around [GetAllChatsResponse] that prevents serialization issues of lists of [UUID]s + * All chat rooms your user has access to */ @Serializable -internal class GetAllChatsResponseImpl( +data class GetAllChatsResponse( @SerialName("friend_chat_rooms") - val friendChatRooms: List, + val friendChatRooms: List, @SerialName("game_chat_rooms") - val gameChatRooms: List, + val gameChatRooms: List, @SerialName("lobby_chat_rooms") - val lobbyChatRooms: List -) { - internal fun to() = GetAllChatsResponse( - friendChatRooms.map { UUID.fromString(it) }, - gameChatRooms.map { UUID.fromString(it) }, - lobbyChatRooms.map { UUID.fromString(it) } - ) -} - -/** - * All chat rooms your user has access to - */ -data class GetAllChatsResponse( - val friendChatRooms: List, - val gameChatRooms: List, - val lobbyChatRooms: List + val lobbyChatRooms: List ) /** diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt index 4dcc0a15a1d2d..a4f1c97355889 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/ChatMessageList.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import java.time.Duration import java.time.Instant -import java.util.* +import java.util.UUID /** Interval to redraw the chat message list in milliseconds */ private const val REDRAW_INTERVAL = 5000L diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt index 25875428e3fa8..be0bc3e93eb53 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/FriendListV2.kt @@ -1,6 +1,7 @@ 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 @@ -17,9 +18,12 @@ import com.unciv.ui.components.KeyCharAndCode 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.keyShortcuts import com.unciv.ui.components.extensions.onActivation +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.ConfirmPopup import com.unciv.ui.popups.InfoPopup @@ -146,6 +150,7 @@ internal class FriendListV2( if (searchString == "") { return@onActivation } + Log.debug("Searching for player '%s'", searchString) Concurrency.run { val response = InfoPopup.wrap(base.stage) { @@ -211,6 +216,17 @@ internal class FriendListV2( } }).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 } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt index 04f8f7878fc6b..f756df27b5de6 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt @@ -1,5 +1,6 @@ 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.Table @@ -71,6 +72,9 @@ class MultiplayerGameScreen(private val me: UUID, initialChatRoom: Triple Date: Thu, 8 Jun 2023 17:53:46 +0200 Subject: [PATCH 140/152] Fixed defeated civs not shown in Multiplayer popup --- .../com/unciv/ui/popups/RegisterLoginPopup.kt | 4 ++- .../MultiplayerGameScreen.kt | 29 ++++++++++++++++++- .../ui/screens/worldscreen/WorldScreen.kt | 6 ++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt index 24c900aac6157..02969e458ae04 100644 --- a/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt +++ b/core/src/com/unciv/ui/popups/RegisterLoginPopup.kt @@ -26,7 +26,9 @@ 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. + * 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) { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt index f756df27b5de6..3444401353f55 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerGameScreen.kt @@ -3,7 +3,9 @@ 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 @@ -16,6 +18,7 @@ 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.onClick +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.images.ImageGetter @@ -24,10 +27,24 @@ 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( @@ -135,8 +152,18 @@ class MultiplayerGameScreen(private val me: UUID, initialChatRoom: Triple Date: Fri, 9 Jun 2023 13:43:29 +0200 Subject: [PATCH 141/152] Improved the game list and added a new popup for modifications --- .../screens/multiplayerscreens/GameListV2.kt | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt index 29c3ec0a73552..dc5fa30ecdefb 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/GameListV2.kt @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ 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.onActivation import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton @@ -52,14 +54,8 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv private fun addGame(game: GameOverviewResponse) { add(game.name.toTextButton().onClick { onSelected(game) }).padRight(10f).padBottom(5f) - val time = "[${Duration.between(game.lastActivity, Instant.now()).formatShort()}] ago".tr() - add(time).padRight(10f).padBottom(5f) - add(game.lastPlayer.username).padRight(10f).padBottom(5f) - add(game.gameDataID.toString()).padRight(10f).padBottom(5f) + add(convertTime(game.lastActivity)).padRight(10f).padBottom(5f) - add(PencilButton().apply { onClick { - ToastPopup("Renaming game ${game.gameUUID} not implemented yet", screen.stage) - } }).padRight(5f).padBottom(5f) add(ChatButton().apply { onClick { Log.debug("Opening chat room ${game.chatRoomUUID} from game list") val popup = Popup(screen.stage) @@ -74,6 +70,15 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv 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() + } } /** @@ -112,11 +117,26 @@ class GameListV2(private val screen: BaseScreen, private val onSelected: (GameOv } } + 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() } } - } From f0444f53591a5086be551addfb66a1b797ee4409 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 9 Jun 2023 23:07:17 +0200 Subject: [PATCH 142/152] Fixed build by moving ktorVersion to buildSrc dir --- build.gradle.kts | 3 +-- buildSrc/src/main/kotlin/BuildConfig.kt | 1 + gradle.properties | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index eee7c4517642d..ad894306e6e20 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,7 @@ import com.unciv.build.BuildConfig.gdxVersion +import com.unciv.build.BuildConfig.ktorVersion import com.unciv.build.BuildConfig.roboVMVersion -val ktorVersion: String by project - // You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used configurations.all { resolutionStrategy { diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 49fa851433e74..7fbcdcdb96619 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -7,5 +7,6 @@ object BuildConfig { const val appVersion = "4.6.12-patch1" const val gdxVersion = "1.11.0" + const val ktorVersion = "2.2.3" const val roboVMVersion = "2.3.1" } diff --git a/gradle.properties b/gradle.properties index 25fed11a6baab..78e3d7b7b06e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,3 @@ android.enableJetifier=true org.gradle.parallel=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -ktorVersion=2.2.3 From 1b95ae1036280f87f3bd1dab111f71f47eae0014 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Jun 2023 00:54:38 +0200 Subject: [PATCH 143/152] Fixed broken CreateLobbyPopup, added extra "invalid password" answer --- .../com/unciv/ui/popups/CreateLobbyPopup.kt | 4 +--- .../multiplayerscreens/LobbyBrowserScreen.kt | 2 +- .../multiplayerscreens/LobbyBrowserTable.kt | 23 +++++++++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt index 29cc84463f50f..fc34eacb6aa4b 100644 --- a/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt +++ b/core/src/com/unciv/ui/popups/CreateLobbyPopup.kt @@ -13,7 +13,7 @@ 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) { +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 } @@ -27,7 +27,6 @@ class CreateLobbyPopup(private val base: BaseScreen, me: AccountResponse) : Popu Log.error("Popup to create a new lobby without a valid APIv2 server! This is not supported!") } recreate() - open() } private fun recreate() { @@ -49,7 +48,6 @@ class CreateLobbyPopup(private val base: BaseScreen, me: AccountResponse) : Popu addCloseButton() addOKButton(action = ::onClose).row() equalizeLastTwoButtonWidths() - open() } private fun onClose() { diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt index b855f7322a34e..c61ce0812d677 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserScreen.kt @@ -59,7 +59,7 @@ class LobbyBrowserScreen : BaseScreen() { val lobbyButtons = Table() newLobbyButton.onClick { - CreateLobbyPopup(this as BaseScreen, me) + CreateLobbyPopup(this as BaseScreen, me).open() } updateButton.onClick { lobbyBrowserTable.triggerUpdate() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt index 7250e91b57af9..f80383fb1c7ab 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyBrowserTable.kt @@ -2,6 +2,8 @@ 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 @@ -11,6 +13,7 @@ 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 @@ -44,10 +47,22 @@ internal class LobbyBrowserTable(private val screen: BaseScreen, private val lob maxLength = 120 ) { InfoPopup.load(stage) { - screen.game.onlineMultiplayer.api.lobby.join(lobby.uuid, it) - Concurrency.runOnGLThread { - lobbyJoinCallback() - screen.game.pushScreen(LobbyScreen(lobby)) + 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) + } } } } From 50ada3b27b232de3edec37a2fa9cf2121224cbbb Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Jun 2023 12:32:36 +0200 Subject: [PATCH 144/152] Replaced all wildcard imports with named imports --- .../com/unciv/logic/multiplayer/ApiVersion.kt | 19 ++++++----- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 16 +++++---- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 34 +++++++++++++------ .../logic/multiplayer/apiv2/AuthHelper.kt | 7 ++-- .../apiv2/EndpointImplementations.kt | 24 +++++++++---- .../multiplayer/apiv2/JsonSerializers.kt | 2 +- .../multiplayer/apiv2/ResponseStructs.kt | 2 +- .../multiplayer/apiv2/WebSocketStructs.kt | 2 +- .../storage/ApiV2FileStorageEmulator.kt | 2 +- 9 files changed, 69 insertions(+), 39 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt index 8dd807bd52373..76bac2e388b0a 100644 --- a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -9,15 +9,16 @@ import com.unciv.logic.multiplayer.apiv2.DEFAULT_CONNECT_TIMEOUT import com.unciv.logic.multiplayer.apiv2.UncivNetworkException import com.unciv.logic.multiplayer.apiv2.VersionResponse import com.unciv.utils.Log -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +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.isSuccess +import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 7a18c99867d52..b9ebcff4d1419 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -11,11 +11,14 @@ import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.utils.Concurrency import com.unciv.utils.Log -import io.ktor.client.call.* -import io.ktor.client.plugins.websocket.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.websocket.* +import io.ktor.client.call.body +import io.ktor.client.plugins.websocket.ClientWebSocketSession +import io.ktor.client.request.get +import io.ktor.http.isSuccess +import io.ktor.websocket.Frame +import io.ktor.websocket.FrameType +import io.ktor.websocket.close +import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -29,7 +32,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import java.time.Instant -import java.util.* +import java.util.Random +import java.util.UUID import java.util.concurrent.atomic.AtomicReference /** diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index c83b9c13e008e..398cb8c66696e 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -4,17 +4,29 @@ import com.unciv.UncivGame import com.unciv.logic.UncivShowableException import com.unciv.utils.Concurrency import com.unciv.utils.Log -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.websocket.* -import io.ktor.client.plugins.websocket.cio.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.* -import io.ktor.serialization.kotlinx.json.* +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.plugin +import io.ktor.client.plugins.websocket.ClientWebSocketSession +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.cio.webSocketRawSession +import io.ktor.client.request.get +import io.ktor.http.DEFAULT_PORT +import io.ktor.http.ParametersBuilder +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.Url +import io.ktor.http.appendPathSegments +import io.ktor.http.encodedPath +import io.ktor.http.isSecure +import io.ktor.http.userAgent +import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.serialization.SerializationException diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt index 0848bb193fb6c..1dd29e7132d31 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt @@ -1,8 +1,11 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.utils.Log -import io.ktor.client.request.* -import io.ktor.http.* +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.http.CookieEncoding +import io.ktor.http.HttpHeaders +import io.ktor.http.encodeCookieValue import java.time.Instant import java.util.concurrent.atomic.AtomicReference diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt index a7ba5ecb81a57..be2cdd5866aa6 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -7,13 +7,23 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.utils.Log -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.plugins.cookies.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.util.network.* +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.cookies.get +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.http.path +import io.ktor.http.setCookie +import io.ktor.util.network.UnresolvedAddressException import java.io.IOException import java.time.Instant import java.util.UUID diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt index 9da20a6e8f1ee..013ec6d3b0a12 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.time.Instant -import java.util.* +import java.util.UUID /** * Serializer for the ApiStatusCode enum to make encoding/decoding as integer work diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 696adb16095ec..751b34abb6583 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -7,7 +7,7 @@ package com.unciv.logic.multiplayer.apiv2 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.Instant -import java.util.* +import java.util.UUID /** * The account data diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt index 5064332ba5de4..0dd38726ead20 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -3,7 +3,7 @@ package com.unciv.logic.multiplayer.apiv2 import com.unciv.logic.event.Event import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.* +import java.util.UUID /** * Enum of all events that can happen in a friendship diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 513d881187d42..2cbdbe5ddde51 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -3,7 +3,7 @@ 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 java.util.* +import java.util.UUID /** * Transition helper that emulates file storage behavior using the API v2 From 1db199a69a07b752ec303018a80646f1d7caa8f8 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Jun 2023 01:31:02 +0200 Subject: [PATCH 145/152] Moved the default message to the ApiStatusCode enum (MPv2) --- .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 37 +----------- .../multiplayer/apiv2/ResponseStructs.kt | 57 ++++++++++--------- 2 files changed, 30 insertions(+), 64 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt index 398cb8c66696e..109eb7b8d10cd 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -217,39 +217,4 @@ open class ApiV2Wrapper(baseUrl: String) { /** * APIv2 exception class that is compatible with [UncivShowableException] */ -class ApiException(val error: ApiErrorResponse) : UncivShowableException(lookupErrorMessage(error.statusCode)) - -/** - * Convert an API status code to a string that can be translated and shown to users - */ -private fun lookupErrorMessage(statusCode: ApiStatusCode): String { - return when (statusCode) { - ApiStatusCode.Unauthenticated -> "You are not logged in. Please login first." - ApiStatusCode.NotFound -> "The operation couldn't be completed, since the resource was not found." - ApiStatusCode.InvalidContentType -> "The media content type was invalid. Please report this as a bug." - ApiStatusCode.InvalidJson -> "The server didn't understand the sent data. Please report this as a bug." - ApiStatusCode.PayloadOverflow -> "The amount of data sent to the server was too large. Please report this as a bug." - ApiStatusCode.LoginFailed -> "The login failed. Is the username and password correct?" - ApiStatusCode.UsernameAlreadyOccupied -> "The selected username is already taken. Please choose another name." - ApiStatusCode.InvalidPassword -> "This password is not valid. Please choose another password." - ApiStatusCode.EmptyJson -> "The server encountered an empty JSON problem. Please report this as a bug." - ApiStatusCode.InvalidUsername -> "The username is not valid. Please choose another one." - ApiStatusCode.InvalidDisplayName -> "The display name is not valid. Please choose another one." - ApiStatusCode.FriendshipAlreadyRequested -> "You have already requested friendship with this player. Please wait until the request is accepted." - ApiStatusCode.AlreadyFriends -> "You are already friends, you can't request it again." - ApiStatusCode.MissingPrivileges -> "You don't have the required privileges to perform this operation." - ApiStatusCode.InvalidMaxPlayersCount -> "The maximum number of players for this lobby is out of the supported range for this server. Please adjust the number. Two players should always work." - ApiStatusCode.AlreadyInALobby -> "You are already in another lobby. You need to close or leave the other lobby before." - ApiStatusCode.InvalidUuid -> "The operation could not be completed, since an invalid UUID was given. Please retry later or restart the game. If the problem persists, please report this as a bug." - ApiStatusCode.InvalidLobbyUuid -> "The lobby was not found. Maybe it has already been closed?" - ApiStatusCode.InvalidFriendUuid -> "You must be friends with the other player before this action can be completed. Try again later." - ApiStatusCode.GameNotFound -> "The game was not found on the server. Try again later. If the problem persists, the game was probably already removed from the server, sorry." - ApiStatusCode.InvalidMessage -> "This message could not be sent, since it was invalid. Remove any invalid characters and try again." - ApiStatusCode.WsNotConnected -> "The WebSocket is not available. Please restart the game and try again. If the problem persists, please report this as a bug." - ApiStatusCode.LobbyFull -> "The lobby is currently full. You can't join right now." - ApiStatusCode.InvalidPlayerUUID -> "The ID of the player was invalid. Does the player exist? Please try again. If the problem persists, please report this as a bug." - ApiStatusCode.InternalServerError -> "Internal server error. Please report this as a bug." - ApiStatusCode.DatabaseError -> "Internal server database error. Please report this as a bug." - ApiStatusCode.SessionError -> "Internal session error. Please report this as a bug." - } -} +class ApiException(val error: ApiErrorResponse) : UncivShowableException(error.statusCode.message) diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt index 751b34abb6583..1da30a7892ea0 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -46,38 +46,39 @@ data class ApiErrorResponse( * The status code represents a unique identifier for an error. * Error codes in the range of 1000..2000 represent client errors that could be handled * by the client. Error codes in the range of 2000..3000 represent server errors. + * The [message] is a user-showable default string for every possible status code. */ @Serializable(with = ApiStatusCodeSerializer::class) -enum class ApiStatusCode(val value: Int) { - Unauthenticated(1000), - NotFound(1001), - InvalidContentType(1002), - InvalidJson(1003), - PayloadOverflow(1004), +enum class ApiStatusCode(val value: Int, val message: String) { + Unauthenticated(1000, "You are not logged in. Please login first."), + NotFound(1001, "The operation couldn't be completed, since the resource was not found."), + InvalidContentType(1002, "The media content type was invalid. Please report this as a bug."), + InvalidJson(1003, "The server didn't understand the sent data. Please report this as a bug."), + PayloadOverflow(1004, "The amount of data sent to the server was too large. Please report this as a bug."), - LoginFailed(1005), - UsernameAlreadyOccupied(1006), - InvalidPassword(1007), - EmptyJson(1008), - InvalidUsername(1009), - InvalidDisplayName(1010), - FriendshipAlreadyRequested(1011), - AlreadyFriends(1012), - MissingPrivileges(1013), - InvalidMaxPlayersCount(1014), - AlreadyInALobby(1015), - InvalidUuid(1016), - InvalidLobbyUuid(1017), - InvalidFriendUuid(1018), - GameNotFound(1019), - InvalidMessage(1020), - WsNotConnected(1021), - LobbyFull(1022), - InvalidPlayerUUID(1023), + LoginFailed(1005, "The login failed. Is the username and password correct?"), + UsernameAlreadyOccupied(1006, "The selected username is already taken. Please choose another name."), + InvalidPassword(1007, "This password is not valid. Please choose another password."), + EmptyJson(1008, "The server encountered an empty JSON problem. Please report this as a bug."), + InvalidUsername(1009, "The username is not valid. Please choose another one."), + InvalidDisplayName(1010, "The display name is not valid. Please choose another one."), + FriendshipAlreadyRequested(1011, "You have already requested friendship with this player. Please wait until the request is accepted."), + AlreadyFriends(1012, "You are already friends, you can't request it again."), + MissingPrivileges(1013, "You don't have the required privileges to perform this operation."), + InvalidMaxPlayersCount(1014, "The maximum number of players for this lobby is out of the supported range for this server. Please adjust the number. Two players should always work."), + AlreadyInALobby(1015, "You are already in another lobby. You need to close or leave the other lobby before."), + InvalidUuid(1016, "The operation could not be completed, since an invalid UUID was given. Please retry later or restart the game. If the problem persists, please report this as a bug."), + InvalidLobbyUuid(1017, "The lobby was not found. Maybe it has already been closed?"), + InvalidFriendUuid(1018, "You must be friends with the other player before this action can be completed. Try again later."), + GameNotFound(1019, "The game was not found on the server. Try again later. If the problem persists, the game was probably already removed from the server, sorry."), + InvalidMessage(1020, "This message could not be sent, since it was invalid. Remove any invalid characters and try again."), + WsNotConnected(1021, "The WebSocket is not available. Please restart the game and try again. If the problem persists, please report this as a bug."), + LobbyFull(1022, "The lobby is currently full. You can't join right now."), + InvalidPlayerUUID(1023, "The ID of the player was invalid. Does the player exist? Please try again. If the problem persists, please report this as a bug."), - InternalServerError(2000), - DatabaseError(2001), - SessionError(2002); + InternalServerError(2000, "Internal server error. Please report this as a bug."), + DatabaseError(2001, "Internal server database error. Please report this as a bug."), + SessionError(2002, "Internal session error. Please report this as a bug."); companion object { private val VALUES = values() From 7f7134f6638c9945372795c5265c65109016b96e Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 22 Jun 2023 19:48:56 +0200 Subject: [PATCH 146/152] Fixed imports --- core/src/com/unciv/logic/multiplayer/FriendList.kt | 2 +- core/src/com/unciv/ui/popups/options/MultiplayerTab.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/FriendList.kt b/core/src/com/unciv/logic/multiplayer/FriendList.kt index 303b1c5330215..c5a26786e69ac 100644 --- a/core/src/com/unciv/logic/multiplayer/FriendList.kt +++ b/core/src/com/unciv/logic/multiplayer/FriendList.kt @@ -1,7 +1,7 @@ package com.unciv.logic.multiplayer import com.unciv.UncivGame -import java.util.* +import java.util.UUID class FriendList { private val settings = UncivGame.Current.settings diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 3296658c7c7e6..248590bac3aea 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -20,22 +20,22 @@ 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.Log 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.* +import java.util.UUID fun multiplayerTab( optionsPopup: OptionsPopup From 19445ef11be81f40c7d1b5ee0ae7e8012bb766a1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 24 Jun 2023 14:04:01 +0200 Subject: [PATCH 147/152] Replaced most runBlocking calls with Concurrency.runBlocking calls This adds more user-friendly crash handling to the function. --- .../logic/multiplayer/OnlineMultiplayer.kt | 3 +- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 13 +++++---- .../storage/ApiV2FileStorageEmulator.kt | 14 +++++----- core/src/com/unciv/ui/popups/AuthPopup.kt | 6 ++-- core/src/com/unciv/ui/popups/InfoPopup.kt | 3 +- .../unciv/ui/popups/LobbyInvitationPopup.kt | 3 +- .../multiplayerscreens/LobbyPlayerList.kt | 28 +++++++++---------- .../screens/multiplayerscreens/LobbyScreen.kt | 1 - .../MultiplayerOverviewTable.kt | 14 ++++++---- 9 files changed, 43 insertions(+), 42 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 5df0308bc38fd..763a3decd5b29 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.runBlocking import java.time.Duration import java.time.Instant import java.util.Collections @@ -63,7 +62,7 @@ class OnlineMultiplayer: Disposable { private val apiImpl = ApiV2(baseUrl) val api: ApiV2 get() { - if (runBlocking { apiImpl.isCompatible() }) { + if (Concurrency.runBlocking { apiImpl.isCompatible() } == true) { return apiImpl } throw UncivShowableException("Unsupported server API") diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 1c6784bd17d93..03eedea637fd8 100644 --- a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -29,7 +29,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 @@ -147,7 +146,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { job.cancel() } for (job in websocketJobs) { - runBlocking { + Concurrency.runBlocking { job.join() } } @@ -336,7 +335,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? { if (size < 2) { @@ -353,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() } @@ -364,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/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt index 977318a27416a..8403b8718326f 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -2,8 +2,8 @@ package com.unciv.logic.multiplayer.storage import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.utils.Concurrency import com.unciv.utils.Log -import kotlinx.coroutines.runBlocking import java.util.UUID private const val PREVIEW_SUFFIX = "_Preview" @@ -36,13 +36,13 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { } fun loadFileData(fileName: String): String { - return runBlocking { + return Concurrency.runBlocking { if (fileName.endsWith(PREVIEW_SUFFIX)) { loadPreviewData(fileName.dropLast(8)) } else { loadGameData(fileName) } - } + }!! } override suspend fun getFileMetaData(fileName: String): FileMetaData { @@ -50,13 +50,13 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { } fun deleteFile(fileName: String) { - return runBlocking { + return Concurrency.runBlocking { if (fileName.endsWith(PREVIEW_SUFFIX)) { deletePreviewData(fileName.dropLast(8)) } else { deleteGameData(fileName) } - } + }!! } override suspend fun deleteGameData(gameId: String) { @@ -70,11 +70,11 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { } override fun authenticate(userId: String, password: String): Boolean { - return runBlocking { api.auth.loginOnly(userId, password) } + return Concurrency.runBlocking { api.auth.loginOnly(userId, password) }!! } override fun setPassword(newPassword: String): Boolean { - return runBlocking { api.account.setPassword(newPassword, suppress = true) } + return Concurrency.runBlocking { api.account.setPassword(newPassword, suppress = true) }!! } } diff --git a/core/src/com/unciv/ui/popups/AuthPopup.kt b/core/src/com/unciv/ui/popups/AuthPopup.kt index cd5dc2a2bb14e..1349ffa893fad 100644 --- a/core/src/com/unciv/ui/popups/AuthPopup.kt +++ b/core/src/com/unciv/ui/popups/AuthPopup.kt @@ -4,10 +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 kotlinx.coroutines.runBlocking +import com.unciv.utils.Concurrency class AuthPopup(stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) : Popup(stage) { @@ -21,7 +21,7 @@ class AuthPopup(stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) button.onClick { try { - runBlocking { 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/InfoPopup.kt b/core/src/com/unciv/ui/popups/InfoPopup.kt index 0145daba69791..900d9aef35472 100644 --- a/core/src/com/unciv/ui/popups/InfoPopup.kt +++ b/core/src/com/unciv/ui/popups/InfoPopup.kt @@ -3,7 +3,6 @@ package com.unciv.ui.popups import com.badlogic.gdx.scenes.scene2d.Stage import com.unciv.logic.UncivShowableException import com.unciv.utils.Concurrency -import kotlinx.coroutines.runBlocking /** Variant of [Popup] with one label and a cancel button * @param stageToShowOn Parent [Stage], see [Popup.stageToShowOn] @@ -62,7 +61,7 @@ class InfoPopup( } } } - runBlocking { job.join() } + 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 index bc89ed98e6626..08cadf5ceff06 100644 --- a/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt +++ b/core/src/com/unciv/ui/popups/LobbyInvitationPopup.kt @@ -4,7 +4,6 @@ import com.unciv.logic.multiplayer.apiv2.IncomingInvite import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Concurrency import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking /** * Popup that handles an [IncomingInvite] to a lobby @@ -36,7 +35,7 @@ class LobbyInvitationPopup( } override fun open(force: Boolean) { - runBlocking { setupJob.join() } + Concurrency.runBlocking { setupJob.join() } super.open(force) } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index b243249283c14..943840b459ec4 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -17,8 +17,8 @@ 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 kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.UUID @@ -42,7 +42,7 @@ class LobbyPlayerList( .apply { this.setAlignment(Align.center) } .surroundWithCircle(50f, color = Color.GRAY) .onClick { - runBlocking { + Concurrency.runBlocking { mutex.withLock { players.add(LobbyPlayer(null, Constants.random)) } @@ -60,7 +60,7 @@ class LobbyPlayerList( * Add the specified player to the player list and recreate the view */ internal fun addPlayer(player: AccountResponse): Boolean { - runBlocking { + Concurrency.runBlocking { mutex.withLock { players.add(LobbyPlayer(player)) } @@ -73,8 +73,8 @@ class LobbyPlayerList( * Remove the specified player from the player list and recreate the view */ internal fun removePlayer(player: UUID): Boolean { - var modified: Boolean - runBlocking { + var modified = false // the default will always be overwritten + Concurrency.runBlocking { mutex.withLock { modified = players.removeAll { it.account?.uuid == player } } @@ -99,14 +99,14 @@ class LobbyPlayerList( return } - runBlocking { + Concurrency.runBlocking { mutex.withLock { for (i in players.indices) { row() val movements = VerticalGroup() movements.space(5f) movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { - if (runBlocking { + if (Concurrency.runBlocking { var changed = false mutex.withLock { if (i > 0) { @@ -117,12 +117,12 @@ class LobbyPlayerList( } } changed - }) { + } == true) { recreate() } }) movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { - if (runBlocking { + if (Concurrency.runBlocking { var changed = false mutex.withLock { if (i < players.size - 1) { @@ -134,7 +134,7 @@ class LobbyPlayerList( } } changed - }) { + } == true) { recreate() } }) @@ -155,14 +155,14 @@ class LobbyPlayerList( kickButton.onClick { var success = true if (!player.isAI) { - runBlocking { + Concurrency.runBlocking { success = true == InfoPopup.wrap(stage) { api.lobby.kick(lobbyUUID, player.account!!.uuid) } } } if (success) { - runBlocking { + Concurrency.runBlocking { mutex.withLock { success = players.remove(player) } @@ -208,7 +208,7 @@ class LobbyPlayerList( } private fun reassignRemovedModReferences() { - runBlocking { + Concurrency.runBlocking { mutex.withLock { for (player in players) { if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) @@ -249,7 +249,7 @@ class LobbyPlayerList( * Refresh the view of the human players based on the [currentPlayers] response from the server */ internal fun updateCurrentPlayers(currentPlayers: List) { - runBlocking { + Concurrency.runBlocking { mutex.withLock { val humanPlayers = players.filter { !it.isAI }.map { it.account!! } val toBeRemoved = mutableListOf() diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt index 40c9743c0746a..40c256dcd2d1e 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -177,7 +177,6 @@ class LobbyScreen( events.receive(LobbyLeave::class, { it.lobbyUUID == lobbyUUID }) { Log.debug("Player %s left lobby %s", it.player, lobbyUUID) lobbyPlayerList.removePlayer(it.player.uuid) - recreate() ToastPopup("${it.player.username} has left the lobby", stage) } events.receive(LobbyKick::class, { it.lobbyUUID == lobbyUUID }) { diff --git a/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt b/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt index 6c28b032de4d6..bc9869bc6f586 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/MultiplayerOverviewTable.kt @@ -14,7 +14,6 @@ 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 kotlinx.coroutines.runBlocking import java.util.UUID class MultiplayerOverviewTable( @@ -31,16 +30,19 @@ class MultiplayerOverviewTable( else ChatMessageList(true, Pair(ChatRoomType.Game, gameName), chatRoomUUID, UncivGame.Current.onlineMultiplayer), val disposables: MutableList = mutableListOf() ) : EmpireOverviewTabPersistableData() { - constructor(overview: GameOverviewResponse) : this(overview.chatRoomUUID, overview.name) + constructor(overview: GameOverviewResponse?) : this(overview?.chatRoomUUID, overview?.name ?: "failed to load multiplayer game") override fun isEmpty() = false } override val persistableData = (persistedData as? MultiplayerTabPersistableData) ?: MultiplayerTabPersistableData( - runBlocking { - // This operation shouldn't fail; however, this wraps into an InfoPopup before crashing the game - InfoPopup.wrap(overviewScreen.stage) { + 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 } ) From d9f19309dc2ba6cdae0778b84d08be3bd4530315 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 25 Jun 2023 01:39:21 +0200 Subject: [PATCH 148/152] Reworked the APIv2 file storage mechanism --- .../logic/multiplayer/OnlineMultiplayer.kt | 33 ++++++++++--- ...StorageEmulator.kt => ApiV2FileStorage.kt} | 46 +++++++++++-------- .../storage/OnlineMultiplayerServer.kt | 5 +- 3 files changed, 56 insertions(+), 28 deletions(-) rename core/src/com/unciv/logic/multiplayer/storage/{ApiV2FileStorageEmulator.kt => ApiV2FileStorage.kt} (61%) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 763a3decd5b29..52dd7c19104bd 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -39,8 +39,8 @@ 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) @@ -49,9 +49,25 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) * * 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. + * 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. * - * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. + * 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. + * + * 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: Disposable { private val settings @@ -65,7 +81,7 @@ class OnlineMultiplayer: Disposable { if (Concurrency.runBlocking { apiImpl.isCompatible() } == true) { return apiImpl } - throw UncivShowableException("Unsupported server API") + throw UncivShowableException("Unsupported server API: $baseUrl") } private val files = UncivGame.Current.files @@ -130,6 +146,7 @@ class OnlineMultiplayer: Disposable { * 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() { apiVersion = determineServerAPI() @@ -144,11 +161,12 @@ class OnlineMultiplayer: Disposable { } else { apiImpl.initialize() } + ApiV2FileStorage.setApi(apiImpl) } } /** - * Determine whether the object has been initialized + * Determine whether the instance has been initialized */ fun isInitialized(): Boolean { return (this::featureSet.isInitialized) && (this::apiVersion.isInitialized) && (apiVersion != ApiVersion.APIv2 || apiImpl.isInitialized()) @@ -457,7 +475,7 @@ class OnlineMultiplayer: Disposable { * * Only use this method for APIv1 servers. This method doesn't check the API version, though. * - * This is a blocking method. + * This is a blocking method doing network operations. */ private fun isAliveAPIv1(): Boolean { var statusOk = false @@ -543,6 +561,7 @@ class OnlineMultiplayer: Disposable { * 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) { diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt similarity index 61% rename from core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt rename to core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt index 8403b8718326f..c59a015851d24 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorage.kt @@ -1,7 +1,12 @@ 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 @@ -9,13 +14,30 @@ import java.util.UUID private const val PREVIEW_SUFFIX = "_Preview" /** - * Transition helper that emulates file storage behavior using the API v2 + * 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. */ -class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { +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) + api!!.game.upload(uuid, data) } override suspend fun savePreviewData(gameId: String, data: String) { @@ -25,7 +47,7 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { override suspend fun loadGameData(gameId: String): String { val uuid = UUID.fromString(gameId.lowercase()) - return api.game.get(uuid, cache = false)!!.gameData + return api!!.game.get(uuid, cache = false)!!.gameData } override suspend fun loadPreviewData(gameId: String): String { @@ -70,23 +92,11 @@ class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { } override fun authenticate(userId: String, password: String): Boolean { - return Concurrency.runBlocking { api.auth.loginOnly(userId, password) }!! + return Concurrency.runBlocking { api!!.auth.loginOnly(userId, password) }!! } override fun setPassword(newPassword: String): Boolean { - return Concurrency.runBlocking { api.account.setPassword(newPassword, suppress = true) }!! + return Concurrency.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/OnlineMultiplayerServer.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt index b0ab8ea3721fd..631935c692b21 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt @@ -13,7 +13,7 @@ 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. * @@ -45,7 +45,7 @@ class OnlineMultiplayerServer( if (!UncivGame.Current.onlineMultiplayer.hasAuthentication() && !UncivGame.Current.onlineMultiplayer.api.isAuthenticated()) { Log.error("User credentials not available, further execution may result in errors!") } - return ApiV2FileStorageWrapper.storage!! + return ApiV2FileStorage } UncivServerFileStorage.apply { serverUrl = this@OnlineMultiplayerServer.serverUrl @@ -75,7 +75,6 @@ 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 From 5f258b8a681b6a9fe7c33bd23fab204350c87380 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 25 Jun 2023 02:26:28 +0200 Subject: [PATCH 149/152] Smaller network improvements, extended docs --- core/src/com/unciv/UncivGame.kt | 43 ++++++------------- .../com/unciv/logic/multiplayer/ApiVersion.kt | 9 +++- .../logic/multiplayer/OnlineMultiplayer.kt | 17 ++++++-- .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 12 +++--- .../storage/OnlineMultiplayerServer.kt | 1 - 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index a07e66c291694..b94cfe3385588 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -183,7 +183,7 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci // 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) } @@ -464,7 +464,6 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci 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() onlineMultiplayer.dispose() } @@ -550,35 +549,19 @@ open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpeci /** * Replace the [onlineMultiplayer] instance in-place * - * This might be useful if the server URL or other core values - * got changed. This is a blocking operation. + * 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. */ - fun refreshOnlineMultiplayer() { - val mp = Concurrency.runBlocking { - val newMultiplayer = OnlineMultiplayer() - newMultiplayer.initialize() - - // Check if the server is available in case the feature set has changed - try { - newMultiplayer.checkServerStatus() - } catch (ex: Exception) { - debug("Couldn't connect to server: " + ex.message) - } - newMultiplayer - } - if (mp != null) { - Concurrency.runBlocking { - Concurrency.runOnGLThread { - val oldMultiplayer = Current.onlineMultiplayer - Current.onlineMultiplayer = mp - Concurrency.run { - oldMultiplayer.dispose() - } - } - } - } else { - Log.error("Failed to refresh online multiplayer") - } + suspend fun refreshOnlineMultiplayer() { + val oldMultiplayer = Current.onlineMultiplayer + val newMultiplayer = OnlineMultiplayer() + newMultiplayer.initialize() + oldMultiplayer.dispose() + Concurrency.runOnGLThread { + Current.onlineMultiplayer = newMultiplayer + }.join() } } 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/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 52dd7c19104bd..e1f00328fb307 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -14,7 +14,11 @@ 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 @@ -75,14 +79,21 @@ class OnlineMultiplayer: Disposable { // 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 - private val apiImpl = ApiV2(baseUrl) + + /** + * 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 { apiImpl.isCompatible() } == true) { + if (Concurrency.runBlocking { awaitInitialized(); apiImpl.isCompatible() } == true) { return apiImpl } - throw UncivShowableException("Unsupported server API: $baseUrl") + throw UncivShowableException("Unsupported server API: [$baseUrl]") } + private val apiImpl = ApiV2(baseUrl) private val files = UncivGame.Current.files val multiplayerServer = OnlineMultiplayerServer() diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt index 03eedea637fd8..dbbbc481e667e 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 @@ -37,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 { @@ -110,8 +114,6 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { } } } - ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) - ApiV2FileStorageWrapper.api = this initialized = true } @@ -158,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 diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt index 631935c692b21..f553a5d48dc62 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerServer.kt @@ -52,7 +52,6 @@ class OnlineMultiplayerServer( this.authHeader = authHeader } } - } /** From c69c9b3b9634f9e9d20980136deb449ffcdbdf4d Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 25 Jun 2023 02:29:18 +0200 Subject: [PATCH 150/152] Reworked the access to the api and apiVersion properties, fixed MultiplayerTab's refresh calls --- .../logic/multiplayer/OnlineMultiplayer.kt | 86 ++++++++++++------- .../unciv/ui/popups/options/MultiplayerTab.kt | 19 ++-- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index e1f00328fb307..3b9d5948fc723 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -108,34 +108,46 @@ class OnlineMultiplayer: Disposable { private val lastAllGamesRefresh: AtomicReference = AtomicReference() private val lastCurGameRefresh: AtomicReference = AtomicReference() - val games: Set get() = savedGames.values.toSet() - val multiplayerGameUpdater: Job - - /** Server API auto-detection happens in the coroutine [initialize] */ - lateinit var apiVersion: ApiVersion + 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!! + } - 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) - - 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() } + // 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 doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) - throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } } - }.launchIn(CoroutineScope(Dispatcher.DAEMON)) + 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 { + // 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 { @@ -160,15 +172,16 @@ class OnlineMultiplayer: Disposable { * You must await its completion before using the [api] and its related functionality. */ suspend fun initialize() { - apiVersion = determineServerAPI() - Log.debug("Server at '$baseUrl' detected API version: $apiVersion") - checkServerStatus() + 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 - isAliveAPIv1() // this is called for any API version since it updates the featureSet implicitly - if (apiVersion == ApiVersion.APIv2) { + if (apiVersionImpl == ApiVersion.APIv1) { + isAliveAPIv1() + } + if (apiVersionImpl == ApiVersion.APIv2) { if (hasAuthentication()) { - apiImpl.initialize(Pair(settings.multiplayer.userName, settings.multiplayer.passwords[baseUrl]?:"")) + apiImpl.initialize(Pair(settings.multiplayer.userName, settings.multiplayer.passwords[baseUrl] ?: "")) } else { apiImpl.initialize() } @@ -180,7 +193,7 @@ class OnlineMultiplayer: Disposable { * Determine whether the instance has been initialized */ fun isInitialized(): Boolean { - return (this::featureSet.isInitialized) && (this::apiVersion.isInitialized) && (apiVersion != ApiVersion.APIv2 || apiImpl.isInitialized()) + return (this::featureSet.isInitialized) && (apiVersionImpl != null) && (apiVersionImpl != ApiVersion.APIv2 || apiImpl.isInitialized()) } /** @@ -188,7 +201,8 @@ class OnlineMultiplayer: Disposable { */ suspend fun awaitInitialized() { while (!isInitialized()) { - delay(1) + // Using an active sleep loop here is not the best way, but the simplest; it could be improved later + delay(20) } } @@ -545,7 +559,13 @@ class OnlineMultiplayer: Disposable { } /** - * Start a background runner that periodically checks for new game updates ([ApiVersion.APIv0] and [ApiVersion.APIv1] only) + * 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)) { diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index 248590bac3aea..fb1dc9be3ccb4 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -209,10 +209,13 @@ private fun addMultiplayerServerOptions( 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) + } } - popup.reuseWith("Success! Detected $apiVersion!", true) } else { popup.reuseWith("Failed!", true) } @@ -220,19 +223,25 @@ private fun addMultiplayerServerOptions( }.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) + Concurrency.runOnGLThread { + popup.reuseWith("Success! Detected $apiVersion!", true) + } } } } else if (apiVersion != null) { Concurrency.runOnGLThread { - popup.reuseWith("Success! Detected $apiVersion!", true) + 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") From c024098cc4ef7a364d090a48b557005a06710b37 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 26 Jun 2023 01:11:59 +0200 Subject: [PATCH 151/152] Reworked the LobbyPlayerList and the LobbyScreen sync (mutex behavior) It enforces the use of the OpenGL render thread for synchronization instead of mutexing. --- .../multiplayerscreens/LobbyPlayerList.kt | 218 ++++++++---------- .../screens/multiplayerscreens/LobbyScreen.kt | 59 ++--- 2 files changed, 134 insertions(+), 143 deletions(-) diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt index 943840b459ec4..ee731537d341f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyPlayerList.kt @@ -19,12 +19,21 @@ 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 kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock 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, @@ -35,18 +44,17 @@ class LobbyPlayerList( startPlayers: List = listOf(), private val base: MapOptionsInterface ) : Table() { - private val mutex = Mutex() // used to synchronize changes to the players list - internal val players: MutableList = startPlayers.map { LobbyPlayer(it) }.toMutableList() + // 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 { - Concurrency.runBlocking { - mutex.withLock { - players.add(LobbyPlayer(null, Constants.random)) - } - } + playersImpl.add(LobbyPlayer(null, Constants.random)) recreate() update?.invoke() } @@ -58,33 +66,30 @@ class LobbyPlayerList( /** * 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 { - Concurrency.runBlocking { - mutex.withLock { - players.add(LobbyPlayer(player)) - } - } + 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 { - var modified = false // the default will always be overwritten - Concurrency.runBlocking { - mutex.withLock { - modified = players.removeAll { it.account?.uuid == player } - } - } + 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() @@ -99,74 +104,51 @@ class LobbyPlayerList( return } - Concurrency.runBlocking { - mutex.withLock { - for (i in players.indices) { - row() - val movements = VerticalGroup() - movements.space(5f) - movements.addActor("↑".toLabel(fontSize = Constants.headingFontSize).onClick { - if (Concurrency.runBlocking { - var changed = false - mutex.withLock { - if (i > 0) { - changed = true - val above = players[i - 1] - players[i - 1] = players[i] - players[i] = above - } - } - changed - } == true) { - recreate() - } - }) - movements.addActor("↓".toLabel(fontSize = Constants.headingFontSize).onClick { - if (Concurrency.runBlocking { - var changed = false - mutex.withLock { - if (i < players.size - 1) { - changed = true - val below = players[i + 1] - players[i + 1] = players[i] - players[i] = below - - } - } - changed - } == true) { - recreate() - } - }) - if (editable) { - add(movements) - } + 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 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 - if (!player.isAI) { - Concurrency.runBlocking { - success = true == InfoPopup.wrap(stage) { - api.lobby.kick(lobbyUUID, player.account!!.uuid) - } - } + 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) { - Concurrency.runBlocking { - mutex.withLock { - success = players.remove(player) - } - } + success = playersImpl.remove(player) } else { base.updateTables() } @@ -174,16 +156,16 @@ class LobbyPlayerList( 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) - } } } + 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() @@ -202,24 +184,28 @@ class LobbyPlayerList( /** * 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. */ - internal fun updateParameters() { - base.gameSetupInfo.gameParameters.players = players.map { it.to() }.toMutableList() + 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() { - Concurrency.runBlocking { - mutex.withLock { - for (player in players) { - if (!base.ruleset.nations.containsKey(player.chosenCiv) || base.ruleset.nations[player.chosenCiv]!!.isCityState) - player.chosenCiv = Constants.random - } + 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] @@ -247,25 +233,23 @@ class LobbyPlayerList( /** * 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) { - Concurrency.runBlocking { - mutex.withLock { - 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) { - players.remove(r) - } - for (newPlayer in currentPlayers) { - if (newPlayer !in humanPlayers) { - players.add(LobbyPlayer(newPlayer)) - } - } + 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 index 40c256dcd2d1e..cef62e531172f 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/LobbyScreen.kt @@ -89,6 +89,7 @@ class LobbyScreen( 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() @@ -108,9 +109,12 @@ class LobbyScreen( } 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) - lobbyPlayerList.recreate() + Concurrency.runOnGLThread { + lobbyPlayerList.recreate() + } }) changeLobbyNameButton.onActivation { @@ -282,21 +286,16 @@ class LobbyScreen( refreshedLobbyPlayers.add(owner) } - // This construction prevents null pointer exceptions when `refresh` + // 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 function is executed - while (true) { - try { - lobbyPlayerList.removePlayer(owner.uuid) - break - } catch (_: NullPointerException) { - delay(1) - } + // `lobbyPlayerList` might be uninitialized when this point is reached + while (!lobbyPlayerListInitialized) { + delay(10) } - lobbyPlayerList.updateCurrentPlayers(refreshedLobbyPlayers) - lobbyName = lobby.name Concurrency.runOnGLThread { + lobbyPlayerList.updateCurrentPlayers(refreshedLobbyPlayers) + lobbyName = lobby.name recreate() } } @@ -361,6 +360,8 @@ class LobbyScreen( /** * 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) @@ -375,11 +376,11 @@ class LobbyScreen( GameStarter.startNewGame(gameSetupInfo, lobbyStart.gameUUID.toString()) } catch (exception: Exception) { Log.error( - "Failed to create a new GameInfo for game %s: %s", + "Failed to create a new GameInfo for game %s: %s\n%s", lobbyStart.gameUUID, - exception + exception, + exception.stackTraceToString() ) - exception.printStackTrace() Concurrency.runOnGLThread { popup.apply { reuseWith("It looks like we can't make a map with the parameters you requested!") @@ -395,26 +396,32 @@ class LobbyScreen( Concurrency.runOnGLThread { popup.reuseWith("Uploading...") } - runBlocking { - InfoPopup.wrap(stage) { - game.onlineMultiplayer.createGame(gameInfo) - true - } - Log.debug("Uploaded game %s", lobbyStart.gameUUID) + val uploadSuccess = InfoPopup.wrap(stage) { + game.onlineMultiplayer.createGame(gameInfo) + true } - Concurrency.runOnGLThread { - popup.close() - game.loadGame(gameInfo) + 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") + Log.error("Not yet implemented: lockTables") } override fun unlockTables() { - Log.error("Not yet implemented") + Log.error("Not yet implemented: unlockTables") } override fun updateTables() { From f03884db80338c4cee5018e2f12f52ff0cd40e62 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 6 Jul 2023 16:18:03 +0200 Subject: [PATCH 152/152] Fixed an instant app crash on Android due to uninitialized UncivGame --- android/src/com/unciv/app/AndroidLauncher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 5ad39c09c8c6a..343908d729f5b 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -92,7 +92,7 @@ open class AndroidLauncher : AndroidApplication() { } override fun onResume() { - if (game?.onlineMultiplayer?.isInitialized() == true && game?.onlineMultiplayer?.apiVersion == ApiVersion.APIv2) { + if (game != null && game?.isInitialized == true && game?.onlineMultiplayer?.isInitialized() == true && game?.onlineMultiplayer?.apiVersion == ApiVersion.APIv2) { game?.onlineMultiplayer?.api?.enableReconnecting() } try {