Files
Lab8-Prog/docs stuff/all_sources.txt
T
2026-06-12 17:42:16 +03:00

4148 lines
146 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
=== ./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<String> = 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<HumanBeingProperty>,
propertyExtractor: (HumanBeingProperty) -> String,
query: String,
): Stream<HumanBeingProperty> {
if (query.isBlank()) return items.stream()
val lower = query.trim().lowercase()
return items.stream()
.filter { propertyExtractor(it).lowercase().contains(lower) }
}
fun sort(
stream: Stream<HumanBeingProperty>,
comparator: Comparator<HumanBeingProperty>?,
): Stream<HumanBeingProperty> {
if (comparator == null) return stream
return stream.sorted(comparator)
}
fun applyFiltersAndSort(
items: List<HumanBeingProperty>,
filters: Map<String, String>,
comparators: List<Comparator<HumanBeingProperty>>,
): ObservableList<HumanBeingProperty> {
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<HumanBeingProperty> = FXCollections.observableArrayList()
private var currentFilters: Map<String, String> = emptyMap()
private var currentComparators: List<Comparator<HumanBeingProperty>> = emptyList()
private val collectionListener = ListChangeListener<HumanBeingProperty> { _ ->
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<HumanBeingProperty>? {
if (message.isBlank()) return emptyList()
val lines = message.lines().filter { it.isNotBlank() }
val items = mutableListOf<HumanBeingProperty>()
var currentLines = mutableListOf<String>()
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<String>): 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<String, String>) {
currentFilters = filters
reapplyFiltersAndSort()
}
fun updateComparators(comparators: List<Comparator<HumanBeingProperty>>) {
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<String> = 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<UUID?>(humanBeing.id)
val ownerId = SimpleObjectProperty<UUID>(humanBeing.ownerId)
val name = SimpleStringProperty(humanBeing.name)
val coordinateX = SimpleObjectProperty<Long>(humanBeing.coordinates.x)
val coordinateY = SimpleObjectProperty<Int>(humanBeing.coordinates.y)
val creationDate = SimpleObjectProperty<LocalDate>(humanBeing.creationDate)
val realHero = SimpleBooleanProperty(humanBeing.realHero)
val hasToothpick = SimpleBooleanProperty(humanBeing.hasToothpick)
val impactSpeed = SimpleObjectProperty<Double>(humanBeing.impactSpeed)
val soundtrackName = SimpleStringProperty(humanBeing.soundtrackName)
val minutesOfWaiting = SimpleObjectProperty<Float>(humanBeing.minutesOfWaiting)
val weaponType = SimpleObjectProperty<WeaponType?>(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<HumanBeingProperty> = 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<out HumanBeingProperty>? ->
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<HumanBeingProperty>) {
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<WeaponTypeOption>()
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<HumanBeingProperty>()
private val canvas = CollectionCanvas()
private val canvasPlaceholder = Label()
private val usernameLabel = Label()
private val localeCombo = ComboBox<LocaleDisplay>()
private val addButton = Button()
private val editButton = Button()
private val deleteButton = Button()
private val refreshButton = Button()
private val filterFields = mutableMapOf<String, TextField>()
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<HumanBeingProperty, String>()
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<HumanBeingProperty>? = sortColumn?.let { col ->
val asc = sortAscending
val comp = Comparator<HumanBeingProperty> { 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<LocaleDisplay>()
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<Locale> = SimpleObjectProperty(Locale("ru"))
private val strings: Map<Locale, Map<String, String>> = 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<String>, 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<Locale> = 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<HumanBeing> {
val beings = mutableListOf<HumanBeing>()
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<String, String>? {
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<String, String>? {
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<String> {
val smth = mutableListOf<String>()
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<HumanBeing> =
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<HumanBeing> {
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<Float> {
synchronized(collection) {
return collection.map { it.minutesOfWaiting }.sortedDescending()
}
}
fun loadFromFile(items: List<HumanBeing>) {
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<String, Command>()
private val history = ArrayDeque<String>()
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<String> = history.toList()
fun getCommands(): Map<String, Command> = 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<Unit>() {
override fun compute() {
val response: Any = try {
val authRequest = try {
AppJson.decodeFromString<AuthRequest>(json)
} catch (_: SerializationException) { null }
if (authRequest != null) {
handleAuth(authRequest)
} else {
val request = try {
AppJson.decodeFromString<Request>(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<String>) {
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<String, String>)[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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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 <uuid>"
override val type = CommandType.MODEL
override val args = listOf(CommandArgType.STRING)
override fun execute(args: List<String>, humanBeing: HumanBeing?, userId: String): Response {
if (args.isEmpty()) return Response(false, "[Ошибка] Укажите id. Пример: update <UUID>")
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<String>, 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<String>, 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 <uuid>"
override val type = CommandType.SIMPLE
override val args = listOf(CommandArgType.STRING)
override fun execute(args: List<String>, humanBeing: HumanBeing?, userId: String): Response {
if (args.isEmpty()) return Response(false, "[Ошибка] Укажите id. Пример: remove_by_id <uuid>")
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<String>, 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<String>, 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<String>, humanBeing: HumanBeing?, userId: String): Response {
val commands = mutableMapOf<String, Command>()
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<CommandArgType>,
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<UUID> {
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<LocalDate> {
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<CommandInfo>? = 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<String> = 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<HumanBeing> {
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<CommandArgType>
val type: CommandType
fun execute(args: List<String>, 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<Scanner>()
private val fileHistory = ArrayDeque<String>()
/**
* Читает строку.
* Если есть активный скрипт — читает из него.
* Иначе — интерактивный ввод через 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<String, ClientCommand>()
private val history = ArrayDeque<String>(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<String> = history.toList()
fun getCommands(): Map<String, ClientCommand> = commands.toMap()
private fun addToHistory(name: String) {
if (history.size >= 12) history.removeFirst()
history.addLast(name)
}
fun getCommandNames(): List<String> = 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<Response>(
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<AuthResponse>(
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<String>) {
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<CommandArgType> = listOf(CommandArgType.NONE)
override val type = CommandType.SIMPLE
override fun execute(args: List<String>, 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 <username> <password> or auth register <username> <password>"
override val args: List<CommandArgType> = listOf(CommandArgType.NONE)
override val type: CommandType = CommandType.SIMPLE
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
if (args.isEmpty()) return "[Ошибка] Укажите действие, логин и пароль. Пример: auth login <username> <password>"
if (args[1].isEmpty() || args[2].isEmpty()) return "[Ошибка] Укажите логин и пароль. Пример: auth login <username> <password>"
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<CommandArgType> = listOf(CommandArgType.NONE)
override val type: CommandType = CommandType.SIMPLE
override fun execute(args: List<String>, 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<CommandArgType>,
override var type: CommandType,
private val networkManager: NetworkManager,
): ClientCommand {
override fun execute(args: List<String>, 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<String>, 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<String>, 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<CommandArgType>
val type: CommandType
fun execute(args: List<String>, 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<String>, 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<BackendServer>()
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<BackendServer> {
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<Unit>() {
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<Request>(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<String>) {
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()
}