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 +}