4148 lines
146 KiB
Plaintext
4148 lines
146 KiB
Plaintext
=== ./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()
|
||
}
|