Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c803c86a2f | ||
|
|
d33b666bb9 | ||
|
|
708411b5b6 |
No files matched your search
@@ -1,7 +1,29 @@
|
||||
# Quests-OG
|
||||
Quests plugin for TrueOG
|
||||
|
||||
## Quest NPCs
|
||||
Six player NPCs, one per home tier, powered by a shaded [NpcApi](https://github.com/Eisi05/NpcApi-Spigot) 2.3.3
|
||||
(the last release for Java 17 / 1.19.4). Tier 1 is a greeter that explains the ladder in chat; tiers 2-6 open the
|
||||
quest menu directly on their quest, with a **Claim** button on the player's current quest. Nametags are per player:
|
||||
green ✔ for completed tiers, yellow for the current one, gray ✖ for locked ones.
|
||||
|
||||
NPCs are never persisted by NpcApi; they are rebuilt from `config.yml` on every enable.
|
||||
|
||||
### `/questnpc` (permission `questsog.admin`, default op)
|
||||
- `/questnpc set <tier>` — place (or move) the NPC for that tier at your location. Writes `npcs.<tier>` to config.yml.
|
||||
- `/questnpc remove <tier>` — delete the NPC and its config entry.
|
||||
- `/questnpc tp <tier>` — teleport to the NPC.
|
||||
- `/questnpc list` — show configured and spawned tiers.
|
||||
- `/questnpc reload` — re-read the `npcs` section and respawn.
|
||||
|
||||
### Skins
|
||||
The bundled config ships a MineSkin texture per tier (sage, adventurer, miner, sorcerer, ranger, dark knight), so a
|
||||
fresh install only needs `/questnpc set <tier>` for each. Each `npcs.<tier>.skin` takes a raw texture `value` and
|
||||
`signature` (for example from [mineskin.org](https://mineskin.org)); an entry with only a skin is an NPC that is not
|
||||
placed yet. Leave both blank for the default skin. An invalid signature results in the default skin.
|
||||
|
||||
## Permissions used
|
||||
- questsog.admin
|
||||
- essentials.sethome.multiple.homes-2
|
||||
- essentials.sethome.multiple.homes-3
|
||||
- essentials.sethome.multiple.homes-4
|
||||
|
||||
+26
-3
@@ -1,6 +1,7 @@
|
||||
/* This is free and unencumbered software released into the public domain */
|
||||
|
||||
import com.github.jengelman.gradle.plugins.shadow.internal.RelocationUtil
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
import java.io.BufferedReader
|
||||
import org.gradle.kotlin.dsl.provideDelegate
|
||||
|
||||
@@ -56,6 +57,7 @@ repositories {
|
||||
mavenCentral() // Import the Maven Central Maven Repository.
|
||||
gradlePluginPortal() // Import the Gradle Plugin Portal Maven Repository.
|
||||
maven { url = uri("https://repo.purpurmc.org/snapshots") } // Import the PurpurMC Maven Repository.
|
||||
maven { url = uri("https://jitpack.io") } // Import JitPack (NpcApi).
|
||||
maven { url = uri("file://${System.getProperty("user.home")}/.m2/repository") }
|
||||
System.getProperty("SELF_MAVEN_LOCAL_REPO")?.let { // TrueOG Bootstrap mavenLocal().
|
||||
val dir = file(it)
|
||||
@@ -70,6 +72,11 @@ repositories {
|
||||
}
|
||||
|
||||
/* ---------------------- Java project deps ---------------------------- */
|
||||
// NpcApi is compiled against but kept off runtimeClasspath so stage 1 never relocates it; see pluginJar.
|
||||
val npcApi: Configuration by configurations.creating { isTransitive = false }
|
||||
|
||||
configurations.compileOnly { extendsFrom(npcApi) }
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged.
|
||||
compileOnly("net.luckperms:api:5.5") // Import the LuckPerms API.
|
||||
@@ -87,6 +94,7 @@ dependencies {
|
||||
attributes { attribute(kotlinAttribute, true) }
|
||||
} // Import TrueOG network DiamondBank-OG Kotlin API (from source).
|
||||
implementation(project(":libs:GxUI-OG")) // Shade TrueOG Network GxUI-OG progress menu API into this plugin.
|
||||
npcApi("com.github.Eisi05:NpcApi:2.3.3") // Shade NpcApi (last Java 17 release; supports 1.19.4 via v1_19_R3).
|
||||
}
|
||||
|
||||
configurations.runtimeClasspath {
|
||||
@@ -101,8 +109,10 @@ tasks.withType<AbstractArchiveTask>().configureEach { // Ensure reproducible .ja
|
||||
}
|
||||
|
||||
/* ----------------------------- Shadow -------------------------------- */
|
||||
// Stage 1: shade and relocate every ordinary dependency (lettuce and its netty 4.2 included).
|
||||
tasks.shadowJar {
|
||||
archiveClassifier.set("") // Use empty string instead of null.
|
||||
archiveClassifier.set("core")
|
||||
destinationDirectory.set(layout.buildDirectory.dir("tmp/shadowCore")) // Intermediate; not a deployable jar.
|
||||
isEnableRelocation = false
|
||||
relocationPrefix = "${project.group}.shadow"
|
||||
relocate("kotlin", "net.trueog.diamondbankog.shadow.kotlin")
|
||||
@@ -112,9 +122,22 @@ tasks.shadowJar {
|
||||
minimize { exclude(dependency("org.slf4j:slf4j-nop:.*")) }
|
||||
}
|
||||
|
||||
tasks.jar { archiveClassifier.set("part") } // Applies to root jarfile only.
|
||||
// Stage 2: layer NpcApi onto the relocated core. NpcApi hooks the server's own netty pipeline, so its
|
||||
// io.netty references must stay untouched; only its package is relocated, and only after stage 1 ran.
|
||||
val pluginJar by
|
||||
tasks.registering(ShadowJar::class) {
|
||||
archiveClassifier.set("") // The final plugin jar; use empty string instead of null.
|
||||
from(zipTree(tasks.shadowJar.flatMap { it.archiveFile }))
|
||||
from(provider { zipTree(npcApi.singleFile) }) {
|
||||
exclude("de/eisi05/npc/api/utils/Metrics*.class") // Drop bStats; a no-op stub ships in src/main/kotlin.
|
||||
exclude("META-INF/**")
|
||||
}
|
||||
relocate("de.eisi05", "${project.group}.shadow.de.eisi05") // Relocate NpcApi (and our call sites).
|
||||
}
|
||||
|
||||
tasks.build { dependsOn(tasks.spotlessApply, tasks.shadowJar) } // Build depends on spotless and shadow.
|
||||
tasks.jar { enabled = false } // Only the shaded plugin jar is deployable; skip the thin jar.
|
||||
|
||||
tasks.build { dependsOn(tasks.spotlessApply, pluginJar) } // Build depends on spotless and the final plugin jar.
|
||||
|
||||
/* --------------------------- Javac opts ------------------------------- */
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
|
||||
+1
-1
Submodule libs/GxUI-OG updated: cc77691f2e...a6f9abcbd0.
+1
-1
Submodule libs/Utilities-OG updated: 1692db2964...87919ebf0e.
@@ -0,0 +1,17 @@
|
||||
package de.eisi05.npc.api.utils
|
||||
|
||||
import java.util.concurrent.Callable
|
||||
import org.bukkit.plugin.Plugin
|
||||
|
||||
/**
|
||||
* No-op replacement for NpcApi's bundled bStats `Metrics`. The real class is excluded from the shaded jar (see
|
||||
* `relocateNpcApi` in build.gradle.kts); this keeps `NpcApi`'s constructor linking without phoning home.
|
||||
*/
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
class Metrics(plugin: Plugin, serviceId: Int) {
|
||||
fun addCustomChart(chart: CustomChart) {}
|
||||
|
||||
open class CustomChart(val chartId: String)
|
||||
|
||||
class SingleLineChart(chartId: String, callable: Callable<Int>) : CustomChart(chartId)
|
||||
}
|
||||
@@ -26,37 +26,11 @@ class ClaimQuest : CommandExecutor {
|
||||
|
||||
QuestsOG.scope.launch {
|
||||
try {
|
||||
if (debug) QuestsOG.plugin.logger.info("/claimquest coroutine entered for ${sender.name}")
|
||||
val isEligible = nextQuest.isEligible(sender)
|
||||
if (debug) QuestsOG.plugin.logger.info("/claimquest isEligible=$isEligible")
|
||||
if (isEligible == null) {
|
||||
UtilitiesOG.trueogMessage(
|
||||
sender,
|
||||
"<red>Something went wrong while checking your quest eligibility. Contact an administrator.<reset>",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isEligible) {
|
||||
val successful = nextQuest.consumeQuestItems(sender)
|
||||
if (!successful) {
|
||||
UtilitiesOG.trueogMessage(
|
||||
sender,
|
||||
"<red>Something wrong while trying to consume the quest items.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
nextQuest.reward(sender)
|
||||
val homeCount = HomesProgression.getHomeCount(nextQuest)
|
||||
val questName = nextQuest::class.simpleName
|
||||
UtilitiesOG.logToConsole("[Quests-OG]", "${sender.name} claimed quest $questName")
|
||||
UtilitiesOG.trueogMessage(
|
||||
sender,
|
||||
"<green>Claimed quest! You now have <yellow>$homeCount<green> homes.<reset>",
|
||||
)
|
||||
return@launch
|
||||
} else {
|
||||
UtilitiesOG.trueogMessage(sender, "<red>You must meet all the quest's requirements first.<reset>")
|
||||
val result = QuestClaimer.claim(sender, nextQuest)
|
||||
if (debug) QuestsOG.plugin.logger.info("/claimquest result=$result")
|
||||
QuestClaimer.notify(sender, result)
|
||||
if (result is QuestClaimer.Result.Claimed) {
|
||||
MainThreadBlock.runOnMainThread { QuestsOG.questNpcs.refreshNames(sender) }
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
QuestsOG.plugin.logger.severe("/claimquest failed: ${t.message}")
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
package net.trueog.questsOG
|
||||
|
||||
import java.io.File
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.configuration.ConfigurationSection
|
||||
import org.bukkit.configuration.file.YamlConfiguration
|
||||
|
||||
/** Raw Mojang texture value + signature, applied to an NPC as-is. */
|
||||
data class NpcSkinData(val value: String, val signature: String)
|
||||
|
||||
/** Where one home-tier NPC stands. World is resolved lazily so config can parse before worlds load. */
|
||||
data class NpcSpawn(
|
||||
val tier: Int,
|
||||
val world: String,
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double,
|
||||
val yaw: Float,
|
||||
val pitch: Float,
|
||||
val skin: NpcSkinData?,
|
||||
) {
|
||||
fun toLocation(): Location? = Bukkit.getWorld(world)?.let { Location(it, x, y, z, yaw, pitch) }
|
||||
}
|
||||
|
||||
class Config private constructor() {
|
||||
lateinit var redisUrl: String
|
||||
var debug: Boolean = false
|
||||
/** Placed NPCs (entries with a world). */
|
||||
var npcSpawns: Map<Int, NpcSpawn> = emptyMap()
|
||||
|
||||
/** Skins per tier, including tiers that have a skin configured but no position yet. */
|
||||
var npcSkins: Map<Int, NpcSkinData> = emptyMap()
|
||||
|
||||
companion object {
|
||||
const val MIN_TIER = 1
|
||||
const val MAX_TIER = 6
|
||||
private const val NPCS_KEY = "npcs"
|
||||
|
||||
private fun configFile() = File(QuestsOG.plugin.dataFolder, "config.yml")
|
||||
|
||||
fun create(): Config? {
|
||||
val config = Config()
|
||||
val file = File(QuestsOG.plugin.dataFolder, "config.yml")
|
||||
val file = configFile()
|
||||
if (!file.exists()) {
|
||||
QuestsOG.plugin.saveDefaultConfig()
|
||||
}
|
||||
@@ -24,8 +55,101 @@ class Config private constructor() {
|
||||
}
|
||||
|
||||
config.debug = yamlConfig.getBoolean("debug", false)
|
||||
config.applyNpcs(yamlConfig)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/** Re-reads only the npcs section from disk and updates the live config. */
|
||||
fun reloadNpcSpawns(): Map<Int, NpcSpawn> {
|
||||
QuestsOG.config.applyNpcs(YamlConfiguration.loadConfiguration(configFile()))
|
||||
return QuestsOG.config.npcSpawns
|
||||
}
|
||||
|
||||
/** Writes a tier's position to config.yml, keeping any skin already configured for it. */
|
||||
fun writeNpcSpawn(spawn: NpcSpawn) {
|
||||
val file = configFile()
|
||||
val yaml = YamlConfiguration.loadConfiguration(file)
|
||||
val path = "$NPCS_KEY.${spawn.tier}"
|
||||
yaml.set("$path.world", spawn.world)
|
||||
yaml.set("$path.x", spawn.x)
|
||||
yaml.set("$path.y", spawn.y)
|
||||
yaml.set("$path.z", spawn.z)
|
||||
yaml.set("$path.yaw", spawn.yaw.toDouble())
|
||||
yaml.set("$path.pitch", spawn.pitch.toDouble())
|
||||
if (!yaml.isConfigurationSection("$path.skin")) {
|
||||
yaml.set("$path.skin.value", "")
|
||||
yaml.set("$path.skin.signature", "")
|
||||
}
|
||||
yaml.save(file)
|
||||
QuestsOG.config.applyNpcs(yaml)
|
||||
}
|
||||
|
||||
/** Removes only the tier's position; a configured skin is kept for the next /questnpc set. */
|
||||
fun deleteNpcSpawn(tier: Int) {
|
||||
val file = configFile()
|
||||
val yaml = YamlConfiguration.loadConfiguration(file)
|
||||
val path = "$NPCS_KEY.$tier"
|
||||
if (yaml.isConfigurationSection("$path.skin")) {
|
||||
listOf("world", "x", "y", "z", "yaw", "pitch").forEach { yaml.set("$path.$it", null) }
|
||||
} else {
|
||||
yaml.set(path, null)
|
||||
}
|
||||
if (yaml.getConfigurationSection(NPCS_KEY)?.getKeys(false).isNullOrEmpty()) {
|
||||
yaml.createSection(NPCS_KEY) // Keep `npcs: {}` rather than dropping the key.
|
||||
}
|
||||
yaml.save(file)
|
||||
QuestsOG.config.applyNpcs(yaml)
|
||||
}
|
||||
|
||||
private fun Config.applyNpcs(yaml: YamlConfiguration) {
|
||||
val (spawns, skins) = parseNpcs(yaml)
|
||||
npcSpawns = spawns
|
||||
npcSkins = skins
|
||||
}
|
||||
|
||||
/** An entry with only a skin is legal: it is an unplaced NPC waiting for /questnpc set. */
|
||||
private fun parseNpcs(yaml: YamlConfiguration): Pair<Map<Int, NpcSpawn>, Map<Int, NpcSkinData>> {
|
||||
val spawns = sortedMapOf<Int, NpcSpawn>()
|
||||
val skins = sortedMapOf<Int, NpcSkinData>()
|
||||
val section = yaml.getConfigurationSection(NPCS_KEY) ?: return spawns to skins
|
||||
for (key in section.getKeys(false)) {
|
||||
val tier = key.toIntOrNull()
|
||||
if (tier == null || tier < MIN_TIER || tier > MAX_TIER) {
|
||||
QuestsOG.plugin.logger.warning("Ignoring npcs.$key: tier must be $MIN_TIER-$MAX_TIER")
|
||||
continue
|
||||
}
|
||||
val entry = section.getConfigurationSection(key)
|
||||
if (entry == null) {
|
||||
QuestsOG.plugin.logger.warning("Ignoring npcs.$key: not a section")
|
||||
continue
|
||||
}
|
||||
val skin = parseSkin(entry.getConfigurationSection("skin"))
|
||||
if (skin != null) skins[tier] = skin
|
||||
val world = entry.getString("world")
|
||||
if (world.isNullOrBlank()) {
|
||||
if (skin == null) QuestsOG.plugin.logger.warning("Ignoring npcs.$key: no world and no skin")
|
||||
continue
|
||||
}
|
||||
spawns[tier] =
|
||||
NpcSpawn(
|
||||
tier,
|
||||
world,
|
||||
entry.getDouble("x"),
|
||||
entry.getDouble("y"),
|
||||
entry.getDouble("z"),
|
||||
entry.getDouble("yaw").toFloat(),
|
||||
entry.getDouble("pitch").toFloat(),
|
||||
skin,
|
||||
)
|
||||
}
|
||||
return spawns to skins
|
||||
}
|
||||
|
||||
private fun parseSkin(section: ConfigurationSection?): NpcSkinData? {
|
||||
val value = section?.getString("value")?.takeIf { it.isNotBlank() } ?: return null
|
||||
val signature = section.getString("signature")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return NpcSkinData(value, signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package net.trueog.questsOG
|
||||
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import net.trueog.questsOG.progression.HomesProgression
|
||||
import net.trueog.questsOG.quests.Quest
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/** Shared claim flow used by /claimquest and the quest menu's Claim button. */
|
||||
object QuestClaimer {
|
||||
sealed interface Result {
|
||||
data class Claimed(val homeCount: Int) : Result
|
||||
|
||||
/** The quest is no longer the player's next one (already claimed, or menu was stale). */
|
||||
data object NotCurrentQuest : Result
|
||||
|
||||
data object EligibilityUnknown : Result
|
||||
|
||||
data object NotEligible : Result
|
||||
|
||||
data object ConsumeFailed : Result
|
||||
|
||||
/** Another claim for this player is still running. */
|
||||
data object Busy : Result
|
||||
}
|
||||
|
||||
private val inFlight: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
suspend fun claim(player: Player, quest: Quest): Result {
|
||||
if (!inFlight.add(player.uniqueId)) return Result.Busy
|
||||
try {
|
||||
if (HomesProgression.getNextQuest(player) != quest) return Result.NotCurrentQuest
|
||||
|
||||
val isEligible = quest.isEligible(player) ?: return Result.EligibilityUnknown
|
||||
if (!isEligible) return Result.NotEligible
|
||||
|
||||
if (!quest.consumeQuestItems(player)) return Result.ConsumeFailed
|
||||
quest.reward(player)
|
||||
|
||||
val homeCount = HomesProgression.getHomeCount(quest)
|
||||
UtilitiesOG.logToConsole("[Quests-OG]", "${player.name} claimed quest ${quest::class.simpleName}")
|
||||
return Result.Claimed(homeCount)
|
||||
} finally {
|
||||
inFlight.remove(player.uniqueId)
|
||||
}
|
||||
}
|
||||
|
||||
fun notify(player: Player, result: Result) {
|
||||
val message =
|
||||
when (result) {
|
||||
is Result.Claimed ->
|
||||
"<green>Claimed quest! You now have <yellow>${result.homeCount}<green> homes.<reset>"
|
||||
Result.NotCurrentQuest -> "<red>That quest is not your next quest.<reset>"
|
||||
Result.EligibilityUnknown ->
|
||||
"<red>Something went wrong while checking your quest eligibility. Contact an administrator.<reset>"
|
||||
Result.NotEligible -> "<red>You must meet all the quest's requirements first.<reset>"
|
||||
Result.ConsumeFailed -> "<red>Something wrong while trying to consume the quest items.<reset>"
|
||||
Result.Busy -> "<red>Your previous claim is still being processed.<reset>"
|
||||
}
|
||||
UtilitiesOG.trueogMessage(player, message)
|
||||
}
|
||||
|
||||
/** Short, uncolored text for the menu's error pane. */
|
||||
fun errorSummary(result: Result): String =
|
||||
when (result) {
|
||||
is Result.Claimed -> "Claimed!"
|
||||
Result.NotCurrentQuest -> "This is not your next quest."
|
||||
Result.EligibilityUnknown -> "Could not check your eligibility."
|
||||
Result.NotEligible -> "You must meet all requirements first."
|
||||
Result.ConsumeFailed -> "Could not consume the quest items."
|
||||
Result.Busy -> "Your previous claim is still processing."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package net.trueog.questsOG
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import net.trueog.gxui.progress.ProgressMenu
|
||||
import net.trueog.questsOG.progression.HomesProgression
|
||||
import net.trueog.questsOG.quests.Quest
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
@@ -21,30 +17,7 @@ class QuestGuiCommand : CommandExecutor {
|
||||
|
||||
QuestsOG.scope.launch {
|
||||
try {
|
||||
if (debug) QuestsOG.plugin.logger.info("/questgui coroutine entered for ${sender.name}")
|
||||
val nextQuest = HomesProgression.getNextQuest(sender)
|
||||
if (debug) QuestsOG.plugin.logger.info("/questgui nextQuest=${nextQuest?.javaClass?.simpleName}")
|
||||
val builder = ProgressMenu.builder(QuestsOG.plugin, sender, "&5Home Quests")
|
||||
|
||||
HomesProgression.quests.forEach { quest ->
|
||||
builder.section(questName(quest), stateFor(quest, nextQuest))
|
||||
quest.getRequirements(sender)?.forEach { requirement ->
|
||||
when (requirement) {
|
||||
is ProgressRequirement ->
|
||||
builder.progress(
|
||||
materialFor(requirement),
|
||||
requirement.name,
|
||||
requirement.current,
|
||||
requirement.target,
|
||||
)
|
||||
is BooleanRequirement ->
|
||||
builder.goal(materialFor(requirement), requirement.name, requirement.met)
|
||||
}
|
||||
}
|
||||
if (quest == nextQuest) builder.description("&7Use &e/claimquest &7when ready.")
|
||||
}
|
||||
|
||||
if (debug) QuestsOG.plugin.logger.info("/questgui dispatching builder.open on main thread")
|
||||
val builder = QuestMenus.homesBuilder(sender)
|
||||
MainThreadBlock.runOnMainThread { builder.open() }
|
||||
if (debug) QuestsOG.plugin.logger.info("/questgui done")
|
||||
} catch (t: Throwable) {
|
||||
@@ -55,48 +28,4 @@ class QuestGuiCommand : CommandExecutor {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun questName(quest: Quest): String = "Home ${HomesProgression.getHomeCount(quest)} Quest"
|
||||
|
||||
private fun stateFor(quest: Quest, nextQuest: Quest?): ProgressMenu.State =
|
||||
when {
|
||||
nextQuest == null -> ProgressMenu.State.COMPLETE
|
||||
quest == nextQuest -> ProgressMenu.State.IN_PROGRESS
|
||||
HomesProgression.quests.indexOf(quest) < HomesProgression.quests.indexOf(nextQuest) ->
|
||||
ProgressMenu.State.COMPLETE
|
||||
else -> ProgressMenu.State.NOT_STARTED
|
||||
}
|
||||
|
||||
private fun materialFor(requirement: Requirement): Material =
|
||||
when (requirement.name) {
|
||||
"Total Shards" -> Material.DIAMOND
|
||||
"Hours Played",
|
||||
"Days Played" -> Material.CLOCK
|
||||
"Blocks Travelled" -> Material.LEATHER_BOOTS
|
||||
"Levels" -> Material.EXPERIENCE_BOTTLE
|
||||
"Duels Wins" -> Material.IRON_SWORD
|
||||
"Beaconator" -> Material.BEACON
|
||||
"A Furious Cocktail" -> Material.POTION
|
||||
"Serious Dedication" -> Material.NETHERITE_HOE
|
||||
"Died to \"death.fell.accident.water\"" -> Material.WATER_BUCKET
|
||||
"Blocks Travelled on Pig" -> Material.CARROT_ON_A_STICK
|
||||
"Blocks Travelled on Strider" -> Material.WARPED_FUNGUS_ON_A_STICK
|
||||
"Dolphins Killed" -> Material.DOLPHIN_SPAWN_EGG
|
||||
"Zoglins Killed" -> Material.ZOGLIN_SPAWN_EGG
|
||||
"The Cutest Predator" -> Material.AXOLOTL_BUCKET
|
||||
"Two by Two" -> Material.WHEAT
|
||||
"A Complete Catalogue" -> Material.CAT_SPAWN_EGG
|
||||
"Monsters Hunted" -> Material.DIAMOND_SWORD
|
||||
"Died to \"fell while climbing\"" -> Material.LADDER
|
||||
"Died to \"walked into the danger zone due to Zoglin\"" -> Material.MAGMA_BLOCK
|
||||
"Fish Caught" -> Material.FISHING_ROD
|
||||
"Has Villager Head" -> Material.PLAYER_HEAD
|
||||
"Blocks Walked on Water" -> Material.ICE
|
||||
"Blocks Walked Under Water" -> Material.PRISMARINE
|
||||
"Music Discs" -> Material.JUKEBOX
|
||||
"Finished Advancements" -> Material.RED_CONCRETE
|
||||
"Obsidian Mined" -> Material.OBSIDIAN
|
||||
"Dragon Eggs" -> Material.DRAGON_EGG
|
||||
else -> Material.PAPER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package net.trueog.questsOG
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import net.trueog.gxui.progress.ProgressMenu
|
||||
import net.trueog.questsOG.progression.HomesProgression
|
||||
import net.trueog.questsOG.quests.Quest
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/** Builds the home quest progress menu shared by /questgui and the quest NPCs. */
|
||||
object QuestMenus {
|
||||
/** Suspends while fetching requirements; open the returned builder on the main thread. */
|
||||
suspend fun homesBuilder(player: Player): ProgressMenu.Builder {
|
||||
val debug = QuestsOG.config.debug
|
||||
val nextQuest = HomesProgression.getNextQuest(player)
|
||||
if (debug)
|
||||
QuestsOG.plugin.logger.info("Quest menu for ${player.name}: nextQuest=${nextQuest?.javaClass?.simpleName}")
|
||||
val builder = ProgressMenu.builder(QuestsOG.plugin, player, "&5Home Quests")
|
||||
|
||||
HomesProgression.quests.forEach { quest ->
|
||||
val state = stateFor(quest, nextQuest)
|
||||
builder.section(questName(quest), state)
|
||||
quest.getRequirements(player)?.forEach { requirement ->
|
||||
when (requirement) {
|
||||
is ProgressRequirement ->
|
||||
builder.progress(
|
||||
materialFor(requirement),
|
||||
requirement.name,
|
||||
requirement.current,
|
||||
requirement.target,
|
||||
)
|
||||
is BooleanRequirement -> builder.goal(materialFor(requirement), requirement.name, requirement.met)
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
ProgressMenu.State.IN_PROGRESS -> {
|
||||
builder.description("&7Click &aClaim &7when ready.")
|
||||
builder.action(
|
||||
Material.NETHER_STAR,
|
||||
"&aClaim Quest",
|
||||
listOf("&7Claims this quest once all", "&7requirements are met."),
|
||||
) { menu, clicker ->
|
||||
onClaimClicked(menu, clicker, quest)
|
||||
}
|
||||
}
|
||||
ProgressMenu.State.COMPLETE -> builder.description("&aCompleted.")
|
||||
ProgressMenu.State.NOT_STARTED -> builder.description("&7Complete the previous quest first.")
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/** Inventory click handler (main thread): claim off-thread, then report back into the still-open menu. */
|
||||
private fun onClaimClicked(menu: ProgressMenu, player: Player, quest: Quest) {
|
||||
val debug = QuestsOG.config.debug
|
||||
QuestsOG.scope.launch {
|
||||
try {
|
||||
val result = QuestClaimer.claim(player, quest)
|
||||
if (debug) QuestsOG.plugin.logger.info("Claim click by ${player.name}: $result")
|
||||
MainThreadBlock.runOnMainThread {
|
||||
if (result is QuestClaimer.Result.Claimed) {
|
||||
player.closeInventory()
|
||||
QuestsOG.questNpcs.refreshNames(player)
|
||||
} else {
|
||||
menu.showActionError(QuestClaimer.errorSummary(result))
|
||||
}
|
||||
QuestClaimer.notify(player, result)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
QuestsOG.plugin.logger.severe("Claim click failed: ${t.message}")
|
||||
if (debug) t.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun questName(quest: Quest): String = "Home ${HomesProgression.getHomeCount(quest)} Quest"
|
||||
|
||||
fun stateFor(quest: Quest, nextQuest: Quest?): ProgressMenu.State =
|
||||
when {
|
||||
nextQuest == null -> ProgressMenu.State.COMPLETE
|
||||
quest == nextQuest -> ProgressMenu.State.IN_PROGRESS
|
||||
HomesProgression.quests.indexOf(quest) < HomesProgression.quests.indexOf(nextQuest) ->
|
||||
ProgressMenu.State.COMPLETE
|
||||
else -> ProgressMenu.State.NOT_STARTED
|
||||
}
|
||||
|
||||
fun materialFor(requirement: Requirement): Material =
|
||||
when (requirement.name) {
|
||||
"Total Shards" -> Material.DIAMOND
|
||||
"Hours Played",
|
||||
"Days Played" -> Material.CLOCK
|
||||
"Blocks Travelled" -> Material.LEATHER_BOOTS
|
||||
"Levels" -> Material.EXPERIENCE_BOTTLE
|
||||
"Duels Wins" -> Material.IRON_SWORD
|
||||
"Beaconator" -> Material.BEACON
|
||||
"A Furious Cocktail" -> Material.POTION
|
||||
"Serious Dedication" -> Material.NETHERITE_HOE
|
||||
"Died to \"death.fell.accident.water\"" -> Material.WATER_BUCKET
|
||||
"Blocks Travelled on Pig" -> Material.CARROT_ON_A_STICK
|
||||
"Blocks Travelled on Strider" -> Material.WARPED_FUNGUS_ON_A_STICK
|
||||
"Dolphins Killed" -> Material.DOLPHIN_SPAWN_EGG
|
||||
"Zoglins Killed" -> Material.ZOGLIN_SPAWN_EGG
|
||||
"The Cutest Predator" -> Material.AXOLOTL_BUCKET
|
||||
"Two by Two" -> Material.WHEAT
|
||||
"A Complete Catalogue" -> Material.CAT_SPAWN_EGG
|
||||
"Monsters Hunted" -> Material.DIAMOND_SWORD
|
||||
"Died to \"fell while climbing\"" -> Material.LADDER
|
||||
"Died to \"walked into the danger zone due to Zoglin\"" -> Material.MAGMA_BLOCK
|
||||
"Fish Caught" -> Material.FISHING_ROD
|
||||
"Has Villager Head" -> Material.PLAYER_HEAD
|
||||
"Blocks Walked on Water" -> Material.ICE
|
||||
"Blocks Walked Under Water" -> Material.PRISMARINE
|
||||
"Music Discs" -> Material.JUKEBOX
|
||||
"Finished Advancements" -> Material.RED_CONCRETE
|
||||
"Obsidian Mined" -> Material.OBSIDIAN
|
||||
"Dragon Eggs" -> Material.DRAGON_EGG
|
||||
else -> Material.PAPER
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package net.trueog.questsOG
|
||||
|
||||
import de.eisi05.npc.api.NpcApi
|
||||
import de.eisi05.npc.api.objects.NpcConfig
|
||||
import kotlinx.coroutines.*
|
||||
import me.realized.duels.api.Duels
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage
|
||||
@@ -7,6 +9,9 @@ import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver
|
||||
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags
|
||||
import net.luckperms.api.LuckPerms
|
||||
import net.trueog.diamondbankog.api.DiamondBankAPIKotlin
|
||||
import net.trueog.questsOG.npc.QuestNpcCommand
|
||||
import net.trueog.questsOG.npc.QuestNpcListener
|
||||
import net.trueog.questsOG.npc.QuestNpcRegistry
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.plugin.Plugin
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
@@ -22,12 +27,16 @@ class QuestsOG : JavaPlugin() {
|
||||
lateinit var luckPerms: LuckPerms
|
||||
lateinit var duels: Duels
|
||||
lateinit var mobHeads: Plugin
|
||||
lateinit var npcApi: NpcApi
|
||||
lateinit var questNpcs: QuestNpcRegistry
|
||||
var mm =
|
||||
MiniMessage.builder()
|
||||
.tags(TagResolver.builder().resolver(StandardTags.color()).resolver(StandardTags.reset()).build())
|
||||
.build()
|
||||
|
||||
fun isRedisInitialized() = ::redis.isInitialized
|
||||
|
||||
fun isNpcApiInitialized() = ::npcApi.isInitialized
|
||||
}
|
||||
|
||||
override fun onEnable() {
|
||||
@@ -87,9 +96,34 @@ class QuestsOG : JavaPlugin() {
|
||||
this.server.pluginManager.registerEvents(Events(), this)
|
||||
getCommand("claimquest")?.setExecutor(ClaimQuest())
|
||||
getCommand("questgui")?.setExecutor(QuestGuiCommand())
|
||||
|
||||
// NpcApi is shaded and hooks player connections, so it is torn down in onDisable. If it cannot
|
||||
// start (unsupported server build), quests stay usable through the commands; only NPCs are lost.
|
||||
questNpcs = QuestNpcRegistry()
|
||||
try {
|
||||
npcApi = NpcApi.createInstance(this, NpcConfig().autoUpdate(false).debug(Companion.config.debug))
|
||||
} catch (t: Throwable) {
|
||||
logger.severe("NpcApi failed to start, quest NPCs are disabled: ${t.message}")
|
||||
if (Companion.config.debug) t.printStackTrace()
|
||||
return
|
||||
}
|
||||
questNpcs.spawnAll()
|
||||
val npcListener = QuestNpcListener(questNpcs)
|
||||
npcListener.registerLuckPerms()
|
||||
this.server.pluginManager.registerEvents(npcListener, this)
|
||||
getCommand("questnpc")?.let {
|
||||
val questNpcCommand = QuestNpcCommand(questNpcs)
|
||||
it.setExecutor(questNpcCommand)
|
||||
it.tabCompleter = questNpcCommand
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
if (isNpcApiInitialized()) {
|
||||
questNpcs.despawnAll()
|
||||
NpcApi.disable()
|
||||
}
|
||||
|
||||
if (isRedisInitialized()) {
|
||||
redis.shutdown()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import de.eisi05.npc.api.objects.NPC
|
||||
import java.io.IOException
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import net.trueog.questsOG.progression.HomesProgression
|
||||
import net.trueog.questsOG.quests.Quest
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/** One spawned home-tier NPC. Tier 1 is the greeter and has no quest. */
|
||||
class QuestNpc(val tier: Int, val npc: NPC) {
|
||||
val quest: Quest?
|
||||
get() = HomesProgression.quests.getOrNull(tier - 2)
|
||||
|
||||
/** Re-evaluates this NPC's nametag for one viewer. Main thread only. */
|
||||
fun refreshName(player: Player) {
|
||||
if (npc.viewers.contains(player.uniqueId)) npc.updateName(player)
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
try {
|
||||
npc.delete()
|
||||
} catch (e: IOException) {
|
||||
QuestsOG.plugin.logger.warning("Failed to remove quest NPC for tier $tier: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import de.eisi05.npc.api.events.NpcInteractEvent
|
||||
import de.eisi05.npc.api.interfaces.NpcClickAction
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.launch
|
||||
import net.trueog.questsOG.MainThreadBlock
|
||||
import net.trueog.questsOG.QuestMenus
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
|
||||
/**
|
||||
* Handles clicks on a home-tier NPC. NpcApi dispatches these on the main thread. The greeter talks; every other tier
|
||||
* opens the quest menu directly on its own quest.
|
||||
*/
|
||||
class QuestNpcClickAction(private val tier: Int) : NpcClickAction {
|
||||
override fun call(event: NpcInteractEvent) {
|
||||
val player = event.player
|
||||
if (!debounce(player.uniqueId)) return
|
||||
if (QuestsOG.config.debug) QuestsOG.plugin.logger.info("${player.name} clicked quest NPC tier $tier")
|
||||
|
||||
if (tier == QuestNpcNames.GREETER_TIER) {
|
||||
QuestNpcNames.GREETER_LINES.forEach {
|
||||
UtilitiesOG.trueogMessage(player, it.replace("<player>", player.name) + "<reset>")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
QuestsOG.scope.launch {
|
||||
try {
|
||||
val builder = QuestMenus.homesBuilder(player)
|
||||
MainThreadBlock.runOnMainThread { builder.openSection(tier - 2) }
|
||||
} catch (t: Throwable) {
|
||||
QuestsOG.plugin.logger.severe("Quest NPC click (tier $tier) failed: ${t.message}")
|
||||
if (QuestsOG.config.debug) t.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** A single interaction can arrive as both a left and a right click; collapse them. */
|
||||
const val DEBOUNCE_MILLIS = 500L
|
||||
val lastClick = ConcurrentHashMap<UUID, Long>()
|
||||
|
||||
fun debounce(uuid: UUID): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val previous = lastClick.put(uuid, now)
|
||||
return previous == null || now - previous >= DEBOUNCE_MILLIS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import net.trueog.questsOG.Config
|
||||
import net.trueog.questsOG.NpcSpawn
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.command.TabCompleter
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/** Admin command for placing the home-tier NPCs. Positions persist to config.yml. */
|
||||
class QuestNpcCommand(private val registry: QuestNpcRegistry) : CommandExecutor, TabCompleter {
|
||||
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
reply(sender, "<red>You do not have permission to do that.<reset>")
|
||||
return true
|
||||
}
|
||||
|
||||
when (args.getOrNull(0)?.lowercase()) {
|
||||
"set" -> set(sender, args)
|
||||
"remove" -> remove(sender, args)
|
||||
"tp" -> teleport(sender, args)
|
||||
"list" -> list(sender)
|
||||
"reload" -> {
|
||||
registry.reload()
|
||||
reply(sender, "<green>Reloaded quest NPCs from config.<reset>")
|
||||
}
|
||||
else -> reply(sender, "<yellow>Usage: /$label <set|remove|tp|list|reload> [tier]<reset>")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(sender: CommandSender, args: Array<out String>) {
|
||||
val player = sender as? Player ?: return playerOnly(sender)
|
||||
val tier = parseTier(sender, args) ?: return
|
||||
val location = player.location
|
||||
val existingSkin = QuestsOG.config.npcSkins[tier]
|
||||
Config.writeNpcSpawn(
|
||||
NpcSpawn(
|
||||
tier,
|
||||
location.world.name,
|
||||
location.x,
|
||||
location.y,
|
||||
location.z,
|
||||
location.yaw,
|
||||
location.pitch,
|
||||
existingSkin,
|
||||
)
|
||||
)
|
||||
registry.respawnFromConfig(tier)
|
||||
UtilitiesOG.trueogMessage(
|
||||
sender,
|
||||
"<green>Placed the tier <yellow>$tier<green> quest NPC at your location.<reset>",
|
||||
)
|
||||
}
|
||||
|
||||
private fun remove(sender: CommandSender, args: Array<out String>) {
|
||||
val tier = parseTier(sender, args) ?: return
|
||||
if (QuestsOG.config.npcSpawns[tier] == null) {
|
||||
reply(sender, "<red>No NPC is configured for tier $tier.<reset>")
|
||||
return
|
||||
}
|
||||
Config.deleteNpcSpawn(tier)
|
||||
registry.despawn(tier)
|
||||
reply(sender, "<green>Removed the tier <yellow>$tier<green> quest NPC.<reset>")
|
||||
}
|
||||
|
||||
private fun teleport(sender: CommandSender, args: Array<out String>) {
|
||||
val player = sender as? Player ?: return playerOnly(sender)
|
||||
val tier = parseTier(sender, args) ?: return
|
||||
val npc = registry.get(tier)
|
||||
if (npc == null) {
|
||||
reply(sender, "<red>The tier $tier quest NPC is not spawned.<reset>")
|
||||
return
|
||||
}
|
||||
player.teleport(npc.npc.location)
|
||||
reply(sender, "<green>Teleported to the tier <yellow>$tier<green> quest NPC.<reset>")
|
||||
}
|
||||
|
||||
private fun list(sender: CommandSender) {
|
||||
reply(sender, "<gold>Quest NPCs:<reset>")
|
||||
for (tier in Config.MIN_TIER..Config.MAX_TIER) {
|
||||
val spawn = QuestsOG.config.npcSpawns[tier]
|
||||
val skin = if (QuestsOG.config.npcSkins[tier] != null) "custom skin" else "default skin"
|
||||
val line =
|
||||
if (spawn == null) {
|
||||
"<gray>Tier $tier: not placed ($skin)"
|
||||
} else {
|
||||
val state = if (registry.get(tier) != null) "<green>spawned" else "<red>waiting for world"
|
||||
val coords = "%.1f %.1f %.1f".format(spawn.x, spawn.y, spawn.z)
|
||||
"<yellow>Tier $tier: $state <gray>${spawn.world} $coords ($skin)"
|
||||
}
|
||||
reply(sender, "$line<reset>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTier(sender: CommandSender, args: Array<out String>): Int? {
|
||||
val tier = args.getOrNull(1)?.toIntOrNull()
|
||||
if (tier == null || tier < Config.MIN_TIER || tier > Config.MAX_TIER) {
|
||||
reply(sender, "<red>Tier must be a number from ${Config.MIN_TIER} to ${Config.MAX_TIER}.<reset>")
|
||||
return null
|
||||
}
|
||||
return tier
|
||||
}
|
||||
|
||||
private fun playerOnly(sender: CommandSender) {
|
||||
reply(sender, "<red>Only players can use this subcommand.<reset>")
|
||||
}
|
||||
|
||||
/** Console has no Player, so expand MiniMessage ourselves for non-player senders. */
|
||||
private fun reply(sender: CommandSender, message: String) {
|
||||
if (sender is Player) UtilitiesOG.trueogMessage(sender, message)
|
||||
else sender.sendMessage(UtilitiesOG.trueogExpand(message))
|
||||
}
|
||||
|
||||
override fun onTabComplete(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
alias: String,
|
||||
args: Array<out String>,
|
||||
): List<String> {
|
||||
if (!sender.hasPermission(PERMISSION)) return emptyList()
|
||||
return when (args.size) {
|
||||
1 -> SUBCOMMANDS.filter { it.startsWith(args[0], ignoreCase = true) }
|
||||
2 -> if (args[0].lowercase() in TIER_SUBCOMMANDS) TIERS.filter { it.startsWith(args[1]) } else emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PERMISSION = "questsog.admin"
|
||||
val SUBCOMMANDS = listOf("set", "remove", "tp", "list", "reload")
|
||||
val TIER_SUBCOMMANDS = setOf("set", "remove", "tp")
|
||||
val TIERS = (Config.MIN_TIER..Config.MAX_TIER).map(Int::toString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import net.luckperms.api.event.user.UserDataRecalculateEvent
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.world.WorldLoadEvent
|
||||
|
||||
/**
|
||||
* Keeps the registry in sync with the server: spawns NPCs whose world loads late and re-renders nametags once LuckPerms
|
||||
* applies a new homes permission. Join refresh needs no handler: NpcApi shows NPCs (evaluating names) after join.
|
||||
*/
|
||||
class QuestNpcListener(private val registry: QuestNpcRegistry) : Listener {
|
||||
@EventHandler
|
||||
fun onWorldLoad(event: WorldLoadEvent) {
|
||||
registry.onWorldLoaded(event.world)
|
||||
}
|
||||
|
||||
/** LuckPerms fires this off the main thread and after reward() lands, so hop back before touching packets. */
|
||||
fun registerLuckPerms() {
|
||||
QuestsOG.luckPerms.eventBus.subscribe(QuestsOG.plugin, UserDataRecalculateEvent::class.java) { event ->
|
||||
val player = Bukkit.getPlayer(event.user.uniqueId) ?: return@subscribe
|
||||
Bukkit.getScheduler().runTask(QuestsOG.plugin, Runnable { registry.refreshNames(player) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import de.eisi05.npc.api.objects.NpcName
|
||||
import de.eisi05.npc.api.utils.SerializableFunction
|
||||
import de.eisi05.npc.api.wrapper.objects.WrappedComponent
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
|
||||
import net.trueog.questsOG.Config
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import net.trueog.questsOG.progression.HomesProgression
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/** Nametags and greeter text for the home-tier NPCs. Nametags are evaluated per viewer on the main thread. */
|
||||
object QuestNpcNames {
|
||||
const val GREETER_TIER = Config.MIN_TIER
|
||||
|
||||
private const val GREETER_NAME = "<light_purple>Quest Master"
|
||||
|
||||
/** Sent to a player who clicks the greeter. `<player>` is replaced with their name. */
|
||||
val GREETER_LINES =
|
||||
listOf(
|
||||
"<gold>Welcome, <yellow><player><gold>! Extra homes are earned by completing quests.",
|
||||
"<gold>Each quest giver near me unlocks one more home. Talk to them to see what they ask for.",
|
||||
"<gold>You can also check your progress any time with <yellow>/questgui<gold>.",
|
||||
)
|
||||
|
||||
private val legacy = LegacyComponentSerializer.legacySection()
|
||||
|
||||
fun npcName(tier: Int): NpcName =
|
||||
if (tier == GREETER_TIER) NpcName.ofLegacy(toLegacy(GREETER_NAME))
|
||||
else NpcName.of(TierName(tier), WrappedComponent.parseFromLegacy(toLegacy(lockedName(tier))))
|
||||
|
||||
/**
|
||||
* Viewer-specific nametag: completed once the player holds that many homes, current for the next tier, else locked.
|
||||
*/
|
||||
fun nameFor(tier: Int, player: Player): String {
|
||||
val homes = HomesProgression.getCurrentHomeCount(player)
|
||||
return when {
|
||||
homes >= tier -> "<green>Home $tier Quest ✔"
|
||||
homes == tier - 1 -> "<yellow>Home $tier Quest"
|
||||
else -> lockedName(tier)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockedName(tier: Int) = "<gray>Home $tier Quest ✖"
|
||||
|
||||
private fun toLegacy(miniMessage: String): String = legacy.serialize(QuestsOG.mm.deserialize(miniMessage))
|
||||
|
||||
/** Explicit class rather than a lambda: NpcApi requires Serializable functions, though we never persist NPCs. */
|
||||
private class TierName(private val tier: Int) : SerializableFunction<Player, WrappedComponent> {
|
||||
override fun apply(player: Player): WrappedComponent =
|
||||
WrappedComponent.parseFromLegacy(toLegacy(nameFor(tier, player)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package net.trueog.questsOG.npc
|
||||
|
||||
import de.eisi05.npc.api.objects.NPC
|
||||
import de.eisi05.npc.api.objects.NpcOption
|
||||
import de.eisi05.npc.api.objects.NpcSkin
|
||||
import de.eisi05.npc.api.objects.Skin
|
||||
import net.trueog.questsOG.Config
|
||||
import net.trueog.questsOG.NpcSpawn
|
||||
import net.trueog.questsOG.QuestsOG
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.World
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
/**
|
||||
* Owns the live quest NPCs. NPCs are ephemeral: built from config on enable, never saved through NpcApi, and removed on
|
||||
* disable. All methods must run on the main thread.
|
||||
*/
|
||||
class QuestNpcRegistry {
|
||||
private val npcs = sortedMapOf<Int, QuestNpc>()
|
||||
|
||||
/** Spawns whose world was not loaded yet; retried from [onWorldLoaded]. */
|
||||
private val pendingWorlds = mutableMapOf<Int, NpcSpawn>()
|
||||
|
||||
private val debug
|
||||
get() = QuestsOG.config.debug
|
||||
|
||||
fun get(tier: Int): QuestNpc? = npcs[tier]
|
||||
|
||||
fun tiers(): List<Int> = npcs.keys.toList()
|
||||
|
||||
fun spawnAll() {
|
||||
QuestsOG.config.npcSpawns.values.forEach { spawn(it) }
|
||||
}
|
||||
|
||||
/** @return True if the NPC is live; false if its world is not loaded yet (spawn deferred). */
|
||||
fun spawn(spawn: NpcSpawn): Boolean {
|
||||
despawn(spawn.tier)
|
||||
val location = spawn.toLocation()
|
||||
if (location == null) {
|
||||
pendingWorlds[spawn.tier] = spawn
|
||||
if (debug) QuestsOG.plugin.logger.info("Deferring NPC tier ${spawn.tier}: world ${spawn.world} not loaded")
|
||||
return false
|
||||
}
|
||||
|
||||
val npc = NPC(location, QuestNpcNames.npcName(spawn.tier))
|
||||
npc.setOption(NpcOption.SHOW_TAB_LIST, false)
|
||||
npc.setOption(NpcOption.COLLISION, false)
|
||||
npc.setOption(NpcOption.LOOK_AT_PLAYER, LOOK_AT_RANGE)
|
||||
spawn.skin?.let { npc.setOption(NpcOption.SKIN, NpcSkin.of(Skin(null, it.value, it.signature))) }
|
||||
npc.setClickEvent(QuestNpcClickAction(spawn.tier))
|
||||
npc.setEnabled(true)
|
||||
npc.showNpcToAllPlayers()
|
||||
|
||||
npcs[spawn.tier] = QuestNpc(spawn.tier, npc)
|
||||
if (debug)
|
||||
QuestsOG.plugin.logger.info(
|
||||
"Spawned NPC tier ${spawn.tier} at ${spawn.world} ${spawn.x} ${spawn.y} ${spawn.z}"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun despawn(tier: Int) {
|
||||
pendingWorlds.remove(tier)
|
||||
npcs.remove(tier)?.let {
|
||||
it.remove()
|
||||
if (debug) QuestsOG.plugin.logger.info("Despawned NPC tier $tier")
|
||||
}
|
||||
}
|
||||
|
||||
fun despawnAll() {
|
||||
tiers().forEach { despawn(it) }
|
||||
}
|
||||
|
||||
/** Spawns (or moves) a tier from the current config, or despawns it if the config no longer has it. */
|
||||
fun respawnFromConfig(tier: Int) {
|
||||
val spawn = QuestsOG.config.npcSpawns[tier]
|
||||
if (spawn == null) despawn(tier) else spawn(spawn)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
despawnAll()
|
||||
Config.reloadNpcSpawns()
|
||||
spawnAll()
|
||||
}
|
||||
|
||||
fun onWorldLoaded(world: World) {
|
||||
pendingWorlds.values.filter { it.world == world.name }.forEach { spawn(it) }
|
||||
}
|
||||
|
||||
fun refreshNames(player: Player) {
|
||||
npcs.values.forEach { it.refreshName(player) }
|
||||
}
|
||||
|
||||
fun refreshNamesForAll() {
|
||||
Bukkit.getOnlinePlayers().forEach(::refreshNames)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Blocks within which an NPC turns its head to follow the viewer. */
|
||||
const val LOOK_AT_RANGE = 8.0
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,40 @@ redisUrl: redis://^{KEYDB_PASSWORD}@127.0.0.1:6379/0
|
||||
|
||||
# Debug logging. Leave false in production, enable when reproducing a bug.
|
||||
debug: false
|
||||
|
||||
# Home quest NPCs, one per home tier. Tier 1 is the greeter; tiers 2-6 open that
|
||||
# home quest. Place them in-game with /questnpc set <tier>, which adds world/x/y/z/
|
||||
# yaw/pitch to the entry below and spawns it. An entry with only a skin is an NPC
|
||||
# that is not placed yet. skin holds a raw texture value + signature (MineSkin);
|
||||
# leave both blank for the default skin.
|
||||
npcs:
|
||||
1:
|
||||
# Quest Master: elderly robed sage
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTc0NjEzODU5NzcyMiwKICAicHJvZmlsZUlkIiA6ICJhYzI2NjllOWRlNzg0MDVjOTYzZGU0YzM0ZGYwZGU1MyIsCiAgInByb2ZpbGVOYW1lIiA6ICJJbnRlcmdhbGF0dGljbyIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83MWFlZGQ0MDM5MjhjYTNiOGZlZDdiZjZhODU4ZGRlODkxZGIxOTAzOTdmN2ZiNTY1NTM3OTUzOWEzYzU1ZTE2IgogICAgfQogIH0KfQ=="
|
||||
signature: "jEtLBeWxQkZIsrEQbUd8AwFaQFn8CIqi7r/v0+4pV63iMj9eY89VWRHtXeoLRz7Gz4saK6uQhlXh0Ur3aihMDlsV/HhHitUXbCow60/ol3P1Xyz6MjQgaWbmyKEU6CrleTM/2Zk2SruxCOCYW6Nv8q8DnS04BiqhmYWOB4LlkmlSRGt2K3gU95LZKcWyHVJiJ0Xk2hRXjMDvt2Zhk2N50WUIsYe0PNkWCKjhqPEwtFKKzBtOTU2QhwGIXdl1zn23DPOGLy8UwiAm1LAQYZU/RK582GziHagYZWPsqSAugl2mEcPPhi3GhUu699z+qvjZgTBviwiJLgJpmE0yzpC6j/Xm7NWiU91CdnqVnu7Z9ferep5fPsPkVq24X7c+XxAOpdcF3G9l+ZIIUYkmBXbIMqddtHu7slfYDoJpQB/WCneyEV4CNTck1lTQ0vYhPxl//OgKUHGyWfvFm0E0msbxlcD00qpctNXVEEtHnXQZOxWxCqlEZgBMfGYXjoYPRHtlQ6DIT4h3Th+dnhSDigBgH+I9pjE1Am/J+xAmcLt3L+Ti3rWb6EzXAk7hXZ5wFNfzUyCl3f7KtCq3ReiRCFLOqY5PBNIFeTBsyyAzaQwupUiRFQLUP2eRHnkj8lqbb0yjaeTJOvmUsCRc2hMm7F2Ole0Bm63y1xzurE1bC70qies="
|
||||
2:
|
||||
# Home 2: rugged adventurer
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTYxNDY3OTEwMjE0NiwKICAicHJvZmlsZUlkIiA6ICI0OWIzODUyNDdhMWY0NTM3YjBmN2MwZTFmMTVjMTc2NCIsCiAgInByb2ZpbGVOYW1lIiA6ICJiY2QyMDMzYzYzZWM0YmY4IiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlLzk4MTY4YWQ3MDliNTczZTU5YWIyODA3YTBlZmY2MWViZmQ3NDAzZGE3MzgwNmY5Zjg4OTYyYmE0ZDljMDkxYmIiCiAgICB9CiAgfQp9"
|
||||
signature: "T1sEx290jfEwWyQ5O/N0dBVQAywPU6nF3rdwN8hxNQgQXVLneQIDwKn5GXQqDGNTwhZII5dc4fx/lNuJqaMhzkVpZg+QKMxhXCnVCMOoqMmLJ7lkPPNoAdJATMC+YXcnDGMSq0lp+4nKXEKlCd8BygyjwzWEWZ6sRYnjKdVuPuYU188S50u/tQGhdAbOqvkE8a0kDpv9zUgp1qM+JimDhLbjkvkhp/GCpg56LY1wt7jis7WCdCXUVKSGyylZPsGTKYSELVmGLQ346okn1S24Q0lPP1Z/ywbHyapHNou108HOAUon9MfSoVWcyCrtBj2GOmB/a2BUX9Lcfvg5uh7SduYW4uUKOm540WKWUhmxovmuK0khgldB02C1FAq6bKCJRTvz04CE5BDFJTLNlYPukJ75yxLPzqwv2Vzi+43AE27Cy0KOJtGyFM9y2KNKMbWe5RC5gbQAHirPqWTxGsoCvOD9fMc3P+lKsKHbgJqWd7dR8/wqFVN++oyFfxPkeR4qm+JroB1u1U1pLFVHr8RBeROoVa37O/QhEq/Nq/JPMhXD8lswoQyPHMomsrDeqaPmZ7u+qmf0K+tHqBzFQ0mVd7B9xzX6cycHznZG4+lfEOYN0ZlOjwif+jz5VbnELBul8LAvYTvbXHOpxyStMx5wfBe/nrjCiPvWk3cSZBwIU+Q="
|
||||
3:
|
||||
# Home 3: miner (slim model)
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTc3NzAyMTE4MTkzMywKICAicHJvZmlsZUlkIiA6ICI2ZDcwZjM0OGFjODA0MWM5YjY4ZDA4MWUwMTUyNzVjNSIsCiAgInByb2ZpbGVOYW1lIiA6ICJ0ZXN0YWx0MSIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS9iYTRiNTFiZjg1M2JlZjdlODhjMGQxODhhOGNhMWE2ZGUwZmFhMjIyZjdhYmI1MmU3YjhjYWY3YmNmNzBhZTYyIiwKICAgICAgIm1ldGFkYXRhIiA6IHsKICAgICAgICAibW9kZWwiIDogInNsaW0iCiAgICAgIH0KICAgIH0sCiAgICAiQ0FQRSIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvY2I0MGE5MmUzMmI1N2ZkNzMyYTAwZmMzMjVlN2FmYjAwYTdjYTc0OTM2YWQ1MGQ4ZTg2MDE1MmU0ODJjZmJkZSIKICAgIH0KICB9Cn0="
|
||||
signature: "rrbpDzHiAZ9CKNjU2DGkHMZclAV264HxryAa7w2xX0TpqVuUcLtSv5hzg27ZBVg7bypaTVQP4q+a4Dc4MlShwNDPNpX+w4BROGGg5g4lq9JanY5WyBrmFsps54G+orC0uzxNzcLzwx2JxlZDBR6QxP9oLYk48hr0QCI4GG5hhptnSBAOfCLFwma1cxPLwKiWQig2g9WcGup65cKNkoaaeBnjkV3HbusWkv3SeCFyqSslaXSd6I4KX+SEqS+2Cupvzra7JhiF9iaR7vHJyIlnrV7ISt01nMnlYs9uVlfzfq/iAOC8M5nfk1ZOyPYElkzpKaphkXN65rgocojfjJh4sho9BAhrlUoDBoACEdmL/lyucRZuqEhkaH025p5j3uc8tbgZpOCcuHNBG4AWzNLoQnFsqJwOStMU925PM3v3pO7cxpi94T4cB6/WXUkL+g77VyNIbIOBFmRElt7DCeZFAbA/K0JmhiUv7YmX/moiiH+868kUyYmh+l0HQyRsgpBNZrpt72TnoxdTY22W4pLpxee0iW4Yk4P9xE92V9lCFy7Yv/Vluxc5AgQ9vH/R4QYocyMh/K2Q+3XIOSDmG46aNQ2lmrlIZh8cdMgBelT1OE3fzQ303DLjKXQjKuW93YHp32JBcQagBqOBtg5zKirCfbwk6ChZMyoXJC4OCDBxbsQ="
|
||||
4:
|
||||
# Home 4: purple sorcerer / alchemist
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTc2NzM3NjUzMDQyOCwKICAicHJvZmlsZUlkIiA6ICJkMWY0NjYwMGJkMjM0ZmU1YjFhMWJhNDNlMGM4NzQwNSIsCiAgInByb2ZpbGVOYW1lIiA6ICJTZWJUaGVIZXJtaXQiLAogICJzaWduYXR1cmVSZXF1aXJlZCIgOiB0cnVlLAogICJ0ZXh0dXJlcyIgOiB7CiAgICAiU0tJTiIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZWNlMTMwZTBhNzg2ZWU3N2YxNWQxZGFhNzRlNGE1MTg0YzY0MjU5MWUzMmYyZWQzMzBmMzE3MWIwYTY0OWEyZiIKICAgIH0sCiAgICAiQ0FQRSIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvY2Q5ZDgyYWIxN2ZkOTIwMjJkYmQ0YTg2Y2RlNGMzODJhNzU0MGUxMTdmYWU3YjlhMjg1MzY1ODUwNWE4MDYyNSIKICAgIH0KICB9Cn0="
|
||||
signature: "XAlz+0KJUe6Q59XUvK1NamLLk2pwYzJDbsyEmE8Y1V+aARn/40N7mRl5iCuW3dFJzO4hYmwKB2wunusPqRPvAI1kyrUX9cDFeEBJu66cB/Ov2x5MinYVUVkRuzBGkdKbKJX9iBHSjsqsjkuipSiJ7J2f2ROtLcKj798kV4EN0p+4gNtLV+OguRnEgpGQct0uvY96bAZzaAgrOowXislTk/XcyqybEyiEWGpnvVJLEAy8HFhMRREyobfjgJDlToP7WOjBZy6Z+QDwF3LQBOhG/wO/YQDrRPk1IHhxSkGtM2Lc+HmURz3q/x0r/5sjXv83E9kKNN/ZrKvRN6NwINNqJdkIpoVui5j0Bk9pYnoI2WgvAc89Eh3mix1cMhVAaGe6Z9ylcvSLqZHcXrDDC5tJMv9n57UUmTFYCmdVAmMua3ikZxQmvp6QPRNKg6lUDZNKQrilTchjmQORI0zsckADurNdSRTM0tjW+B1q/+hA4JLuekCVOHkNhsC++WnMfcZq8OsoEwYTyhKNOIHqRUJQqoeEZSd73JhWjnA70jLtjmXWrwnrglZOo/k7nh6t+dpKm0qoUxu9ZufkGic5N65KsQeOBKmMWa3eu7pvHu5uITC8BnwXwG6Q7JJC7lHPX+IKpfq9YgJNX/doXOFGBdNqy8R7kv+DaSiBDtheJB6XRlc="
|
||||
5:
|
||||
# Home 5: hooded ranger
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTYyMTQ4Mzc3NTM5NCwKICAicHJvZmlsZUlkIiA6ICJiMGQ0YjI4YmMxZDc0ODg5YWYwZTg2NjFjZWU5NmFhYiIsCiAgInByb2ZpbGVOYW1lIiA6ICJNaW5lU2tpbl9vcmciLAogICJzaWduYXR1cmVSZXF1aXJlZCIgOiB0cnVlLAogICJ0ZXh0dXJlcyIgOiB7CiAgICAiU0tJTiIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNWQxYWVlNDUyZmIxZmVjYTY2ZTUyNzcyOGMyM2U0OGU5MjZkZGU3ZThiOThiN2ZmMWRmMmM5NmQ4ZmE1NzI0ZCIKICAgIH0KICB9Cn0="
|
||||
signature: "q42pU53ciEBjQuvOC+OY/WR3vyTtfjrQYPqoHAaf+NNiFdAPC6bkut1rmtnWRkdBy3V1RMqBRksLa4Z37OlPRMnLtkhrfQ+ctgoWZypBDlCEmp0RRoSjW1tuIAbX0Hk/Lw5esvR+mlo7Be5HeNX+zfXy8Wa7OsZhp7AyrlsddHVSxUy7JLdyWrzPRoUO4GXrhA1/fXOZSf7nTsXClKweqdUXru2b43W8LWkI3vOmPymfSlsT7r0FtQLDgQUsyRc0iC3tzz1om3ihjsQ8eehV+8BXnCFSuX/OlqzO/zn7/FS8UXNSWy4+lakZC8/NR0lYszCRmmbcpxXvpYNeJZ4koLpq1aTvA2S5v9xifHIWuh6BjNg1m5PVczi0CDxtTfIUhSuC/WEsxM0HxSq7LghGUVwJiv2gmioeOGtklYRwy0mt24KlUD0mpfgrSA8anIUSbbZgdNw1BU2oW05Al+VeYoWlk2eWWBWpI5GzjpPoiQx3y+VnMo4yzeGYSe8PLZ9MzlcZANIXwNVLPBD7RRYBQwwM0ppyZ1kuOuRxdemNdPzNKUhzph2uUM2It2PmN7A6oUKQtJE1U3VjA2mFghIWRFo50rirHYAI29WOHWW0K7z5USIrfmA8vvIScUdZ8ZP9+fJrBkn76fRKuyNNoDqnktxLlkQxCk1I2wsZTBU2mBA="
|
||||
6:
|
||||
# Home 6: dark knight with gold crown
|
||||
skin:
|
||||
value: "ewogICJ0aW1lc3RhbXAiIDogMTc0NTAxOTcyNjgxOSwKICAicHJvZmlsZUlkIiA6ICIwODY3MjJhOTI4NWM0NzRkYTYyMWJlMGIyM2RlMDNkOSIsCiAgInByb2ZpbGVOYW1lIiA6ICJTdGlja3ltM2xvbiIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS8zMmE0YTQ1ZTNmZDJmYWExZmJiMjIzYzMyNjNiZTA3NWMwODI4NDYzY2JiYmRiZjhkMTEyY2QzYWYwY2NlODU3IgogICAgfQogIH0KfQ=="
|
||||
signature: "By58fNfjNH+tKfthzqJL9qNgUq2TMtZC/hb9+vz1uQrnqlJ7Qd7ghy2qZWBcNg/KMjihI7XDO0l37jON1yUopaoZxDVGH4/NdmaeWUeUQ+Hjz0pxxVP0GdbjvQIwqLTYe1Og/Z/9BLOqct0cXd7ZgtmMwn7uB0MrtZlSnj9e903M6lFrJiuklFw+/99c/VL1P2T5xfppXfHfTM+czF4GH+qxlNU1Vmf8S2tcYY/ZV9ZWHl5uKkEakN9bKfWjMzDX6p3PuAv9UGsVQXJlfLeeHsH4LKfeMlt4tQJrW8/zcUShpEc29I+BHU6R9Kbp3Tyv9RaO10aA4EuVTSbwuogFcgWGvzMi0lCraNRixBxPby5tEuciJ6empOpovl+doTLDI9wrE6uT04gAP3gVfbLgWcNw04A2/bfo4QE1jOAu7ttkRtFhQGl9m/1mlkLVfRM+8ngw+HRnaNYLXJI1/DQ3TaiRgZYqEaHkaCsJ/g0Q9J8oEYjT2mg53G8RiIuaPD41rj337uI10WS63q7hbGzAOLQNy+cGu5oL08y6syJo3wbvPdEiX+df57mprANelKk3YNzoW65jRn59qqpS5JJrizhbj8xZVB1m/CesI4pNj5uLsJ1OeUKvSNNVQ2cVH2XcU2E/1N3qsxNncydIHfA4A+6xrdQ14WPjjejex2+qaFY="
|
||||
@@ -13,3 +13,11 @@ commands:
|
||||
questgui:
|
||||
description: Open the layered homes quest menu.
|
||||
usage: /questgui
|
||||
questnpc:
|
||||
description: Manage the home quest NPCs.
|
||||
usage: /questnpc <set|remove|tp|list|reload> [tier]
|
||||
permission: questsog.admin
|
||||
permissions:
|
||||
questsog.admin:
|
||||
description: Manage Quests-OG NPCs.
|
||||
default: op
|
||||
Reference in new issue
Block a user