Kite Wiki Kotlin Scripting cho Minecraft với Paper API mới — server Paper 1.21.11 · tổng hợp từ tài liệu chính thức, định hướng hiện đại

Kite là plugin cung cấp runtime scripting Kotlin cho server Minecraft dựa trên Paper. Script được biên dịch thành Java bytecode (hiệu năng gần bằng native), có toàn quyền truy cập Bukkit/Paper API.

Wiki này được viết theo định hướng chỉ dùng API Paper hiện đại: Adventure Component, sendRichMessage, Paper scheduler tương thích Folia — hạn chế tối đa các hàm Bukkit cũ đã deprecated.

⚡ Paper API mới 🧩 Adventure Component 🪶 Folia-ready 🧵 Async / Entity Scheduler 📦 Tự tải thư viện

1Giới thiệu về Kite

Kite là gì?

Kite là runtime scripting Kotlin cho server Minecraft. Thay vì một ngôn ngữ script riêng (như Skript), bạn viết bằng Kotlin và truy cập trực tiếp toàn bộ Bukkit/Paper API — với sức mạnh ngang một plugin Java thực thụ.

Điểm mạnh

Yêu cầu hệ thống

Thành phầnYêu cầu
ServerDựa trên Paper — bạn đang dùng 1.21.11
JavaJava 21
Kiến thứcKotlin cơ bản
📌 Ghi chú phiên bản

Kite đã có phiên bản hỗ trợ Paper 1.21.11 — khớp với server của bạn; chỉ cần đặt script lên server là chạy. Nếu dùng IntelliJ để có gợi ý code đúng bản API 1.21.11, bạn nên khai báo paper-api:1.21.11 tường minh trong starter project — xem mục 3.

2Cài đặt

  1. Tải JAR của Kite từ Modrinth, Hangar, hoặc GitHub (github.com/EchoNineLabs/Kite).
  2. Đặt file JAR vào thư mục plugins.
  3. Khởi động lại server → thư mục plugins/Kite/scripts được tạo tự động.
✅ Kiểm tra nhanh

Tạo plugins/Kite/scripts/test.kite.kts:

onLoad {
    println("Kite đang chạy! 🪁")
}

Chạy /kite reloadall và quan sát console.

3Nâng cấp Paper API lên bản mới

💡 Chỉ cần nếu bạn dùng IDE

Đặt script trực tiếp lên server thì không cần phần này — Kite đã hỗ trợ Paper 1.21.11 và tự biên dịch. Mục này chỉ dành cho ai dùng IntelliJ và muốn gợi ý code khớp bản API 1.21.11.

Kite đã có bản hỗ trợ Paper 1.21.11 — bạn không cần tự build. Tuy nhiên, Kite mặc định đi kèm sẵn paper-api 1.21.1. Để IDE gợi ý code đúng bản 1.21.11 (khớp server của bạn), bạn khai báo paper-api:1.21.11 tường minh trong build.gradle.kts của starter project — không phải bằng annotation trong script:

dependencies {
    // Loại bỏ Paper API 1.21.1 mà Kite đi kèm
    api("dev.echonine.kite:kite:1.2.4") {
        exclude(module = "paper-api")
    }
    // Khai báo Paper API khớp server của bạn (1.21.11)
    api("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
}

Sau khi sửa, reload Gradle trong IntelliJ. Script của bạn giờ được IDE biên dịch với Paper API 1.21.11, khớp với server và có đầy đủ method/class mới nhất.

⚠️ Quan trọng
  • Annotation @file:Dependency dùng để tải thư viện runtime phụ trợ, không để đổi Paper API version.
  • Việc khai báo version Paper API (cho IDE gợi ý code) chỉ thực hiện qua starter project Gradle.
  • Server thực tế phải chạy bản Paper tương đương hoặc mới hơn API bạn khai báo.

4Cấu trúc script

Đuôi file & vị trí

Script có đuôi .kite.kts, đặt trong plugins/Kite/scripts (trực tiếp hoặc trong thư mục con).

Script một file

// scripts/hello.kite.kts
onLoad {
    println("Xin chào từ script một file!")
}

Script đa file

Nếu đặt trong thư mục con, thư mục đó bắt buộc có file entry point tên main.kite.kts. Các file khác import qua @file:Import.

// scripts/my_plugin/main.kite.kts
@file:Import("helpers.kite.kts")

onLoad {
    println("Đã nạp my_plugin!")
    printHelper() // hàm từ helpers.kite.kts
}
// scripts/my_plugin/helpers.kite.kts
fun printHelper() {
    println("Tôi là helper! 🧰")
}
💡 Ghi chú

Script một file và đa file có thể dùng đồng thời trong cùng server.

5Vòng đời script

Mỗi script có hai khối: onLoad (sau khi nạp) và onUnload (trước khi gỡ). Dùng chúng để khởi tạo và dọn dẹp tài nguyên.

⚠️ Lời khuyên từ nhà phát triển

Hãy tận dụng triệt để hai khối này để hủy task, xóa dữ liệu tạm… khi script bị gỡ.

onLoad {
    println("Hello, World!")
}

onUnload {
    println("Goodbye, World!")
}

⚠️ Biên dịch bất đồng bộ

Kite biên dịch script thành bytecode (cache tại plugins/Kite/cache). Các lời gọi hàm, khởi tạo property/object ở top-level chạy ngay sau biên dịch, nhưng vì biên dịch là bất đồng bộ, bạn nên tránh gọi hàm non-thread-safe ở ngoài các khối onLoad/onUnload.

🚫 Ví dụ SAI — sẽ ném exception

getTargetBlockExact chỉ gọi được trên main thread, nhưng đoạn dưới chạy ngay sau biên dịch:

val targetBlock = server.getPlayer("Player").getTargetBlockExact(10)

onLoad {
    println(targetBlock.type)
}
✅ Ví dụ ĐÚNG — đưa vào trong onLoad
onLoad {
    val targetBlock = server.getPlayer("Player").getTargetBlockExact(10)
    println(targetBlock.type)
}

6Script Context

Kite cung cấp các biến context luôn có sẵn trong mọi script. Hãy dùng chúng thay cho các truy cập static của Bukkit (xem mục Bukkit cũ → Paper mới).

BiếnKiểuÝ nghĩa
nameFileTên script
entryPointFileFile điểm vào
pluginJavaPluginInstance plugin Kite
serverServerInstance server (Paper)
loggerComponentLoggerLogger hỗ trợ Adventure
onLoad {
    logger.info("Script " + name.name + " đã được nạp!")
    println(server.name)
}

Import tự động

Hầu hết import Bukkit/Paper/Adventure được thêm tự động lúc biên dịch:

API plugin khác phải import tường minh (xem Ví dụ 4).

7Adventure Text — hệ thống chat hiện đại

Adventure là thư viện chat của Paper, thay thế hoàn toàn ChatColor và chuỗi màu kiểu cũ. Kite tự import sẵn net.kyori.adventure.text.

7.1 — Rich Text (mini tags) — nhanh nhất

Kite cung cấp tiện ích sendRichMessage nhận chuỗi có tag màu:

player.sendRichMessage("<red>Máu thấp!</red> <yellow>Bạn có <gold>${player.health}</gold> HP.")
player.sendRichMessage("<bold>Nhấn để mở web: <click:open_url:'https://example.com'><underline>tại đây</click>.")

7.2 — Component trực tiếp

val msg = Component.text("Chào ")
    .color(NamedTextColor.GREEN)
    .append(Component.text(player.name).color(NamedTextColor.YELLOW))
    .append(Component.text("!").decorate(TextDecoration.BOLD))

player.sendMessage(msg)

7.3 — Các tiện ích chính

Ngữ cảnhHàm hiện đại
Gửi chat playerplayer.sendRichMessage("...") / player.sendMessage(Component)
Gửi chat player (không parse)player.sendPlainMessage("...")
Broadcast toàn serverserver.sendRichMessage("...")
Action barplayer.sendRichActionBar("...") / player.sendActionBar(Component)
Titleplayer.showTitle(Title.title(Component, Component))
Kickplayer.kick(Component)
Loggerlogger.info(Component) — logger là ComponentLogger

8Bukkit cũ → Paper mới

Bảng quy đổi nhanh các thói quen Bukkit API cũ sang Paper/Adventure hiện đại. Trong wiki này, mọi ví dụ đều dùng cột bên phải.

❌ Bukkit cũ (legacy)✅ Paper/Adventure mới
player.sendMessage("&aHello")player.sendRichMessage("<green>Hello")
server.broadcastMessage("Hi")server.sendRichMessage("Hi")
Bukkit.getPlayer(name)server.getPlayer(name) (dùng context server)
Bukkit.getServer()Biến context server có sẵn
ChatColor.REDNamedTextColor.RED / tag <red>
player.getLocation()player.location
player.getWorld()player.world
player.sendTitle(a, b, 10, 20, 10)player.showTitle(Title.title(Component, Component))
player.sendActionBar("...") (String)player.sendRichActionBar("...")
player.kick("reason")player.kick(Component.text("reason"))
Định thời qua Bukkit.getScheduler()server.asyncScheduler / player.scheduler (xem Scheduler)
Bukkit.isPrimaryThread()server.isPrimaryThread
💡 Nguyên tắc chung
  • Ưu tiên Adventure để làm text, không dùng ChatColor/mã &.
  • Dùng biến context (server, plugin, logger) thay cho truy cập static Bukkit.*.
  • Dùng Paper scheduler (EntityScheduler, AsyncScheduler, GlobalRegionScheduler) để tương thích Folia.

9Event Listener

Tạo listener bằng on<Event>. Event tự động hủy đăng ký khi script gỡ. Luôn dùng Adventure để gửi message.

Ví dụ cơ bản

on<PlayerJoinEvent> { event ->
    if (event.player.hasPlayedBefore() == true) {
        event.player.sendRichMessage("<gray>Chào mừng trở lại, <red>${event.player.name}!")
    }
}

Chỉ định priority

on<PlayerDeathEvent>(priority = EventPriority.MONITOR) {
    // xử lý sau cùng, không thay đổi kết quả event
}

Title chào mừng bằng Adventure

on<PlayerJoinEvent> { event ->
    val player = event.player
    player.showTitle(
        Title.title(
            Component.text("Chào mừng!").color(NamedTextColor.GOLD),
            Component.text(player.name).color(NamedTextColor.YELLOW)
        )
    )
}
🧩 Custom events

Lắng nghe event tùy biến của plugin khác bằng cách import loại event và dùng on<YourCustomEvent>.

10Đăng ký lệnh (Command)

Lệnh qua API Kite tự động hủy đăng ký khi script gỡ. Chỉ cần tên lệnh là bắt buộc.

command("echo") {
    description = "In ra message người chơi truyền vào."
    permission = "scripts.command.echo"
    usage = "/echo [message]"
    aliases = listOf("e", "print")

    execute { sender, args ->
        val message = args.joinToString(" ")
        sender.sendRichMessage(message)
    }

    tabComplete { sender, args ->
        return@tabComplete emptyList()
    }
}

Tham số khối lệnh

Thuộc tínhÝ nghĩa
descriptionMô tả lệnh
permissionPermission cần thiết
usageCú pháp sử dụng
aliasesDanh sách lệnh tắt
execute { sender, args -> }Logic khi gõ lệnh
tabComplete { sender, args -> }Gợi ý tab

Kiểm tra người gọi là player + truy cập world qua property

command("heal") {
    permission = "scripts.command.heal"
    execute { sender, args ->
        if (sender is Player) {
            sender.health = sender.maxHealth
            sender.sendRichMessage("<green>Bạn đã được hồi máu!")
            // sender.world — property Paper thay cho getWorld()
            println(sender.world.name)
        } else {
            sender.sendRichMessage("<red>Lệnh này chỉ dùng cho người chơi.")
        }
    }
}

11Scheduler — bộ lập lịch Folia-ready

Task qua API Kite tự động hủy khi script gỡ. Nếu dùng trực tiếp Bukkit/Paper API, bạn phải tự quản lý vòng đời. Trong wiki này, ta ưu tiên Paper scheduler thay vì BukkitScheduler cũ (không tương thích Folia).

11.1 — EntityScheduler (khuyến nghị cho player)

Thực thi task trên region sở hữu entity — an toàn với Folia. Hàm: run, runDelayed, runAtFixedRate.

on<PlayerJoinEvent> { event ->
    event.player.scheduler.run {
        event.player.sendPlainMessage("Xin chào, ${it.name}, chào mừng đến server!")
    }
    event.player.scheduler.runDelayed(30, TimeUnit.SECONDS, {
        event.player.kick(Component.text("Bạn đã chơi hơn 30 giây... Quá lâu rồi!"))
    })
    event.player.scheduler.runAtFixedRate(5, 5, TimeUnit.SECONDS, {
        event.player.level = event.player.level + 1
    })
}

11.2 — AsyncScheduler (cho I/O, HTTP)

Chạy bất đồng bộ, tách khỏi tick server. Hàm: runNow, runDelayed, runAtFixedRate.

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

val client = HttpClient.newHttpClient()

command("country") {
    permission = "scripts.country"
    execute { sender, args ->
        if (sender is Player) {
            server.asyncScheduler.runNow {
                // Kiểm tra thread bằng Paper API
                sender.sendRichMessage("<gray>Thread: <yellow>${Thread.currentThread().name}")
                val req = HttpRequest.newBuilder().GET()
                    .uri(URI("https://get.geojs.io/v1/ip/country/full/${sender.address.address}"))
                    .build()
                val country = client.send(req, HttpResponse.BodyHandlers.ofString()).body().trim()
                sender.sendRichMessage("<green>Quốc gia: <yellow>$country")
            }
        }
    }
}

11.3 — GlobalRegionScheduler

Chạy trên global region thread; nếu không dùng Folia thì quay về main thread.

server.globalRegionScheduler.runDelayed(10, TimeUnit.SECONDS) {
    server.sendRichMessage("<gold>10 giây đã trôi qua!")
}

11.4 — BukkitScheduler (chỉ khi không dùng Folia)

Bám theo tick server, không tương thích Folia. Hàm: runTask, runTaskAsync, runTaskTimer, runTaskTimerAsync.

server.scheduler.runTaskTimer(delayTicks = 20, periodTicks = 20 * 10) {
    server.sendRichMessage("<aqua>Thông báo định kỳ (BukkitScheduler)")
}
✅ Quy tắc chọn scheduler
  • Thao tác trên một entity/playerplayer.scheduler (EntityScheduler).
  • I/O, HTTP, tính toán nặngserver.asyncScheduler.
  • Task toàn server, không theo entityserver.globalRegionScheduler.
  • Chỉ chạy không Foliaserver.scheduler (BukkitScheduler).

12Quản lý thư viện (Dependencies)

Kite tự tải và nạp thư viện từ Maven repository lúc chạy.

Khai báo Repository

Dùng @file:Repository (lặp lại được). Nên dùng mirror thay vì Maven Central gốc:

@file:Repository("https://maven-central.storage-download.googleapis.com/maven2")

Khai báo Dependency

@file:Dependency("com.github.ben-manes.caffeine:caffeine:3.2.3")

// Tắt giải quyết transitive nếu cần:
@file:Dependency("groupId:artifactId:version", withTransitiveDependencies = false)

Relocation (đổi package) — tùy chọn

@file:Relocation("pattern.from", "pattern.to")

Dependency tải vào plugins/Kite/libs. Reload script để biên dịch; thành công in Script ... successfully loaded.

💡 Với IntelliJ IDEA

Repository/dependency khai báo bằng annotation cũng nên thêm vào build.gradle.kts để có gợi ý code.

13Tích hợp IDE (IntelliJ IDEA) — tùy chọn

✅ IDE KHÔNG bắt buộc

Script chạy trực tiếp trên server: chỉ cần đặt file .kite.kts vào plugins/Kite/scripts/ rồi chạy /kite reloadall — Kite tự biên dịch ngay tại runtime. Không cần Gradle hay IntelliJ để chạy.

Mục này dành cho ai muốn có gợi ý code, bắt lỗi sớm, và xem tài liệu API khi viết — hoàn toàn tùy chọn.

  1. Clone starter project:
    git clone https://github.com/EchoNineLabs/KiteScripting.git
    Mở trong IntelliJ IDEA và load Gradle.
  2. Kích hoạt Kite Script definition: SettingsLanguages & FrameworksKotlinKotlin ScriptingScan Classpath. Khi thấy "1 new definition(s) found"ApplyOK. Xác nhận Kite Script (.kite.kts) đã bật.
  3. Nâng cấp Paper API (nếu muốn) — xem mục 3.

Thêm dependency remote tùy biến (ví dụ PlaceholderAPI) — phải khai báo cả annotation lẫn build script:

// example.kite.kts
@file:Repository("https://repo.extendedclip.com/releases/")
@file:Dependency("me.clip:placeholderapi:2.11.7")
// build.gradle.kts
repositories {
    maven { url = uri("https://repo.extendedclip.com/releases/") }
}
dependencies {
    api("me.clip:placeholderapi:2.11.7")
}
⚠️ Lưu ý

Nhớ thêm dependency vào cả build script, nếu không sẽ không có gợi ý code.

Ghi chú viết script trong IDE

14Xử lý sự cố

14.1 — Import xung đột

Các API tự import có thể xung đột kiểu (vd org.bukkit.Sound vs net.kyori.adventure.sound.Sound). Giải pháp: import tường minh, dùng tên đầy đủ, hoặc alias:

import net.kyori.adventure.sound.Sound as AdventureSound

14.2 — Unresolved References (fork server)

Một số fork làm hỏng class loading, khiến mọi script biên dịch thất bại. Không xảy ra trên Paper/Folia/Purpur/Pufferfish. Giải pháp: thêm JVM flag

-Dkite.compat.dynamic-server-jar=true

Tính năng này thử nghiệm, không đảm bảo hoạt động mọi trường hợp.

14.3 — Trùng tên class (Duplicate JVM Class Name)

Xảy ra với @Import: hai script cùng tên file (dù khác thư mục) biên dịch thành cùng class Name_kite. Giải pháp: dùng tên khác, hoặc khai báo package:

// extras/main.kite.kts
package extras

fun fromExtras() = "Từ thư mục extras"
// my_script/main.kite.kts
@file:Import("../extras/main.kite.kts")
import extras.*

onLoad {
    println(fromExtras())
}

15Ví dụ script hoàn chỉnh (Paper API mới)

Các script mẫu đều dùng Adventure, context server, và Paper scheduler. Lưu mỗi đoạn thành file .kite.kts trong plugins/Kite/scripts rồi reload.

Ví dụ 1 — Chào mừng thành viên

Gửi lời chào khác nhau cho thành viên mới/cũ, kèm task chào muộn sau 5 giây bằng server.scheduler.

// welcome.kite.kts
on<PlayerJoinEvent> { event ->
    val player = event.player
    val isNew = !player.hasPlayedBefore()

    if (isNew) {
        player.sendRichMessage("<gold>🎉 Chào mừng lần đầu, <bold>${player.name}</bold> đến server!")
    } else {
        player.sendRichMessage("<gray>Chào mừng trở lại, <red>${player.name}</gray>!")
    }

    // Task chào muộn sau 5 giây (100 tick)
    server.scheduler.runTask(100) {
        if (player.isOnline) {
            player.sendRichMessage("<aqua>Nhớ xem luật server: <click:open_url:'https://example.com/rules'><underline>tại đây</click>!")
        }
    }
}

onUnload {
    println("Đã gỡ script welcome.")
}

Ví dụ 2 — Lệnh warp ngẫu nhiên

Lệnh /randomtp dịch chuyển người chơi ngẫu nhiên. Dùng property sender.world, getHighestBlockYAt (Paper) và EntityScheduler.

// randomtp.kite.kts
import org.bukkit.Location

command("randomtp") {
    description = "Dịch chuyển ngẫu nhiên trong bán kính cho trước."
    permission = "scripts.randomtp"
    usage = "/randomtp <bán_kính>"
    aliases = listOf("rtp")

    execute { sender, args ->
        if (sender !is Player) {
            sender.sendRichMessage("<red>Chỉ người chơi mới dùng được lệnh này.")
            return@execute
        }
        val radius = args.firstOrNull()?.toIntOrNull() ?: 500
        val world = sender.world            // Paper property, thay cho getWorld()

        val x = (-radius..radius).random()
        val z = (-radius..radius).random()
        val y = world.getHighestBlockYAt(x, z)   // Paper API
        val target = Location(world, x.toDouble(), (y + 1).toDouble(), z.toDouble())

        // EntityScheduler — an toàn với Folia
        sender.scheduler.run {
            sender.teleport(target)
            sender.sendRichMessage("<green>Đã dịch chuyển đến $x, $z!")
        }
    }
}

Ví dụ 3 — Broadcast định kỳ

Mỗi 30 giây gửi một thông báo xoay vòng. Dùng server.sendRichMessageBukkitScheduler (chỉ chạy không-Folia; nếu dùng Folia hãy chuyển sang globalRegionScheduler).

// auto_announce.kite.kts
val announcements = listOf(
    "<gold>💡 Mẹo: <white>Nhấn <yellow>Ctrl+F</white> để tìm kiếm trên web.",
    "<gold>💡 Mẹo: <white>Sử dụng <yellow>/help</white> để xem danh sách lệnh.",
    "<gold>💡 Mẹo: <white>Tham gia Discord của chúng tôi!"
)
var index = 0

onLoad {
    server.scheduler.runTaskTimer(delayTicks = 20 * 5, periodTicks = 20 * 30) {
        server.sendRichMessage(announcements[index])
        index = (index + 1) % announcements.size
    }
    server.sendRichMessage("<green>✓ Auto-announce đã bật.")
}

onUnload {
    server.sendRichMessage("<red>✕ Auto-announce đã tắt.")
}

Ví dụ 4 — Gọi API plugin khác (PlaceholderAPI)

Import tường minh vì không phải API Bukkit/Paper. Gửi kết quả bằng Adventure.

// papi.kite.kts
@file:Repository("https://repo.extendedclip.com/releases/")
@file:Dependency("me.clip:placeholderapi:2.11.7")

import me.clip.placeholderapi.PlaceholderAPI

command("stats") {
    permission = "scripts.stats"
    execute { sender, args ->
        if (sender is Player) {
            val uptime = PlaceholderAPI.setPlaceholders(sender, "%server_uptime%")
            val pings  = PlaceholderAPI.setPlaceholders(sender, "%player_ping%")
            sender.sendRichMessage("<yellow>Uptime: <white>$uptime\n<yellow>Ping: <white>$pings ms")
        }
    }
}

Ví dụ 5 — HTTP request bất đồng bộ

Tra cứu quốc gia theo IP. Dùng AsyncScheduler để không chặn main thread.

// country.kite.kts
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

val httpClient = HttpClient.newBuilder().connectTimeout(java.time.Duration.ofSeconds(5)).build()

command("country") {
    permission = "scripts.country"
    execute { sender, args ->
        if (sender is Player) {
            sender.sendRichMessage("<gray>Đang tra cứu quốc gia của bạn...")
            server.asyncScheduler.runNow {
                try {
                    val ip = sender.address.address.hostAddress
                    val req = HttpRequest.newBuilder().GET().uri(URI("https://get.geojs.io/v1/ip/country/full/$ip")).build()
                    val country = httpClient.send(req, HttpResponse.BodyHandlers.ofString()).body().trim()
                    sender.sendRichMessage("<green>Quốc gia: <yellow>$country")
                } catch (e: Exception) {
                    sender.sendRichMessage("<red>Không tra cứu được: <white>${e.message}")
                }
            }
        }
    }
}

Ví dụ 6 — Đa file + tự tải thư viện (Caffeine)

Dự án đa file, dùng thư viện cache Caffeine tải từ Maven, kết hợp Adventure để gửi message.

File 1: plugins/Kite/scripts/cache_demo/helpers.kite.kts

// helpers.kite.kts
fun greet(playerName: String): String = "Xin chào $playerName! 👋"

File 2: plugins/Kite/scripts/cache_demo/main.kite.kts

// main.kite.kts (entry point của cache_demo)
@file:Repository("https://maven-central.storage-download.googleapis.com/maven2")
@file:Dependency("com.github.ben-manes.caffeine:caffeine:3.2.3")
@file:Import("helpers.kite.kts")

import com.github.benmanes.caffeine.cache.Caffeine

val cache = Caffeine.newBuilder()
    .expireAfterWrite(java.time.Duration.ofMinutes(5))
    .build<String, String>()

command("greet") {
    permission = "scripts.greet"
    execute { sender, args ->
        val name = args.firstOrNull() ?: sender.name
        val cached = cache.getIfPresent(name)
        if (cached != null) {
            sender.sendRichMessage("<gray>(cache) $cached")
        } else {
            val message = greet(name)
            cache.put(name, message)
            sender.sendRichMessage("<green>$message")
        }
    }
}
⚠️ Lưu ý thư mục đa file

Thư mục con phải có main.kite.kts làm entry point. Nếu hai thư mục có file cùng tên, khai báo package để tránh lỗi trùng class (xem mục 14.3).

Ví dụ 7 — Kit (lệnh cấp trang bị)

Lệnh /kit cấp các bộ trang bị theo tên, kèm gợi ý tab tự động. Item dùng Material, message dùng Adventure.

// kit.kite.kts
import org.bukkit.Material
import org.bukkit.inventory.ItemStack

val kits = mapOf(
    "starter" to listOf(
        ItemStack(Material.STONE_SWORD),
        ItemStack(Material.COOKED_BEEF, 16),
        ItemStack(Material.LEATHER_HELMET)
    ),
    "diamond" to listOf(
        ItemStack(Material.DIAMOND_SWORD),
        ItemStack(Material.DIAMOND_CHESTPLATE),
        ItemStack(Material.GOLDEN_APPLE, 4)
    )
)

command("kit") {
    description = "Cấp kit cho người chơi."
    permission = "scripts.kit"
    usage = "/kit <starter|diamond>"
    execute { sender, args ->
        if (sender !is Player) {
            sender.sendRichMessage("<red>Chỉ người chơi mới dùng được lệnh này.")
            return@execute
        }
        val kit = args.firstOrNull() ?: "starter"
        val items = kits[kit]
        if (items == null) {
            sender.sendRichMessage("<red>Kit '<yellow>$kit</red>' không tồn tại. Có: <yellow>${kits.keys.joinToString(", ")}")
            return@execute
        }
        items.forEach { sender.inventory.addItem(it) }
        sender.sendRichMessage("<green>Đã nhận kit <yellow>$kit</green>! 🎒")
    }
    tabComplete { sender, args ->
        return@tabComplete kits.keys.filter { it.startsWith(args.lastOrNull() ?: "") }
    }
}

Ví dụ 8 — Rank (prefix theo quyền)

Gán prefix cho người chơi dựa trên permission node (rank.vip, rank.mvp, rank.admin). Hiển thị khi vào server và khi chạy lệnh /rank.

// rank.kite.kts
val ranks = linkedMapOf(
    "vip"   to "<gold>[VIP] ",
    "mvp"   to "<light_purple>[MVP] ",
    "admin" to "<red>[ADMIN] "
)

// Hàm tìm rank của player theo permission node
fun getRank(player: Player): String? = ranks.keys.find { player.hasPermission("rank.$it") }

on<PlayerJoinEvent> { event ->
    val prefix = getRank(event.player)?.let { ranks[it] } ?: ""
    event.player.sendRichMessage("$prefix<gray>Xin chào ${event.player.name}!")
}

command("rank") {
    permission = "scripts.rank"
    execute { sender, args ->
        if (sender !is Player) return@execute
        val rank = getRank(sender)
        if (rank == null) {
            sender.sendRichMessage("<gray>Bạn chưa có rank.")
            return@execute
        }
        sender.sendRichMessage("${ranks[rank]}<green>Rank của bạn: <yellow>$rank")
    }
}

Ví dụ 9 — Warp menu (GUI Inventory)

Mở một GUI 9×3 chứa các nút warp. Click vào item sẽ đóng menu và dịch chuyển người chơi. Dùng Adventure để đặt tên item và title menu.

// warpmenu.kite.kts
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
import org.bukkit.event.inventory.InventoryClickEvent
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor

data class Warp(val label: String, val material: Material, val x: Int, val z: Int)

val warps = listOf(
    Warp("Spawn", Material.RED_BED, 0, 0),
    Warp("Mine", Material.STONE_PICKAXE, 100, -50),
    Warp("Farm", Material.WHEAT, -200, 80)
)

val menuTitle = Component.text("Warp Menu").color(NamedTextColor.GOLD)

fun teleportToWarp(player: Player, warp: Warp) {
    val y = player.world.getHighestBlockYAt(warp.x, warp.z)
    val loc = Location(player.world, warp.x + 0.5, (y + 1).toDouble(), warp.z + 0.5)
    player.scheduler.run {   // EntityScheduler, Folia-safe
        player.teleport(loc)
        player.sendRichMessage("<green>Đã dịch chuyển đến <yellow>${warp.label}</green>!")
    }
}

command("warp") {
    description = "Mở menu warp."
    permission = "scripts.warp"
    execute { sender, args ->
        if (sender !is Player) {
            sender.sendRichMessage("<red>Chỉ người chơi dùng được lệnh này.")
            return@execute
        }
        val target = args.firstOrNull()?.lowercase()
        if (target != null) {
            val warp = warps.find { it.label.lowercase() == target }
            if (warp != null) { teleportToWarp(sender, warp); return@execute }
            sender.sendRichMessage("<red>Warp '<yellow>$target</red>' không tồn tại.")
            return@execute
        }
        openWarpMenu(sender)
    }
}

fun openWarpMenu(player: Player) {
    val inv: Inventory = Bukkit.createInventory(null, 9 * 3, menuTitle)
    warps.forEachIndexed { i, warp ->
        val icon = ItemStack(warp.material)
        icon.editMeta { displayName(Component.text(warp.label).color(NamedTextColor.GREEN)) }
        inv.setItem(i, icon)
    }
    player.openInventory(inv)
}

on<InventoryClickEvent> { event ->
    if (event.view.title() != menuTitle) return@on
    event.isCancelled = true
    val player = event.whoClicked as? Player ?: return@on
    val slot = event.rawSlot
    if (slot in warps.indices) {
        player.closeInventory()
        teleportToWarp(player, warps[slot])
    }
}

Ví dụ 10 — Fly toggle

Lệnh /fly bật/tắt bay. Dùng property isFlying và Adventure để báo trạng thái.

// fly.kite.kts
command("fly") {
    description = "Bật/tắt chế độ bay."
    permission = "scripts.fly"
    execute { sender, args ->
        if (sender !is Player) {
            sender.sendRichMessage("<red>Chỉ người chơi dùng được lệnh này.")
            return@execute
        }
        sender.isFlying = !sender.isFlying
        sender.sendRichMessage(
            "<green>Chế độ bay: <${if (sender.isFlying) "green" else "red"}>${if (sender.isFlying) "BẬT" else "TẮT"} ✈️"
        )
    }
}

Ví dụ 11 — PvP toggle theo từng người chơi

Mỗi người chơi tự bật/tắt PvP của mình. Nếu một trong hai bên tắt PvP thì hủy sát thương.

// pvp.kite.kts
import org.bukkit.event.entity.EntityDamageByEntityEvent

// Lưu tên những player đang bật PvP
val pvpEnabled = mutableSetOf<String>()

command("pvp") {
    description = "Bật/tắt PvP của bản thân."
    permission = "scripts.pvp"
    execute { sender, args ->
        if (sender !is Player) return@execute
        val name = sender.name
        val on = if (name in pvpEnabled) { pvpEnabled.remove(name); false } else { pvpEnabled.add(name); true }
        sender.sendRichMessage(
            "<gold>PvP: <${if (on) "green" else "red"}>${if (on) "BẬT" else "TẮT"} ⚔️"
        )
    }
}

on<EntityDamageByEntityEvent> { event ->
    val damager = event.damager as? Player ?: return@on
    val victim  = event.entity as? Player ?: return@on
    if (damager.name !in pvpEnabled || victim.name !in pvpEnabled) {
        event.isCancelled = true
        damager.sendRichMessage("<red>PvP đang tắt — không thể gây sát thương lẫn nhau!")
    }
}

Ví dụ 12 — Tương tác NPC với FancyNpcs

Lắng nghe event tương tác NPC của plugin FancyNpcs (NpcInteractEvent) để chạy hành động khi người chơi click vào NPC. Vì là API của plugin khác, bạn phải import tường minh. Không cần khai báo version — xem giải thích bên dưới.

💡 Vì sao không cần version?
  • FancyNpcs đã cài trên server → Kite truy cập thẳng classes của plugin đang chạy qua classpath, nên không cần @file:Dependency (và không cần version).
  • @file:Dependency chỉ bắt buộc cho thư viện chưa có trên server (Caffeine, HTTP client…) — lúc đó Maven mới cần version để tải artifact.
  • Placeholder {version} không hoạt động trong annotation (chuỗi tĩnh) — nó chỉ có tác dụng trong Gradle build script.
  • Bạn chỉ cần khai báo build.gradle.kts dạng compileOnly để IDE gợi ý code, không phải để chạy.
📦 Chuẩn bị
  • Cài plugin FancyNpcs trên server (Paper 1.21.11).
  • Tạo NPC bằng /npc create <tên>.
  • Dùng đúng version API tương ứng bản FancyNpcs server đang chạy trong build.gradle.kts.
// npc.kite.kts — không cần @file:Dependency vì FancyNpcs đã có trên server
import de.oliver.fancynpcs.api.events.NpcInteractEvent

on<NpcInteractEvent> { event ->
    val player = event.player          // người chơi click
    val npcName = event.npc.data.name  // tên NPC bị click

    when (npcName) {
        "shop" -> {
            player.sendRichMessage("<gold>🛒 Chào mừng đến cửa hàng!")
            // mở GUI shop, gọi plugin economy... tại đây
        }
        "quest" -> player.sendRichMessage("<green>📜 Bạn đã bắt đầu nhiệm vụ!")
        "heal" -> {
            player.health = player.maxHealth
            player.sendRichMessage("<green>💖 NPC đã hồi máu cho bạn!")
        }
        else -> player.sendRichMessage("<gray>Bạn tương tác với NPC <yellow><bold>$npcName")
    }
}

Chỉ để IDE gợi ý code, thêm vào build.gradle.kts — dùng compileOnly (không đóng gói, vì plugin đã có trên server):

repositories {
    maven { url = uri("https://repo.fancyinnovations.com/releases") }
}
dependencies {
    compileOnly("de.oliver:FancyNpcs:2.10.0.356")   // version khớp bản FancyNpcs trên server
}
💡 Ghi chú
  • Nếu cần tên NPC để so sánh với id trong config, dùng event.npc.data.name (id NPC FancyNpcs).
  • Muốn phân biệt click chuột trái/phải, dùng property event.clickType của event.
  • Bạn có thể kết hợp when với nhiều hành động: mở GUI, trao item, gọi Vault/PlaceholderAPI…

Check permission + gửi message từ message.yml

Nâng cấp: khi click NPC, check quyền (player.hasPermission(...)). Nếu chưa có quyền thì load message từ file message.yml (đã tạo ở Ví dụ 14) và gửi cho người chơi. Message có thể là 1 dòng hoặc nhiều dòng — dùng key lines: để khai báo danh sách.

💡 Dùng chung message.yml với các script khác

message.yml nằm trên đĩa, bất kỳ script nào cũng đọc được bằng cùng pattern YamlConfiguration.loadConfiguration(File(scriptFile, "message.yml"))không cần gọi hàm từ script khác. Mỗi script tự load file khi onLoad là cách đơn giản và chắc chắn nhất.

Thêm vào message.yml (từ Ví dụ 14) các key liên quan NPC:

# message.yml — thêm phần này
npc:
  no_perm: "<red>⛔ Bạn chưa có quyền dùng NPC này!"
  no_perm_lines:            # dạng nhiều dòng (block)
    - "<red>⛔ Không đủ quyền."
    - "<gray>Cần quyền <yellow>vip.npc.shop"
    - "<gray>Liên hệ admin để mua VIP."
  deny_perm: "<gold><bold>✋ <gold>NPC tạm khóa"
// npc_perm.kite.kts — check quyền + gửi message từ message.yml
import de.oliver.fancynpcs.api.events.NpcInteractEvent
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File

// Load message.yml nằm cạnh script (giống Ví dụ 14):
val msg = YamlConfiguration.loadConfiguration(File(scriptFile, "message.yml"))

on<NpcInteractEvent> { event ->
    val player = event.player
    val npcName = event.npc.data.name

    // Chỉ check quyền cho NPC "shop" (đổi tùy bạn)
    if (npcName == "shop") {
        if (player.hasPermission("vip.npc.shop")) {
            // ✅ CÓ quyền → thực thi actions / lệnh
            player.sendRichMessage("<green>🛒 Chào mừng VIP!")
            // player tự chạy lệnh (mở GUI shop, gọi economy...):
            player.performCommand("shop")
            // console chạy lệnh (trao quyền, gửi quà, teleport...):
            server.dispatchCommand(server.consoleSender, "lp user ${player.name} permission set vip.gift true")
            server.dispatchCommand(server.consoleSender, "give ${player.name} diamond 5")
            // các action khác: hồi máu, cấp item, đổi gamemode... tùy bạn
            player.health = player.maxHealth
            player.inventory.addItem(ItemStack(Material.GOLDEN_APPLE, 1))
        } else {
            // ❌ CHƯA quyền → gửi message từ message.yml
            sendNpcMessage(player, "npc.no_perm_lines")
        }
    }
}

// Gửi message: 1 dòng (String) hoặc nhiều dòng (List), tự nhận diện
fun sendNpcMessage(player: Player, path: String) {
    val obj = msg.get(path) ?: return
    when (obj) {
        is String -> player.sendRichMessage(obj)              // 1 dòng
        is List<*> -> obj.filterIsInstance<String>()
            .forEach { player.sendRichMessage(it) }          // block nhiều dòng
    }
}
💡 Giải thích
  • player.hasPermission("vip.npc.shop") — trả về true/false dựa trên quyền hiện có của player (bao gồm cả quyền từ group/plugin quản lý quyền như LuckPerms).
  • Có quyền → chạy actions: gửi message, player.performCommand("shop") (player tự chạy lệnh) hoặc server.dispatchCommand(server.consoleSender, "...") (console chạy lệnh — trao quyền, gửi quà, teleport…). Bạn có thể viết thêm when (npcName) để NPC "shop" / "quest" / "heal" mỗi cái có chuỗi actions riêng.
  • Hàm sendNpcMessage nhận diện tự động: nếu value là String → gửi 1 dòng; nếu là List → gửi từng dòng (block nhiều line). Nhờ vậy cùng 1 hàm dùng được cho cả 2 dạng.
  • msg.get(path) trả về Any?, nên dùng is String / is List<*> để rẽ nhánh an toàn kiểu.
  • Đổi npc.no_perm_linesnpc.no_perm để gửi message 1 dòng thay vì block.

Ví dụ 13 — Bắt Custom Event của plugin khác

Ngoài event Bukkit/Paper, script có thể bắt event tùy biến do plugin khác call. Ví dụ: sau khi login thành công, plugin của bạn gọi PlayerAuthSuccessEvent → script bắt event đó và chạy các actions / commands.

⚠️ Điều kiện để bắt được custom event
  • Class event phải extends org.bukkit.event.Event — class bạn đã viết là đạt chuẩn.
  • Plugin phải gọi event bằng Bukkit.getPluginManager().callEvent(event) ở đúng thời điểm.
  • Import tường minh class event (gói không tự import): import me.ipapervn.modules.login.PlayerAuthSuccessEvent.
  • Không cần version — plugin đã cài trên server, Kite truy cập thẳng class của nó qua classpath.

Script mẫu: JOIN → LOGIN THÀNH CÔNG → chạy commands

// auth_flow.kite.kts — đặt vào plugins/Kite/scripts/
import me.ipapervn.modules.login.PlayerAuthSuccessEvent   // event plugin của bạn

// 1) Player vừa JOIN (event Bukkit chuẩn, tự động có)
on<PlayerJoinEvent> { event ->
    val player = event.player
    player.sendRichMessage("<gray>🔐 Vui lòng đăng nhập: <yellow>/login <mật_khẩu>")

    // VD: console chạy lệnh — đặt player về spectator chờ xác thực:
    server.dispatchCommand(server.consoleSender, "gamemode spectator ${player.name}")
    // có thể gọi lệnh plugin khác, vd ẩn danh / bảo vệ spawn...
    // actions trước khác: lưu vị trí, ẩn danh, set gamemode... tùy bạn
}

// 2) LOGIN THÀNH CÔNG → chạy actions / commands
on<PlayerAuthSuccessEvent> { event ->
    val player = event.player
    player.sendRichMessage("<green>✅ Đăng nhập thành công! Chào mừng <gold>${player.name}")

    player.performCommand("spawn")                                            // player tự chạy lệnh
    server.dispatchCommand(server.consoleSender, "give ${player.name} diamond 1")   // console chạy lệnh
    server.dispatchCommand(server.consoleSender, "lp user ${player.name} parent add member")

    player.showTitle(Title.title(
        Component.text("Chào mừng!").color(NamedTextColor.GOLD),
        Component.text(player.name).color(NamedTextColor.YELLOW)
    ))
    player.inventory.addItem(ItemStack(Material.GOLDEN_APPLE, 1))
}
💡 Chạy lệnh trong Kite
  • player.performCommand("...") — người chơi tự chạy lệnh.
  • server.dispatchCommand(server.consoleSender, "...")console chạy lệnh (cấp quyền, trao item, lệnh admin).
  • Chèn biến vào chuỗi lệnh: "give ${player.name} diamond 1".

Về phía plugin — cần đúng 2 điều

💡 Lưu ý
  • on<Event> tự đăng ký/hủy listener theo vòng đời script — không cần tự quản lý listener.
  • Muốn chạy thêm actions trước khi login thành công, dùng on<PlayerJoinEvent> (đã có trong ví dụ) — không cần thêm class/event nào trong plugin.
  • Muốn sắp thứ tự giữa các listener cùng 1 event, dùng priority = EventPriority.HIGHEST/LOWEST.

Ví dụ 14 — Tạo & đọc file (message.yml)

Kite cho phép bạn thao tác trực tiếp với file trên ổ đĩa bằng Kotlin/Java API chuẩn. Cách phổ biến nhất: dùng file YAML (message.yml, config.yml, lang/vi.yml…) để chứa thông điệp/khai báo mà nhiều script cùng dùng chung. Nhờ đó bạn đổi nội dung mà không cần sửa code — chỉ sửa file rồi reload.

1) Tạo file message.yml (đặt ở plugins/Kite/scripts/message.yml)

# ===== message.yml =====
join:
  welcome: "<gold>Chào mừng <yellow>{player}<gold> đến server! 🎉"
  tip: "<gray>Mẹo: gõ <green>/help</green> để xem lệnh."
death:
  message: "<red>💀 {player} đã tử vong bởi {killer}"
reward:
  daily: "<green>✅ Nhận quà hằng ngày: {amount} 🌟"
reload:
  done: "<green>Đã reload message.yml"

2) Script đọc file — message_loader.kite.kts

Dùng YamlConfiguration (có sẵn trong Paper, không cần thêm thư viện) để nạp file thành object rồi truy cập theo path. File nằm cạnh script nên dùng File(scriptFile, "message.yml"):

// message_loader.kite.kts
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File

// File message.yml nằm CÙNG thư mục script:
val msg = YamlConfiguration.loadConfiguration(File(scriptFile, "message.yml"))

fun sendJoin(player: Player) {
    val welcome = msg.getString("join.welcome")!!.replace("{player}", player.name)
    player.sendRichMessage(welcome)
    player.sendRichMessage(msg.getString("join.tip")!!)
}

on<PlayerJoinEvent> { e -> sendJoin(e.player) }

on<PlayerDeathEvent> { e ->
    val killer = e.damageSource?.causingEntity?.name ?: "tự nhiên"
    val text = msg.getString("death.message")!!
        .replace("{player}", e.player.name)
        .replace("{killer}", killer)
    server.broadcast(Component.text(replaceColor(text)))
}

fun replaceColor(s: String) = s.replace("<", "<").replace(">", ">").replace("&", "&")
💡 scriptFile — đường dẫn script hiện tại

Kite cung cấp biến scriptFile (kiểu File) trỏ tới file .kite.kts đang chạy. Dùng File(scriptFile, "message.yml") để mở file cùng thư mục một cách an toàn, không phụ thuộc working directory.

3) Các script khác CÙNG dùng file này

message.yml nằm trên đĩa, bất kỳ script nào cũng đọc được. Cách gọn: đặt code nạp + lấy thông điệp vào một hàm dùng chung, mỗi script onLoad sẽ nạp file. Ví dụ script thứ 2 dùng message.yml để gửi quà:

// daily_reward.kite.kts — script riêng, vẫn đọc chung message.yml
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File

val msg = YamlConfiguration.loadConfiguration(File(scriptFile, "message.yml"))

on<PlayerJoinEvent> { e ->
    val player = e.player
    if (hasClaimedToday(player.uniqueId)) return@on
    player.sendRichMessage(
        msg.getString("reward.daily")!!.replace("{amount}", "50")
    )
    player.inventory.addItem(ItemStack(Material.DIAMOND, 1))
}

fun hasClaimedToday(uuid: java.util.UUID): Boolean = false // bạn ghi vào file khác để nhớ

4) Ghi / tạo file từ script

Muốn tạo file hoặc cập nhật dữ liệu, dùng YamlConfiguration.save(File). Đây là cách lưu điểm, quà hằng ngày, số lần login… Mẹo: tự sinh message.yml mặc định khi chưa có:

// config_init.kite.kts — tạo message.yml nếu chưa tồn tại
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File

val f = File(scriptFile, "message.yml")
if (!f.exists()) {
    f.parentFile.mkdirs()                                   // tạo thư mục nếu thiếu
    val def = YamlConfiguration()
    def.set("join.welcome", "<gold>Chào mừng {player}!")
    def.set("reward.daily", "<green>Nhận {amount} 🌟")
    def.save(f)                                             // ghi ra đĩa
    server.consoleSender.sendPlainMessage("[Kite] Đã tạo message.yml")
}
⚠️ Lưu ý quan trọng
  • Đường dẫn an toàn: luôn đặt file trong thư mục script (File(scriptFile, "...")) để tránh ghi ra ngoài — là lỗ hổng bảo mật nếu cho người khác nạp script.
  • Charset: dùng YamlConfiguration thì tiếng Việt lưu tốt. Nếu tự đọc bằng File.readText(), chỉ rõ encoding: File(...).readText(Charsets.UTF_8).
  • Reload: sau khi sửa message.yml, chạy /kite reload message_loader để script đọc lại file mới.

Ví dụ 15 — Quest NPC: flag trạng thái bằng quyền (kiểu ConditionalEvents)

Ví dụ này hookup FancyNpcs làm NPC — lắng nghe NpcInteractEvent khi người chơi click NPC. Không cần @file:Dependency vì FancyNpcs đã cài trên server (chi tiết ở Ví dụ 12).

✅ 1 script quản lý MỌI NPC — không tạo file kts cho từng NPC

File npc_single.kite.kts đọc cấu hình từ npc.yml. 1 script duy nhất xử lý tất cả NPC. Muốn thêm NPC chỉ cần thêm 1 khối trong npc.yml rồi /kite reloadallkhông sửa code.

📦 Chuẩn bị NPC bằng FancyNpcs
  • Cài plugin FancyNpcs (Paper 1.21.11).
  • Đứng vị trí muốn đặt, tạo NPC: /npc create shop (hoặc /npc create healer…). Tên NPC = id, khớp với key trong npc.yml.
  • Đặt skin/model: /npc skin <tên>.

Bước 1 — Đặt script vào plugins/Kite/scripts/. Lần chạy đầu tiên nó tự tạo file npc.yml mẫu (khối shop). Mở ra sửa theo ý bạn — và thêm thêm các NPC khác bằng cách copy khối rồi đổi tên.

👀 npc.yml — sau khi bạn thêm thêm NPC healer
# npc.yml
npcs:
  shop:              # /npc create shop
    perm: "vip.npc.shop"
    need: "GOLD_INGOT"    # item cần nộp để mở
    amount: 10          # số lượng cần
    cmd_gained:          # lệnh chạy khi đã mở
      - "shop"
      - "give {player} diamond 1"
  healer:            # /npc create healer — NPC mới
    perm: "vip.npc.healer"
    need: "DIAMOND"
    amount: 1
    cmd_gained: []
🧩 Giải thích — từng key
perm         → quyền = flag "đã mở"; player có nó là xong quest
need         → item phải nộp để mở (tên Material, in hoa)
amount       → số lượng item cần nộp
cmd_gained   → lệnh chạy khi đã mở (danh sách, chạy theo thứ tự)

Thêm NPC mới: copy 1 khối → đổi tên + perm
→ tạo NPC cùng tên trong FancyNpcs
→ /kite reloadall
không cần sửa script.
💬 Message để trong script luôn — gọn

Khác với Ví dụ 14 (message.yml để ngoài cho nhiều script dùng chung), npc_single tự chứa message bằng sendRichMessage để đúng tiêu chí "1 file gọn, không tách rời". Sau này muốn tách message ra ngoài thì tham khảo Ví dụ 14.

Bước 2 — Script npc_single.kite.kts (chỉ 1 file, tự chứa tất cả).

📄 File script HOÀN CHỈNH đã có sẵn

Bạn không cần gõ lại — file npc_single.kite.kts (đầy đủ comment, xử lý lỗi Material, log onLoad/onUnload) đã được tạo cùng thư mục. Đặt nó vào plugins/Kite/scripts/ rồi /kite load npc_single là chạy.

🧠 Cấu trúc chính của script
  • Tự tạo npc.yml: nếu chưa có, ghi khối shop mẫu vào File(scriptFile, "npc.yml").
  • on<NpcInteractEvent>: lấy npcId = event.npc.data.namecfg.getConfigurationSection("npcs.$npcId"); null → bỏ qua an toàn.
  • Đã mở (hasPermission(perm)) → chạy cmd_gained (thay {player}) → gửi message "đã mở", không làm lại nv.
  • Chưa mở → đếm item (need + amount): đủ → removeItems + console gán quyền + thông báo; chưa đủ → nhắc nhiệm vụ.
  • Hàm removeItems trừ item khỏi inventory.
  • onLoad/onUnload: log các NPC đang quản lý khi nạp/gỡ.

Toàn bộ script (để copy):

// npc_single.kite.kts — Quest NPC, MỘT file duy nhất, tự chứa tất cả
import de.oliver.fancynpcs.api.events.NpcInteractEvent
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File

// ---- 1. CẤU HÌNH (tự tạo npc.yml nếu chưa có) ----
val npcFile = File(scriptFile, "npc.yml")
if (!npcFile.exists()) {
    npcFile.parentFile.mkdirs()
    val def = YamlConfiguration()
    def.set("npcs.shop.perm", "vip.npc.shop")
    def.set("npcs.shop.need", "GOLD_INGOT")
    def.set("npcs.shop.amount", 10)
    def.set("npcs.shop.cmd_gained", listOf("shop", "give {player} diamond 1"))
    def.save(npcFile)
    logger.info("[npc_single] Đã tự tạo npc.yml mặc định")
}
val cfg = YamlConfiguration.loadConfiguration(npcFile)

// ---- 2. SỰ KIỆN TƯƠNG TÁC NPC (FancyNpcs) ----
on<NpcInteractEvent> { event ->
    val player = event.player
    val npcId = event.npc.data.name
    val sec = cfg.getConfigurationSection("npcs.$npcId") ?: return@on
    val perm = sec.getString("perm") ?: return@on

    // ĐÃ mở (có quyền) → chạy lệnh, không làm lại nhiệm vụ
    if (player.hasPermission(perm)) {
        sec.getStringList("cmd_gained")
            .filter { it.isNotBlank() }
            .forEach { raw -> player.performCommand(raw.replace("{player}", player.name)) }
        player.sendRichMessage("<green>✨ Bạn đã mở NPC này rồi!")
        return@on
    }

    // CHƯA mở → kiểm tra nộp item
    val mat = Material.valueOf(sec.getString("need")!!.uppercase())
    val amount = sec.getInt("amount", 1)
    val have = player.inventory.all(mat).values.sumOf { it.amount }

    if (have < amount) {
        player.sendRichMessage("<gold>📜 Nhiệm vụ: mang <yellow>$amount ${mat.name.lowercase()}</yellow> cho ta.")
        player.sendRichMessage("<gray>Quay lại khi đủ để mở quyền.")
    } else {
        removeItems(player, mat, amount)
        server.dispatchCommand(server.consoleSender, "lp user ${player.name} permission set $perm true")
        player.sendRichMessage("<green>✅ Hoàn thành! Đã mở quyền cho bạn.")
    }
}

// ---- 3. HÀM TIỆN ÍCH ----
fun removeItems(player: Player, mat: Material, count: Int) {
    var left = count
    player.inventory.all(mat).forEach { (_, item) ->
        if (left <= 0) return
        val take = minOf(left, item.amount)
        item.amount -= take
        left -= take
    }
}
💡 Giải thích vòng khép kín + cấu hình
  • Quyền = flag: player.hasPermission(perm) trả lời "đã hoàn thành chưa?". Vừa check điều kiện vừa lưu trạng thái — không cần file ghi nhớ riêng.
  • Thêm NPC mới = thêm 1 khối trong npc.yml, không sửa script. Copy khối healer: → đổi tên + perm → tạo NPC cùng tên → /kite reloadall.
  • NPC không nằm trong npc.ymlgetConfigurationSection("npcs.$id") trả null → bị bỏ qua an toàn.
  • Lần đầu: chưa quyền → nhắc nhiệm vụ, yêu cầu nộp item.
  • Đủ item: removeItems thu item → lp user ... permission set ... true gán quyền.
  • Lần sau: nhánh if (hasPermission) chạy lệnh từ cmd_gainedkhông làm lại nv.
  • Placeholder {player} được thay trong lệnh; message dùng MiniMessage (<green>…).
  • Xóa quyền để "reset" quest cho player: lp user <tên> permission unset <perm>.

16Lệnh quản lý & tài nguyên

Các lệnh quản lý (yêu cầu permission kite.manage)

LệnhChức năng
/kite list (script)Liệt kê tất cả script
/kite load (script)Nạp script theo tên
/kite unload (script)Gỡ script đang nạp
/kite reload (script)Reload một script
/kite reloadallReload tất cả script

Phần (script)tùy chọn — nếu bỏ qua, áp dụng cho toàn bộ script.

🚨 Cảnh báo bảo mật

Chỉ cấp quyền các lệnh này cho những người bạn tin tưởng, vì chúng có thể nạp/gỡ/reload mã chạy trên server.

Đường dẫn hữu ích