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..a6480b8 --- /dev/null +++ b/web/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + application + kotlin("jvm") version "1.5.21" +} + +repositories { + mavenCentral() +} + +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-server-netty:1.6.2") + 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..be8f999 --- /dev/null +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/Application.kt @@ -0,0 +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.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 = "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..1eac825 --- /dev/null +++ b/web/src/main/kotlin/ru/org/codingteam/hyperspace/web/features/Games.kt @@ -0,0 +1,47 @@ +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 +import kotlinx.coroutines.sync.withLock + +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("/") { + 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) + } + post("/") { + val newGame = call.receive() + val id = mutex.withLock { + val nextId = ++lastId + storage[nextId] = newGame + nextId + } + call.respond(id) + } + } + } +} 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/features/GamesTests.kt b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt new file mode 100644 index 0000000..1b8539a --- /dev/null +++ b/web/src/test/kotlin/ru/org/codingteam/hyperspace/web/features/GamesTests.kt @@ -0,0 +1,94 @@ +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, + 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 { + sendRequest("/api/game/") { + receive>() + assertEquals(HttpStatusCode.OK, response.status()) + response.content + val list = runBlocking { receive>() } + assertEquals(list, emptyList()) + } + } + } + + @Test + fun testGameNotFound() { + withTestApplication { + 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()) + } + } + } +}