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:
Crsi 2023-06-18 17:17:59 +02:00 committed by GitHub
parent fa68b8746e
commit bd3aa54670
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 3351 additions and 4 deletions

View File

@ -1,9 +1,7 @@
import com.unciv.build.BuildConfig.gdxVersion import com.unciv.build.BuildConfig.gdxVersion
import com.unciv.build.BuildConfig.ktorVersion
import com.unciv.build.BuildConfig.roboVMVersion 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 // You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used
configurations.all { resolutionStrategy { configurations.all { resolutionStrategy {
@ -12,7 +10,6 @@ configurations.all { resolutionStrategy {
buildscript { buildscript {
repositories { repositories {
// Chinese mirrors for quicker loading for chinese devs - uncomment if you're chinese // Chinese mirrors for quicker loading for chinese devs - uncomment if you're chinese
// maven{ url = uri("https://maven.aliyun.com/repository/central") } // 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 { allprojects {
apply(plugin = "eclipse") apply(plugin = "eclipse")
apply(plugin = "idea") apply(plugin = "idea")
@ -116,12 +125,26 @@ project(":ios") {
project(":core") { project(":core") {
apply(plugin = "kotlin") apply(plugin = "kotlin")
// Serialization features (especially JSON)
apply(plugin = "kotlinx-serialization")
dependencies { dependencies {
"implementation"("com.badlogicgames.gdx:gdx:$gdxVersion") "implementation"("com.badlogicgames.gdx:gdx:$gdxVersion")
"implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
"implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") "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")
} }

View File

@ -8,5 +8,6 @@ object BuildConfig {
const val appVersion = "4.7.1" const val appVersion = "4.7.1"
const val gdxVersion = "1.11.0" const val gdxVersion = "1.11.0"
const val ktorVersion = "2.2.3"
const val roboVMVersion = "2.3.1" const val roboVMVersion = "2.3.1"
} }

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

View 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') }
}

View 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."
}
}

View 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")
}
}
}

View 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

View 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())
}
}

View 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?
)

View 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
)

View File

@ -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)
}

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

View File

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