mirror of
https://github.com/yairm210/Unciv.git
synced 2024-12-22 20:04:24 +07:00
Multiplayer v2: networking stack, dependencies & API definition (#9589)
* Added new ktor dependency for developing multiplayer API v2 * Added the api package, including endpoint implementations, cookie helpers, serializers and structs * Fixed a bunch of problems related to error handling * Fixed some API incompatibilities, added getFriends() method Rename the Api class to ApiV2Wrapper, added a chat room screen Replaced logging dependency, renamed the endpoint implementations * Dropped the extra logger to remove dependencies, added the APIv2 class * Restructured the project to make ApiV2 class the center * Improved chat handling, added server game detail caching Added a generic HTTP request wrapper that can retry requests easily Added a default handler to retry requests after session refreshing * Updated the API structs based on the new OpenAPI specifications Switched endpoint implementations to use the new 'request', updated WebSocket structs * Updated the auth helper, added the UncivNetworkException Fixed some more issues due to refactoring APIv2 handler Fixed some issues and some minor incompatibilities with the new API * Implemented the LobbyBrowserTable, added missing API endpoint Fixed login and auth issues in the main menu screen * Added new WebSocket structs for handling invites and friends Updated the API reference implementation * Added GET cache, allowed all WS messages to be Events, added missing endpoints Added func to dispose and refresh OnlineMultiplayer, only show set username for APIv2 * Reworked the ApiV2 class to improve WebSocket handling for every login Added small game fetch, fixed lobby start, some smaller fixes * Change the user ID after logging in to fix later in-game issues Attention: Afterwards, there is restoration of the previous player ID. Therefore, it won't be possible to revert back to APIv0 or APIv1 behavior easily (i.e., without saving the player ID before logging in the first time). Added serializer class for WebSocket's FriendshipEvent enum Fixed chat room access and cancelling friendships * Fixed WebSocket re-connecting, outsourced configs Updated the RegisterLoginPopup to ask if the user wants to use the new servers Implemented a self-contained API version check with side-effects Fixed various problems with WebSocket connections Don't show kick button for lobby owner, handle network issues during login * Added English translations for ApiStatusCode, fixed broken APIv1 games for uncivserver.xyz Fixed subpaths in baseUrl, added server settings button * Added WS-based Android turn checker, added a new event channel, fixed APIWrapper Added a logout hook, implemented ensureConnectedWebSocket Merge branch 'master' into dev * Throttle auto-reconnect for WS on Android in background, added reload notice for your turn popup Implemented real pinging with awaiting responses, fixed ping-related problems * Adapted new getAllChats API, added outstanding friend request list, improved styling * Added the ApiVersion enum and the ApiV2 storage emulator * Updated the APIv2 file storage emulator * Replaced all wildcard imports with named imports
This commit is contained in:
parent
fa68b8746e
commit
bd3aa54670
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
|
147
core/src/com/unciv/logic/multiplayer/ApiVersion.kt
Normal file
147
core/src/com/unciv/logic/multiplayer/ApiVersion.kt
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
618
core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt
Normal file
618
core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt
Normal file
@ -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<Frame>? = 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<Instant?> = AtomicReference()
|
||||
|
||||
/** Cache for the game details to make certain lookups faster */
|
||||
private val gameDetails: MutableMap<UUID, TimedGameDetails> = mutableMapOf()
|
||||
|
||||
/** List of channel that extend the usage of the [EventBus] system, see [getWebSocketEventChannel] */
|
||||
private val eventChannelList = mutableListOf<SendChannel<Event>>()
|
||||
|
||||
/** 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<String, Channel<Exception?>> = 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<Event> {
|
||||
// We're using CONFLATED channels here to avoid usage of possibly huge amounts of memory
|
||||
val c = Channel<Event>(capacity = CONFLATED)
|
||||
eventChannelList.add(c as SendChannel<Event>)
|
||||
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<String, String>? = 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<Exception?>(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') }
|
||||
}
|
255
core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt
Normal file
255
core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt
Normal file
@ -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<Job>()
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
64
core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt
Normal file
64
core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt
Normal file
@ -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<Pair<String, Int?>?> = AtomicReference()
|
||||
|
||||
/** Credentials used during the last successful login */
|
||||
internal var lastSuccessfulCredentials: AtomicReference<Pair<String, String>?> = AtomicReference()
|
||||
|
||||
/** Timestamp of the last successful login */
|
||||
private var lastSuccessfulAuthentication: AtomicReference<Instant?> = 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<String, String>? = 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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
27
core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt
Normal file
27
core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt
Normal file
@ -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
|
File diff suppressed because it is too large
Load Diff
118
core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt
Normal file
118
core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt
Normal file
@ -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<ApiStatusCode> {
|
||||
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<Instant> {
|
||||
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<UUID> {
|
||||
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>(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<WebSocketMessageType> {
|
||||
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<FriendshipEvent> {
|
||||
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())
|
||||
}
|
||||
}
|
124
core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt
Normal file
124
core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt
Normal file
@ -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?
|
||||
)
|
391
core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt
Normal file
391
core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt
Normal file
@ -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<ChatSmall>,
|
||||
@SerialName("game_chat_rooms")
|
||||
val gameChatRooms: List<ChatSmall>,
|
||||
@SerialName("lobby_chat_rooms")
|
||||
val lobbyChatRooms: List<ChatSmall>
|
||||
)
|
||||
|
||||
/**
|
||||
* The response to a get chat
|
||||
*
|
||||
* [messages] should be sorted by the datetime of message.created_at.
|
||||
*/
|
||||
@Serializable
|
||||
data class GetChatResponse(
|
||||
val members: List<ChatMember>,
|
||||
val messages: List<ChatMessage>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<FriendResponse>,
|
||||
@SerialName("friend_requests")
|
||||
val friendRequests: List<FriendRequestResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* An overview of games a player participates in
|
||||
*/
|
||||
@Serializable
|
||||
data class GetGameOverviewResponse(
|
||||
val games: List<GameOverviewResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<GetInvite>
|
||||
)
|
||||
|
||||
/**
|
||||
* The lobbies that are open
|
||||
*/
|
||||
@Serializable
|
||||
data class GetLobbiesResponse(
|
||||
val lobbies: List<LobbyResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<AccountResponse>,
|
||||
@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
|
||||
)
|
@ -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)
|
||||
}
|
344
core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt
Normal file
344
core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt
Normal file
@ -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 }
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user