=== ./gui-client/src/main/kotlin/guiclient/network/ClientProvider.kt === package guiclient.network import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import models.HumanBeing import network.NetworkManager as ClientNetworkManager import network.AuthRequest import network.AuthResponse import network.AuthType import network.Request import network.Response import java.security.MessageDigest import java.util.UUID class ClientProvider( host: String, port: Int, ) { private val net = ClientNetworkManager(host, port) suspend fun auth(login: String, password: String, type: AuthType): AuthResponse? = withContext(Dispatchers.IO) { net.sendRequest(AuthRequest(login, md5Hash(password), type)) } suspend fun execute(commandName: String, args: List = emptyList(), humanBeing: HumanBeing? = null): Response? = withContext(Dispatchers.IO) { val request = Request( commandName = commandName, args = args, humanBeing = humanBeing, jwt = net.getToken() ) net.sendRequest(request) } fun getToken(): String = net.getToken() fun setToken(token: String) = net.setToken(token) fun clearToken() = net.clearToken() fun getUUID(): UUID = net.getUUID() fun setUUID(id: String) = net.setUUID(id) fun close() = net.close() private fun md5Hash(input: String): String { val md = MessageDigest.getInstance("MD5") val hashBytes = md.digest(input.toByteArray()) return hashBytes.joinToString("") { String.format("%02x", it) } } } === ./gui-client/src/main/kotlin/util/StreamUtils.kt === package util import javafx.collections.FXCollections import javafx.collections.ObservableList import model.HumanBeingProperty import java.util.Comparator import java.util.stream.Collectors import java.util.stream.Stream object StreamUtils { fun filter( items: List, propertyExtractor: (HumanBeingProperty) -> String, query: String, ): Stream { if (query.isBlank()) return items.stream() val lower = query.trim().lowercase() return items.stream() .filter { propertyExtractor(it).lowercase().contains(lower) } } fun sort( stream: Stream, comparator: Comparator?, ): Stream { if (comparator == null) return stream return stream.sorted(comparator) } fun applyFiltersAndSort( items: List, filters: Map, comparators: List>, ): ObservableList { var stream = items.stream() for ((key, query) in filters) { if (query.isNotBlank()) { val lower = query.trim().lowercase() stream = stream.filter { prop -> val fieldValue = when (key) { "name" -> prop.name.value "coordinates.x" -> prop.coordinateX.value.toString() "coordinates.y" -> prop.coordinateY.value.toString() "creationDate" -> prop.creationDate.value.toString() "realHero" -> prop.realHero.value.toString() "hasToothpick" -> prop.hasToothpick.value.toString() "impactSpeed" -> prop.impactSpeed.value.toString() "soundtrackName" -> prop.soundtrackName.value "minutesOfWaiting" -> prop.minutesOfWaiting.value.toString() "weaponType" -> prop.weaponType.value?.name ?: "" "car.cool" -> prop.carCool.value.toString() else -> "" } fieldValue.lowercase().contains(lower) } } } if (comparators.isNotEmpty()) { val combined = comparators.reduce { a, b -> a.thenComparing(b) } stream = stream.sorted(combined) } return stream.collect(Collectors.collectingAndThen( Collectors.toList(), { FXCollections.observableArrayList(it) } )) } } === ./gui-client/src/main/kotlin/Main.kt === import javafx.application.Application import javafx.stage.Stage import model.Session import view.AuthView import view.MainView class App : Application() { override fun start(stage: Stage) { AuthView(stage) { val mainStage = Stage() MainView(mainStage).show() }.show() } override fun stop() { Session.client?.close() } } fun main() { Application.launch(App::class.java) } === ./gui-client/src/main/kotlin/controller/AuthController.kt === package controller import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import localization.AppLocale import model.Session import network.AuthType import guiclient.network.ClientProvider import javafx.application.Platform class AuthController { private val scope = CoroutineScope(Dispatchers.Default) fun authenticate( host: String, port: Int, username: String, password: String, isLogin: Boolean, onResult: (Boolean, String) -> Unit, ) { scope.launch { Session.client?.close() val provider = ClientProvider(host, port) val type = if (isLogin) AuthType.LOGIN else AuthType.REGISTER val response = provider.auth(username, password, type) if (response == null) { Platform.runLater { onResult(false, AppLocale.get("auth.error.server")) } return@launch } if (response.success) { provider.setToken(response.accessToken ?: "") provider.setUUID(response.id ?: "") Session.client = provider Session.username = username Session.userId = response.id?.let { kotlin.runCatching { java.util.UUID.fromString(it) }.getOrNull() } Platform.runLater { onResult(true, "") } } else { val errorKey = if (isLogin) "auth.error.failed" else "auth.error.register.failed" Platform.runLater { onResult(false, response.message.ifEmpty { AppLocale.get(errorKey) }) } } } } } === ./gui-client/src/main/kotlin/controller/MainController.kt === package controller import javafx.application.Platform import javafx.collections.FXCollections import javafx.collections.ListChangeListener import javafx.collections.ObservableList import kotlinx.coroutines.* import localization.AppLocale import model.HumanBeingProperty import model.Session import util.StreamUtils import java.util.Comparator import java.util.UUID class MainController { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var pollingJob: Job? = null val filteredList: ObservableList = FXCollections.observableArrayList() private var currentFilters: Map = emptyMap() private var currentComparators: List> = emptyList() private val collectionListener = ListChangeListener { _ -> reapplyFiltersAndSort() } fun start() { Session.collection.addListener(collectionListener) startPolling() } fun stop() { pollingJob?.cancel() scope.cancel() Session.collection.removeListener(collectionListener) } private fun startPolling() { pollingJob = scope.launch { while (isActive) { delay(3000) refreshCollection() } } } fun requestRefresh() { scope.launch { refreshCollection() } } private suspend fun refreshCollection() { val response = Session.client?.execute("show") ?: return val currentIds = Session.collection.map { it.id.value }.toSet() val serverItems = parseShowResponse(response.message) ?: return val serverIds = serverItems.map { it.id.value }.toSet() val toRemove = Session.collection.filter { it.id.value !in serverIds } val toAdd = serverItems.filter { it.id.value !in currentIds } val toUpdate = serverItems.filter { item -> val existing = Session.collection.find { it.id.value == item.id.value } existing != null && existing.name.value != item.name.value } Platform.runLater { Session.collection.removeAll(toRemove) Session.collection.addAll(toAdd) for (updated in toUpdate) { val idx = Session.collection.indexOfFirst { it.id.value == updated.id.value } if (idx >= 0) { Session.collection[idx] = updated } } } } private fun parseShowResponse(message: String): List? { if (message.isBlank()) return emptyList() val lines = message.lines().filter { it.isNotBlank() } val items = mutableListOf() var currentLines = mutableListOf() var braceCount = 0 for (line in lines) { currentLines.add(line) braceCount += line.count { it == '{' } braceCount -= line.count { it == '}' } if (braceCount == 0 && currentLines.isNotEmpty()) { val parsed = parseHumanBeingBlock(currentLines) if (parsed != null) items.add(parsed) currentLines.clear() } } return items } private fun parseHumanBeingBlock(lines: List): HumanBeingProperty? { val text = lines.joinToString("\n") val id = extractValue(text, "id\\s*=\\s*([\\w-]+)")?.let { safeUUID(it) } val ownerId = extractValue(text, "owner\\s*=\\s*([\\w-]+)")?.let { safeUUID(it) } ?: return null val name = extractValue(text, "name\\s*=\\s*(.+?)\\s*\\(a\\.k\\.a\\. Ryan Gosling\\)") ?: return null val coordX = extractValue(text, "x=(-?\\d+)")?.toLongOrNull() ?: 0L val coordY = extractValue(text, "y=(-?\\d+)")?.toIntOrNull() ?: 0 val creationDate = extractValue(text, "creationDate\\s*=\\s*(\\S+)")?.let { safeLocalDate(it) } val realHero = extractValue(text, "realHero\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false val hasToothpick = extractValue(text, "hasToothpick\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false val impactSpeed = extractValue(text, "impactSpeed\\s*=\\s*(-?\\d+\\.?\\d*)")?.toDoubleOrNull() ?: 0.0 val soundtrackName = extractValue(text, "soundtrackName\\s*=\\s*(.+)") ?: "" val minutesOfWaiting = extractValue(text, "minutesOfWait\\s*=\\s*(-?\\d+\\.?\\d*)")?.toFloatOrNull() ?: 0f val weaponType = extractValue(text, "weaponType\\s*=\\s*(\\w+)")?.let { safeWeaponType(it) } val carCool = extractValue(text, "car\\.cool\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false val date = creationDate ?: java.time.LocalDate.now() val human = models.HumanBeing( id = id, ownerId = ownerId, name = name, coordinates = models.Coordinates(coordX, coordY), creationDate = date, realHero = realHero, hasToothpick = hasToothpick, impactSpeed = impactSpeed, soundtrackName = soundtrackName, minutesOfWaiting = minutesOfWaiting, weaponType = weaponType, car = models.Car(carCool), ) return HumanBeingProperty(human) } private fun extractValue(text: String, pattern: String): String? { val regex = Regex(pattern) return regex.find(text)?.groupValues?.getOrNull(1) } private fun safeUUID(value: String): UUID? { return try { UUID.fromString(value) } catch (_: Exception) { null } } private fun safeLocalDate(value: String): java.time.LocalDate? { return try { java.time.LocalDate.parse(value) } catch (_: Exception) { null } } private fun safeWeaponType(value: String): models.WeaponType? { return try { models.WeaponType.valueOf(value.uppercase()) } catch (_: Exception) { null } } fun updateFilters(filters: Map) { currentFilters = filters reapplyFiltersAndSort() } fun updateComparators(comparators: List>) { currentComparators = comparators reapplyFiltersAndSort() } private fun reapplyFiltersAndSort() { val result = StreamUtils.applyFiltersAndSort( Session.collection, currentFilters, currentComparators, ) Platform.runLater { filteredList.setAll(result) } } fun executeCommand( name: String, args: List = emptyList(), humanBeing: models.HumanBeing? = null, callback: (Boolean, String) -> Unit = { _, _ -> }, ) { scope.launch { val response = Session.client?.execute(name, args, humanBeing) val success = response?.success ?: false val message = response?.message ?: AppLocale.get("auth.error.server") Platform.runLater { callback(success, message) } } } fun deleteObject(humanBeingProperty: HumanBeingProperty, callback: (Boolean, String) -> Unit) { val id = humanBeingProperty.id.value ?: return executeCommand(name = "remove_by_id", args = listOf(id.toString()), callback = callback) } } === ./gui-client/src/main/kotlin/model/HumanBeingProperty.kt === package model import javafx.beans.property.* import models.Car import models.Coordinates import models.HumanBeing import models.WeaponType import java.time.LocalDate import java.util.UUID class HumanBeingProperty(humanBeing: HumanBeing) { val id = SimpleObjectProperty(humanBeing.id) val ownerId = SimpleObjectProperty(humanBeing.ownerId) val name = SimpleStringProperty(humanBeing.name) val coordinateX = SimpleObjectProperty(humanBeing.coordinates.x) val coordinateY = SimpleObjectProperty(humanBeing.coordinates.y) val creationDate = SimpleObjectProperty(humanBeing.creationDate) val realHero = SimpleBooleanProperty(humanBeing.realHero) val hasToothpick = SimpleBooleanProperty(humanBeing.hasToothpick) val impactSpeed = SimpleObjectProperty(humanBeing.impactSpeed) val soundtrackName = SimpleStringProperty(humanBeing.soundtrackName) val minutesOfWaiting = SimpleObjectProperty(humanBeing.minutesOfWaiting) val weaponType = SimpleObjectProperty(humanBeing.weaponType) val carCool = SimpleBooleanProperty(humanBeing.car.cool) fun toHumanBeing(): HumanBeing = HumanBeing( id = id.value, ownerId = ownerId.value, name = name.value, coordinates = Coordinates(coordinateX.value, coordinateY.value), creationDate = creationDate.value, realHero = realHero.value, hasToothpick = hasToothpick.value, impactSpeed = impactSpeed.value, soundtrackName = soundtrackName.value, minutesOfWaiting = minutesOfWaiting.value, weaponType = weaponType.value, car = Car(carCool.value), ) companion object { fun fromHumanBeing(h: HumanBeing): HumanBeingProperty = HumanBeingProperty(h) } } === ./gui-client/src/main/kotlin/model/Session.kt === package model import javafx.collections.FXCollections import javafx.collections.ObservableList import guiclient.network.ClientProvider import java.util.UUID object Session { var client: ClientProvider? = null var token: String = "" var userId: UUID? = null var username: String = "" val collection: ObservableList = FXCollections.observableArrayList() val userIdFilter: UUID? get() = userId fun isOwnObject(ownerId: UUID): Boolean = userId == ownerId fun clear() { token = "" userId = null username = "" collection.clear() } } === ./gui-client/src/main/kotlin/view/CollectionCanvas.kt === package view import javafx.animation.AnimationTimer import javafx.scene.canvas.Canvas import javafx.scene.canvas.GraphicsContext import javafx.scene.control.Alert import javafx.scene.control.ButtonType import javafx.scene.input.MouseEvent import javafx.scene.paint.Color import javafx.scene.text.Font import localization.AppLocale import model.HumanBeingProperty import model.Session class CollectionCanvas : Canvas() { private val gc: GraphicsContext = graphicsContext2D private var animationTime = 0.0 private var canvasWidth: Double = 0.0 private var canvasHeight: Double = 0.0 private val padding = 40.0 private val defaultRange = 200.0 private var scaleX: Double = 1.0 private var scaleY: Double = 1.0 private var offsetX: Double = 0.0 private var offsetY: Double = 0.0 private var minRadius = 8.0 private var maxRadius = 35.0 private val renderTimer: AnimationTimer = object : AnimationTimer() { override fun handle(now: Long) { animationTime = now / 1_000_000_000.0 render() } } init { widthProperty().addListener { _, _, _ -> scheduleRender() } heightProperty().addListener { _, _, _ -> scheduleRender() } addEventHandler(MouseEvent.MOUSE_CLICKED, this::onClick) renderTimer.start() Session.collection.addListener { _: javafx.collections.ListChangeListener.Change? -> scheduleRender() } } private fun scheduleRender() { render() } private fun render() { canvasWidth = width canvasHeight = height if (canvasWidth <= 0.0 || canvasHeight <= 0.0) return gc.clearRect(0.0, 0.0, canvasWidth, canvasHeight) drawBackground() val items = Session.collection.toList() if (items.isEmpty()) { drawEmptyMessage() return } computeScale(items) val glowIntensity = computeGlowIntensity() for (item in items) { drawItem(item, glowIntensity) } } private fun drawBackground() { gc.fill = Color.web("#1a1a2e") gc.fillRect(0.0, 0.0, canvasWidth, canvasHeight) gc.stroke = Color.web("#16213e") gc.lineWidth = 0.5 val gridSize = 40.0 var x = 0.0 while (x <= canvasWidth) { gc.strokeLine(x, 0.0, x, canvasHeight) x += gridSize } var y = 0.0 while (y <= canvasHeight) { gc.strokeLine(0.0, y, canvasWidth, y) y += gridSize } } private fun drawEmptyMessage() { gc.fill = Color.GRAY gc.font = Font(18.0) gc.fillText(AppLocale.get("visualization.title"), canvasWidth / 2 - 60, canvasHeight / 2) } private fun computeScale(items: List) { val minX = items.minOf { it.coordinateX.value } val maxX = items.maxOf { it.coordinateX.value } val minY = items.minOf { it.coordinateY.value } val maxY = items.maxOf { it.coordinateY.value } val rangeX = if (maxX == minX) defaultRange else (maxX - minX).toDouble() val rangeY = if (maxY == minY) defaultRange else (maxY - minY).toDouble() val availWidth = canvasWidth - 2 * padding val availHeight = canvasHeight - 2 * padding scaleX = availWidth / rangeX scaleY = availHeight / rangeY offsetX = padding - minX * scaleX offsetY = padding - minY * scaleY } private fun computeGlowIntensity(): Double { return 0.4 + 0.4 * (1.0 + kotlin.math.sin(animationTime * 3.0)) / 2.0 } private fun drawItem(item: HumanBeingProperty, glowIntensity: Double) { val px = worldToScreenX(item.coordinateX.value.toDouble()) val py = worldToScreenY(item.coordinateY.value.toDouble()) val radius = mapImpactSpeed(item.impactSpeed.value) val color = ownerColor(item.ownerId.value) val glowColor = Color(color.red, color.green, color.blue, glowIntensity * 0.5) gc.save() gc.setFill(glowColor) gc.fillOval(px - radius * 1.8, py - radius * 1.8, radius * 3.6, radius * 3.6) gc.setFill(color) gc.fillOval(px - radius, py - radius, radius * 2, radius * 2) gc.setStroke(Color.WHITE) gc.setLineWidth(1.5) gc.strokeOval(px - radius, py - radius, radius * 2, radius * 2) gc.setFill(Color.WHITE) gc.setFont(Font(10.0)) val label = item.name.value.take(10) gc.fillText(label, px - radius, py - radius - 4) gc.restore() } private fun worldToScreenX(worldX: Double): Double = worldX * scaleX + offsetX private fun worldToScreenY(worldY: Double): Double = worldY * scaleY + offsetY private fun screenToWorldX(screenX: Double): Double = (screenX - offsetX) / scaleX private fun screenToWorldY(screenY: Double): Double = (screenY - offsetY) / scaleY private fun mapImpactSpeed(speed: Double): Double { val items = Session.collection if (items.isEmpty()) return minRadius val minSpeed = items.minOf { it.impactSpeed.value } val maxSpeed = items.maxOf { it.impactSpeed.value } if (maxSpeed == minSpeed) return (minRadius + maxRadius) / 2 return minRadius + (speed - minSpeed) / (maxSpeed - minSpeed) * (maxRadius - minRadius) } private fun ownerColor(ownerId: java.util.UUID): Color { val hue = (ownerId.hashCode() and 0x7FFFFFFF) % 360 return Color.hsb(hue.toDouble(), 0.7, 0.9) } private fun onClick(event: MouseEvent) { val mx = event.x val my = event.y val items = Session.collection.toList() val hitRadius = 15.0 for (item in items) { val px = worldToScreenX(item.coordinateX.value.toDouble()) val py = worldToScreenY(item.coordinateY.value.toDouble()) val r = mapImpactSpeed(item.impactSpeed.value) val dx = mx - px val dy = my - py if (dx * dx + dy * dy <= (r + hitRadius) * (r + hitRadius)) { showItemInfo(item) return } } } private fun showItemInfo(item: HumanBeingProperty) { val info = buildString { appendLine("${AppLocale.get("info.id")}: ${item.id.value}") appendLine("${AppLocale.get("table.name")}: ${item.name.value}") appendLine("X: ${item.coordinateX.value}, Y: ${item.coordinateY.value}") appendLine("${AppLocale.get("table.creationDate")}: ${item.creationDate.value}") appendLine("${AppLocale.get("table.realHero")}: ${item.realHero.value}") appendLine("${AppLocale.get("table.hasToothpick")}: ${item.hasToothpick.value}") appendLine("${AppLocale.get("table.impactSpeed")}: ${item.impactSpeed.value}") appendLine("${AppLocale.get("table.soundtrackName")}: ${item.soundtrackName.value}") appendLine("${AppLocale.get("table.minutesOfWaiting")}: ${item.minutesOfWaiting.value}") appendLine("${AppLocale.get("table.weaponType")}: ${item.weaponType.value?.name ?: "—"}") appendLine("${AppLocale.get("table.car.cool")}: ${item.carCool.value}") appendLine("${AppLocale.get("info.owner")}: ${item.ownerId.value}") } val alert = Alert(Alert.AlertType.INFORMATION, info.trimEnd(), ButtonType.OK) alert.title = AppLocale.get("info.title") alert.headerText = item.name.value alert.show() } override fun isResizable(): Boolean = true override fun prefWidth(height: Double): Double = 400.0 override fun prefHeight(width: Double): Double = 400.0 } === ./gui-client/src/main/kotlin/view/EditDialog.kt === package view import javafx.geometry.Insets import javafx.geometry.Pos import javafx.scene.control.* import javafx.scene.layout.GridPane import javafx.scene.layout.VBox import javafx.stage.Modality import javafx.stage.Stage import javafx.stage.Window import localization.AppLocale import models.Car import models.Coordinates import models.HumanBeing import models.WeaponType import model.Session import java.time.LocalDate class EditDialog(private val owner: Window, private val existing: HumanBeing? = null) { private val dialog = Stage() private val nameField = TextField() private val coordXField = TextField() private val coordYField = TextField() private val realHeroCheck = CheckBox() private val hasToothpickCheck = CheckBox() private val impactSpeedField = TextField() private val soundtrackField = TextField() private val minutesField = TextField() private val weaponTypeCombo = ComboBox() private val carCoolCheck = CheckBox() private var result: HumanBeing? = null fun showAndWait(): HumanBeing? { dialog.initModality(Modality.WINDOW_MODAL) dialog.initOwner(owner) dialog.scene = javafx.scene.Scene(buildUI(), 420.0, 520.0) dialog.isResizable = false if (existing != null) populateFields(existing) updateTitle() dialog.showAndWait() return result } private fun buildUI(): VBox { val form = GridPane() form.hgap = 8.0 form.vgap = 8.0 form.padding = Insets(16.0) form.alignment = Pos.CENTER_LEFT var row = 0 form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.name") }, nameField) row++ form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.coordinates.x") }, coordXField) row++ form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.coordinates.y") }, coordYField) row++ realHeroCheck.isSelected = true form.addRow(row, realHeroCheck.also { AppLocale.bindLabel(it, "table.realHero") }) row++ hasToothpickCheck.isSelected = true form.addRow(row, hasToothpickCheck.also { AppLocale.bindLabel(it, "table.hasToothpick") }) row++ form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.impactSpeed") }, impactSpeedField) row++ form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.soundtrackName") }, soundtrackField) row++ form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.minutesOfWaiting") }, minutesField) row++ weaponTypeCombo.items.add(WeaponTypeOption("—", null)) WeaponType.entries.forEach { wt -> weaponTypeCombo.items.add(WeaponTypeOption(wt.name, wt)) } weaponTypeCombo.selectionModel.selectFirst() form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.weaponType") }, weaponTypeCombo) row++ carCoolCheck.isSelected = true form.addRow(row, carCoolCheck.also { AppLocale.bindLabel(it, "table.car.cool") }) val buttonBar = javafx.scene.layout.HBox(8.0).apply { alignment = Pos.CENTER_RIGHT padding = Insets(0.0, 16.0, 16.0, 16.0) children.addAll( Button().also { AppLocale.bindLabel(it, "dialog.ok") it.setOnAction { onOk() } }, Button().also { AppLocale.bindLabel(it, "dialog.cancel") it.setOnAction { dialog.close() } }, ) } return VBox(8.0, form, buttonBar) } private fun updateTitle() { val key = if (existing != null) "dialog.title.edit" else "dialog.title.add" dialog.title = AppLocale.get(key) } private fun populateFields(h: HumanBeing) { nameField.text = h.name coordXField.text = h.coordinates.x.toString() coordYField.text = h.coordinates.y.toString() realHeroCheck.isSelected = h.realHero hasToothpickCheck.isSelected = h.hasToothpick impactSpeedField.text = h.impactSpeed.toString() soundtrackField.text = h.soundtrackName minutesField.text = h.minutesOfWaiting.toString() if (h.weaponType != null) { val selected = weaponTypeCombo.items.find { it.type == h.weaponType } if (selected != null) weaponTypeCombo.selectionModel.select(selected) } carCoolCheck.isSelected = h.car.cool } private fun onOk() { val name = nameField.text.trim() if (name.isEmpty()) { showError(AppLocale.get("dialog.error.name")) return } val coordX = coordXField.text.trim().toLongOrNull() if (coordX == null) { showError("X: " + AppLocale.get("dialog.error.number")); return } val coordY = coordYField.text.trim().toIntOrNull() if (coordY == null) { showError("Y: " + AppLocale.get("dialog.error.number")); return } val impactSpeed = impactSpeedField.text.trim().toDoubleOrNull() if (impactSpeed == null) { showError("impactSpeed: " + AppLocale.get("dialog.error.number")); return } val minutes = minutesField.text.trim().toFloatOrNull() if (minutes == null) { showError("minutesOfWaiting: " + AppLocale.get("dialog.error.number")); return } val soundtrack = soundtrackField.text.trim() result = HumanBeing( id = existing?.id, ownerId = existing?.ownerId ?: Session.userId ?: run { showError(AppLocale.get("dialog.error.no.user")); return }, name = name, coordinates = Coordinates(coordX, coordY), creationDate = existing?.creationDate ?: LocalDate.now(), realHero = realHeroCheck.isSelected, hasToothpick = hasToothpickCheck.isSelected, impactSpeed = impactSpeed, soundtrackName = soundtrack, minutesOfWaiting = minutes, weaponType = weaponTypeCombo.selectionModel.selectedItem?.type, car = Car(carCoolCheck.isSelected), ) dialog.close() } private fun showError(message: String) { val alert = Alert(Alert.AlertType.ERROR, message, ButtonType.OK) alert.initOwner(dialog) alert.show() } private data class WeaponTypeOption(val label: String, val type: WeaponType?) { override fun toString() = label } } === ./gui-client/src/main/kotlin/view/MainView.kt === package view import controller.MainController import javafx.beans.property.SimpleObjectProperty import javafx.geometry.Insets import javafx.geometry.Orientation import javafx.geometry.Pos import javafx.scene.Node import javafx.scene.Scene import view.CollectionCanvas import javafx.scene.control.* import javafx.scene.layout.* import javafx.stage.Stage import models.HumanBeing import localization.AppLocale import model.HumanBeingProperty import model.Session import java.util.Comparator class MainView(private val stage: Stage) { private val controller = MainController() private val tableView = TableView() private val canvas = CollectionCanvas() private val canvasPlaceholder = Label() private val usernameLabel = Label() private val localeCombo = ComboBox() private val addButton = Button() private val editButton = Button() private val deleteButton = Button() private val refreshButton = Button() private val filterFields = mutableMapOf() private var sortColumn: String? = null private var sortAscending = true fun show() { updateTitle() val scene = Scene(buildUI(), 1100.0, 700.0) stage.scene = scene stage.minWidth = 800.0 stage.minHeight = 500.0 stage.show() controller.start() } private fun buildUI(): BorderPane { val root = BorderPane() root.top = buildToolbar() root.center = buildContent() root.bottom = buildCommandBar() return root } private fun buildToolbar(): ToolBar { AppLocale.bindLabel(usernameLabel, "main.user") updateUsernameText() val logoutItem = MenuItem() AppLocale.bind(logoutItem.textProperty(), "main.logout") logoutItem.setOnAction { onLogout() } val userMenu = MenuButton().also { menu -> AppLocale.bindLabel(menu, "main.user") menu.items.add(logoutItem) } AppLocale.availableLocales().forEach { loc -> localeCombo.items.add(LocaleDisplay(AppLocale.localeDisplayName(loc), loc)) } val currentLocaleIndex = AppLocale.availableLocales().indexOf(AppLocale.currentLocale.value) if (currentLocaleIndex >= 0) localeCombo.selectionModel.select(currentLocaleIndex) localeCombo.setOnAction { val selected = localeCombo.selectionModel.selectedItem ?: return@setOnAction AppLocale.currentLocale.set(selected.locale) updateLocale() } val spacer = Region() HBox.setHgrow(spacer, Priority.ALWAYS) val toolbar = ToolBar( Label().also { AppLocale.bindLabel(it, "main.user"); it.visibleProperty().bind(usernameLabel.visibleProperty()) }, usernameLabel, Separator(Orientation.VERTICAL), Label().also { AppLocale.bindLabel(it, "main.locale") }, localeCombo, spacer, userMenu, ) return toolbar } private fun buildContent(): Node { val splitPane = SplitPane() splitPane.orientation = Orientation.HORIZONTAL splitPane.items.addAll(buildTablePane(), buildCanvasPane()) splitPane.setDividerPositions(0.55) return splitPane } private fun buildTablePane(): Node { val filterBar = GridPane() filterBar.hgap = 4.0 filterBar.vgap = 2.0 filterBar.padding = Insets(4.0) val columns = listOf( "name" to { p: HumanBeingProperty -> p.name.value }, "coordinates.x" to { p: HumanBeingProperty -> p.coordinateX.value.toString() }, "coordinates.y" to { p: HumanBeingProperty -> p.coordinateY.value.toString() }, "creationDate" to { p: HumanBeingProperty -> p.creationDate.value.toString() }, "realHero" to { p: HumanBeingProperty -> p.realHero.value.toString() }, "hasToothpick" to { p: HumanBeingProperty -> p.hasToothpick.value.toString() }, "impactSpeed" to { p: HumanBeingProperty -> p.impactSpeed.value.toString() }, "soundtrackName" to { p: HumanBeingProperty -> p.soundtrackName.value }, "minutesOfWaiting" to { p: HumanBeingProperty -> p.minutesOfWaiting.value.toString() }, "weaponType" to { p: HumanBeingProperty -> (p.weaponType.value?.name ?: "") }, "car.cool" to { p: HumanBeingProperty -> p.carCool.value.toString() }, ) for ((i, col) in columns.withIndex()) { val (key, extractor) = col val headerLabel = Label() AppLocale.bindLabel(headerLabel, "table.$key") headerLabel.setOnMouseClicked { toggleSort(key) } headerLabel.style = "-fx-font-weight: bold; -fx-cursor: hand;" filterBar.add(headerLabel, i, 0) val filterField = TextField() filterField.promptText = AppLocale.get("main.filter") filterField.textProperty().addListener { _, _, _ -> applyFilters() } filterFields[key] = filterField filterBar.add(filterField, i, 1) val tableCol = TableColumn() AppLocale.bind(tableCol.textProperty(), "table.$key") tableCol.cellValueFactory = javafx.util.Callback { features -> SimpleObjectProperty(extractor(features.value)) } tableView.columns.add(tableCol) } val tableHolder = VBox(filterBar, tableView) VBox.setVgrow(tableView, Priority.ALWAYS) return tableHolder } private fun buildCanvasPane(): Node { AppLocale.bindLabel(canvasPlaceholder, "visualization.title") canvasPlaceholder.alignment = Pos.CENTER canvasPlaceholder.style = "-fx-font-size: 16; -fx-text-fill: gray;" val stackPane = StackPane(canvasPlaceholder, canvas) stackPane.prefWidth = 400.0 canvas.widthProperty().bind(stackPane.widthProperty()) canvas.heightProperty().bind(stackPane.heightProperty()) return stackPane } private fun buildCommandBar(): HBox { AppLocale.bindLabel(addButton, "button.add") AppLocale.bindLabel(editButton, "button.edit") AppLocale.bindLabel(deleteButton, "button.delete") AppLocale.bindLabel(refreshButton, "button.refresh") addButton.setOnAction { onAdd() } editButton.setOnAction { onEdit() } deleteButton.setOnAction { onDelete() } refreshButton.setOnAction { controller.requestRefresh() } val bar = HBox(8.0, addButton, editButton, deleteButton, refreshButton) bar.padding = Insets(8.0) bar.alignment = Pos.CENTER_LEFT return bar } private fun toggleSort(key: String) { if (sortColumn == key) { sortAscending = !sortAscending } else { sortColumn = key sortAscending = true } val comparator: Comparator? = sortColumn?.let { col -> val asc = sortAscending val comp = Comparator { a, b -> val va = extractSortValue(a, col) val vb = extractSortValue(b, col) if (asc) va.compareTo(vb) else vb.compareTo(va) } comp } controller.updateComparators(if (comparator != null) listOf(comparator) else emptyList()) } private fun extractSortValue(prop: HumanBeingProperty, key: String): String = when (key) { "name" -> prop.name.value "coordinates.x" -> prop.coordinateX.value.toString().padStart(20, '0') "coordinates.y" -> prop.coordinateY.value.toString().padStart(10, '0') "creationDate" -> prop.creationDate.value.toString() "realHero" -> if (prop.realHero.value) "1" else "0" "hasToothpick" -> if (prop.hasToothpick.value) "1" else "0" "impactSpeed" -> prop.impactSpeed.value.toString().padStart(20, '0') "soundtrackName" -> prop.soundtrackName.value "minutesOfWaiting" -> prop.minutesOfWaiting.value.toString().padStart(20, '0') "weaponType" -> prop.weaponType.value?.name ?: "" "car.cool" -> if (prop.carCool.value) "1" else "0" else -> "" } private fun applyFilters() { val filters = filterFields.mapValues { it.value.text } controller.updateFilters(filters) } init { tableView.items = controller.filteredList } private fun updateUsernameText() { usernameLabel.text = Session.username.ifEmpty { "—" } } private fun updateLocale() { updateTitle() updateUsernameText() filterFields.forEach { (_, field) -> field.promptText = AppLocale.get("main.filter") } } private fun updateTitle() { stage.title = AppLocale.get("main.title") } private fun onLogout() { controller.stop() Session.client?.clearToken() Session.clear() stage.close() AuthView(Stage()).show() } private fun onAdd() { val dialog = EditDialog(stage.scene.window) val humanBeing = dialog.showAndWait() ?: return controller.executeCommand(name = "add", humanBeing = humanBeing) { success, message -> if (success) controller.requestRefresh() else showError(message) } } private fun onEdit() { val selected = tableView.selectionModel.selectedItem if (selected == null) { showError(AppLocale.get("error.no.selection")) return } if (!Session.isOwnObject(selected.ownerId.value)) { showError(AppLocale.get("error.not.owner")) return } val dialog = EditDialog(stage.scene.window, selected.toHumanBeing()) val humanBeing = dialog.showAndWait() ?: return val id = selected.id.value ?: return controller.executeCommand(name = "update", args = listOf(id.toString()), humanBeing = humanBeing) { success, message -> if (success) controller.requestRefresh() else showError(message) } } private fun onDelete() { val selected = tableView.selectionModel.selectedItem ?: return if (!Session.isOwnObject(selected.ownerId.value)) { showError(AppLocale.get("error.not.owner")) return } val confirm = Alert(Alert.AlertType.CONFIRMATION) confirm.title = AppLocale.get("delete.confirm.title") confirm.headerText = AppLocale.get("delete.confirm") confirm.showAndWait().ifPresent { result -> if (result == ButtonType.OK) { controller.deleteObject(selected) { success, message -> if (success) controller.requestRefresh() else showError(message) } } } } private fun showError(message: String) { val alert = Alert(Alert.AlertType.ERROR, message, ButtonType.OK) alert.show() } private class LocaleDisplay(val displayName: String, val locale: java.util.Locale) { override fun toString() = displayName } } === ./gui-client/src/main/kotlin/view/AuthView.kt === package view import controller.AuthController import javafx.geometry.Insets import javafx.geometry.Pos import javafx.scene.Scene import javafx.scene.control.* import javafx.scene.layout.HBox import javafx.scene.layout.VBox import javafx.stage.Stage import localization.AppLocale class AuthView(private val stage: Stage, private val onSuccess: (() -> Unit)? = null) { private val controller = AuthController() private val hostField = TextField("localhost") private val portField = TextField("8080") private val usernameField = TextField() private val passwordField = PasswordField() private val loginToggle = ToggleButton() private val registerToggle = ToggleButton() private val submitButton = Button() private val errorLabel = Label() private val localeCombo = ComboBox() fun show() { updateTitle() stage.scene = Scene(buildUI(), 400.0, 440.0) stage.isResizable = false stage.show() } private fun buildUI(): VBox { val toggleGroup = ToggleGroup() loginToggle.toggleGroup = toggleGroup registerToggle.toggleGroup = toggleGroup loginToggle.isSelected = true AppLocale.bindLabel(loginToggle, "auth.login") AppLocale.bindLabel(registerToggle, "auth.register") val toggleBox = HBox(10.0, loginToggle, registerToggle) val usernameLabel = Label() AppLocale.bindLabel(usernameLabel, "auth.username") AppLocale.bindPrompt(usernameField, "auth.username") val usernameBox = VBox(3.0, usernameLabel, usernameField) val passwordLabel = Label() AppLocale.bindLabel(passwordLabel, "auth.password") AppLocale.bindPrompt(passwordField, "auth.password") val passwordBox = VBox(3.0, passwordLabel, passwordField) hostField.promptText = "localhost" val hostBox = VBox(3.0, Label().also { AppLocale.bindLabel(it, "auth.host") }, hostField) portField.promptText = "8080" val portBox = VBox(3.0, Label().also { AppLocale.bindLabel(it, "auth.port") }, portField) val serverBox = HBox(8.0, hostBox, portBox) AppLocale.availableLocales().forEach { loc -> localeCombo.items.add(LocaleDisplay(AppLocale.localeDisplayName(loc), loc)) } localeCombo.selectionModel.selectFirst() localeCombo.setOnAction { val selected = localeCombo.selectionModel.selectedItem ?: return@setOnAction AppLocale.currentLocale.set(selected.locale) updateTitle() } val localeLabel = Label() AppLocale.bindLabel(localeLabel, "main.locale") val localeBox = HBox(10.0, localeLabel, localeCombo) submitButton.setOnAction { onLogin() } bindSubmitText() toggleGroup.selectedToggleProperty().addListener { _, _, _ -> bindSubmitText() } errorLabel.style = "-fx-text-fill: red;" val form = VBox(12.0, toggleBox, usernameBox, passwordBox, serverBox, localeBox, submitButton, errorLabel, ) form.alignment = Pos.CENTER form.padding = Insets(30.0) return form } private fun bindSubmitText() { val key = if (loginToggle.isSelected) "auth.submit.login" else "auth.submit.register" AppLocale.bind(submitButton.textProperty(), key) } private fun updateTitle() { stage.title = AppLocale.get("auth.title") } private fun onLogin() { val host = hostField.text.trim() val portText = portField.text.trim() val username = usernameField.text.trim() val password = passwordField.text.trim() if (host.isEmpty() || portText.isEmpty() || username.isEmpty() || password.isEmpty()) { errorLabel.text = AppLocale.get("auth.error.empty") return } val port = portText.toIntOrNull() if (port == null || port !in 1..65535) { errorLabel.text = AppLocale.get("auth.error.invalid.port") return } val isLogin = loginToggle.isSelected submitButton.isDisable = true errorLabel.text = "" controller.authenticate(host, port, username, password, isLogin) { success, message -> submitButton.isDisable = false if (success) { stage.close() onSuccess?.invoke() } else { errorLabel.text = message } } } private class LocaleDisplay(val displayName: String, val locale: java.util.Locale) { override fun toString() = displayName } } === ./gui-client/src/main/kotlin/localization/AppLocale.kt === package localization import javafx.beans.property.ObjectProperty import javafx.beans.property.SimpleObjectProperty import javafx.scene.control.Labeled import java.util.Locale object AppLocale { val currentLocale: ObjectProperty = SimpleObjectProperty(Locale("ru")) private val strings: Map> = mapOf( Locale("ru") to mapOf( "auth.title" to "Вход / Регистрация", "auth.login" to "Вход", "auth.register" to "Регистрация", "auth.username" to "Имя пользователя", "auth.password" to "Пароль", "auth.host" to "Сервер", "auth.port" to "Порт", "auth.error.invalid.port" to "Некорректный порт (1–65535)", "auth.submit.login" to "Войти", "auth.submit.register" to "Зарегистрироваться", "auth.error.empty" to "Заполните все поля", "auth.error.failed" to "Ошибка входа. Проверьте логин и пароль.", "auth.error.register.failed" to "Ошибка регистрации. Возможно, пользователь уже существует.", "auth.error.server" to "Сервер недоступен", "auth.success.login" to "Успешный вход", "auth.success.register" to "Успешная регистрация", "main.title" to "Коллекция HumanBeing", "main.user" to "Пользователь", "main.logout" to "Выйти", "main.locale" to "Язык", "main.filter" to "Фильтр", "visualization.title" to "Визуализация", "table.name" to "Имя", "table.coordinates.x" to "X", "table.coordinates.y" to "Y", "table.creationDate" to "Дата создания", "table.realHero" to "Герой", "table.hasToothpick" to "Зубочистка", "table.impactSpeed" to "Скорость", "table.soundtrackName" to "Саундтрек", "table.minutesOfWaiting" to "Минуты ожидания", "table.weaponType" to "Оружие", "table.car.cool" to "Крутая тачка", "table.owner" to "Владелец", "button.add" to "Добавить", "button.edit" to "Редактировать", "button.delete" to "Удалить", "button.refresh" to "Обновить", "dialog.title.add" to "Добавление объекта", "dialog.title.edit" to "Редактирование объекта", "dialog.ok" to "OK", "dialog.cancel" to "Отмена", "dialog.error.validation" to "Ошибка валидации", "dialog.error.name" to "Имя не может быть пустым", "dialog.error.number" to "Некорректное число", "dialog.error.no.user" to "Ошибка: пользователь не идентифицирован", "info.title" to "Информация об объекте", "info.owner" to "Владелец", "info.id" to "ID", "delete.confirm" to "Удалить этот объект?", "delete.confirm.title" to "Подтверждение удаления", "error.no.selection" to "Объект не выбран", "error.not.owner" to "Вы не владелец этого объекта", ), Locale("nl") to mapOf( "auth.title" to "Inloggen / Registreren", "auth.login" to "Inloggen", "auth.register" to "Registreren", "auth.username" to "Gebruikersnaam", "auth.password" to "Wachtwoord", "auth.host" to "Server", "auth.port" to "Poort", "auth.error.invalid.port" to "Ongeldige poort (1–65535)", "auth.submit.login" to "Inloggen", "auth.submit.register" to "Registreren", "auth.error.empty" to "Vul alle velden in", "auth.error.failed" to "Inloggen mislukt. Controleer uw gegevens.", "auth.error.register.failed" to "Registratie mislukt. Gebruiker bestaat mogelijk al.", "auth.error.server" to "Server niet bereikbaar", "auth.success.login" to "Succesvol ingelogd", "auth.success.register" to "Succesvol geregistreerd", "main.title" to "HumanBeing Collectie", "main.user" to "Gebruiker", "main.logout" to "Uitloggen", "main.locale" to "Taal", "main.filter" to "Filter", "visualization.title" to "Visualisatie", "table.name" to "Naam", "table.coordinates.x" to "X", "table.coordinates.y" to "Y", "table.creationDate" to "Aanmaakdatum", "table.realHero" to "Held", "table.hasToothpick" to "Tandenstoker", "table.impactSpeed" to "Snelheid", "table.soundtrackName" to "Soundtrack", "table.minutesOfWaiting" to "Wachtminuten", "table.weaponType" to "Wapen", "table.car.cool" to "Coole auto", "table.owner" to "Eigenaar", "button.add" to "Toevoegen", "button.edit" to "Bewerken", "button.delete" to "Verwijderen", "button.refresh" to "Vernieuwen", "dialog.title.add" to "Object toevoegen", "dialog.title.edit" to "Object bewerken", "dialog.ok" to "OK", "dialog.cancel" to "Annuleren", "dialog.error.validation" to "Validatiefout", "dialog.error.name" to "Naam mag niet leeg zijn", "dialog.error.number" to "Ongeldig nummer", "dialog.error.no.user" to "Fout: gebruiker niet geïdentificeerd", "info.title" to "Objectinformatie", "info.owner" to "Eigenaar", "info.id" to "ID", "delete.confirm" to "Dit object verwijderen?", "delete.confirm.title" to "Verwijderen bevestigen", "error.no.selection" to "Geen object geselecteerd", "error.not.owner" to "U bent niet de eigenaar van dit object", ), Locale("hu") to mapOf( "auth.title" to "Bejelentkezés / Regisztráció", "auth.login" to "Bejelentkezés", "auth.register" to "Regisztráció", "auth.username" to "Felhasználónév", "auth.password" to "Jelszó", "auth.host" to "Szerver", "auth.port" to "Port", "auth.error.invalid.port" to "Érvénytelen port (1–65535)", "auth.submit.login" to "Belépés", "auth.submit.register" to "Regisztrálás", "auth.error.empty" to "Töltse ki az összes mezőt", "auth.error.failed" to "Sikertelen bejelentkezés. Ellenőrizze adatait.", "auth.error.register.failed" to "Sikertelen regisztráció. Lehet, hogy a felhasználó már létezik.", "auth.error.server" to "Szerver nem elérhető", "auth.success.login" to "Sikeres bejelentkezés", "auth.success.register" to "Sikeres regisztráció", "main.title" to "HumanBeing Gyűjtemény", "main.user" to "Felhasználó", "main.logout" to "Kijelentkezés", "main.locale" to "Nyelv", "main.filter" to "Szűrő", "visualization.title" to "Vizualizáció", "table.name" to "Név", "table.coordinates.x" to "X", "table.coordinates.y" to "Y", "table.creationDate" to "Létrehozás dátuma", "table.realHero" to "Hős", "table.hasToothpick" to "Fogpiszkáló", "table.impactSpeed" to "Sebesség", "table.soundtrackName" to "Soundtrack", "table.minutesOfWaiting" to "Várakozási percek", "table.weaponType" to "Fegyver", "table.car.cool" to "Menő autó", "table.owner" to "Tulajdonos", "button.add" to "Hozzáadás", "button.edit" to "Szerkesztés", "button.delete" to "Törlés", "button.refresh" to "Frissítés", "dialog.title.add" to "Objektum hozzáadása", "dialog.title.edit" to "Objektum szerkesztése", "dialog.ok" to "OK", "dialog.cancel" to "Mégse", "dialog.error.validation" to "Érvényesítési hiba", "dialog.error.name" to "A név nem lehet üres", "dialog.error.number" to "Érvénytelen szám", "dialog.error.no.user" to "Hiba: a felhasználó nincs azonosítva", "info.title" to "Objektum információ", "info.owner" to "Tulajdonos", "info.id" to "ID", "delete.confirm" to "Törli ezt az objektumot?", "delete.confirm.title" to "Törlés megerősítése", "error.no.selection" to "Nincs objektum kiválasztva", "error.not.owner" to "Ön nem a tulajdonosa ennek az objektumnak", ), Locale("en", "AU") to mapOf( "auth.title" to "Sign In / Sign Up", "auth.login" to "Sign In", "auth.register" to "Sign Up", "auth.username" to "Username", "auth.password" to "Password", "auth.host" to "Server", "auth.port" to "Port", "auth.error.invalid.port" to "Invalid port (1–65535)", "auth.submit.login" to "Sign In", "auth.submit.register" to "Sign Up", "auth.error.empty" to "Fill in all fields, mate", "auth.error.failed" to "Login failed. Check ya credentials.", "auth.error.register.failed" to "Registration failed. Reckon that user already exists.", "auth.error.server" to "Server's cactus", "auth.success.login" to "G'day! Logged in successfully", "auth.success.register" to "Registered, bonza!", "main.title" to "HumanBeing Collection", "main.user" to "User", "main.logout" to "Logout", "main.locale" to "Language", "main.filter" to "Filter", "visualization.title" to "Visualisation", "table.name" to "Name", "table.coordinates.x" to "X", "table.coordinates.y" to "Y", "table.creationDate" to "Creation Date", "table.realHero" to "Hero", "table.hasToothpick" to "Toothpick", "table.impactSpeed" to "Impact Speed", "table.soundtrackName" to "Soundtrack", "table.minutesOfWaiting" to "Minutes of Waitin'", "table.weaponType" to "Weapon", "table.car.cool" to "Carn't", "table.owner" to "Owner", "button.add" to "Add", "button.edit" to "Edit", "button.delete" to "Delete", "button.refresh" to "Refresh", "dialog.title.add" to "Add Object", "dialog.title.edit" to "Edit Object", "dialog.ok" to "OK", "dialog.cancel" to "Cancel", "dialog.error.validation" to "Validation Error", "dialog.error.name" to "Name can't be empty", "dialog.error.number" to "Invalid number, mate", "dialog.error.no.user" to "Error: user not identified, mate", "info.title" to "Object Info", "info.owner" to "Owner", "info.id" to "ID", "delete.confirm" to "Reckon you wanna delete this?", "delete.confirm.title" to "Confirm Deletion", "error.no.selection" to "No object selected", "error.not.owner" to "You're not the owner of this object", ), ) fun get(key: String): String { return strings[currentLocale.value]?.get(key) ?: key } fun bind(property: javafx.beans.property.Property, key: String) { property.value = get(key) currentLocale.addListener { _, _, _ -> property.value = get(key) } } fun bindLabel(labeled: Labeled, key: String) { bind(labeled.textProperty(), key) } fun bindPrompt(textField: javafx.scene.control.TextInputControl, key: String) { bind(textField.promptTextProperty(), key) } fun availableLocales(): List = strings.keys.toList() fun localeDisplayName(locale: Locale): String = when (locale) { Locale("ru") -> "Русский" Locale("nl") -> "Nederlands" Locale("hu") -> "Magyar" Locale("en", "AU") -> "English (Australia)" else -> locale.displayName } } === ./server/src/main/kotlin/database/tables/CoordinatesTable.kt === package database.tables import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable object CoordinatesTable : UUIDTable("coordinates") { val x = long("x").nullable() val y = integer("y").nullable() } === ./server/src/main/kotlin/database/tables/HumanBeingTable.kt === package database.tables import models.WeaponType import org.jetbrains.exposed.v1.javatime.date import org.postgresql.util.PGobject import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable object HumanBeingTable : UUIDTable("humanBeing") { val owner_id = reference("owner_id", UserTable.id) val name = text("name") val coordinates = reference("coordinates", CoordinatesTable.id) val creationDate = date("creation_date") val realHero = bool("real_hero") val hasToothpick = bool("has_toothpick") val impactSpeed = double("impact_speed") val soundtrackName = text("soundtrack_name") val minutesOfWaiting = float("minutes_of_waiting") val car = reference("car", CarTable.id) val weaponType = customEnumeration( name = "weapon_type", sql = "weapontype", fromDb = { value -> val strValue = when (value) { is PGobject -> value.value!! is String -> value else -> error("Unexpected type: ${value::class}") } WeaponType.valueOf(strValue.uppercase()) }, toDb = { value -> PGobject().apply { type = "weapontype" this.value = value.name } } ).nullable().default(null) }=== ./server/src/main/kotlin/database/tables/CarTable.kt === package database.tables import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable object CarTable : UUIDTable("car") { val cool = bool("cool").nullable() }=== ./server/src/main/kotlin/database/tables/UserTable.kt === package database.tables import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable object UserTable: UUIDTable("user") { val username = text("username") val password = text("password") // change val token = text("fresh_token").nullable() }=== ./server/src/main/kotlin/database/CacheInvalidator.kt === package database import io.github.oshai.kotlinlogging.KotlinLogging import org.postgresql.PGConnection import javax.sql.DataSource private val logger = KotlinLogging.logger {} class CacheInvalidator( private val dataSource: DataSource, private val onInvalidated: () -> Unit ) { @Volatile private var running = false private var thread: Thread? = null fun start() { running = true thread = Thread { while (running) { try { dataSource.connection.use { conn -> conn.autoCommit = true val pgConn = conn.unwrap(PGConnection::class.java) conn.createStatement().use { stmt -> stmt.execute("LISTEN cache_changed") } logger.info { "Listening for cache changes via PG NOTIFY..." } while (running && !conn.isClosed) { val notifications = pgConn.notifications if (notifications != null && notifications.isNotEmpty()) { logger.debug { "Cache invalidation received" } onInvalidated() } Thread.sleep(500) } } } catch (e: Exception) { if (!running) break logger.warn(e) { "Cache listener error, reconnecting in 5s..." } Thread.sleep(5000) } } }.also { it.isDaemon = true }.apply { name = "pg-notify-listener" start() } } fun stop() { running = false thread?.interrupt() } } === ./server/src/main/kotlin/database/Repository.kt === package database import auth.JWTManager import database.tables.CarTable import database.tables.CoordinatesTable import database.tables.HumanBeingTable import database.tables.UserTable import io.github.oshai.kotlinlogging.KotlinLogging import models.Car import models.Coordinates import models.HumanBeing import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.transaction import java.util.UUID private val logger = KotlinLogging.logger {} class Repository( private val jwtManager: JWTManager, ) { fun read(): List { val beings = mutableListOf() transaction { val humans = HumanBeingTable .selectAll() for (human in humans) { logger.trace { "read. $human" } val cord = CoordinatesTable .select(CoordinatesTable.x, CoordinatesTable.y) .where(CoordinatesTable.id eq human[HumanBeingTable.coordinates]) .single() val car = CarTable .select(CarTable.cool) .where(CarTable.id eq human[HumanBeingTable.car]) .single() val newCords = Coordinates(cord[CoordinatesTable.x]!!, cord[CoordinatesTable.y]!!) val newCar = Car(car[CarTable.cool]!!) val newBeing = HumanBeing( name = human[HumanBeingTable.name], ownerId = human[HumanBeingTable.owner_id].value, coordinates = newCords, car = newCar, id = human[HumanBeingTable.id].value, creationDate = human[HumanBeingTable.creationDate], realHero = human[HumanBeingTable.realHero], hasToothpick = human[HumanBeingTable.hasToothpick], impactSpeed = human[HumanBeingTable.impactSpeed], soundtrackName = human[HumanBeingTable.soundtrackName], minutesOfWaiting = human[HumanBeingTable.minutesOfWaiting], weaponType = human[HumanBeingTable.weaponType] ) beings.add(newBeing) } } return beings } fun write(item: HumanBeing): UUID? { var humanId: UUID? = null try { transaction { val cords = CoordinatesTable.insertAndGetId { it[x] = item.coordinates.x it[y] = item.coordinates.y } val cars = CarTable.insertAndGetId { it[cool] = item.car.cool } humanId = HumanBeingTable.insertAndGetId { it[owner_id] = item.ownerId it[name] = item.name it[coordinates] = cords it[creationDate] = item.creationDate it[realHero] = item.realHero it[hasToothpick] = item.hasToothpick it[impactSpeed] = item.impactSpeed it[soundtrackName] = item.soundtrackName it[minutesOfWaiting] = item.minutesOfWaiting it[car] = cars it[weaponType] = item.weaponType }.value exec("NOTIFY cache_changed") } } catch (e: Exception) { logger.error(e) { "write failed. Here what's going on: \n $e \n " } return humanId } return humanId } fun update(id: UUID, item: HumanBeing): Boolean { var allGood = false transaction { val check = HumanBeingTable .select(HumanBeingTable.id) .where(HumanBeingTable.id eq id) .singleOrNull() if (check != null) { val refs = HumanBeingTable .select(HumanBeingTable.coordinates, HumanBeingTable.car) .where(HumanBeingTable.id eq id) .single() CoordinatesTable.update( { CoordinatesTable.id eq refs[HumanBeingTable.coordinates] } ) { it[x] = item.coordinates.x it[y] = item.coordinates.y } CarTable.update( { CarTable.id eq refs[HumanBeingTable.car] } ) { it[cool] = item.car.cool } HumanBeingTable.update( { HumanBeingTable.id eq id } ) { it[owner_id] = item.ownerId it[name] = item.name it[creationDate] = item.creationDate it[realHero] = item.realHero it[impactSpeed] = item.impactSpeed it[soundtrackName] = item.soundtrackName it[minutesOfWaiting] = item.minutesOfWaiting it[hasToothpick] = item.hasToothpick it[weaponType] = item.weaponType } exec("NOTIFY cache_changed") allGood = true } } return allGood } fun delete(id: UUID): Boolean { var allGood = true try { transaction { val refs = HumanBeingTable .select(HumanBeingTable.coordinates, HumanBeingTable.car) .where { HumanBeingTable.id eq id } .singleOrNull() if (refs != null) { HumanBeingTable.deleteWhere { HumanBeingTable.id eq id } CoordinatesTable.deleteWhere { CoordinatesTable.id eq refs[HumanBeingTable.coordinates] } CarTable.deleteWhere { CarTable.id eq refs[HumanBeingTable.car] } exec("NOTIFY cache_changed") } else { allGood = false } } } catch (e: Exception) { allGood = false } return allGood } fun login(username: String, password: String): Pair? { var id: String? = null transaction { id = UserTable.select(UserTable.id) .where { (UserTable.username eq username) and (UserTable.password eq password) } .singleOrNull() ?.get(UserTable.id)?.toString() } if (id == null) return null val newToken = jwtManager.createJWT(60, id!!) transaction { UserTable.update({ UserTable.id eq UUID.fromString(id) }) { it[UserTable.token] = newToken } } return Pair(newToken, id!!) } fun register(username: String, password: String): Pair? { var id: String? = null transaction { val check = UserTable.select(UserTable.username) .where { UserTable.username eq username } .singleOrNull() if (check == null) { id = UserTable.insertAndGetId { it[UserTable.username] = username it[UserTable.password] = password }.value.toString() } } if (id == null) return null val newToken = jwtManager.createJWT(60, id!!) transaction { UserTable.update({ UserTable.id eq UUID.fromString(id) }) { it[UserTable.token] = newToken } } return Pair(newToken, id!!) } fun deleteByOwner(ownerId: UUID): Int { var deletedCount = 0 transaction { val humanIds = HumanBeingTable .select(HumanBeingTable.id, HumanBeingTable.coordinates, HumanBeingTable.car) .where { HumanBeingTable.owner_id eq ownerId } .toList() val coordIds = humanIds.map { it[HumanBeingTable.coordinates] } val carIds = humanIds.map { it[HumanBeingTable.car] } deletedCount = HumanBeingTable.deleteWhere { HumanBeingTable.owner_id eq ownerId } CoordinatesTable.deleteWhere { CoordinatesTable.id inList coordIds } CarTable.deleteWhere { CarTable.id inList carIds } exec("NOTIFY cache_changed") } return deletedCount } } === ./server/src/main/kotlin/database/DatabaseManager.kt === package database import com.zaxxer.hikari.* import database.tables.CarTable import database.tables.CoordinatesTable import database.tables.HumanBeingTable import database.tables.UserTable import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.transaction class DatabaseManager { lateinit var dataSource: HikariDataSource private set fun start() { val config = HikariConfig().apply { jdbcUrl = System.getenv("DB_URL") ?: "jdbc:postgresql://localhost:5432/prog7_db" driverClassName = "org.postgresql.Driver" username = System.getenv("DB_USER") ?: error("DB_USER not set") password = System.getenv("DB_PASS") ?: error("DB_PASS not set") maximumPoolSize = 11 isAutoCommit = false transactionIsolation = "TRANSACTION_REPEATABLE_READ" validate() } dataSource = HikariDataSource(config) Database.connect(dataSource) transaction { exec( """ DO ${'$'}${'$'} BEGIN CREATE TYPE weapontype AS ENUM ('HAMMER', 'SHOTGUN', 'BAT'); EXCEPTION WHEN duplicate_object THEN null; END ${'$'}${'$'}; """ ) SchemaUtils.create(CoordinatesTable) SchemaUtils.create(CarTable) SchemaUtils.create(HumanBeingTable) SchemaUtils.create(UserTable) } } fun test(): List { val smth = mutableListOf() transaction { val rows = HumanBeingTable.selectAll() rows.forEach { row -> smth.add(row[HumanBeingTable.name]) } } return smth } }=== ./server/src/main/kotlin/collection/CollectionManager.kt === package collection import models.HumanBeing import mu.KotlinLogging import java.time.LocalDate import java.util.Collections import java.util.TreeSet import java.util.UUID private val logger = KotlinLogging.logger {} class CollectionManager { private val collection: MutableSet = Collections.synchronizedSortedSet(TreeSet()) val initDate: LocalDate = LocalDate.now() fun add(humanBeing: HumanBeing): Boolean { logger.info { "[ADD] Element created ${humanBeing.id}" } return collection.add(humanBeing) } fun update(id: UUID, updated: HumanBeing): Boolean { synchronized(collection) { val old = getById(id) ?: return false collection.remove(old) val replaced = updated.copy(id = old.id, creationDate = old.creationDate) logger.info { "[UPDATE] Element updated ${old.id}" } return collection.add(replaced) } } fun removeById(id: UUID): Boolean { val target = getById(id) ?: return false logger.info { "[REMOVE] Element deleted ${target.id}" } return collection.remove(target) } fun clear() { collection.clear() logger.info { "[CLEAR] Collection cleared" } } fun getAll(): TreeSet { synchronized(collection) { return TreeSet(collection) } } fun getById(id: UUID): HumanBeing? { synchronized(collection) { return collection.firstOrNull { it.id == id } } } fun size(): Int = collection.size fun isEmpty(): Boolean = collection.isEmpty() fun getMax(): HumanBeing? { synchronized(collection) { return collection.maxWithOrNull(Comparator.naturalOrder()) } } fun getMin(): HumanBeing? { synchronized(collection) { return collection.minWithOrNull(Comparator.naturalOrder()) } } fun sumOfMinutesOfWaiting(): Double { synchronized(collection) { return collection.sumOf { it.minutesOfWaiting.toDouble() } } } fun minByName(): HumanBeing? { synchronized(collection) { return collection.minByOrNull { it.name } } } fun getMinutesOfWaitingDescending(): List { synchronized(collection) { return collection.map { it.minutesOfWaiting }.sortedDescending() } } fun loadFromFile(items: List) { synchronized(collection) { collection.clear() collection.addAll(items) } logger.info { "[LOAD] loaded ${items.size} elements" } } fun getInfo(): String = """ |Тип коллекции : ${collection::class.simpleName} |Тип элементов : ${HumanBeing::class.simpleName} |Дата инициал. : $initDate |Кол-во элемен.: ${collection.size} """.trimMargin() } === ./server/src/main/kotlin/auth/JWTManager.kt === package auth import io.fusionauth.jwt.JWTExpiredException import io.fusionauth.jwt.JWTUnavailableForProcessingException import io.fusionauth.jwt.Signer import io.fusionauth.jwt.Verifier import io.fusionauth.jwt.domain.JWT import io.fusionauth.jwt.hmac.HMACSigner import io.fusionauth.jwt.hmac.HMACVerifier import java.time.ZoneOffset import java.time.ZonedDateTime class JWTManager ( private val jwtSecret: String = System.getenv("JWT_SECRET") ?: error("JWT_SECRET not set"), private val issuer: String = "aklivtsov.tech", ) { private val signer: Signer = HMACSigner.newSHA256Signer(jwtSecret) private val verifier: Verifier = HMACVerifier.newVerifier(jwtSecret) fun createJWT(expiredAfterMinutes: Long, userId: String): String { val jwt: JWT = JWT() .setIssuer(issuer) .setIssuedAt(ZonedDateTime.now(ZoneOffset.UTC)) .setSubject(userId) .setExpiration(ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(expiredAfterMinutes)) return JWT.getEncoder().encode(jwt, signer) } fun getUserId(jwt: String): String? { return try { JWT.getDecoder().decode(jwt, verifier).subject } catch (e: Exception) { null } } fun validateJWT(jwt: String, userId: String): Boolean { return try { JWT.getDecoder().decode(jwt, verifier) true } catch (e: JWTExpiredException) { false } catch (e: JWTUnavailableForProcessingException) { false } catch (e: Exception) { false } } }=== ./server/src/main/kotlin/runner/CommandInvoker.kt === package runner import auth.JWTManager import commands.Command import mu.KotlinLogging import network.Request import network.Response /** * Серверный инвокер. * * Принимает Request, находит команду, выполняет, * возвращает Response готовый для отправки клиенту. */ private val logger = KotlinLogging.logger {} class CommandInvoker( private val jwtManager: JWTManager ) { private val commands = mutableMapOf() private val history = ArrayDeque() fun register(command: Command) { commands[command.name] = command } fun execute(request: Request): Response { val command = commands[request.commandName] ?: return Response(false, "Неизвестная команда: '${request.commandName}'") if (request.jwt == "no token") return Response(false, "Нет токена. Войдите или зарегистрируйтесь") if (!jwtManager.validateJWT(request.jwt, request.jwt)) return Response(false, "Время истекло. Выполните вход заново.") val userId = jwtManager.getUserId(request.jwt) ?: return Response(false, "Не удалось определить пользователя") addToHistory(request.commandName) return try { command.execute(request.args, request.humanBeing, userId) } catch (e: Exception) { Response(false, "[Ошибка выполнения] ${e.message}") } } fun getHistory(): List = history.toList() fun getCommands(): Map = commands.toMap() private fun addToHistory(name: String) { if (history.size >= 12) history.removeFirst() history.addLast(name) } } === ./server/src/main/kotlin/network/NetworkManager.kt === package network import database.Repository import kotlinx.serialization.SerializationException import mu.KotlinLogging import runner.CommandInvoker import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress import java.util.concurrent.Executors import java.util.concurrent.ForkJoinPool import java.util.concurrent.RecursiveAction import java.util.concurrent.RecursiveTask private val logger = KotlinLogging.logger {} class NetworkManager( private val port: Int, private val invoker: CommandInvoker, private val repository: Repository ) { private val socket = DatagramSocket(port) private val readerPool = Executors.newFixedThreadPool(4) private val processingPool = ForkJoinPool(Runtime.getRuntime().availableProcessors()) private val senderPool = ForkJoinPool(Runtime.getRuntime().availableProcessors()) fun start() { logger.info { "Server started on port $port. Waiting for connection..." } repeat(4) { readerPool.submit(::readLoop) } try { Thread.currentThread().join() } catch (_: InterruptedException) {} } private fun readLoop() { val buf = ByteArray(65507) while (!socket.isClosed) { try { val packet = DatagramPacket(buf.copyOf(), buf.size) socket.receive(packet) val address = packet.address val clientPort = packet.port val json = String(packet.data, 0, packet.length, Charsets.UTF_8) processingPool.submit(ProcessRequestTask(json, address, clientPort)) } catch (e: Exception) { if (!socket.isClosed) logger.warn(e) { "Read error: ${e.message}" } else break } } } inner class ProcessRequestTask( private val json: String, private val address: InetAddress, private val clientPort: Int ) : RecursiveTask() { override fun compute() { val response: Any = try { val authRequest = try { AppJson.decodeFromString(json) } catch (_: SerializationException) { null } if (authRequest != null) { handleAuth(authRequest) } else { val request = try { AppJson.decodeFromString(json) } catch (e: SerializationException) { logger.warn(e) { "Invalid request: ${e.message}" } senderPool.submit( SendResponseTask( Response(false, "Некорректный формат запроса"), address, clientPort ) ) return } logger.info { "'${request.commandName}' from $address:$clientPort." } invoker.execute(request) } } catch (e: Exception) { Response(false, "Внутренняя ошибка сервера: ${e.message}") } senderPool.submit(SendResponseTask(response, address, clientPort)) } private fun handleAuth(authRequest: AuthRequest): AuthResponse { val res = if (authRequest.type == AuthType.LOGIN) repository.login(authRequest.login, authRequest.password) else repository.register(authRequest.login, authRequest.password) return if (res != null) AuthResponse(true, "success", res.first, res.second) else AuthResponse(false, "login failed", null, null) } } inner class SendResponseTask( private val response: Any, private val address: InetAddress, private val clientPort: Int ) : RecursiveAction() { override fun compute() { try { val bytes = when (response) { is Response -> AppJson.encodeToString(Response.serializer(), response) is AuthResponse -> AppJson.encodeToString(AuthResponse.serializer(), response) else -> return }.toByteArray(Charsets.UTF_8) val responsePacket = DatagramPacket(bytes, bytes.size, address, clientPort) synchronized(socket) { socket.send(responsePacket) } } catch (e: Exception) { logger.warn(e) { "Send error: ${e.message}" } } } } fun stop() { logger.info { "Shutting down..." } socket.close() readerPool.shutdownNow() processingPool.shutdown() senderPool.shutdown() } } === ./server/src/main/kotlin/Main.kt === import auth.JWTManager import collection.CollectionManager import commands.* import database.CacheInvalidator import database.DatabaseManager import database.Repository import mu.KotlinLogging import network.NetworkManager import runner.CommandInvoker import java.io.File import kotlin.system.exitProcess private val logger = KotlinLogging.logger {} fun main(args: Array) { System.setProperty("slf4j.internal.verbosity", "WARN") loadDotEnv() val port = if (args.isNotEmpty() && args.size > 1) args[1].toInt() else 8080 logger.info{ "[startup] Starting server on port $port with $args args" } val jwtManager = JWTManager() val database = DatabaseManager() database.start() val repository = Repository(jwtManager) val manager = CollectionManager() manager.loadFromFile(repository.read()) // Синхронизация кэша между серверами через PG NOTIFY val cacheInvalidator = CacheInvalidator(database.dataSource) { manager.loadFromFile(repository.read()) } cacheInvalidator.start() val invoker = CommandInvoker(jwtManager) registerServerCommands(invoker, manager, repository) // (Ctrl+C or kill) val server = NetworkManager(port = port, invoker = invoker, repository = repository) Runtime.getRuntime().addShutdownHook(Thread { logger.info{ "Exit..." } cacheInvalidator.stop() server.stop() }) // Server-side console Thread { val scanner = java.util.Scanner(System.`in`) logger.info{ "[Server-console] Server-side commands: exit" } while (scanner.hasNextLine()) { when (scanner.nextLine().trim().lowercase()) { "exit" -> { logger.info{ "[Server-console] Shutting down..." } exitProcess(0) } else -> logger.info{ "[Server-console] Server-side commands: exit" } } } }.also { it.isDaemon = true }.start() server.start() } private fun loadDotEnv() { val envFile = File(".env") if (!envFile.exists()) { logger.warn { ".env not found, using system env vars" } return } envFile.forEachLine { line -> val trimmed = line.trim() if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEachLine val idx = trimmed.indexOf('=') if (idx == -1) return@forEachLine val key = trimmed.substring(0, idx).trim() val value = trimmed.substring(idx + 1).trim().removeSurrounding("\"").removeSurrounding("'") setEnv(key, value) } } @Suppress("UNCHECKED_CAST") private fun setEnv(key: String, value: String) { try { val processEnv = System.getenv()::class.java val field = processEnv.getDeclaredField("m") field.isAccessible = true (field.get(System.getenv()) as MutableMap)[key] = value } catch (_: Exception) { System.setProperty(key, value) } } fun registerServerCommands( invoker: CommandInvoker, manager: CollectionManager, repository: Repository ) { listOf( InfoCommand(manager), ShowCommand(manager), AddCommand(manager, repository), UpdateCommand(manager, repository), RemoveByIdCommand(manager, repository), ClearCommand(manager, repository), AddIfMaxCommand(manager, repository), AddIfMinCommand(manager, repository), HistoryCommand(invoker), SyncCommand(invoker), SumOfMinutesCommand(manager), MinByNameCommand(manager), PrintDescendingMinutesCommand(manager), ).forEach { invoker.register(it)} logger.info{ " [startup] Registered commands: ${invoker.getCommands().keys}" } } === ./server/src/main/kotlin/commands/HistoryCommand.kt === package commands import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import runner.CommandInvoker import models.HumanBeing import network.Response @Serializable class HistoryCommand(@Contextual private val invoker: CommandInvoker) : Command { override val name = "history" override val description = "вывести последние 12 команд" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { val history = invoker.getHistory() if (history.isEmpty()) return Response(true, "История пуста.") return Response(true, history.mapIndexed { i, cmd -> " ${i + 1}. $cmd" }.joinToString("\n")) } } === ./server/src/main/kotlin/commands/MinByNameCommand.kt === package commands import collection.CollectionManager import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response /** * Выводит любой элемент с минимальным значением поля [HumanBeing.name]. */ @Serializable class MinByNameCommand(@Contextual private val manager: CollectionManager) : Command { override val name = "min_by_name" override val description = "вывести элемент с минимальным именем" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response = Response(true, manager.minByName()?.toString() ?: "Коллекция пуста.") } === ./server/src/main/kotlin/commands/AddIfMaxCommand.kt === package commands import collection.CollectionManager import database.Repository import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response import java.util.UUID @Serializable class AddIfMaxCommand( @Contextual val manager: CollectionManager, @Contextual private val repository: Repository ) : Command { override val name = "add_if_max" override val description = "добавить элемент, если он больше максимального" override val type = CommandType.MODEL override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { humanBeing ?: return Response(false, "[Ошибка] Объект HumanBeing не передан.") val secured = humanBeing.copy(ownerId = UUID.fromString(userId)) val max = manager.getMax() return if (max == null || secured > max) { secured.id = repository.write(secured) secured.id ?: return Response(false, "Не удалось добавить элемент") manager.add(secured) Response(true, "Элемент добавлен (превышает максимум).") } else { Response(false, "Элемент не добавлен: не превышает текущий максимум (${max.name}).") } } } === ./server/src/main/kotlin/commands/AddCommand.kt === package commands import collection.CollectionManager import database.Repository import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response import java.util.UUID @Serializable class AddCommand( @Contextual private val manager: CollectionManager, @Contextual private val repository: Repository ) : Command { override val name = "add" override val description = "добавить новый элемент в коллекцию" override val args = listOf(CommandArgType.NONE) override val type = CommandType.MODEL override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { humanBeing ?: return Response(false, "[Ошибка] Объект HumanBeing не передан.") val secured = humanBeing.copy(ownerId = UUID.fromString(userId)) secured.id = repository.write(secured) secured.id ?: return Response(false, "Не удалось добавить элемент") return if (manager.add(secured)) Response(true, "Элемент добавлен: ${secured.name} [${secured.id}]") else Response(false, "Элемент уже существует в коллекции.") } } === ./server/src/main/kotlin/commands/AddIfMinCommand.kt === package commands import collection.CollectionManager import database.Repository import models.HumanBeing import kotlinx.serialization.Serializable import kotlinx.serialization.Contextual import network.Response import java.util.UUID @Serializable class AddIfMinCommand( @Contextual private val manager: CollectionManager, @Contextual private val repository: Repository ) : Command { override val name = "add_if_min" override val description = "добавить элемент, если он меньше минимального" override val type = CommandType.MODEL override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { humanBeing ?: return Response(false, "[Ошибка] Объект HumanBeing не передан.") val secured = humanBeing.copy(ownerId = UUID.fromString(userId)) val min = manager.getMin() return if (min == null || secured < min) { secured.id = repository.write(secured) secured.id ?: return Response(false, "Не удалось добавить элемент") manager.add(secured) Response(true, "Элемент добавлен (меньше минимума).") } else { Response(false, "Элемент не добавлен: не меньше текущего минимума (${min.name}).") } } } === ./server/src/main/kotlin/commands/PrintDescendingMinutesCommand.kt === package commands import collection.CollectionManager import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response /** * Выводит значения поля [models.HumanBeing.minutesOfWaiting] в порядке убывания. */ @Serializable class PrintDescendingMinutesCommand(@Contextual private val manager: CollectionManager) : Command { override val name = "print_field_descending_minutes_of_waiting" override val description = "вывести minutesOfWaiting в порядке убывания" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { val values = manager.getMinutesOfWaitingDescending() return if (values.isEmpty()) Response(true, "Коллекция пуста.") else Response(true, values.joinToString("\n")) } } === ./server/src/main/kotlin/commands/UpdateCommand.kt === package commands import collection.CollectionManager import database.Repository import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response import java.util.UUID @Serializable class UpdateCommand( @Contextual private val manager: CollectionManager, @Contextual private val repository: Repository ) : Command { override val name = "update" override val description = "обновить элемент по id: update " override val type = CommandType.MODEL override val args = listOf(CommandArgType.STRING) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { if (args.isEmpty()) return Response(false, "[Ошибка] Укажите id. Пример: update ") humanBeing ?: return Response(false, "[Ошибка] Объект HumanBeing не передан.") val id = try { UUID.fromString(args[0]) } catch (e: IllegalArgumentException) { return Response(false, "[Ошибка] Некорректный UUID: '${args[0]}'") } val existing = manager.getById(id) ?: return Response(false, "Элемент не найден.") if (existing.ownerId.toString() != userId) return Response(false, "Нет прав: объект принадлежит другому пользователю.") if (repository.update(id, humanBeing)) { manager.update(id, humanBeing) return Response(true, "Элемент обновлён.") } else { return Response(false, "[Ошибка] Элемент не найден и не был обновлён.") } } } === ./server/src/main/kotlin/commands/InfoCommand.kt === package commands import collection.CollectionManager import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response @Serializable class InfoCommand(@Contextual private val manager: CollectionManager) : Command { override val name = "info" override val description = "вывести информацию о коллекции" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response = Response(true, manager.getInfo()) } === ./server/src/main/kotlin/commands/ClearCommand.kt === package commands import collection.CollectionManager import database.Repository import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response import java.util.UUID @Serializable class ClearCommand( @Contextual private val manager: CollectionManager, @Contextual private var repository: Repository ) : Command { override val name = "clear" override val description = "очистить коллекцию" override val args = listOf(CommandArgType.NONE) override val type = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { if (userId.isBlank()) return Response(false, "Не удалось определить пользователя.") val ownerId = UUID.fromString(userId) val deletedCount = repository.deleteByOwner(ownerId) val toRemove = manager.getAll().filter { it.ownerId == ownerId } toRemove.forEach { manager.removeById(it.id!!) } return Response(true, "Удалено объектов: $deletedCount.") } } === ./server/src/main/kotlin/commands/RemoveByIdCommand.kt === package commands import collection.CollectionManager import database.Repository import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import java.util.UUID import models.HumanBeing import network.Response @Serializable class RemoveByIdCommand( @Contextual private val manager: CollectionManager, @Contextual private val repository: Repository ) : Command { override val name = "remove_by_id" override val description = "удалить элемент по id: remove_by_id " override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.STRING) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { if (args.isEmpty()) return Response(false, "[Ошибка] Укажите id. Пример: remove_by_id ") val id = try { UUID.fromString(args[0]) } catch (e: IllegalArgumentException) { return Response(false, "[Ошибка] Некорректный UUID: '${args[0]}'") } val existing = manager.getById(id) ?: return Response(false, "Элемент не найден.") if (existing.ownerId.toString() != userId) return Response(false, "Нет прав: объект принадлежит другому пользователю.") if (repository.delete(id)) { manager.removeById(id) return Response(true, "Элемент удалён.") } else { return Response(false, "[Ошибка] Элемент с id '$id' не найден и не был удалён.") } } } === ./server/src/main/kotlin/commands/ShowCommand.kt === package commands import collection.CollectionManager import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response @Serializable class ShowCommand(@Contextual private val manager: CollectionManager) : Command { override val name = "show" override val description = "вывести все элементы коллекции" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { if (manager.isEmpty()) return Response(true, "Коллекция пуста.") return Response(true, manager.getAll().joinToString("\n") { it.toString() }) } } === ./server/src/main/kotlin/commands/SumOfMinutesCommand.kt === package commands import collection.CollectionManager import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.Response /** * Выводит сумму значений поля [models.HumanBeing.minutesOfWaiting]. */ @Serializable class SumOfMinutesCommand(@Contextual private val manager: CollectionManager) : Command { override val name = "sum_of_minutes_of_waiting" override val description = "вывести сумму minutesOfWaiting всех элементов" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response = Response(true, "Сумма minutesOfWaiting: ${manager.sumOfMinutesOfWaiting()}") } === ./server/src/main/kotlin/commands/SyncCommand.kt === package commands import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import models.HumanBeing import network.CommandInfo import network.Response import runner.CommandInvoker @Serializable class SyncCommand( @Contextual private val invoker: CommandInvoker ) : Command { override val name: String = "sync" override val description: String = "Получает все доступные с сервера команды" override val type = CommandType.SIMPLE override val args = listOf(CommandArgType.NONE) override fun execute(args: List, humanBeing: HumanBeing?, userId: String): Response { val commands = mutableMapOf() for (command in invoker.getCommands().values) { if (command.name == "sync") { continue } commands[command.name] = command } val commandInfos = commands.values.map { command -> CommandInfo( name = command.name, description = command.description, type = command.type, args = command.args ) } return Response( success = true, message = "хуй", commands = commandInfos ) } } === ./common/src/main/kotlin/network/AuthRequest.kt === package network import kotlinx.serialization.Serializable @Serializable data class AuthRequest( val login: String, val password: String, val type: AuthType, ) === ./common/src/main/kotlin/network/CommandInfo.kt === package network import commands.CommandArgType import commands.CommandType import kotlinx.serialization.Serializable @Serializable data class CommandInfo( val name: String, val description: String, val args: List, val type: CommandType ) === ./common/src/main/kotlin/network/AuthType.kt === package network import kotlinx.serialization.Serializable @Serializable enum class AuthType { LOGIN, REGISTER }=== ./common/src/main/kotlin/network/utils/UUIDSerializer.kt === package network.utils import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import java.util.UUID /** * Кастомный KSerializer для UUID. * UUID не поддерживается kotlinx.serialization из коробки. */ object UUIDSerializer : KSerializer { override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString()) override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) } === ./common/src/main/kotlin/network/utils/LocalDateSerializer.kt === package network.utils import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import java.time.LocalDate /** * Кастомный KSerializer для LocalDate. * Сериализует как ISO строку: "2025-04-12" */ object LocalDateSerializer : KSerializer { override val descriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString()) override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString()) } === ./common/src/main/kotlin/network/Response.kt === package network import kotlinx.serialization.Serializable @Serializable data class Response( val success: Boolean, val message: String, val commands: List? = null, ) === ./common/src/main/kotlin/network/Request.kt === package network import kotlinx.serialization.Serializable import models.HumanBeing @Serializable data class Request( val commandName: String, val args: List = emptyList(), val humanBeing: HumanBeing? = null, val jwt: String ) === ./common/src/main/kotlin/network/AuthResponse.kt === package network import kotlinx.serialization.Serializable @Serializable data class AuthResponse ( val success: Boolean, val message: String, val accessToken: String?, val id: String? )=== ./common/src/main/kotlin/network/AppJson.kt === package network import kotlinx.serialization.json.Json val AppJson = Json { ignoreUnknownKeys = true encodeDefaults = true } === ./common/src/main/kotlin/models/Car.kt === package models import kotlinx.serialization.Serializable /** * Автомобиль, которым владеет [HumanBeing]. * * @property cool признак того, что автомобиль крутой */ @Serializable data class Car( val cool: Boolean ) === ./common/src/main/kotlin/models/Coordinates.kt === package models import kotlinx.serialization.Serializable /** * Координаты местоположения [HumanBeing]. * * @property x координата X * @property y координата Y */ @Serializable data class Coordinates( val x: Long, val y: Int ) === ./common/src/main/kotlin/models/WeaponType.kt === package models import kotlinx.serialization.Serializable /** * Перечисление типов оружия, которым может владеть [HumanBeing]. * Поле [weaponType] в [HumanBeing] может быть null. */ @Serializable enum class WeaponType { HAMMER, SHOTGUN, BAT; } === ./common/src/main/kotlin/models/HumanBeing.kt === package models import kotlinx.serialization.Serializable import network.utils.LocalDateSerializer import network.utils.UUIDSerializer import java.time.LocalDate import java.util.UUID /** * Основная сущность коллекции — Райан Гослинг. * * @property id уникальный идентификатор, генерируется автоматически * @property name имя героя (Райан Гослинг), не может быть null или пустой строкой * @property coordinates координаты героя, не может быть null * @property creationDate дата создания записи, генерируется автоматически * @property realHero является ли настоящим героем (конечно) * @property hasToothpick есть ли зубочистка (естественно) * @property impactSpeed скорость удара, не может быть null * @property soundtrackName название саундтрека, не может быть null (Nightcall - Kavinsky) * @property minutesOfWaiting количество минут ожидания * @property weaponType тип оружия, может быть null * @property car автомобиль героя, не может быть null */ @Serializable data class HumanBeing( @Serializable(with = UUIDSerializer::class) var id: UUID? = null, @Serializable(with = UUIDSerializer::class) val ownerId: UUID, val name: String, val coordinates: Coordinates, @Serializable(with = LocalDateSerializer::class) val creationDate: LocalDate = LocalDate.now(), val realHero: Boolean, val hasToothpick: Boolean, val impactSpeed: Double, val soundtrackName: String, val minutesOfWaiting: Float, val weaponType: WeaponType?, val car: Car ) : Comparable { init { require(name.isNotBlank()) { "Имя не может быть пустым" } } /** * Сравниваем по имени и id */ override fun compareTo(other: HumanBeing): Int { val byName = this.name.compareTo(other.name) if (byName != 0) return byName return this.id.toString().compareTo(other.id.toString()) } /** * Два объекта считаются одинаковыми, если совпадают их [id]. */ override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is HumanBeing) return false return id == other.id } override fun hashCode(): Int = id.hashCode() /** * Красивое строковое представление для команды [show]. */ override fun toString(): String = """ |HumanBeing (It Drives) { | owner = $ownerId | id = $id | name = $name (a.k.a. Ryan Gosling) | coordinates = (x=${coordinates.x}, y=${coordinates.y}) | creationDate = $creationDate | realHero = $realHero | hasToothpick = $hasToothpick | impactSpeed = $impactSpeed | soundtrackName = $soundtrackName | minutesOfWait = $minutesOfWaiting | weaponType = ${weaponType ?: "null"} | car.cool = ${car.cool} |} """.trimMargin() } === ./common/src/main/kotlin/commands/CommandArgType.kt === package commands import kotlinx.serialization.Serializable // why there's only two types? yes. @Serializable enum class CommandArgType { STRING, NONE } === ./common/src/main/kotlin/commands/Command.kt === package commands import models.HumanBeing import network.Response interface Command { val name: String val description: String val args: List val type: CommandType fun execute(args: List, humanBeing: HumanBeing? = null, userId: String = ""): Response } === ./common/src/main/kotlin/commands/CommandType.kt === package commands enum class CommandType { SIMPLE, MODEL }=== ./client/src/main/kotlin/input/IOManager.kt === package input import org.jline.reader.EndOfFileException import org.jline.reader.LineReader import org.jline.reader.LineReaderBuilder import org.jline.reader.UserInterruptException import org.jline.reader.impl.history.DefaultHistory import org.jline.terminal.TerminalBuilder import java.io.File import java.io.FileNotFoundException import java.util.ArrayDeque import java.util.Scanner class IOManager { val isInteractive: Boolean = true private val terminal = TerminalBuilder.builder() .system(true) .build() private val history = DefaultHistory() private val lineReader: LineReader = LineReaderBuilder.builder() .terminal(terminal) .history(history) // .completer(StringsCompleter(invoker.getCommandNames())) .option(LineReader.Option.HISTORY_IGNORE_DUPS, true) .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true) .build() private val scriptQueue = ArrayDeque() private val fileHistory = ArrayDeque() /** * Читает строку. * Если есть активный скрипт — читает из него. * Иначе — интерактивный ввод через JLine. */ fun readLine(prompt: String): String? { // Режим скрипта while (scriptQueue.isNotEmpty()) { val scanner = scriptQueue.peek() if (scanner.hasNextLine()) { return scanner.nextLine() } scanner.close() scriptQueue.pop() fileHistory.pop() } return try { lineReader.readLine(prompt) } catch (e: UserInterruptException) { "" } catch (e: EndOfFileException) { null } } fun addScriptScanner(filePath: String) { if (fileHistory.contains(filePath)) { printErrConsole("Скрипт уже был выполнен: $filePath") return } try { fileHistory.push(filePath) scriptQueue.push(Scanner(File(filePath), Charsets.UTF_8)) } catch (e: FileNotFoundException) { printErrConsole("Файл не найден: $filePath") } } fun print(text: String) = println(text) fun printErrConsole(text: String) = System.err.println(text) }=== ./client/src/main/kotlin/input/HumanBeingBuilder.kt === package input import models.Car import models.Coordinates import models.HumanBeing import models.WeaponType import network.NetworkManager import java.util.UUID /** * Интерактивный построитель объекта [HumanBeing]. * * Читает поля по одному из [inputManager], показывает приглашения, * валидирует ввод и повторяет запрос при ошибке. * * Поля [HumanBeing.id] и [HumanBeing.creationDate] генерируются * автоматически и не запрашиваются у пользователя. * * @property inputManager источник ввода */ class HumanBeingBuilder( private val inputManager: IOManager, private val networkManager: NetworkManager ) { /** * Запускает интерактивный ввод всех полей и возвращает готовый [HumanBeing]. * * @throws IllegalStateException если EOF */ fun build(): HumanBeing { val name = readNonBlankString("Введите имя:") val coordinates = readCoordinates() val realHero = readBoolean("Герой? (true/false):") val hasToothpick = readBoolean("Есть зубочистка? (true/false):") val impactSpeed = readDouble("Введите скорость удара (impactSpeed):") val soundtrackName = readNonBlankString("Введите название саундтрека:") val minutesOfWaiting = readFloat("Введите минуты ожидания (minutesOfWaiting):") val weaponType = readNullableWeaponType() val car = readCar() return HumanBeing( name = name, coordinates = coordinates, realHero = realHero, hasToothpick = hasToothpick, impactSpeed = impactSpeed, soundtrackName = soundtrackName, minutesOfWaiting = minutesOfWaiting, weaponType = weaponType, car = car, ownerId = networkManager.getUUID() ) } private fun readCoordinates(): Coordinates { val x = readLong("Введите координату X (тип Long):") val y = readInt("Введите координату Y (тип Int):") return Coordinates(x, y) } private fun readCar(): Car { val cool = readBoolean("Крутой автомобиль ? (true/false):") return Car(cool) } /** * Читает непустую строку. Повторяет запрос при пустом вводе. */ private fun readNonBlankString(prompt: String): String { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") if (input.isNotBlank()) return input.trim() inputManager.print("[Ошибка] Строка не может быть пустой.") } } /** * Читает Long. Повторяет запрос при некорректном вводе. */ private fun readLong(prompt: String): Long { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") try { return input.trim().toLong() } catch (e: NumberFormatException) { inputManager.print("[Ошибка] Ожидается целое число (Long). Попробуйте снова.") } } } /** * Читает Int. Повторяет запрос при некорректном вводе. */ private fun readInt(prompt: String): Int { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") try { return input.trim().toInt() } catch (e: NumberFormatException) { inputManager.print("[Ошибка] Ожидается целое число (Int). Попробуйте снова.") } } } /** * Читает Double. Повторяет запрос при некорректном вводе. */ private fun readDouble(prompt: String): Double { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") try { return input.trim().toDouble() } catch (e: NumberFormatException) { inputManager.print("[Ошибка] Ожидается число с плавающей точкой (Double). Попробуйте снова.") } } } /** * Читает Float. Повторяет запрос при некорректном вводе. */ private fun readFloat(prompt: String): Float { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") try { return input.trim().toFloat() } catch (e: NumberFormatException) { inputManager.print("[Ошибка] Ожидается число (Float). Попробуйте снова.") } } } /** * Читает Boolean (true/false). Повторяет запрос при некорректном вводе. */ private fun readBoolean(prompt: String): Boolean { while (true) { val input = inputManager.readLine(" > $prompt") ?: throw IllegalStateException("EOF") when (input.trim().lowercase()) { "true" -> return true "false" -> return false else -> inputManager.print("[Ошибка] Ожидается 'true' или 'false'. Попробуйте снова.") } } } /** * Читает [WeaponType] или null (пустая строка). * Перед вводом выводит список допустимых констант. */ private fun readNullableWeaponType(): WeaponType? { val constants = WeaponType.entries.joinToString(", ") while (true) { val input = inputManager.readLine(" > Введите тип оружия (доступные: $constants) или оставьте пустым для null:") ?: throw IllegalStateException("EOF") if (input.isBlank()) return null try { return WeaponType.valueOf(input.trim().uppercase()) } catch (e: IllegalArgumentException) { inputManager.print("[Ошибка] Неизвестный тип оружия '$input'. Допустимые значения: $constants") } } } /** * Выводит приглашение к вводу только в интерактивном режиме. * В режиме скрипта приглашения не выводятся. */ private fun printPrompt(message: String) { if (inputManager.isInteractive) inputManager.print(" > $message ") } } === ./client/src/main/kotlin/runner/CommandInvoker.kt === package runner import commands.ClientCommand import commands.CommandType import input.HumanBeingBuilder import input.IOManager import network.NetworkManager class CommandInvoker( private val io: IOManager, private val networkManager: NetworkManager ) { private val commands = mutableMapOf() private val history = ArrayDeque(12) fun register(command: ClientCommand) { commands[command.name] = command } fun execute(input: String) { val parts = input.trim().split(" ") val name = parts[0].lowercase().trim() val args = parts.drop(1) val command = commands[name] if (command == null) { io.print("Неизвестная команда: '$name'. Введите 'help' для справки.") return } addToHistory(name) try { if (command.type == CommandType.MODEL) { val model = HumanBeingBuilder(io, networkManager).build() val res = command.execute(args, model) io.print(res) } else { val res = command.execute(args) io.print(res) } } catch (e: IllegalStateException) { io.print("[Ошибка выполнения] ${e.message}") } } fun getHistory(): List = history.toList() fun getCommands(): Map = commands.toMap() private fun addToHistory(name: String) { if (history.size >= 12) history.removeFirst() history.addLast(name) } fun getCommandNames(): List = commands.keys.toList() } === ./client/src/main/kotlin/network/NetworkManager.kt === package network import kotlinx.serialization.SerializationException import java.net.InetSocketAddress import java.net.PortUnreachableException import java.nio.ByteBuffer import java.nio.channels.DatagramChannel import java.util.UUID class NetworkManager( private val host: String, private val port: Int, private val bufferSize: Int = 65507, private val timeoutMS: Long = 3000, private val retryDelayMS: Long = 5000, private val maxRetries: Int = 3, ) { private var token: String = "no token" private var id: String = " no id" private var channel: DatagramChannel = openChannel() private fun openChannel(): DatagramChannel = DatagramChannel.open().also { it.configureBlocking(false) it.connect(InetSocketAddress(host, port)) } fun sendRequest(authRequest: AuthRequest): AuthResponse? { val requestBytes = AppJson.encodeToString(AuthRequest.serializer(), authRequest) .toByteArray(Charsets.UTF_8) repeat(maxRetries) { attempt -> try { channel.write(ByteBuffer.wrap(requestBytes)) val response = waitForAuthResponse() if (response != null) return response } catch (e: PortUnreachableException) { reconnect() } catch (e: Exception) { reconnect() } println( "[Client] Сервер не отвечает " + "(попытка ${attempt + 1}/$maxRetries). " + "Повтор через ${retryDelayMS / 1000} сек..." ) Thread.sleep(retryDelayMS) } println("[Client] Сервер недоступен. Вход не выполнен.") return null } fun sendRequest(request: Request): Response? { val requestBytes = AppJson.encodeToString(Request.serializer(), request) .toByteArray(Charsets.UTF_8) repeat(maxRetries) { attempt -> try { channel.write(ByteBuffer.wrap(requestBytes)) val response = waitForResponse() if (response != null) return response } catch (e: PortUnreachableException) { reconnect() } catch (e: Exception) { reconnect() } println( "[Client] Сервер не отвечает " + "(попытка ${attempt + 1}/$maxRetries). " + "Повтор через ${retryDelayMS / 1000} сек..." ) Thread.sleep(retryDelayMS) } println("[Client] Сервер недоступен. Команда не выполнена.") return null } private fun waitForResponse(): Response? { val buf = ByteBuffer.allocate(bufferSize) val deadline = System.currentTimeMillis() + timeoutMS while (System.currentTimeMillis() < deadline) { try { buf.clear() if (channel.read(buf) > 0) { buf.flip() return AppJson.decodeFromString( Charsets.UTF_8.decode(buf).toString() ) } } catch (e: PortUnreachableException) { return null } catch (e: SerializationException) { System.err.println("[Client] Некорректный ответ сервера: ${e.message}") return null } Thread.sleep(50) } return null } private fun waitForAuthResponse(): AuthResponse? { val buf = ByteBuffer.allocate(bufferSize) val deadline = System.currentTimeMillis() + timeoutMS while (System.currentTimeMillis() < deadline) { try { buf.clear() if (channel.read(buf) > 0) { buf.flip() return AppJson.decodeFromString( Charsets.UTF_8.decode(buf).toString() ) } } catch (e: PortUnreachableException) { return null } catch (e: SerializationException) { System.err.println("[Client] Некорректный ответ сервера: ${e.message}") return null } Thread.sleep(50) } return null } private fun reconnect() { try { channel.close() } catch (_: Exception) {} channel = openChannel() } fun close() { try { channel.close() } catch (_: Exception) {} } fun setToken(newToken: String) { token = newToken } fun getToken(): String = token fun clearToken() { token = "no token" } fun setUUID(newToken: String) { id = newToken } fun getUUID(): UUID { return UUID.fromString(id) } } === ./client/src/main/kotlin/Main.kt === import commands.AuthCommand import commands.ExecuteScriptCommand import commands.ExitCommand import commands.HelpCommand import commands.LogoutCommand import commands.SyncCommand import network.NetworkManager import input.IOManager import runner.CommandInvoker fun main(args: Array) { val host = if (args.isNotEmpty()) args[0] else "localhost" val port = if (args.size >= 2) args[1].toIntOrNull() ?: 8080 else 8080 val io = IOManager() // Name by Boris Bosenko val client = NetworkManager(host, port) val invoker = CommandInvoker(io, client) // Shutdown hook Runtime.getRuntime().addShutdownHook(Thread { client.close() }) // do auth stuff listOf( HelpCommand(invoker), AuthCommand(networkManager = client), ).forEach { invoker.register(it)} while (client.getToken() == "no token") { val line = io.readLine(" ☭ ") ?: break if (line.isBlank()) continue invoker.execute(line) } io.print(""" |════════════════════════════════════════════ |Lab6 Client — подключение к $host:$port |════════════════════════════════════════════ """.trimMargin()) registerBasicCommands(invoker, client, io) runClientRepl(io, invoker) } fun registerBasicCommands(invoker: CommandInvoker,client: NetworkManager, io: IOManager) { listOf( ExecuteScriptCommand(io), SyncCommand(invoker, client), LogoutCommand(client) ).forEach { invoker.register(it)} } fun runClientRepl(io: IOManager, invoker : CommandInvoker) { // sync commands w server invoker.execute("sync") var isRunning = true invoker.register(ExitCommand {isRunning = false}) while (isRunning) { val line = io.readLine(" ☭ ") ?: break if (line.isBlank()) continue invoker.execute(line) } io.print("До свидания!") } === ./client/src/main/kotlin/commands/ExitCommand.kt === package commands import models.HumanBeing class ExitCommand(private val onExit: () -> Unit) : ClientCommand { override val name = "exit" override val description = "завершить клиент" override val args: List = listOf(CommandArgType.NONE) override val type = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String { onExit() return "" } }=== ./client/src/main/kotlin/commands/AuthCommand.kt === package commands import models.HumanBeing import network.AuthRequest import network.AuthResponse import network.AuthType import network.NetworkManager import java.security.MessageDigest class AuthCommand( private val networkManager: NetworkManager, ): ClientCommand { override val name: String = "auth" override val description: String = "вход или регистрация. auth login or auth register " override val args: List = listOf(CommandArgType.NONE) override val type: CommandType = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String { if (args.isEmpty()) return "[Ошибка] Укажите действие, логин и пароль. Пример: auth login " if (args[1].isEmpty() || args[2].isEmpty()) return "[Ошибка] Укажите логин и пароль. Пример: auth login " val res = if (args[0] == "login") { networkManager.sendRequest(AuthRequest(args[1], md5Hash(args[2]), AuthType.LOGIN)) } else if (args[0] == "register") { networkManager.sendRequest(AuthRequest(args[1], md5Hash(args[2]), AuthType.REGISTER)) } else { null } if (res == null) return "[!] Сервер не отвечает." if (res.success) { networkManager.setToken(res.accessToken!!) networkManager.setUUID(res.id!!) } return res.message } private fun md5Hash(input: String): String { val md = MessageDigest.getInstance("MD5") val hashBytes = md.digest(input.toByteArray()) return hashBytes.joinToString("") { String.format("%02x", it) } } }=== ./client/src/main/kotlin/commands/LogoutCommand.kt === package commands import models.HumanBeing import network.NetworkManager class LogoutCommand(private val networkManager: NetworkManager): ClientCommand { override val name: String = "logout" override val description: String = "выход из аккаунта" override val args: List = listOf(CommandArgType.NONE) override val type: CommandType = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String { networkManager.clearToken() return "Пользователь вышел. Войдите заново" } }=== ./client/src/main/kotlin/commands/ServerCommand.kt === package commands import models.HumanBeing import network.Request import network.NetworkManager class ServerCommand( override var name: String, override var description: String, override var args: List, override var type: CommandType, private val networkManager: NetworkManager, ): ClientCommand { override fun execute(args: List, humanBeing: HumanBeing?): String { val request = Request( commandName = this.name, args = args, humanBeing = humanBeing, jwt = networkManager.getToken() ) val response = networkManager.sendRequest(request) return response?.message ?: "[!] Сервер не ответил" } }=== ./client/src/main/kotlin/commands/HelpCommand.kt === package commands import models.HumanBeing import runner.CommandInvoker class HelpCommand(private val invoker: CommandInvoker) : ClientCommand { override val name = "help" override val description = "вывести справку по доступным командам" override val args = listOf(CommandArgType.NONE) override val type = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String = invoker.getCommands().values .sortedBy { it.name } .joinToString("\n") { " ${it.name.padEnd(40)} — ${it.description}" } }=== ./client/src/main/kotlin/commands/ExecuteScriptCommand.kt === package commands import input.IOManager import models.HumanBeing class ExecuteScriptCommand(private val io: IOManager) : ClientCommand { override val name = "execute_script" override val description = "Выполняет скрипт из файла" override val args = listOf(CommandArgType.STRING) override val type = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String { io.addScriptScanner(args[0]) return "" } }=== ./client/src/main/kotlin/commands/ClientCommand.kt === package commands import models.HumanBeing interface ClientCommand { val name: String val description: String val args: List val type: CommandType fun execute(args: List, humanBeing: HumanBeing? = null): String }=== ./client/src/main/kotlin/commands/SyncCommand.kt === package commands import models.HumanBeing import network.NetworkManager import network.Request import runner.CommandInvoker class SyncCommand ( private val invoker: CommandInvoker, private val client: NetworkManager, ) : ClientCommand { override val name = "sync" override val description = "получить команды с сервера" override val args = listOf(CommandArgType.NONE) override val type = CommandType.SIMPLE override fun execute(args: List, humanBeing: HumanBeing?): String { val initialRequest = Request( commandName = "sync", humanBeing = null, jwt = client.getToken(), ) val res = client.sendRequest(initialRequest) if (res == null) { return ("[!] Сервер недоступен. Попробуйте позже.") } else { res.commands?.forEach { serverCommand -> invoker.register( ServerCommand( name = serverCommand.name, description = serverCommand.description, type = serverCommand.type, args = serverCommand.args, networkManager = client ) ) } return ("| Подключен. | Введите 'help' для справки.") } } }=== ./gateway/src/main/kotlin/core/GatewayManager.kt === package core import network.AppJson import network.Request import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.ForkJoinPool import java.util.concurrent.RecursiveAction import java.util.concurrent.RecursiveTask import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger data class BackendServer(val host: String, val port: Int) { @Volatile var isAlive: Boolean = false override fun toString() = "$host:$port" } class GatewayManager( private val listenPort: Int, private val backendsConfig: String, private val healthCheckIntervalMs: Long = 5_000L, private val discoveryIntervalMs: Long = 15_000L, private val probeTimeoutMs: Int = 2_000 ) { private val backends = CopyOnWriteArrayList() private val socket = DatagramSocket(listenPort) private val roundRobinCounter = AtomicInteger(0) private val readerPool = Executors.newFixedThreadPool(4) private val processingPool = ForkJoinPool(Runtime.getRuntime().availableProcessors()) private val senderPool = ForkJoinPool(Runtime.getRuntime().availableProcessors()) private val healthPool = Executors.newScheduledThreadPool(2) private val discoveryPool = Executors.newSingleThreadScheduledExecutor() fun start() { println("[Gateway] Listening on port $listenPort") println("[Gateway] Backend config: $backendsConfig") discoverBackends() startHealthChecks() startDiscovery() repeat(4) { readerPool.submit(::readLoop) } try { Thread.currentThread().join() } catch (_: InterruptedException) {} } private fun resolveBackends(): List { return if (backendsConfig.contains(",")) { backendsConfig.split(",").map { s -> val parts = s.trim().split(":") BackendServer(parts[0], parts[1].toInt()) } } else { val parts = backendsConfig.split(":") val hostname = parts[0] val port = parts[1].toInt() try { InetAddress.getAllByName(hostname).map { BackendServer(it.hostAddress, port) } } catch (e: Exception) { println("[Gateway] DNS resolution failed for $hostname: ${e.message}") emptyList() } } } private fun discoverBackends() { val resolved = resolveBackends() if (resolved.isEmpty()) return val existingByAddr = backends.associateBy { "${it.host}:${it.port}" } val newBackends = resolved.filter { "${it.host}:${it.port}" !in existingByAddr } val aliveCount = resolved.count { existingByAddr["${it.host}:${it.port}"]?.isAlive == true } resolved.forEach { newB -> existingByAddr["${newB.host}:${newB.port}"]?.let { newB.isAlive = it.isAlive } } backends.removeAll { "${it.host}:${it.port}" !in resolved.map { "${it.host}:${it.port}" }.toSet() } backends.addAllAbsent(newBackends) if (newBackends.isNotEmpty()) println("[Gateway] Discovered ${newBackends.size} new backend(s): ${newBackends.joinToString()}") println("[Gateway] Backends: ${backends.size} total, $aliveCount alive") } private fun startDiscovery() { discoveryPool.scheduleAtFixedRate( { discoverBackends() }, discoveryIntervalMs, discoveryIntervalMs, TimeUnit.MILLISECONDS ) } private fun startHealthChecks() { println("[Gateway] Initial health check...") backends.forEach { probeServer(it) } println("[Gateway] Alive: ${backends.filter { it.isAlive }.map { "$it" }}") healthPool.scheduleAtFixedRate( { backends.forEach { probeServer(it) } }, healthCheckIntervalMs, healthCheckIntervalMs, TimeUnit.MILLISECONDS ) } private fun probeServer(backend: BackendServer) { val wasAlive = backend.isAlive backend.isAlive = try { val probe = AppJson.encodeToString( Request.serializer(), Request(commandName = "info", jwt = "no token") ).toByteArray(Charsets.UTF_8) DatagramSocket().use { bs -> bs.soTimeout = probeTimeoutMs bs.send(DatagramPacket(probe, probe.size, InetAddress.getByName(backend.host), backend.port)) val buf = ByteArray(65507) bs.receive(DatagramPacket(buf, buf.size)) true } } catch (e: Exception) { false } if (wasAlive != backend.isAlive) println("[Gateway] $backend -> ${if (backend.isAlive) "UP" else "DOWN"}") } private fun pickBackend(): BackendServer? { val alive = backends.filter { it.isAlive } if (alive.isEmpty()) return null return alive[roundRobinCounter.getAndIncrement() % alive.size] } private fun readLoop() { val buf = ByteArray(65507) while (!socket.isClosed) { try { val packet = DatagramPacket(buf.copyOf(), buf.size) socket.receive(packet) val address = packet.address val clientPort = packet.port val data = packet.data.copyOf(packet.length) processingPool.submit(ProxyTask(data, address, clientPort)) } catch (e: Exception) { if (!socket.isClosed) println("[Gateway] Read error: ${e.message}") else break } } } inner class ProxyTask( private val data: ByteArray, private val clientAddress: InetAddress, private val clientPort: Int ) : RecursiveTask() { override fun compute() { val backend = pickBackend() if (backend == null) { val err = """{"success":false,"message":"No alive servers"}""" .toByteArray(Charsets.UTF_8) senderPool.submit(SendTask(err, clientAddress, clientPort)) return } val commandName = try { AppJson.decodeFromString(data.decodeToString()).commandName } catch (_: Exception) { "?" } println("[Gateway] '$commandName' from $clientAddress:$clientPort → $backend") try { DatagramSocket().use { bs -> bs.soTimeout = probeTimeoutMs bs.send(DatagramPacket(data, data.size, InetAddress.getByName(backend.host), backend.port)) val respBuf = ByteArray(65507) val respPacket = DatagramPacket(respBuf, respBuf.size) bs.receive(respPacket) senderPool.submit( SendTask(respPacket.data.copyOf(respPacket.length), clientAddress, clientPort) ) } } catch (e: Exception) { println("[Gateway] Backend $backend failed: ${e.message}") backend.isAlive = false val fallback = pickBackend() if (fallback != null) { processingPool.submit(ProxyTask(data, clientAddress, clientPort)) } else { val err = """{"success":false,"message":"All backends unavailable"}""" .toByteArray(Charsets.UTF_8) senderPool.submit(SendTask(err, clientAddress, clientPort)) } } } } inner class SendTask( private val bytes: ByteArray, private val address: InetAddress, private val port: Int ) : RecursiveAction() { override fun compute() { try { val packet = DatagramPacket(bytes, bytes.size, address, port) synchronized(socket) { socket.send(packet) } } catch (e: Exception) { println("[Gateway] Send error: ${e.message}") } } } fun stop() { println("[Gateway] Shutting down...") socket.close() readerPool.shutdownNow() processingPool.shutdown() senderPool.shutdown() healthPool.shutdownNow() discoveryPool.shutdownNow() } } === ./gateway/src/main/kotlin/Main.kt === import core.GatewayManager fun main(args: Array) { val listenPort = if (args.isNotEmpty()) args[0].toInt() else 9090 val backends = System.getenv("BACKENDS") ?: "localhost:8080,localhost:8081,localhost:8082" val gateway = GatewayManager( listenPort = listenPort, backendsConfig = backends, healthCheckIntervalMs = 5_000L, discoveryIntervalMs = 15_000L, probeTimeoutMs = 7_000 ) Runtime.getRuntime().addShutdownHook(Thread { gateway.stop() }) gateway.start() }