Compare commits

...

9 Commits

Author SHA1 Message Date
Fortern 4ed6b8ec5a ver 1.3
Build Plugin / Build with Maven (push) Successful in 52s
2026-06-08 14:37:28 +08:00
Fortern d850455b98 nbt tools 2026-06-07 19:32:48 +08:00
Fortern 9c3e597789 异步工具
异步函数通过BukkitScheduler提交
2026-06-07 19:31:03 +08:00
Fortern 285f5273c3 ver 1.2 2026-06-07 19:20:48 +08:00
Fortern 8582161da3 workflows 2026-06-07 19:20:48 +08:00
Fortern de3a83deaa Depending on NBT-API 2026-06-07 19:20:48 +08:00
Fortern 9562519d1a ver 1.1 2026-06-07 19:20:48 +08:00
Fortern 29e5216c8c 占位符 2026-06-07 19:20:47 +08:00
Fortern 4cf440b843 区块等级 2026-06-07 19:20:47 +08:00
8 changed files with 562 additions and 25 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Build Plugin
run-name: Building Plugin 🚀
on:
push:
tags:
- 'ver/**'
jobs:
build:
name: Build with Maven
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up JDK 25 and enable Maven cache
uses: actions/setup-java@v5
with:
distribution: 'liberica'
java-version: '25'
cache: 'maven'
- name: Set up Maven
uses: stCarolas/setup-maven@v5
with:
maven-version: 3.9.16
- name: Build and package with Maven
run: mvn -B package
- name: Determine version
id: set-version
shell: bash
run: |
VERSION=${GITHUB_REF#refs/tags/ver/}
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Upload built artifacts
uses: actions/upload-artifact@v3
with:
name: maven-artifacts
path: target/spigot/*.jar
- uses: akkuman/gitea-release-action@v1
with:
name: '${{ steps.set-version.outputs.version }} Release'
files: target/spigot/*.jar
+74 -22
View File
@@ -6,17 +6,85 @@
<groupId>xyz.fortern</groupId>
<artifactId>fortern-helper</artifactId>
<version>1.0</version>
<version>1.3</version>
<packaging>jar</packaging>
<name>fortern-helper</name>
<repositories>
<repository>
<id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>placeholderapi</id>
<url>https://repo.helpch.at/releases/</url>
</repository>
<repository>
<id>codemc-repo</id>
<url>https://repo.codemc.io/repository/maven-public/</url>
<layout>default</layout>
</repository>
</repositories>
<properties>
<java.version>25</java.version>
<kotlin.version>2.4.0</kotlin.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spigot API -->
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>26.1-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- Kotlin Stdlib https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
<scope>provided</scope>
</dependency>
<!-- JetBrains Java Annotations https://mvnrepository.com/artifact/org.jetbrains/annotations -->
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>26.1.0</version>
<scope>compile</scope>
</dependency>
<!-- Adventure API https://mvnrepository.com/artifact/net.kyori/adventure-api -->
<dependency>
<groupId>net.kyori</groupId>
<artifactId>adventure-api</artifactId>
<version>4.26.1</version>
<scope>provided</scope>
</dependency>
<!-- Adventure Platform Bukkit https://mvnrepository.com/artifact/net.kyori/adventure-platform-bukkit -->
<dependency>
<groupId>net.kyori</groupId>
<artifactId>adventure-platform-bukkit</artifactId>
<version>4.4.1</version>
<scope>provided</scope>
</dependency>
<!-- PlaceholderAPI -->
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>2.12.2</version>
<scope>provided</scope>
</dependency>
<!-- NBT-API -->
<dependency>
<groupId>de.tr7zw</groupId>
<artifactId>item-nbt-api-plugin</artifactId>
<version>2.15.7</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<defaultGoal>clean package</defaultGoal>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
@@ -50,6 +118,11 @@
</goals>
</execution>
</executions>
<configuration>
<finalName>${project.artifactId}-spigot-${project.version}</finalName>
<outputDirectory>${project.build.directory}/spigot</outputDirectory>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</plugin>
</plugins>
<resources>
@@ -59,25 +132,4 @@
</resource>
</resources>
</build>
<repositories>
<repository>
<id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>26.1-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</project>
@@ -1,18 +1,53 @@
package xyz.fortern.forternhelper
import me.clip.placeholderapi.expansion.PlaceholderExpansion
import net.kyori.adventure.platform.bukkit.BukkitAudiences
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import xyz.fortern.forternhelper.async.AsyncManager
import xyz.fortern.forternhelper.command.HelperCommand
import xyz.fortern.forternhelper.listener.ForternListener
import xyz.fortern.forternhelper.placeholder.ForternExpansion
import java.io.File
class Helper : JavaPlugin() {
private lateinit var adventure: BukkitAudiences
private lateinit var expansion: PlaceholderExpansion
private lateinit var asyncManager: AsyncManager
override fun onEnable() {
// Plugin startup logic
this.adventure = BukkitAudiences.create(this)
// init data-folders
logger.info("Initializing data-folders...")
File(this.dataFolder, "block-nbt").mkdirs()
File(this.dataFolder, "item-nbt").mkdirs()
// register asyncManager
logger.info("Registering asyncManager...")
asyncManager = AsyncManager(this)
// register listeners
logger.info("Registering listeners...")
Bukkit.getPluginManager().registerEvents(ForternListener(this), this)
// register commands
logger.info("Registering commands...")
Bukkit.getPluginCommand("helper")!!.setExecutor(HelperCommand(this, adventure, asyncManager))
// register placeholders
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
expansion = ForternExpansion(this)
logger.info("Registering placeholders...")
expansion.register()
}
}
override fun onDisable() {
// Plugin shutdown logic
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
expansion.unregister()
}
}
}
@@ -0,0 +1,88 @@
package xyz.fortern.forternhelper.async
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.CompletableFuture
import java.util.logging.Level
class AsyncManager(
private val plugin: JavaPlugin,
) {
private val tasks: MutableSet<AsyncTask> = HashSet()
init {
// sync
Bukkit.getScheduler().runTaskTimer(plugin, Runnable {
val iterator = tasks.iterator()
while (iterator.hasNext()) {
val asyncTaskTracker = iterator.next()
try {
val result = asyncTaskTracker.tick()
if (result == AsyncTask.Result.RUNNING) {
continue
} else if (result == AsyncTask.Result.TIMEOUT) {
plugin.logger.warning("Async task timed out: ${asyncTaskTracker.info}")
}
} catch (t: Throwable) {
plugin.logger.log(Level.SEVERE, "Error while ticking AsyncManager", t)
}
iterator.remove()
}
}, 0, 0)
}
/**
* 添加一个异步任务[asyncFun],该异步任务结束后,会在主线程同步执行另一个任务[syncFun]。
* 需要超时时间[timeout]。
* 此方法应当在主线程调用。
*/
fun execInMainAfterAsync(info: String, timeout: Int, asyncFun: () -> Unit, syncFun: () -> Unit) {
val future = CompletableFuture<Void>()
val bukkitTask = Bukkit.getScheduler().runTaskAsynchronously(plugin, Runnable {
try {
asyncFun()
future.complete(null)
} catch (t: Throwable) {
future.completeExceptionally(t)
}
})
val asyncTaskTracker = AsyncTask(bukkitTask.taskId, info, future, timeout, syncFun)
tasks.add(asyncTaskTracker)
}
}
class AsyncTask(
val id: Int,
val info: String,
val future: CompletableFuture<Void>,
var timeout: Int,
val syncFun: () -> Unit,
) {
/**
* 每tick执行一次。 返回 [Result.TIMEOUT] 如果超时; 返回 [Result.TIMEOUT] 如果正在运行; 返回 [Result.DONE] 如果完成。
*
* @throws Throwable 当异步任务或同步任务执行出现异常时,抛出那个异常
*/
@Throws(Throwable::class)
fun tick(): Result {
if (future.isDone) {
if (!future.isCompletedExceptionally) {
syncFun.invoke()
} else {
throw future.exceptionNow()
}
return Result.DONE
}
timeout--
if (timeout < 0) {
future.cancel(true)
return Result.TIMEOUT
}
return Result.RUNNING
}
enum class Result {
RUNNING, DONE, TIMEOUT
}
}
@@ -0,0 +1,250 @@
package xyz.fortern.forternhelper.command
import de.tr7zw.nbtapi.NBT
import de.tr7zw.nbtapi.iface.ReadWriteNBT
import net.kyori.adventure.platform.bukkit.BukkitAudiences
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandSender
import org.bukkit.command.TabExecutor
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
import xyz.fortern.forternhelper.async.AsyncManager
import java.io.File
import java.io.FileReader
import java.util.logging.Level
class HelperCommand(
private val plugin: JavaPlugin,
private val adventure: BukkitAudiences,
private val asyncManager: AsyncManager,
) : TabExecutor {
private val subCommands: List<String> = listOf("loadlevel")
private val helpMessages = listOf(
Component.text("fortern-helper v${plugin.description.version}", NamedTextColor.GREEN),
Component.text("/helper help ", NamedTextColor.GOLD)
.append(Component.text("帮助信息", NamedTextColor.WHITE)),
Component.text("/helper loadlevel <chunkPosX> <chunkPosZ> [world] ", NamedTextColor.GOLD)
.append(Component.text("查看区块的加载等级", NamedTextColor.WHITE)),
)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<String>): Boolean {
// onCommand接受到的参数中没有空字符串
handlerCommand(sender, args.toList(), true)
return true
}
override fun onTabComplete(sender: CommandSender, command: Command, label: String, args: Array<String>): List<String>? {
return handlerCommand(
sender,
args.filterIndexed { index, s -> s != "" || index == args.size - 1 },
false
)
}
/**
* 执行命令或补全命令
*
* @param exe true 表示执行命令,false 表示补全命令
* @param args 命令的参数列表,除最后一条前面的每一条都应当是非空的
*/
private fun handlerCommand(sender: CommandSender, args: List<String>, exe: Boolean): List<String>? {
if (args.isEmpty()) {
return if (exe) {
sendHelp(sender)
null
} else {
subCommands
}
}
return when (args[0]) {
"help" -> {
onHelp(sender, exe)
}
"loadlevel" -> {
onLoadLevel(sender, args, exe)
}
"setblock" -> {
setBlockFromNbt(sender, args, exe)
null
}
"getitem" -> {
getItemFromNbt(sender, args, exe)
null
}
else -> {
if (exe) {
adventure.sender(sender).sendMessage(Component.text("错误的子命令"))
null
} else {
// 补全已args[0]开头的子命令
if (args.size == 1) subCommands.filter { it.startsWith(args[0]) } else null
}
}
}
}
private fun getItemFromNbt(sender: CommandSender, args: List<String>, exe: Boolean) {
// args[0]:getitem | args[1]:0 | args[2]:txt
if (!exe) return
if (sender !is Player) return
if (!sender.isOp) return
if (args.size < 2) return
val i = args[1]
val isNbt = if (args.size > 2) args[2] == "nbt" else false
val itemNbtDir = File(plugin.dataFolder, "item-nbt")
var readWriteNBT: ReadWriteNBT? = null
var fileExists = true
var parse = true
asyncManager.execInMainAfterAsync("read item nbt in helper command", 20, run@{
if (isNbt) {
val file = File(itemNbtDir, "${i}.nbt")
if (!file.exists()) {
fileExists = false
return@run
}
try {
readWriteNBT = NBT.readFile(file)
} catch (ex: Exception) {
plugin.logger.log(Level.WARNING, "Error reading nbt", ex)
parse = false
}
} else {
val file = File(itemNbtDir, "${i}.txt")
if (!file.exists()) {
fileExists = false
return@run
}
try {
readWriteNBT = NBT.parseNBT(FileReader(file).readAllAsString())
} catch (ex: Exception) {
plugin.logger.log(Level.WARNING, "Error reading nbt", ex)
parse = false
}
}
}) {
if (!fileExists) {
adventure.sender(sender).sendMessage(Component.text("文件不存在"))
} else if (!parse) {
adventure.sender(sender).sendMessage(Component.text("文件解析错误"))
} else {
if (readWriteNBT != null) {
sender.inventory.addItem(NBT.itemStackFromNBT(readWriteNBT))
}
}
}
return
}
private fun setBlockFromNbt(sender: CommandSender, args: List<String>, exe: Boolean) {
// args[0]:setblock | args[1]:0 | args[2]:txt
if (!exe) return
if (sender !is Player) return
if (!sender.isOp) return
if (args.size < 2) return
val i = args[1]
val isNbt = if (args.size > 2) args[2] == "nbt" else false
val world = sender.world
val blockState = world.getBlockState(0, 128, 0)
val blockNbtDir = File(plugin.dataFolder, "block-nbt")
var readWriteNBT: ReadWriteNBT? = null
var fileExists = true
var parse = true
asyncManager.execInMainAfterAsync("read block nbt in helper command", 20, run@{
if (isNbt) {
val file = File(blockNbtDir, "${i}.nbt")
if (!file.exists()) {
fileExists = false
return@run
}
try {
readWriteNBT = NBT.readFile(file)
} catch (ex: Exception) {
plugin.logger.log(Level.WARNING, "Error reading nbt", ex)
parse = false
}
} else {
val file = File(blockNbtDir, "${i}.txt")
if (!file.exists()) {
fileExists = false
return@run
}
readWriteNBT = NBT.parseNBT(FileReader(file).readAllAsString())
}
}) {
if (!fileExists) {
adventure.sender(sender).sendMessage(Component.text("文件不存在"))
} else if (!parse) {
adventure.sender(sender).sendMessage(Component.text("文件解析错误"))
} else {
if (readWriteNBT != null) {
NBT.modify(blockState) { nbt: ReadWriteNBT ->
nbt.mergeCompound(readWriteNBT)
}
}
}
}
return
}
private fun onHelp(sender: CommandSender, exe: Boolean): List<String>? {
if (exe) sendHelp(sender)
return null
}
private fun onLoadLevel(sender: CommandSender, args: List<String>, exe: Boolean): List<String>? {
// args[0]: loadlevel args[1]:15 args[2]:15 args[3]:world_the_end
if (args.size < 2) {
if (exe) {
adventure.sender(sender).sendMessage(Component.text("指定正确的区块坐标", NamedTextColor.RED))
}
return null
}
var x = 0
var z = 0
if (exe) {
try {
x = args[1].toInt()
z = args[2].toInt()
} catch (_: NumberFormatException) {
adventure.sender(sender).sendMessage(Component.text("指定正确的区块坐标", NamedTextColor.RED))
}
val worldInArg = if (args.size == 4 && args[3].isNotBlank()) {
Bukkit.getWorld(args[3])
} else {
null
}
val world = worldInArg ?: if (sender !is Player) {
adventure.sender(sender).sendMessage(Component.text("世界名称不正确", NamedTextColor.RED))
return null
} else {
sender.world
}
val chunk = world.getChunkAt(x, z)
val loadLevel = chunk.loadLevel
adventure.sender(sender).sendMessage(Component.text("ChunkPos(${args[1]}, ${args[2]}) loadLevel: $loadLevel"))
return null
} else {
if (args.size == 4) {
val worldName = args[3]
return Bukkit.getWorlds().filter { it.name.startsWith(worldName) }.map { it.name }
} else {
return null
}
}
}
private fun sendHelp(sender: CommandSender) {
helpMessages.forEach { adventure.sender(sender).sendMessage(it) }
}
}
@@ -4,10 +4,10 @@ import org.bukkit.entity.Villager
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerInteractEntityEvent
import xyz.fortern.forternhelper.Helper
import org.bukkit.plugin.java.JavaPlugin
import xyz.fortern.forternhelper.reflection.NMSAdapter
class ForternListener(val helper: Helper) : Listener {
class ForternListener(val plugin: JavaPlugin) : Listener {
@EventHandler
fun onTrade(event: PlayerInteractEntityEvent) {
val entity = event.rightClicked
@@ -0,0 +1,53 @@
package xyz.fortern.forternhelper.placeholder
import me.clip.placeholderapi.expansion.PlaceholderExpansion
import org.bukkit.Bukkit
import org.bukkit.OfflinePlayer
import org.bukkit.plugin.java.JavaPlugin
class ForternExpansion(
val plugin: JavaPlugin
) : PlaceholderExpansion() {
override fun getIdentifier(): String {
return "fortern"
}
override fun getAuthor(): String {
return plugin.description.authors.toString()
}
override fun getVersion(): String {
return plugin.description.version
}
override fun persist(): Boolean {
return true
}
override fun onRequest(player: OfflinePlayer, params: String): String {
return if (params.startsWith("loadlevel_")) {
// e.g. peace_the_nether_x,z
val first = params.indexOf('_')
if (first < 0) return "unknown"
val last = params.lastIndexOf('_')
if (last <= first) return "unknown"
val chunkPos = params.substring(last + 1)
val split = chunkPos.split(',')
if (split.size < 2) return "unknown"
val chunkX = split[0]
val chunkZ = split[1]
val worldName = params.substring(first + 1, last)
val world = Bukkit.getWorld(worldName) ?: return "unknown"
try {
val x = Integer.parseInt(chunkX)
val z = Integer.parseInt(chunkZ)
world.getChunkAt(x, z).loadLevel.toString()
} catch (_: NumberFormatException) {
"unknown"
}
} else {
"unknown"
}
}
}
+11 -1
View File
@@ -3,11 +3,21 @@ version: '${version}'
authors: [ Fortern ]
api-version: '26.1'
main: xyz.fortern.forternhelper.Helper
softdepend:
- PlaceholderAPI
depend:
- NBTAPI
libraries:
- org.jetbrains.kotlin:kotlin-stdlib:2.3.21
- net.kyori:adventure-api:4.26.1
- net.kyori:adventure-platform-bukkit:4.4.1
commands:
helper:
description: "游戏主命令"
permission: op
permission: fortern.helper
permissions:
fortern.helper:
default: op