Ah shit. Here we go again.

This commit is contained in:
2026-04-13 01:01:04 +03:00
commit d97bbed00f
53 changed files with 2047 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.2.0'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
id 'com.gradleup.shadow' version '9.3.1'
id 'org.jetbrains.dokka' version '2.2.0'
}
group = 'lab6.prog'
version = '1.0'
repositories {
mavenCentral()
}
shadowJar {
archiveBaseName.set('Lab6-client')
archiveVersion.set('1.0')
manifest {
attributes 'Main-Class': 'MainKt'
}
}
dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0'
implementation "io.ktor:ktor-network:3.4.2"
implementation project(':common')
}
kotlin {
jvmToolchain(17)
}
+113
View File
@@ -0,0 +1,113 @@
import network.NetworkManager
import input.HumanBeingBuilder
import input.IOManager
import network.Request
// BEGIN ---- Move it to CommandValidator
// Команды, которые требуют ввода HumanBeing
private val HUMAN_BEING_COMMANDS = setOf("add", "add_if_max", "add_if_min")
// Команды с аргументом (UUID) в строке команды
private val ARG_COMMANDS = setOf("update", "remove_by_id", "execute_script")
// Все команды, доступные клиенту (save — только серверная)
private val KNOWN_COMMANDS = setOf(
"help", "info", "show", "add", "update",
"remove_by_id", "clear", "execute_script",
"add_if_max", "add_if_min", "history",
"sum_of_minutes_of_waiting", "min_by_name",
"print_field_descending_minutes_of_waiting",
"exit"
)
// END ---- Move it to CommandValidator
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()
val client = NetworkManager(host, port)
io.print("""
|╔══════════════════════════════════════╗
|║ Lab6 Client — подключение к $host:$port
|╚══════════════════════════════════════╝
|Введите 'help' для справки.
""".trimMargin())
// Shutdown hook — закрыть канал при выходе
Runtime.getRuntime().addShutdownHook(Thread {
client.close()
})
runClientRepl(io, client)
}
fun runClientRepl(io: IOManager, client: NetworkManager) {
while (true) {
val line = io.readLine(" ☭ ") ?: break
if (line.isBlank()) continue
val parts = line.trim().split(" ")
val commandName = parts[0].lowercase()
val args = parts.drop(1)
// Move to CommandValidate
if (commandName !in KNOWN_COMMANDS) {
io.print("Неизвестная команда: '$commandName'. Введите 'help' для справки.")
continue
}
if (commandName == "exit") {
io.print("До свидания!")
break
}
// In the event of using fucking script (don't)
if (commandName == "execute_script") {
if (args.isEmpty()) {
io.print("[Ошибка] Укажите путь к файлу. Пример: execute_script script.txt")
continue
}
io.addScriptScanner(args[0])
continue
}
// MOVE TO FUCKING VALIDATE
// 4. Для update — нужен UUID аргумент ДО ввода HumanBeing
if (commandName == "update" && args.isEmpty()) {
io.print("[Ошибка] Укажите id элемента. Пример: update <uuid>")
continue
}
// 5. Строим HumanBeing если нужно
val humanBeing = if (commandName in HUMAN_BEING_COMMANDS ||
commandName == "update") {
try {
HumanBeingBuilder(io).build()
} catch (e: IllegalStateException) {
io.print("[Ошибка] Ввод прерван: ${e.message}")
continue
}
} else null
// 6. Формируем запрос и отправляем
val request = Request(
commandName = commandName,
args = args,
humanBeing = humanBeing
)
val response = client.sendRequest(request)
// 7. Обрабатываем ответ
if (response == null) {
io.print("[!] Сервер недоступен. Попробуйте позже.")
} else {
io.print(response.message)
}
}
}
@@ -0,0 +1,168 @@
package input
import models.Car
import models.Coordinates
import models.HumanBeing
import models.WeaponType
/**
* Интерактивный построитель объекта [HumanBeing].
*
* Читает поля по одному из [inputManager], показывает приглашения,
* валидирует ввод и повторяет запрос при ошибке.
*
* Поля [HumanBeing.id] и [HumanBeing.creationDate] генерируются
* автоматически и не запрашиваются у пользователя.
*
* @property inputManager источник ввода
*/
class HumanBeingBuilder(private val inputManager: IOManager) {
/**
* Запускает интерактивный ввод всех полей и возвращает готовый [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
)
}
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 ")
}
}
+75
View File
@@ -0,0 +1,75 @@
package input
import java.io.File
import java.io.FileNotFoundException
import java.util.ArrayDeque
import java.util.Scanner
/**
* Реализация [input.IOManager] для чтения потока ввода/файла.
* Поддерживает корректную обработку Ctrl+C и Ctrl+D.
*/
class IOManager() {
val isInteractive = true
val inputQueue = ArrayDeque<Scanner>()
val fileHistory = ArrayDeque<String>()
init {
inputQueue.push(Scanner(System.`in`))
}
fun addScriptScanner(filePath: String) {
if (fileHistory.contains(filePath)) {
printErrConsole("Скрипт уже был выполнен: $filePath")
return
}
try {
fileHistory.push(filePath)
inputQueue.push(Scanner(File(filePath), Charsets.UTF_8))
} catch (e: FileNotFoundException) {
printErrConsole("Файл не найден: $filePath")
}
}
// сделать основной Scanner
// при инициализации стандартный сканер добавляется
// стек input, один (console) открыт постоянно
// если файл - в стек кидается Scanner
/**
* Читает строку с промптом ☭.
*/
fun readLine(prompt: String): String? {
while (inputQueue.isNotEmpty()) {
if (inputQueue.size == 1) kotlin.io.print(prompt)
val topInDeque = inputQueue.peek()
if (topInDeque.hasNextLine()) {
return topInDeque.nextLine().toString()
}
if (inputQueue.size > 1) {
topInDeque.close()
inputQueue.pop()
fileHistory.pop()
// snap back to reality
continue
}
inputQueue.pop()
print("Ввод закончен.")
break
}
return null
}
fun print(text: String) {
println(text)
}
fun printErrConsole(text: String) {
System.err.println(text)
}
}
@@ -0,0 +1,84 @@
package network
import kotlinx.serialization.SerializationException
import lab6.prog.network.AppJson
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.DatagramChannel
/**
* UDP-клиент на основе DatagramChannel в неблокирующем режиме.
*
* При недоступности сервера уведомляет пользователя и повторяет
* попытку каждые [retryDelayMS] мс, не более [maxRetries] раз.
*
* @property host адрес сервера
* @property port порт сервера
*/
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 val channel: DatagramChannel = DatagramChannel.open().also {
it.configureBlocking(false)
it.connect(InetSocketAddress(host, port))
}
/**
* Отправляет [Request] и возвращает [Response].
* При недоступности сервера возвращает null.
*/
fun sendRequest(request: Request): Response? {
val requestBytes = AppJson.encodeToString(Request.serializer(), request).toByteArray(Charsets.UTF_8)
repeat(maxRetries) { attempt ->
channel.write(ByteBuffer.wrap(requestBytes))
val response = waitForResponse()
if (response != null) return response
println(
"[Client] Сервер не отвечает " +
"(попытка ${attempt + 1}/$maxRetries). " +
"Повтор через ${retryDelayMS / 1000} сек..."
)
Thread.sleep(retryDelayMS)
}
println("[Client] Сервер недоступен. Команда не выполнена.")
return null
}
/**
* Опрашивает канал до [timeoutMS] мс, возвращает ответ или null.
*/
// catch java.net.PortUnreachableException if server is no more reachable
private fun waitForResponse(): Response? {
val buf = ByteBuffer.allocate(bufferSize)
val deadline = System.currentTimeMillis() + timeoutMS
while (System.currentTimeMillis() < deadline) {
buf.clear()
if (channel.read(buf) > 0) {
buf.flip()
return try {
AppJson.decodeFromString<Response>(Charsets.UTF_8.decode(buf).toString())
} catch (e: SerializationException) {
System.err.println("[Client] Некорректный ответ сервера: ${e.message}")
null
}
}
Thread.sleep(50)
}
return null
}
fun close() = channel.close()
}
@@ -0,0 +1,4 @@
package runner
class CommandValidator {
}