diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 59fb705..6149430 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -14,6 +14,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 1b2a733..9a484e9 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 11ad6e7..188e836 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -34,7 +34,9 @@ val versions = mapOf(
"JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4",
"SCALA_PARSER_COMBINATORS" to "2.4.0",
- "FASTPARSE" to "3.0.2"
+ "FASTPARSE" to "3.0.2",
+ "HTTP4S" to "0.23.29",
+ "CIRCE" to "0.14.10"
)
extra["VERSIONS"] = versions
diff --git a/modules/server/build.gradle.kts b/modules/server/build.gradle.kts
new file mode 100644
index 0000000..9887611
--- /dev/null
+++ b/modules/server/build.gradle.kts
@@ -0,0 +1,79 @@
+import org.gradle.jvm.tasks.Jar
+
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ application
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedPackages.set(listOf("de.nowchess.server"))
+}
+
+application {
+ mainClass.set("de.nowchess.server.ServerApp")
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+tasks.named("jar") {
+ duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.EXCLUDE
+}
+
+dependencies {
+ implementation("org.scala-lang:scala3-compiler_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+
+ implementation(project(":modules:api"))
+ implementation(project(":modules:core"))
+ implementation(project(":modules:io"))
+ implementation(project(":modules:rule"))
+
+ val http4s = versions["HTTP4S"]!!
+ val circe = versions["CIRCE"]!!
+
+ implementation("org.http4s:http4s-ember-server_3:$http4s")
+ implementation("org.http4s:http4s-dsl_3:$http4s")
+ implementation("org.http4s:http4s-circe_3:$http4s")
+ implementation("io.circe:circe-core_3:$circe")
+ implementation("io.circe:circe-generic_3:$circe")
+ implementation("io.circe:circe-parser_3:$circe")
+
+ testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest")
+ testLogging {
+ events("skipped", "failed")
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
diff --git a/modules/server/src/main/scala/de/nowchess/server/BoardRoutes.scala b/modules/server/src/main/scala/de/nowchess/server/BoardRoutes.scala
new file mode 100644
index 0000000..164a8fd
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/BoardRoutes.scala
@@ -0,0 +1,283 @@
+package de.nowchess.server
+
+import cats.effect.IO
+import org.http4s.*
+import org.http4s.dsl.io.*
+import org.http4s.circe.*
+import org.http4s.circe.CirceEntityDecoder.given
+import io.circe.syntax.*
+import de.nowchess.api.board.{Color, Square}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.engine.GameEngine
+import de.nowchess.chess.observer.InvalidMoveEvent
+import de.nowchess.io.fen.{FenExporter, FenParser}
+import de.nowchess.io.pgn.{PgnExporter, PgnParser}
+import JsonCodecs.given
+
+class BoardRoutes(registry: GameRegistry):
+
+ def routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ POST -> Root / "api" / "board" / "game" / "import" / "fen" => importFen(req)
+ case req @ POST -> Root / "api" / "board" / "game" / "import" / "pgn" => importPgn(req)
+ case req @ POST -> Root / "api" / "board" / "game" => createGame(req)
+ case GET -> Root / "api" / "board" / "game" / id => getGame(id)
+ case GET -> Root / "api" / "board" / "game" / id / "stream" => streamGame(id)
+ case POST -> Root / "api" / "board" / "game" / id / "move" / uci => makeMove(id, uci)
+ case req @ GET -> Root / "api" / "board" / "game" / id / "moves" => getLegalMoves(req, id)
+ case POST -> Root / "api" / "board" / "game" / id / "undo" => undoMove(id)
+ case POST -> Root / "api" / "board" / "game" / id / "redo" => redoMove(id)
+ case POST -> Root / "api" / "board" / "game" / id / "resign" => resign(id)
+ case POST -> Root / "api" / "board" / "game" / id / "draw" / action => drawAction(id, action)
+ case GET -> Root / "api" / "board" / "game" / id / "export" / "fen" => exportFen(id)
+ case GET -> Root / "api" / "board" / "game" / id / "export" / "pgn" => exportPgn(id)
+ }
+
+ // ─── Game lifecycle ────────────────────────────────────────────────────────
+
+ private def createGame(req: Request[IO]): IO[Response[IO]] =
+ req.as[CreateGameRequest]
+ .handleError(_ => CreateGameRequest())
+ .flatMap { body =>
+ val white = resolvePlayer(body.white, "white")
+ val black = resolvePlayer(body.black, "black")
+ registry.create(white, black).flatMap { id =>
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) => Created(toGameFullDto(id, entry).asJson)
+ }
+ }
+ }
+
+ private def getGame(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) => Ok(toGameFullDto(id, entry).asJson)
+ }
+
+ private def streamGame(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) => Ok(GameFullEvent("gameFull", toGameFullDto(id, entry)).asJson)
+ }
+
+ private def resign(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
+ case Some(entry) =>
+ IO.blocking {
+ val winner = entry.engine.context.turn.opposite
+ entry.gameOver = Some(GameOverState.Resigned(winner, entry.engine.context))
+ }.flatMap(_ => Ok(OkDto().asJson))
+ }
+
+ // ─── Move-making ───────────────────────────────────────────────────────────
+
+ private def makeMove(id: String, uci: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
+ case Some(entry) =>
+ IO.blocking {
+ entry.lastEvent = None
+ applyUci(entry, uci)
+ entry.lastEvent
+ }.flatMap {
+ case Some(InvalidMoveEvent(_, reason)) => badRequest("INVALID_MOVE", reason)
+ case _ => Ok(toGameStateDto(entry).asJson)
+ }
+ }
+
+ private def getLegalMoves(req: Request[IO], id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
+ case Some(entry) =>
+ val squareOpt = req.uri.query.params.get("square").flatMap(Square.fromAlgebraic)
+ IO.blocking {
+ val ctx = entry.engine.context
+ val rs = entry.engine.ruleSet
+ squareOpt.map(rs.legalMoves(ctx)).getOrElse(rs.allLegalMoves(ctx))
+ }.flatMap { moves =>
+ Ok(LegalMovesDto(moves.map(toLegalMoveDto)).asJson)
+ }
+ }
+
+ private def undoMove(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if !entry.engine.canUndo => badRequest("UNDO_UNAVAILABLE", "No moves to undo")
+ case Some(entry) =>
+ IO.blocking { entry.engine.undo() }.flatMap(_ => Ok(toGameStateDto(entry).asJson))
+ }
+
+ private def redoMove(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if !entry.engine.canRedo => badRequest("REDO_UNAVAILABLE", "No moves to redo")
+ case Some(entry) =>
+ IO.blocking { entry.engine.redo() }.flatMap(_ => Ok(toGameStateDto(entry).asJson))
+ }
+
+ // ─── Draw handling ─────────────────────────────────────────────────────────
+
+ private def drawAction(id: String, action: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
+ case Some(entry) => action match
+ case "offer" =>
+ IO.blocking { entry.drawOffered = true }.flatMap(_ => Ok(OkDto().asJson))
+ case "decline" =>
+ IO.blocking { entry.drawOffered = false }.flatMap(_ => Ok(OkDto().asJson))
+ case "accept" =>
+ if !entry.drawOffered then badRequest("NO_DRAW_OFFER", "No draw offer to accept")
+ else IO.blocking {
+ entry.gameOver = Some(GameOverState.Draw(entry.engine.context))
+ }.flatMap(_ => Ok(OkDto().asJson))
+ case "claim" =>
+ IO.blocking {
+ entry.lastEvent = None
+ entry.engine.processUserInput("draw")
+ entry.lastEvent
+ }.flatMap {
+ case Some(InvalidMoveEvent(_, reason)) => badRequest("DRAW_UNAVAILABLE", reason)
+ case _ => Ok(OkDto().asJson)
+ }
+ case _ => badRequest("INVALID_ACTION", s"Unknown draw action: $action")
+ }
+
+ // ─── Import / Export ───────────────────────────────────────────────────────
+
+ private def importFen(req: Request[IO]): IO[Response[IO]] =
+ req.as[ImportFenRequest].flatMap { body =>
+ FenParser.parseFen(body.fen) match
+ case Left(err) => badRequest("INVALID_FEN", err)
+ case Right(ctx) =>
+ val white = resolvePlayer(body.white, "white")
+ val black = resolvePlayer(body.black, "black")
+ val entry = GameEntry(new GameEngine(ctx), white, black)
+ registry.add(entry).flatMap { id => Created(toGameFullDto(id, entry).asJson) }
+ }
+
+ private def importPgn(req: Request[IO]): IO[Response[IO]] =
+ req.as[ImportPgnRequest].flatMap { body =>
+ val engine = new GameEngine()
+ engine.loadGame(PgnParser, body.pgn) match
+ case Left(err) => badRequest("INVALID_PGN", err)
+ case Right(_) =>
+ val entry = GameEntry(engine, defaultPlayer("white"), defaultPlayer("black"))
+ registry.add(entry).flatMap { id => Created(toGameFullDto(id, entry).asJson) }
+ }
+
+ private def exportFen(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) => Ok(FenExporter.gameContextToFen(entry.activeContext))
+ }
+
+ private def exportPgn(id: String): IO[Response[IO]] =
+ registry.get(id).flatMap {
+ case None => notFound(id)
+ case Some(entry) => Ok(PgnExporter.exportGameContext(entry.activeContext))
+ }
+
+ // ─── Mapping helpers ───────────────────────────────────────────────────────
+
+ private def toGameStateDto(entry: GameEntry): GameStateDto =
+ val ctx = entry.activeContext
+ val (status, winner) = computeStatus(entry)
+ GameStateDto(
+ fen = FenExporter.gameContextToFen(ctx),
+ pgn = PgnExporter.exportGameContext(ctx),
+ turn = ctx.turn.toString.toLowerCase,
+ status = status,
+ winner = winner,
+ moves = ctx.moves.map(moveToUci),
+ undoAvailable = entry.engine.canUndo,
+ redoAvailable = entry.engine.canRedo
+ )
+
+ private def toGameFullDto(id: String, entry: GameEntry): GameFullDto =
+ GameFullDto(
+ gameId = id,
+ white = PlayerInfoDto(entry.white.id.value, entry.white.displayName),
+ black = PlayerInfoDto(entry.black.id.value, entry.black.displayName),
+ state = toGameStateDto(entry)
+ )
+
+ private def toLegalMoveDto(move: Move): LegalMoveDto =
+ val (moveType, promo) = move.moveType match
+ case MoveType.Normal(true) => ("capture", None)
+ case MoveType.Normal(false) => ("normal", None)
+ case MoveType.CastleKingside => ("castleKingside", None)
+ case MoveType.CastleQueenside => ("castleQueenside", None)
+ case MoveType.EnPassant => ("enPassant", None)
+ case MoveType.Promotion(pp) =>
+ val s = pp match
+ case PromotionPiece.Queen => "queen"
+ case PromotionPiece.Rook => "rook"
+ case PromotionPiece.Bishop => "bishop"
+ case PromotionPiece.Knight => "knight"
+ ("promotion", Some(s))
+ LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveType, promo)
+
+ private def moveToUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(pp) =>
+ val char = pp match
+ case PromotionPiece.Queen => "q"
+ case PromotionPiece.Rook => "r"
+ case PromotionPiece.Bishop => "b"
+ case PromotionPiece.Knight => "n"
+ base + char
+ case _ => base
+
+ private def applyUci(entry: GameEntry, uci: String): Unit =
+ if uci.length == 5 then
+ entry.engine.processUserInput(uci.take(4))
+ if entry.engine.isPendingPromotion then
+ val pp = uci.last match
+ case 'q' => PromotionPiece.Queen
+ case 'r' => PromotionPiece.Rook
+ case 'b' => PromotionPiece.Bishop
+ case _ => PromotionPiece.Knight
+ entry.engine.completePromotion(pp)
+ else
+ entry.engine.processUserInput(uci)
+
+ private def computeStatus(entry: GameEntry): (String, Option[String]) =
+ entry.gameOver match
+ case Some(GameOverState.Checkmate(w, _)) => ("checkmate", Some(w.toString.toLowerCase))
+ case Some(GameOverState.Stalemate(_)) => ("stalemate", None)
+ case Some(GameOverState.Draw(_)) => ("draw", None)
+ case Some(GameOverState.Resigned(w, _)) => ("resign", Some(w.toString.toLowerCase))
+ case None => statusFromContext(entry)
+
+ private def statusFromContext(entry: GameEntry): (String, Option[String]) =
+ if entry.engine.isPendingPromotion then ("promotionPending", None)
+ else if entry.drawOffered then ("drawOffered", None)
+ else
+ val ctx = entry.engine.context
+ val rs = entry.engine.ruleSet
+ if rs.isInsufficientMaterial(ctx) then ("insufficientMaterial", None)
+ else if rs.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None)
+ else if rs.isCheck(ctx) then ("check", None)
+ else ("started", None)
+
+ // ─── Utility ───────────────────────────────────────────────────────────────
+
+ private def resolvePlayer(dto: Option[PlayerInfoDto], color: String): PlayerInfo =
+ dto.map(d => PlayerInfo(PlayerId(d.id), d.displayName))
+ .getOrElse(defaultPlayer(color))
+
+ private def defaultPlayer(color: String): PlayerInfo =
+ PlayerInfo(PlayerId(color), color.capitalize)
+
+ private def notFound(id: String): IO[Response[IO]] =
+ NotFound(ApiErrorDto("GAME_NOT_FOUND", s"Game not found: $id").asJson)
+
+ private def badRequest(code: String, msg: String): IO[Response[IO]] =
+ BadRequest(ApiErrorDto(code, msg).asJson)
diff --git a/modules/server/src/main/scala/de/nowchess/server/GameEntry.scala b/modules/server/src/main/scala/de/nowchess/server/GameEntry.scala
new file mode 100644
index 0000000..77a3b82
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/GameEntry.scala
@@ -0,0 +1,41 @@
+package de.nowchess.server
+
+import de.nowchess.api.board.Color
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.player.PlayerInfo
+import de.nowchess.chess.engine.GameEngine
+import de.nowchess.chess.observer.*
+
+enum GameOverState:
+ case Checkmate(winner: Color, context: GameContext)
+ case Stalemate(context: GameContext)
+ case Draw(context: GameContext)
+ case Resigned(winner: Color, context: GameContext)
+
+class GameEntry(
+ val engine: GameEngine,
+ val white: PlayerInfo,
+ val black: PlayerInfo
+):
+ var lastEvent: Option[GameEvent] = None
+ var gameOver: Option[GameOverState] = None
+ var drawOffered: Boolean = false
+
+ engine.subscribe(new Observer:
+ def onGameEvent(event: GameEvent): Unit =
+ lastEvent = Some(event)
+ event match
+ case CheckmateEvent(ctx, winner) => gameOver = Some(GameOverState.Checkmate(winner, ctx))
+ case StalemateEvent(ctx) => gameOver = Some(GameOverState.Stalemate(ctx))
+ case DrawClaimedEvent(ctx) => gameOver = Some(GameOverState.Draw(ctx))
+ case _ => ()
+ )
+
+ def isOver: Boolean = gameOver.isDefined
+
+ def activeContext: GameContext = gameOver match
+ case Some(GameOverState.Checkmate(_, ctx)) => ctx
+ case Some(GameOverState.Stalemate(ctx)) => ctx
+ case Some(GameOverState.Draw(ctx)) => ctx
+ case Some(GameOverState.Resigned(_, ctx)) => ctx
+ case None => engine.context
diff --git a/modules/server/src/main/scala/de/nowchess/server/GameRegistry.scala b/modules/server/src/main/scala/de/nowchess/server/GameRegistry.scala
new file mode 100644
index 0000000..d924376
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/GameRegistry.scala
@@ -0,0 +1,21 @@
+package de.nowchess.server
+
+import cats.effect.{IO, Ref}
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.engine.GameEngine
+
+class GameRegistry(ref: Ref[IO, Map[String, GameEntry]]):
+
+ def create(white: PlayerInfo, black: PlayerInfo): IO[String] =
+ add(GameEntry(new GameEngine(), white, black))
+
+ def add(entry: GameEntry): IO[String] =
+ val id = scala.util.Random.alphanumeric.take(8).mkString
+ ref.update(_ + (id -> entry)).as(id)
+
+ def get(id: String): IO[Option[GameEntry]] =
+ ref.get.map(_.get(id))
+
+object GameRegistry:
+ def make: IO[GameRegistry] =
+ Ref.of[IO, Map[String, GameEntry]](Map.empty).map(new GameRegistry(_))
diff --git a/modules/server/src/main/scala/de/nowchess/server/JsonCodecs.scala b/modules/server/src/main/scala/de/nowchess/server/JsonCodecs.scala
new file mode 100644
index 0000000..f40b7b1
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/JsonCodecs.scala
@@ -0,0 +1,18 @@
+package de.nowchess.server
+
+import io.circe.{Decoder, Encoder}
+import io.circe.generic.semiauto.*
+
+object JsonCodecs:
+ given Decoder[PlayerInfoDto] = deriveDecoder
+ given Encoder[PlayerInfoDto] = deriveEncoder
+ given Decoder[CreateGameRequest] = deriveDecoder
+ given Decoder[ImportFenRequest] = deriveDecoder
+ given Decoder[ImportPgnRequest] = deriveDecoder
+ given Encoder[GameStateDto] = deriveEncoder
+ given Encoder[GameFullDto] = deriveEncoder
+ given Encoder[GameFullEvent] = deriveEncoder
+ given Encoder[LegalMoveDto] = deriveEncoder
+ given Encoder[LegalMovesDto] = deriveEncoder
+ given Encoder[OkDto] = deriveEncoder
+ given Encoder[ApiErrorDto] = deriveEncoder
diff --git a/modules/server/src/main/scala/de/nowchess/server/Models.scala b/modules/server/src/main/scala/de/nowchess/server/Models.scala
new file mode 100644
index 0000000..b5eb6d3
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/Models.scala
@@ -0,0 +1,54 @@
+package de.nowchess.server
+
+case class PlayerInfoDto(id: String, displayName: String)
+
+case class CreateGameRequest(
+ white: Option[PlayerInfoDto] = None,
+ black: Option[PlayerInfoDto] = None
+)
+
+case class ImportFenRequest(
+ fen: String,
+ white: Option[PlayerInfoDto] = None,
+ black: Option[PlayerInfoDto] = None
+)
+
+case class ImportPgnRequest(pgn: String)
+
+case class GameStateDto(
+ fen: String,
+ pgn: String,
+ turn: String,
+ status: String,
+ winner: Option[String],
+ moves: List[String],
+ undoAvailable: Boolean,
+ redoAvailable: Boolean
+)
+
+case class GameFullDto(
+ gameId: String,
+ white: PlayerInfoDto,
+ black: PlayerInfoDto,
+ state: GameStateDto
+)
+
+case class GameFullEvent(`type`: String, game: GameFullDto)
+
+case class LegalMoveDto(
+ from: String,
+ to: String,
+ uci: String,
+ moveType: String,
+ promotion: Option[String]
+)
+
+case class LegalMovesDto(moves: List[LegalMoveDto])
+
+case class OkDto(ok: Boolean = true)
+
+case class ApiErrorDto(
+ code: String,
+ message: String,
+ field: Option[String] = None
+)
diff --git a/modules/server/src/main/scala/de/nowchess/server/ServerApp.scala b/modules/server/src/main/scala/de/nowchess/server/ServerApp.scala
new file mode 100644
index 0000000..4ce9576
--- /dev/null
+++ b/modules/server/src/main/scala/de/nowchess/server/ServerApp.scala
@@ -0,0 +1,17 @@
+package de.nowchess.server
+
+import cats.effect.{ExitCode, IO, IOApp}
+import com.comcast.ip4s.{host, port}
+import org.http4s.ember.server.EmberServerBuilder
+
+object ServerApp extends IOApp:
+ def run(args: List[String]): IO[ExitCode] =
+ GameRegistry.make.flatMap { registry =>
+ EmberServerBuilder
+ .default[IO]
+ .withHost(host"0.0.0.0")
+ .withPort(port"8080")
+ .withHttpApp(BoardRoutes(registry).routes.orNotFound)
+ .build
+ .useForever
+ }.as(ExitCode.Success)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1571957..02c3c9d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -5,4 +5,5 @@ include(
"modules:io",
"modules:rule",
"modules:ui",
+ "modules:server",
)
\ No newline at end of file