From bd3aa54670e6c21dcc138d1b5ea777b2a689600b Mon Sep 17 00:00:00 2001 From: Crsi <47722349+CrsiX@users.noreply.github.com> Date: Sun, 18 Jun 2023 17:17:59 +0200 Subject: [PATCH] Multiplayer v2: networking stack, dependencies & API definition (#9589) * Added new ktor dependency for developing multiplayer API v2 * Added the api package, including endpoint implementations, cookie helpers, serializers and structs * Fixed a bunch of problems related to error handling * Fixed some API incompatibilities, added getFriends() method Rename the Api class to ApiV2Wrapper, added a chat room screen Replaced logging dependency, renamed the endpoint implementations * Dropped the extra logger to remove dependencies, added the APIv2 class * Restructured the project to make ApiV2 class the center * Improved chat handling, added server game detail caching Added a generic HTTP request wrapper that can retry requests easily Added a default handler to retry requests after session refreshing * Updated the API structs based on the new OpenAPI specifications Switched endpoint implementations to use the new 'request', updated WebSocket structs * Updated the auth helper, added the UncivNetworkException Fixed some more issues due to refactoring APIv2 handler Fixed some issues and some minor incompatibilities with the new API * Implemented the LobbyBrowserTable, added missing API endpoint Fixed login and auth issues in the main menu screen * Added new WebSocket structs for handling invites and friends Updated the API reference implementation * Added GET cache, allowed all WS messages to be Events, added missing endpoints Added func to dispose and refresh OnlineMultiplayer, only show set username for APIv2 * Reworked the ApiV2 class to improve WebSocket handling for every login Added small game fetch, fixed lobby start, some smaller fixes * 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). Added serializer class for WebSocket's FriendshipEvent enum Fixed chat room access and cancelling friendships * Fixed WebSocket re-connecting, outsourced configs Updated the RegisterLoginPopup to ask if the user wants to use the new servers Implemented a self-contained API version check with side-effects Fixed various problems with WebSocket connections Don't show kick button for lobby owner, handle network issues during login * Added English translations for ApiStatusCode, fixed broken APIv1 games for uncivserver.xyz Fixed subpaths in baseUrl, added server settings button * Added WS-based Android turn checker, added a new event channel, fixed APIWrapper Added a logout hook, implemented ensureConnectedWebSocket Merge branch 'master' into dev * Throttle auto-reconnect for WS on Android in background, added reload notice for your turn popup Implemented real pinging with awaiting responses, fixed ping-related problems * Adapted new getAllChats API, added outstanding friend request list, improved styling * Added the ApiVersion enum and the ApiV2 storage emulator * Updated the APIv2 file storage emulator * Replaced all wildcard imports with named imports --- build.gradle.kts | 31 +- buildSrc/src/main/kotlin/BuildConfig.kt | 1 + .../com/unciv/logic/multiplayer/ApiVersion.kt | 147 +++ .../unciv/logic/multiplayer/apiv2/ApiV2.kt | 618 +++++++++ .../logic/multiplayer/apiv2/ApiV2Wrapper.kt | 255 ++++ .../logic/multiplayer/apiv2/AuthHelper.kt | 64 + .../com/unciv/logic/multiplayer/apiv2/Conf.kt | 27 + .../apiv2/EndpointImplementations.kt | 1120 +++++++++++++++++ .../multiplayer/apiv2/JsonSerializers.kt | 118 ++ .../logic/multiplayer/apiv2/RequestStructs.kt | 124 ++ .../multiplayer/apiv2/ResponseStructs.kt | 391 ++++++ .../apiv2/UncivNetworkException.kt | 11 + .../multiplayer/apiv2/WebSocketStructs.kt | 344 +++++ .../storage/ApiV2FileStorageEmulator.kt | 104 ++ 14 files changed, 3351 insertions(+), 4 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/ApiVersion.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt create mode 100644 core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt create mode 100644 core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2643dd4473..70886af7ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,7 @@ import com.unciv.build.BuildConfig.gdxVersion +import com.unciv.build.BuildConfig.ktorVersion import com.unciv.build.BuildConfig.roboVMVersion -plugins { - id("io.gitlab.arturbosch.detekt").version("1.23.0-RC3") -} // You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used configurations.all { resolutionStrategy { @@ -12,7 +10,6 @@ configurations.all { resolutionStrategy { buildscript { - repositories { // Chinese mirrors for quicker loading for chinese devs - uncomment if you're chinese // maven{ url = uri("https://maven.aliyun.com/repository/central") } @@ -31,6 +28,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 { + id("io.gitlab.arturbosch.detekt").version("1.23.0-RC3") + kotlin("multiplatform") version "1.8.10" + kotlin("plugin.serialization") version "1.8.10" +} + allprojects { apply(plugin = "eclipse") apply(plugin = "idea") @@ -116,12 +125,26 @@ 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") + // 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/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 0bf6230fbc..d8b14d4660 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -8,5 +8,6 @@ object BuildConfig { const val appVersion = "4.7.1" const val gdxVersion = "1.11.0" + const val ktorVersion = "2.2.3" const val roboVMVersion = "2.3.1" } 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 0000000000..76bac2e388 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt @@ -0,0 +1,147 @@ +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.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 + +/** + * 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; + + 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 + } + val fixedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + + // 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(fixedBaseUrl) + } + } + + // 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", fixedBaseUrl, 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)", 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", fixedBaseUrl, serverFeatureSet) + client.close() + return APIv1 + } catch (e: Exception) { + 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") + } catch (e: Exception) { + Log.debug("Failed to fetch '/api/version' at %s: %s", fixedBaseUrl, 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", fixedBaseUrl, serverVersion) + client.close() + return APIv2 + } catch (e: Exception) { + Log.debug("Failed to de-serialize OK response body of '/api/version' at %s: %s", fixedBaseUrl, e.localizedMessage) + } + } + + client.close() + return null + } + } +} 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 0000000000..1c6784bd17 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt @@ -0,0 +1,618 @@ +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 +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.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 +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 +import kotlinx.serialization.json.Json +import java.time.Instant +import java.util.Random +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference + +/** + * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] + */ +class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable { + + /** 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 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() + + /** 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>() + + /** 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 + * + * 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) + * + * It's recommended to set the credentials correctly in the first run, if possible. + */ + 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, suppress = true)) { + Log.debug("Login failed using provided credentials (username '${credentials.first}')") + } else { + lastSuccessfulAuthentication.set(Instant.now()) + Concurrency.run { + refreshGameDetails() + } + } + } + ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this) + ApiV2FileStorageWrapper.api = this + initialized = true + } + + // ---------------- LIFECYCLE FUNCTIONALITY ---------------- + + /** + * 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()) + } + + /** + * Determine if this class has been fully initialized + */ + fun isInitialized(): Boolean { + return initialized + } + + /** + * Dispose this class and its children and jobs + */ + override fun dispose() { + disableReconnecting() + sendChannel?.close() + for (channel in eventChannelList) { + channel.close() + } + for (job in websocketJobs) { + job.cancel() + } + for (job in websocketJobs) { + runBlocking { + job.join() + } + } + client.cancel() + } + + // ---------------- 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 + } + + // ---------------- 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) + } + + /** + * 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.chatRoomUUID, g.gameDataID, g.name) + } + } + + // ---------------- WEBSOCKET FUNCTIONALITY ---------------- + + /** + * 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). + * + * @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) { + Log.debug("No WebSocket connection, can't send text frame to server: '$text'") + if (suppress) { + return false + } else { + throw UncivNetworkException("WebSocket not connected", null) + } + } + try { + channel.send(Frame.Text(text)) + } catch (e: Throwable) { + 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 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 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. + */ + 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.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 + */ + private suspend fun handleWebSocket(session: ClientWebSocketSession) { + sendChannel?.close() + sendChannel = session.outgoing + + websocketJobs.add(Concurrency.run { + val currentChannel = session.outgoing + while (sendChannel != null && currentChannel == sendChannel) { + try { + sendPing() + } catch (e: Exception) { + Log.debug("Failed to send WebSocket ping: %s", e.localizedMessage) + Concurrency.run { + if (reconnectWebSocket) { + websocket(::handleWebSocket) + } + } + } + delay(DEFAULT_WEBSOCKET_PING_FREQUENCY) + } + Log.debug("It looks like the WebSocket channel has been replaced") + }) + + try { + while (true) { + val incomingFrame = session.incoming.receive() + when (incomingFrame.frameType) { + 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 of size %s which can't be parsed at the moment", incomingFrame.data.size) + } + 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") + 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 + 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) { + Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString()) + } + } + } + } + } catch (e: ClosedReceiveChannelException) { + Log.debug("The WebSocket channel was closed: $e") + sendChannel?.close() + session.close() + session.flush() + Concurrency.run { + if (reconnectWebSocket) { + 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 { + if (reconnectWebSocket) { + websocket(::handleWebSocket) + } + } + throw e + } + } + + /** + * 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. + * It returns the measured round trip time in milliseconds if everything was fine. + */ + 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) + null + } + if (pingMeasurement == null) { + websocket(::handleWebSocket, jobCallback) + } + return pingMeasurement + } + + // ---------------- SESSION FUNCTIONALITY ---------------- + + /** + * Perform post-login hooks and updates + * + * 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() { + enableReconnecting() + 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.", + UncivGame.Current.settings.multiplayer.userId, + me.uuid + ) + UncivGame.Current.settings.multiplayer.userId = me.uuid.toString() + UncivGame.Current.settings.save() + ensureConnectedWebSocket() + } + super.afterLogin() + } + + /** + * 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) { + channel.close() + } + for (job in websocketJobs) { + job.cancel() + } + } + super.afterLogout(success) + } + + /** + * 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(ignoreLastCredentials: Boolean = false): Boolean { + if (!ignoreLastCredentials) { + return false + } + val success = auth.login( + UncivGame.Current.settings.multiplayer.userName, + UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.onlineMultiplayer.multiplayerServer.serverUrl] ?: "", + suppress = true + ) + if (success) { + lastSuccessfulAuthentication.set(Instant.now()) + } + 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 + } +} + +/** + * 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 [chatRoomUUID] may be valid longer (up to the game's lifetime). + */ +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 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 new file mode 100644 index 0000000000..398cb8c666 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt @@ -0,0 +1,255 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +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 +import kotlinx.serialization.json.Json +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * API wrapper around the newly implemented REST API for multiplayer game handling + * + * 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. + */ +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) { + // Do not add install(HttpCookies) because it will break Cookie handling + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + }) + } + install(HttpTimeout) { + requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT + connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT + } + install(WebSockets) { + // Pings are configured manually to enable re-connecting automatically, don't use `pingInterval` + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + defaultRequest { + url(baseUrlImpl) + } + } + + /** 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 */ + protected var websocketJobs = ConcurrentLinkedQueue() + + init { + client.plugin(HttpSend).intercept { request -> + request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett") + 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 (%d ms%s)", + request.method.value, + request.url.toString(), + clientCall.response.status, + clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp, + if (!request.url.protocol.isSecure()) ", insecure!" else "" + ) + clientCall + } + } + + /** + * 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() {} + + /** + * 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 + */ + val account = AccountsApi(client, authHelper) + + /** + * API for authentication management + */ + val auth = AuthApi(client, authHelper, ::afterLogin, ::afterLogout) + + /** + * API for chat management + */ + val chat = ChatApi(client, authHelper) + + /** + * API for friendship management + */ + val friend = FriendApi(client, authHelper) + + /** + * API for game management + */ + val game = GameApi(client, authHelper) + + /** + * API for invite management + */ + val invite = InviteApi(client, authHelper) + + /** + * API for lobby management + */ + val lobby = LobbyApi(client, authHelper) + + /** + * 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. + * Use [jobCallback] to receive the newly created job handling the WS connection. + */ + internal suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit, jobCallback: ((Job) -> Unit)? = null): Boolean { + Log.debug("Starting a new WebSocket connection ...") + + coroutineScope { + try { + 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") + } + } + 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 + } catch (e: Exception) { + Log.debug("Failed to establish WebSocket connection: %s", e.localizedMessage) + return@coroutineScope false + } + } + + return true + } + + /** + * 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]. + */ + suspend fun version(): VersionResponse { + return client.get("api/version").body() + } + +} + +/** + * 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." + } +} 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 0000000000..1dd29e7132 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt @@ -0,0 +1,64 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.utils.Log +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 + +/** + * 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() + + /** + * 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/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt new file mode 100644 index 0000000000..ddcab84794 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt @@ -0,0 +1,27 @@ +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 = 15_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) + +/** Default timeout for a single request (miliseconds) */ +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 diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt new file mode 100644 index 0000000000..be2cdd5866 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt @@ -0,0 +1,1120 @@ +/** + * 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.apiv2 + +import com.unciv.utils.Log +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 + +/** + * 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) + +/** + * Default value for randomly generated passwords + */ +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] + * + * 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. + * 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 + */ +private suspend fun request( + method: HttpMethod, + endpoint: String, + client: HttpClient, + authHelper: AuthHelper, + refine: ((HttpRequestBuilder) -> Unit)? = null, + suppress: Boolean = false, + retry: (suspend () -> Boolean)? = 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: 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 + } + } + + // 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 && 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) + 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()) + 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, + retry = 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())) + } +} + +/** + * 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 } + } +} + +/** + * 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) + */ +class AccountsApi(private val client: HttpClient, private val authHelper: AuthHelper) { + + /** + * 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(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() + } + + /** + * 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, 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() + } + + /** + * 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, ...). + * + * 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() + } + + /** + * Set the [username] of the currently logged-in user + * + * 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 setUsername(username: String, suppress: Boolean = false): Boolean { + return update(UpdateAccountRequest(username, null), suppress) + } + + /** + * Set the [displayName] of the currently logged-in user + * + * 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 setDisplayName(displayName: String, suppress: Boolean = false): Boolean { + return update(UpdateAccountRequest(null, displayName), suppress) + } + + /** + * Update the currently logged in user information + * + * 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 + } + + /** + * 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 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 [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") + true + } else { + false + } + } + + /** + * Register a new user account + * + * 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 { + false + } + } + +} + +/** + * 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, private val afterLogout: suspend (Boolean) -> Unit) { + + /** + * 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 = 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] to get a new session + * + * 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). + * + * @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) { + authHelper.setCookie( + authCookie.value, + authCookie.maxAge, + Pair(username, password) + ) + afterLogin() + true + } else { + Log.error("No recognized, valid session cookie found in login response!") + false + } + } else { + false + } + } + + /** + * Logs out the currently logged in user + * + * 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() + 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 + } + } + +} + +/** + * API wrapper for chat room handling (do not use directly; use the Api class instead) + */ +class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper) { + + /** + * Retrieve all chats a user has access to + * + * 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(suppress: Boolean = false): GetAllChatsResponse? { + return request( + HttpMethod.Get, "api/v2/chats", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, authHelper) + )?.body() + } + + /** + * 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). + * + * @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(roomUUID: UUID, suppress: Boolean = false): GetChatResponse? { + return request( + HttpMethod.Get, "api/v2/chats/$roomUUID", + client, authHelper, + suppress = suppress, + retry = getDefaultRetry(client, 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() + } + +} + +/** + * API wrapper for friend handling (do not use directly; use the Api class instead) + */ +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 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(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 [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 listRequests(suppress: Boolean = false): List? { + return list(suppress = suppress)?.second + } + + /** + * Request friendship with another user + * + * 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 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(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 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(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 + } + +} + +/** + * API wrapper for game handling (do not use directly; use the Api class instead) + */ +class GameApi(private val client: HttpClient, private val authHelper: AuthHelper) { + + /** + * 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. + * + * 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(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 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, 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 }?.getOrNull(0) + } + + /** + * 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]. + * 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). + * + * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [Long] 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 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 + } + +} + +/** + * API wrapper for invite handling (do not use directly; use the Api class instead) + */ +class InviteApi(private val client: HttpClient, private val authHelper: AuthHelper) { + + /** + * Retrieve all invites for the executing user + * + * 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 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 + * 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). + * + * @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/invites", + 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 + } + + /** + * 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 + } + +} + +/** + * API wrapper for lobby handling (do not use directly; use the Api class instead) + */ +class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelper) { + + /** + * Retrieves all open lobbies + * + * 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(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 + } + + /** + * 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] + * + * 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). + * + * @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? = null, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS, suppress: Boolean = false): CreateLobbyResponse? { + return open(CreateLobbyRequest(name, password, maxPlayers), suppress) + } + + /** + * 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). + * + * @throws ApiException: thrown for defined and recognized API problems + * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems + */ + 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) + } + + /** + * Endpoint implementation to create a new lobby + */ + 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() + } + + /** + * 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 + * + * 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. + * + * 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( + 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/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt new file mode 100644 index 0000000000..013ec6d3b0 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt @@ -0,0 +1,118 @@ +package com.unciv.logic.multiplayer.apiv2 + +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 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 + +/** + * 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()) + } +} + +/** + * 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.GameStarted -> GameStartedMessage.serializer() + WebSocketMessageType.UpdateGameData -> UpdateGameDataMessage.serializer() + WebSocketMessageType.ClientDisconnected -> ClientDisconnectedMessage.serializer() + WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer() + WebSocketMessageType.IncomingChatMessage -> IncomingChatMessageMessage.serializer() + WebSocketMessageType.IncomingInvite -> IncomingInviteMessage.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() + } + } + } +} + +/** + * 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()) + } +} + +/** + * Serializer for the FriendshipEvent WebSocket message enum to make encoding/decoding as string work + */ +internal class FriendshipEventSerializer : 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/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt new file mode 100644 index 0000000000..24154d80d4 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt @@ -0,0 +1,124 @@ +/** + * Collection of API request structs in a single file for simplicity + */ + +package com.unciv.logic.multiplayer.apiv2 + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import java.util.UUID + +/** + * 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( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID +) + +/** + * The request to invite a friend into a lobby + */ +@Serializable +data class CreateInviteRequest( + @SerialName("friend_uuid") + @Serializable(with = UUIDSerializer::class) + val friendUUID: UUID, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID +) + +/** + * The parameters to create a lobby + * + * The parameter [maxPlayers] must be greater or equals 2. + */ +@Serializable +data class CreateLobbyRequest( + val name: String, + val password: String?, + @SerialName("max_players") + val maxPlayers: Int +) + +/** + * The request a user sends to the server to upload a new game state (non-WebSocket API) + * + * 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 +) + +/** + * The request to join a lobby + */ +@Serializable +data class JoinLobbyRequest( + val password: String? = null +) + +/** + * The request data of a login request + */ +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +/** + * The request to lookup an account by its username + */ +@Serializable +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 + * + * The parameter [newPassword] 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/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt new file mode 100644 index 0000000000..751b34abb6 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt @@ -0,0 +1,391 @@ +/** + * Collection of API response structs in a single file for simplicity + */ + +package com.unciv.logic.multiplayer.apiv2 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +/** + * The account data + */ +@Serializable +data class AccountResponse( + val username: String, + @SerialName("display_name") + val displayName: String, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID +) + +/** + * 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") + @Serializable(with = ApiStatusCodeSerializer::class) + val statusCode: ApiStatusCode +) { + + /** + * 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 + * + * 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) { + 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), + MissingPrivileges(1013), + InvalidMaxPlayersCount(1014), + AlreadyInALobby(1015), + InvalidUuid(1016), + InvalidLobbyUuid(1017), + InvalidFriendUuid(1018), + GameNotFound(1019), + InvalidMessage(1020), + WsNotConnected(1021), + LobbyFull(1022), + InvalidPlayerUUID(1023), + + InternalServerError(2000), + DatabaseError(2001), + SessionError(2002); + + companion object { + private val VALUES = values() + fun getByValue(value: Int) = VALUES.first { it.value == value } + } +} + +/** + * 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 + * + * The parameter [uuid] should be used to uniquely identify a message. + */ +@Serializable +data class ChatMessage( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val sender: AccountResponse, + val message: String, + @SerialName("created_at") + @Serializable(with = InstantSerializer::class) + 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] + */ +@Serializable +data class CreateLobbyResponse( + @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 (the relationship is identified by the [uuid]) + */ +@Serializable +data class FriendResponse( + @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( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val from: AccountResponse, + 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_uuid") + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, + @SerialName("game_data_id") + val gameDataID: Long, + @SerialName("game_uuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, + @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_uuid") + @Serializable(with = UUIDSerializer::class) + val chatRoomUUID: UUID, + @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 + */ +@Serializable +data class GetAllChatsResponse( + @SerialName("friend_chat_rooms") + val friendChatRooms: List, + @SerialName("game_chat_rooms") + val gameChatRooms: List, + @SerialName("lobby_chat_rooms") + val lobbyChatRooms: List +) + +/** + * 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 + * [friendRequests] is a list of friend requests (incoming and outgoing) + */ +@Serializable +data class GetFriendResponse( + val friends: List, + @SerialName("friend_requests") + 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, + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID +) + +/** + * The invites that an account has received + */ +@Serializable +data class GetInvitesResponse( + val invites: List +) + +/** + * The lobbies that are open + */ +@Serializable +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 + */ +@Serializable +data class LobbyResponse( + @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_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 +) + +/** + * 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 +) { + fun to() = AccountResponse(uuid = uuid, username = username, displayName = displayName) +} + +/** + * 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 + */ +@Serializable +data class VersionResponse( + val version: Int +) 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 0000000000..01019d6f32 --- /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) +} diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt new file mode 100644 index 0000000000..0dd38726ea --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt @@ -0,0 +1,344 @@ +package com.unciv.logic.multiplayer.apiv2 + +import com.unciv.logic.event.Event +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * 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 } + } +} + +/** + * The notification for the clients that a new game has started + */ +@Serializable +data class GameStarted( + @SerialName("game_uuid") + @Serializable(with = UUIDSerializer::class) + val gameUUID: UUID, + @SerialName("game_chat_uuid") + @Serializable(with = UUIDSerializer::class) + val gameChatUUID: UUID, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + @SerialName("lobby_chat_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyChatUUID: UUID, +) : Event + +/** + * 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("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("game_data_id") + val gameDataID: Long +) : Event + +/** + * Notification for clients if a client in their game disconnected + */ +@Serializable +data class ClientDisconnected( + @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("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("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("invite_uuid") + @Serializable(with = UUIDSerializer::class) + val inviteUUID: UUID, + val from: AccountResponse, + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID +) : Event + +/** + * A friend request is sent to a client + */ +@Serializable +data class IncomingFriendRequest( + val from: AccountResponse +) : Event + +/** + * A friendship was modified + */ +@Serializable +data class FriendshipChanged( + val friend: AccountResponse, + val event: FriendshipEvent +) : Event + +/** + * A new player joined the lobby + */ +@Serializable +data class LobbyJoin( + @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("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID +) : Event + +/** + * A player has left the lobby + */ +@Serializable +data class LobbyLeave( + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + val player: AccountResponse +) : Event + +/** + * A player was kicked out of the lobby. + * + * Make sure to check the player if you were kicked ^^ + */ +@Serializable +data class LobbyKick( + @SerialName("lobby_uuid") + @Serializable(with = UUIDSerializer::class) + val lobbyUUID: UUID, + val player: AccountResponse +) : Event + +/** + * 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 +) : Event + +/** + * The base WebSocket message, encapsulating only the type of the message + */ +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 + */ +@Serializable +data class InvalidMessage( + override val type: WebSocketMessageType, +) : WebSocketMessage + +/** + * Message to indicate that a game started + */ +@Serializable +data class GameStartedMessage ( + override val type: WebSocketMessageType, + override val content: GameStarted +) : WebSocketMessageWithContent + +/** + * Message to publish the new game state from the server to all clients + */ +@Serializable +data class UpdateGameDataMessage ( + override val type: WebSocketMessageType, + override val content: UpdateGameData +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client disconnected + */ +@Serializable +data class ClientDisconnectedMessage ( + override val type: WebSocketMessageType, + override val content: ClientDisconnected +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client, who previously disconnected, reconnected + */ +@Serializable +data class ClientReconnectedMessage ( + override val type: WebSocketMessageType, + override val content: ClientReconnected +) : WebSocketMessageWithContent + +/** + * Message to indicate that a user received a new text message via the chat feature + */ +@Serializable +data class IncomingChatMessageMessage ( + override val type: WebSocketMessageType, + override val content: IncomingChatMessage +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client gets invited to a lobby + */ +@Serializable +data class IncomingInviteMessage ( + override val type: WebSocketMessageType, + override val content: IncomingInvite +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client received a friend request + */ +@Serializable +data class IncomingFriendRequestMessage ( + override val type: WebSocketMessageType, + override val content: IncomingFriendRequest +) : WebSocketMessageWithContent + +/** + * Message to indicate that a friendship has changed + */ +@Serializable +data class FriendshipChangedMessage ( + override val type: WebSocketMessageType, + override val content: FriendshipChanged +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client joined the lobby + */ +@Serializable +data class LobbyJoinMessage ( + override val type: WebSocketMessageType, + override val content: LobbyJoin +) : WebSocketMessageWithContent + +/** + * Message to indicate that the current lobby got closed + */ +@Serializable +data class LobbyClosedMessage ( + override val type: WebSocketMessageType, + override val content: LobbyClosed +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client left the lobby + */ +@Serializable +data class LobbyLeaveMessage ( + override val type: WebSocketMessageType, + override val content: LobbyLeave +) : WebSocketMessageWithContent + +/** + * Message to indicate that a client got kicked out of the lobby + */ +@Serializable +data class LobbyKickMessage ( + override val type: WebSocketMessageType, + override val content: LobbyKick +) : WebSocketMessageWithContent + +/** + * Message to indicate that the current user account's data have been changed + */ +@Serializable +data class AccountUpdatedMessage ( + override val type: WebSocketMessageType, + override val content: AccountUpdated +) : WebSocketMessageWithContent + +/** + * Type enum of all known WebSocket messages + */ +@Serializable(with = WebSocketMessageTypeSerializer::class) +enum class WebSocketMessageType(val type: String) { + InvalidMessage("invalidMessage"), + GameStarted("gameStarted"), + UpdateGameData("updateGameData"), + ClientDisconnected("clientDisconnected"), + ClientReconnected("clientReconnected"), + IncomingChatMessage("incomingChatMessage"), + IncomingInvite("incomingInvite"), + IncomingFriendRequest("incomingFriendRequest"), + FriendshipChanged("friendshipChanged"), + LobbyJoin("lobbyJoin"), + LobbyClosed("lobbyClosed"), + LobbyLeave("lobbyLeave"), + LobbyKick("lobbyKick"), + AccountUpdated("accountUpdated"); + + 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/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt new file mode 100644 index 0000000000..9fb57d10f1 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt @@ -0,0 +1,104 @@ +package com.unciv.logic.multiplayer.storage + +import com.unciv.logic.files.UncivFiles +import com.unciv.logic.multiplayer.apiv2.ApiV2 +import com.unciv.utils.Log +import kotlinx.coroutines.runBlocking +import java.util.UUID + +private const val PREVIEW_SUFFIX = "_Preview" + +/** + * Transition helper that emulates file storage behavior using the API v2 + */ +class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage { + + private suspend fun saveGameData(gameId: String, data: String) { + val uuid = UUID.fromString(gameId.lowercase()) + api.game.upload(uuid, data) + } + + @Suppress("UNUSED_PARAMETER") + private suspend fun savePreviewData(gameId: String, data: String) { + // Not implemented for this API + Log.debug("Call to deprecated API 'savePreviewData'") + } + + override fun saveFileData(fileName: String, data: String) { + return runBlocking { + if (fileName.endsWith(PREVIEW_SUFFIX)) { + savePreviewData(fileName.dropLast(8), data) + } else { + saveGameData(fileName, data) + } + } + } + + private suspend fun loadGameData(gameId: String): String { + val uuid = UUID.fromString(gameId.lowercase()) + return api.game.get(uuid, cache = false)!!.gameData + } + + private suspend fun loadPreviewData(gameId: String): String { + // Not implemented for this API + Log.debug("Call to deprecated API 'loadPreviewData'") + // TODO: This could be improved, since this consumes more resources than necessary + return UncivFiles.gameInfoToString(UncivFiles.gameInfoFromString(loadGameData(gameId)).asPreview()) + } + + override fun loadFileData(fileName: String): String { + return runBlocking { + if (fileName.endsWith(PREVIEW_SUFFIX)) { + loadPreviewData(fileName.dropLast(8)) + } else { + loadGameData(fileName) + } + } + } + + override fun getFileMetaData(fileName: String): FileMetaData { + TODO("Not yet implemented") + } + + override fun deleteFile(fileName: String) { + return runBlocking { + if (fileName.endsWith(PREVIEW_SUFFIX)) { + deletePreviewData(fileName.dropLast(8)) + } else { + deleteGameData(fileName) + } + } + } + + @Suppress("UNUSED_PARAMETER") + private suspend fun deleteGameData(gameId: String) { + TODO("Not yet implemented") + } + + private suspend fun deletePreviewData(gameId: String) { + // Not implemented for this API + Log.debug("Call to deprecated API 'deletedPreviewData'") + deleteGameData(gameId) + } + + override fun authenticate(userId: String, password: String): Boolean { + return runBlocking { api.auth.loginOnly(userId, password) } + } + + override fun setPassword(newPassword: String): Boolean { + return runBlocking { api.account.setPassword(newPassword, suppress = true) } + } + +} + +/** + * Workaround to "just get" the file storage handler and the API, but without initializing + * + * TODO: This wrapper should be replaced by better file storage initialization handling. + * + * This object keeps references which are populated during program startup at runtime. + */ +object ApiV2FileStorageWrapper { + var api: ApiV2? = null + var storage: ApiV2FileStorageEmulator? = null +}