Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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]

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
rootProject.name = "hyperspace"
include("client", "core", "server", "web:frontend")
include("client", "core", "server", "web", "web:frontend")
22 changes: 22 additions & 0 deletions web/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Int, GameDefinition>()
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<GameDefinition>()
val id = mutex.withLock {
val nextId = ++lastId
storage[nextId] = newGame
nextId
}
call.respond(id)
}
}
}
}
12 changes: 12 additions & 0 deletions web/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
Original file line number Diff line number Diff line change
@@ -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 <reified T> TestApplicationCall.receive(): T {
val text = response.content!!
return jsonSerializer.readValue(text, T::class.java)
}

private fun <T> 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<List<GameDefinition>>()
assertEquals(HttpStatusCode.OK, response.status())
response.content
val list = runBlocking { receive<List<GameDefinition>>() }
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<Int>()
sendRequest("/api/game/$id") {
val createdGame = receive<GameDefinition>()
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())
}
}
}
}