From 459cf3c7ca2f50f18937d95d396249a20dfb89dc Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Tue, 24 Aug 2021 00:08:14 +0700 Subject: [PATCH 1/5] (#12) web: add a Kotlin web application template --- README.md | 8 +++++-- settings.gradle.kts | 2 +- web/build.gradle.kts | 22 +++++++++++++++++++ .../codingteam/hyperspace/web/Application.kt | 11 ++++++++++ .../hyperspace/web/plugins/Routing.kt | 13 +++++++++++ web/src/main/resources/logback.xml | 12 ++++++++++ .../hyperspace/web/ApplicationTest.kt | 19 ++++++++++++++++ 7 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 web/build.gradle.kts create mode 100644 web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt create mode 100644 web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt create mode 100644 web/src/main/resources/logback.xml create mode 100644 web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt diff --git a/README.md b/README.md index b5b7ebf..76bd7e1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Hyperspace [![Status Umbra][status-umbra]][andivionian-status-classifier] ========== -Hyperspace is a [Slingshot][slingshot] clone written in Clojure. +Hyperspace is a [Slingshot][slingshot] clone written in Clojure and Kotlin. ![Gameplay Footage][gameplay] @@ -29,7 +29,11 @@ Then open a file `web/frontend/build/www/index.html` using a web browser. ## Game server -The game server will use a protocol based on web sockets. It is currently at the early development stage. +To run the game server, use the following shell command: + +```console +$ ./gradlew web:run +``` ## Testing diff --git a/settings.gradle.kts b/settings.gradle.kts index a572687..5157320 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ rootProject.name = "hyperspace" -include("client", "core", "server", "web:frontend") +include("client", "core", "server", "web", "web:frontend") diff --git a/web/build.gradle.kts b/web/build.gradle.kts new file mode 100644 index 0000000..6b1fb60 --- /dev/null +++ b/web/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + application + kotlin("jvm") version "1.5.21" +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("io.ktor:ktor-server-core:1.6.2") + implementation("io.ktor:ktor-websockets:1.6.2") + implementation("io.ktor:ktor-server-netty:1.6.2") + implementation("ch.qos.logback:logback-classic:1.2.3") + testImplementation("io.ktor:ktor-server-tests:1.6.2") + testImplementation("org.jetbrains.kotlin:kotlin-test:1.5.21") +} + +version = "1.0.0" +application { + mainClass.set("ru.org.codingteam.hyperspace.web.ApplicationKt") +} diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt new file mode 100644 index 0000000..21efe57 --- /dev/null +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt @@ -0,0 +1,11 @@ +package ru.org.codingteam.hyperspace.web + +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import ru.org.codingteam.hyperspace.web.plugins.configureRouting + +fun main() { + embeddedServer(Netty, port = 8080, host = "0.0.0.0") { + configureRouting() + }.start(wait = true) +} diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt new file mode 100644 index 0000000..51545df --- /dev/null +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt @@ -0,0 +1,13 @@ +package ru.org.codingteam.hyperspace.web.plugins + +import io.ktor.application.* +import io.ktor.response.* +import io.ktor.routing.* + +fun Application.configureRouting() { + routing { + get("/") { + call.respondText("Hello World!") + } + } +} diff --git a/web/src/main/resources/logback.xml b/web/src/main/resources/logback.xml new file mode 100644 index 0000000..bdbb64e --- /dev/null +++ b/web/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt new file mode 100644 index 0000000..e577dee --- /dev/null +++ b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt @@ -0,0 +1,19 @@ +package ru.org.codingteam.hyperspace.web + +import io.ktor.http.* +import io.ktor.server.testing.* +import ru.org.codingteam.hyperspace.web.plugins.configureRouting +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplicationTest { + @Test + fun testRoot() { + withTestApplication({ configureRouting() }) { + handleRequest(HttpMethod.Get, "/").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals("Hello World!", response.content) + } + } + } +} From c8ffb1efe9ffe9708ef8ee345157ad077bfcd12a Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Mon, 30 Aug 2021 23:55:44 +0700 Subject: [PATCH 2/5] (#12) web: add a read-only game API --- web/build.gradle.kts | 6 +- .../codingteam/hyperspace/web/Application.kt | 28 +++++++++- .../hyperspace/web/features/Games.kt | 35 ++++++++++++ .../hyperspace/web/plugins/Routing.kt | 13 ----- .../hyperspace/web/ApplicationTest.kt | 19 ------- .../hyperspace/web/features/GamesTests.kt | 55 +++++++++++++++++++ 6 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt delete mode 100644 web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt delete mode 100644 web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt create mode 100644 web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 6b1fb60..9e04d49 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -1,6 +1,7 @@ plugins { application kotlin("jvm") version "1.5.21" + kotlin("plugin.serialization") version "1.5.20" } repositories { @@ -8,10 +9,11 @@ repositories { } dependencies { + implementation("ch.qos.logback:logback-classic:1.2.5") + implementation("io.ktor:ktor-jackson:1.6.2") implementation("io.ktor:ktor-server-core:1.6.2") - implementation("io.ktor:ktor-websockets:1.6.2") implementation("io.ktor:ktor-server-netty:1.6.2") - implementation("ch.qos.logback:logback-classic:1.2.3") + implementation("io.ktor:ktor-websockets:1.6.2") testImplementation("io.ktor:ktor-server-tests:1.6.2") testImplementation("org.jetbrains.kotlin:kotlin-test:1.5.21") } diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt index 21efe57..be8f999 100644 --- a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt @@ -1,11 +1,33 @@ package ru.org.codingteam.hyperspace.web +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.jackson.* import io.ktor.server.engine.* import io.ktor.server.netty.* -import ru.org.codingteam.hyperspace.web.plugins.configureRouting +import ru.org.codingteam.hyperspace.web.features.configureGameApi + +fun Application.configureLogging() { + install(CallLogging) +} + +val jsonSerializer = ObjectMapper().apply { + registerKotlinModule() +} + +fun Application.configureSerialization() { + install(ContentNegotiation) { + register(ContentType.Application.Json, JacksonConverter(jsonSerializer)) + } +} fun main() { - embeddedServer(Netty, port = 8080, host = "0.0.0.0") { - configureRouting() + embeddedServer(Netty, port = 8080, host = "localhost") { + configureLogging() + configureSerialization() + configureGameApi() }.start(wait = true) } diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt new file mode 100644 index 0000000..95b9ce5 --- /dev/null +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt @@ -0,0 +1,35 @@ +package ru.org.codingteam.hyperspace.web.features + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class GameDefinition(val width: Int, val height: Int) + +fun Application.configureGameApi() { + val mutex = Mutex() + val storage = mutableMapOf() + routing { + route("/api/game") { + get("/") { + val state = mutex.withLock { storage.values.toList() } + call.respond(state) + } + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respondText( + "Invalid id", + status = HttpStatusCode.BadRequest + ) + val game = mutex.withLock { storage[id] } ?: return@get call.respondText( + "Game not found", + status = HttpStatusCode.NotFound + ) + + call.respond(game) + } + } + } +} diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt deleted file mode 100644 index 51545df..0000000 --- a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/plugins/Routing.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ru.org.codingteam.hyperspace.web.plugins - -import io.ktor.application.* -import io.ktor.response.* -import io.ktor.routing.* - -fun Application.configureRouting() { - routing { - get("/") { - call.respondText("Hello World!") - } - } -} diff --git a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt deleted file mode 100644 index e577dee..0000000 --- a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/ApplicationTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ru.org.codingteam.hyperspace.web - -import io.ktor.http.* -import io.ktor.server.testing.* -import ru.org.codingteam.hyperspace.web.plugins.configureRouting -import kotlin.test.Test -import kotlin.test.assertEquals - -class ApplicationTest { - @Test - fun testRoot() { - withTestApplication({ configureRouting() }) { - handleRequest(HttpMethod.Get, "/").apply { - assertEquals(HttpStatusCode.OK, response.status()) - assertEquals("Hello World!", response.content) - } - } - } -} diff --git a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt new file mode 100644 index 0000000..5e4c875 --- /dev/null +++ b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt @@ -0,0 +1,55 @@ +package ru.org.codingteam.hyperspace.web.features + +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.runBlocking +import ru.org.codingteam.hyperspace.web.configureLogging +import ru.org.codingteam.hyperspace.web.configureSerialization +import ru.org.codingteam.hyperspace.web.jsonSerializer +import kotlin.test.Test +import kotlin.test.assertEquals + +class GamesTests { + private fun withTestApplication(test: TestApplicationEngine.() -> Unit) { + withTestApplication({ + configureLogging() + configureSerialization() + configureGameApi() + }, test) + } + + private inline fun TestApplicationCall.receive(): T { + val text = response.content!! + return jsonSerializer.readValue(text, T::class.java) + } + + private fun TestApplicationEngine.sendRequest(url: String, handler: TestApplicationCall.() -> Unit) { + handleRequest(HttpMethod.Get, url) { + addHeader(HttpHeaders.Accept, ContentType.Application.Json.toString()) + }.apply(handler) + } + + @Test + fun testGameList() { + withTestApplication { + sendRequest("/api/game/") { + receive>() + assertEquals(HttpStatusCode.OK, response.status()) + response.content + val list = runBlocking { receive>() } + assertEquals(list, emptyList()) + } + } + } + + @Test + fun testGameNotFound() { + withTestApplication { + handleRequest(HttpMethod.Get, "/api/game/123") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType) + }.apply { + assertEquals(HttpStatusCode.NotFound, response.status()) + } + } + } +} From 5017299cef2a92142fab224866a3910194e07e55 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Tue, 31 Aug 2021 01:25:49 +0700 Subject: [PATCH 3/5] (#12) web: add an API to create a new game --- .../hyperspace/web/features/Games.kt | 12 +++++ .../hyperspace/web/features/GamesTests.kt | 51 ++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt index 95b9ce5..1eac825 100644 --- a/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt @@ -2,6 +2,7 @@ package ru.org.codingteam.hyperspace.web.features import io.ktor.application.* import io.ktor.http.* +import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.* import kotlinx.coroutines.sync.Mutex @@ -12,6 +13,8 @@ data class GameDefinition(val width: Int, val height: Int) fun Application.configureGameApi() { val mutex = Mutex() val storage = mutableMapOf() + var lastId = 0 + routing { route("/api/game") { get("/") { @@ -30,6 +33,15 @@ fun Application.configureGameApi() { call.respond(game) } + post("/") { + val newGame = call.receive() + val id = mutex.withLock { + val nextId = ++lastId + storage[nextId] = newGame + nextId + } + call.respond(id) + } } } } diff --git a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt index 5e4c875..1b8539a 100644 --- a/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt +++ b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt @@ -23,12 +23,26 @@ class GamesTests { return jsonSerializer.readValue(text, T::class.java) } - private fun TestApplicationEngine.sendRequest(url: String, handler: TestApplicationCall.() -> Unit) { - handleRequest(HttpMethod.Get, url) { - addHeader(HttpHeaders.Accept, ContentType.Application.Json.toString()) + private fun TestApplicationEngine.sendRequest( + url: String, + method: HttpMethod = HttpMethod.Get, + body: T? = null, + handler: TestApplicationCall.() -> Unit = {} + ) { + handleRequest(method, url) { + val jsonContentType = ContentType.Application.Json.toString() + addHeader(HttpHeaders.ContentType, jsonContentType) + addHeader(HttpHeaders.Accept, jsonContentType) + body?.let { setBody(jsonSerializer.writeValueAsString(body)) } }.apply(handler) } + private fun TestApplicationEngine.sendRequest( + url: String, + method: HttpMethod = HttpMethod.Get, + handler: TestApplicationCall.() -> Unit + ) = sendRequest(url, method, null, handler) + @Test fun testGameList() { withTestApplication { @@ -45,11 +59,36 @@ class GamesTests { @Test fun testGameNotFound() { withTestApplication { - handleRequest(HttpMethod.Get, "/api/game/123") { - addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType) - }.apply { + sendRequest("/api/game/123") { assertEquals(HttpStatusCode.NotFound, response.status()) } } } + + @Test + fun newGameShouldBeAdded() { + withTestApplication { + val game = GameDefinition(480, 640) + sendRequest("/api/game/", HttpMethod.Post, game) { + val id = receive() + sendRequest("/api/game/$id") { + val createdGame = receive() + assertEquals(game, createdGame) + } + } + } + } + + @Test + fun newGameIdShouldBeGenerated() { + withTestApplication { + val game = GameDefinition(480, 640) + sendRequest("/api/game/", HttpMethod.Post, game) { + assertEquals(1, receive()) + } + sendRequest("/api/game/", HttpMethod.Post, game) { + assertEquals(2, receive()) + } + } + } } From a395a7b9323a2e3d021c3b33f233580c459b79ac Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Tue, 31 Aug 2021 01:27:24 +0700 Subject: [PATCH 4/5] (#12) web: remove an unused compiler serialization plugin --- web/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 9e04d49..12b28d6 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -1,7 +1,6 @@ plugins { application kotlin("jvm") version "1.5.21" - kotlin("plugin.serialization") version "1.5.20" } repositories { From 9c29908243f7d87a856fa7eae5ece5d0e9cfa21a Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Tue, 31 Aug 2021 01:29:38 +0700 Subject: [PATCH 5/5] (#12) web: remove an unused websocket dependency --- web/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 12b28d6..a6480b8 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -12,7 +12,6 @@ dependencies { implementation("io.ktor:ktor-jackson:1.6.2") implementation("io.ktor:ktor-server-core:1.6.2") implementation("io.ktor:ktor-server-netty:1.6.2") - implementation("io.ktor:ktor-websockets:1.6.2") testImplementation("io.ktor:ktor-server-tests:1.6.2") testImplementation("org.jetbrains.kotlin:kotlin-test:1.5.21") }