Quest NPCs

This commit is contained in:
NotAlexNoyle committed 2026-09-16 14:12:16 -04:00
1 parent c020713543
commit 708411b5b6
19 files changed
+871 -106

No files matched your search

+22
View File
@@ -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
View File
@@ -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 kept off runtimeClasspath so RelocationUtil never sees it; see relocateNpcApi below.
val npcApi: Configuration by configurations.creating { isTransitive = false }
val npcApiVersion = "2.3.3" // Last NpcApi release built for Java 17 (supports 1.19.4 via v1_19_R3).
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,8 @@ 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.
compileOnly("com.github.Eisi05:NpcApi:$npcApiVersion") // Compile against NpcApi (relocated at shade time).
npcApi("com.github.Eisi05:NpcApi:$npcApiVersion") // Shade NpcApi player NPC library into this plugin.
}
configurations.runtimeClasspath {
@@ -101,12 +110,29 @@ tasks.withType<AbstractArchiveTask>().configureEach { // Ensure reproducible .ja
}
/* ----------------------------- Shadow -------------------------------- */
// NpcApi injects a netty handler into the server's player pipeline, so it must keep
// referencing the server's io.netty. Lettuce ships its own netty that the main shadowJar
// relocates under the shadow prefix. Stage 1 renames NpcApi's netty references to a
// placeholder package that the main task maps straight back to io.netty.
val relocateNpcApi by
tasks.registering(ShadowJar::class) {
archiveClassifier.set("npcapi-relocated")
configurations = listOf(npcApi)
relocate("io.netty", "questsog.nettyshim")
exclude("META-INF/**")
exclude("de/eisi05/npc/api/utils/Metrics*.class") // Drop bStats; a no-op stub ships in src/main/kotlin.
}
tasks.shadowJar {
dependsOn(relocateNpcApi)
from(zipTree(relocateNpcApi.flatMap { it.archiveFile }))
archiveClassifier.set("") // Use empty string instead of null.
isEnableRelocation = false
relocationPrefix = "${project.group}.shadow"
relocate("kotlin", "net.trueog.diamondbankog.shadow.kotlin")
relocate("kotlinx", "net.trueog.diamondbankog.shadow.kotlinx")
relocate("questsog.nettyshim", "io.netty") // Map NpcApi's placeholder back onto the server's netty.
relocate("de.eisi05", "${project.group}.shadow.de.eisi05") // Relocate NpcApi (and our call sites).
doFirst { RelocationUtil.configureRelocation(this@shadowJar, relocationPrefix) }
mergeServiceFiles()
minimize { exclude(dependency("org.slf4j:slf4j-nop:.*")) }
@@ -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}")
+125 -1
View File
@@ -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,15 @@ class QuestsOG : JavaPlugin() {
lateinit var luckPerms: LuckPerms
lateinit var duels: Duels
lateinit var mobHeads: Plugin
lateinit var questNpcs: QuestNpcRegistry
var mm =
MiniMessage.builder()
.tags(TagResolver.builder().resolver(StandardTags.color()).resolver(StandardTags.reset()).build())
.build()
fun isRedisInitialized() = ::redis.isInitialized
private var npcApiStarted = false
}
override fun onEnable() {
@@ -87,9 +95,36 @@ 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.createInstance(this, NpcConfig().autoUpdate(false).debug(Companion.config.debug))
npcApiStarted = true
} 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 (npcApiStarted) {
questNpcs.despawnAll()
NpcApi.disable()
npcApiStarted = false
}
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
}
}
+37
View File
@@ -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="
+8
View File
@@ -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