diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f552677 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +max_line_length = 120 + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.bat] +end_of_line = crlf diff --git a/.gitignore b/.gitignore index 108d567..dd744bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,21 @@ +### Gradle ### .gradle build/ !gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### IntelliJ IDEA ### -.idea/ -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ ### Kotlin ### .kotlin -### Eclipse ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### +### IDEs ### +.idea/ +*.iml +out/ .vscode/ -### Mac OS ### -.DS_Store \ No newline at end of file +### OS ### +.DS_Store + +### Local servers / logs ### +run/ +*.log +*.hprof diff --git a/README.md b/README.md index df3d308..d8e2d20 100644 --- a/README.md +++ b/README.md @@ -25,59 +25,70 @@ > All information about this project can be found in our detailed [documentation][docs-thisproject]. -Create NPCs on your server that allow your players to access other subservers through the NPCs. Perform various actions such as sending messages, titles, or sounds, and use your favourite supported NPC provider such as Citizens or FancyNPCs. +Create NPCs on your server that allow your players to access other subservers through the NPCs. Perform various actions such as sending messages, titles, or sounds. NPCs are rendered natively, so no third-party NPC plugin is needed, but Citizens, FancyNpcs, ZNPCsPlus and MythicMobs NPCs can still be used. ## Features - [x] **Custom Join NPC**: Create NPCs to let your players join sub-servers seamlessly! -- [x] **Custom Inventories**: Design the paginated inventory exactly the way you need it! -- [x] **Holograms**: Add beautiful holograms above your NPCs! +- [x] **Use it as a Standalone**: No third-party plugins needed—just drag and drop! +- [x] **In-Game Editor**: Change everything about an NPC without touching a file! +- [x] **Custom Inventories**: Build server selectors on a drag-and-drop canvas with live server lists! +- [x] **Holograms**: Add beautiful holograms above your NPCs, with rotating lines! - [x] **Placeholders**: Use placeholders anywhere—in holograms, items, and more! - [x] **Customize with Actions**: Trigger messages, action bars, titles, sounds, and more when a player interacts with an NPC! -- [x] **Support for Other NPC Plugins**: Integrate NPCs from other plugins like Citizens, FancyNPCs, and more! -- [ ] **Use it as a Standalone**: No third-party plugins needed—just drag and drop! +- [x] **Knows When Servers Are Offline**: Holograms and click actions switch on their own. +- [x] **Lively NPCs**: Equipment, poses, sizes, click animations and speech bubbles. +- [x] **Support for Other NPC Plugins**: Attach NPCs from Citizens, FancyNpcs, ZNPCsPlus or MythicMobs! + +## Supported servers + +Paper (and its forks) from **1.20.6 up to the latest release**, on **Java 21** or newer. +The SimpleCloud API plugin is required; use its build for your server version. +From 1.21.9 on, NPCs are native Mannequin entities. ## Quick start -Install Citizens, FancyNPCs, or ZNPCsPlus, stand where the NPC should appear, and run: +Stand where the NPC should appear, and run: ```text -/scnpcs create [provider] +/scnpcs create ``` -For example, `/scnpcs create lobby Lobby` creates the provider NPC, links it to the -SimpleCloud target, writes its configuration, adds a default hologram, and makes -right-click join the target. If multiple creation providers are installed, add -`citizens`, `fancynpcs`, or `znpcsplus` to the command. +For example, `/scnpcs create lobby Lobby` spawns an NPC wearing your skin, links it +to the SimpleCloud target, adds a default hologram, and makes right-click join the +target. The editor opens right away; later, `/scnpcs edit` while looking at the NPC +brings it back. + +Existing NPCs from other plugins can be attached with +`/scnpcs link `. They keep belonging to their +plugin, SimpleCloud adds the click handling. A hologram can be turned on in the editor. + +Menus are built with `/scnpcs inventory create ` and opened from an NPC or with +`/scnpcs inventory open `. Run `/scnpcs help` for all commands. -Target names do not need `group:` or `ps:` prefixes. A name that exists as both a -group and persistent server is rejected so that it can never resolve silently to -the wrong target. +## Upgrading -Use `/scnpcs edit ` to discover the target, hologram, action, and pushback -editors. Existing provider NPCs can be attached with -`/scnpcs link `. +Drop the new jar over the old one. NPC files are migrated on the first start, and a +backup of every old file is kept next to it. -## Hologram placeholders +## Placeholders -Run `/scnpcs placeholders` in-game for the current list. The placeholders shared -by group and persistent-server targets are: +Run `/scnpcs placeholders` in-game for the full list. Holograms and menus understand +the placeholders of the NPC's target: ```text - - - ``` -Persistent servers also expose ``, ``, and -``. Join-state values in NPC files are always written in lower case. +Holograms and action texts also understand `` and ``, and with +PlaceholderAPI installed `%placeholders%`. Holograms render them for each player. ## Contributing Contributions to SimpleCloud are welcome and highly appreciated. However, before you jump right into it, we would like you to read our [Contribution Guide][docs-contribute]. +For this repository, [`docs/ARCHITECTURE.md`](https://github.com/simplecloudapp/npc-plugin/blob/main/docs/ARCHITECTURE.md) explains the modules. ## License This repository is licensed under [Apache 2.0][license]. @@ -93,19 +104,9 @@ This repository is licensed under [Apache 2.0][license]. [docs-contribute]: https://docs.simplecloud.app/contribute [modrinth]: https://modrinth.com/plugin/npcs-plugin -[maven-central]: https://central.sonatype.com/artifact/app.simplecloud.controller/controller-api -[dev]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api - - -[artifacts]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api -[dev-artifacts]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api - -[badge-maven-central]: https://img.shields.io/maven-central/v/app.simplecloud.controller/controller-api?labelColor=18181b&style=flat-square&color=65a30d&label=Release -[badge-dev]: https://repo.simplecloud.app/api/badge/latest/snapshots/app/simplecloud/controller/controller-api?name=Dev&style=flat-square&color=0ea5e9 [license]: https://opensource.org/licenses/Apache-2.0 -[snapshots]: https://repo.simplecloud.app/#/snapshots [social-x]: https://x.com/simplecloudapp [social-bluesky]: https://bsky.app/profile/simplecloud.app diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..2ec0a48 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + compileOnly(libs.paper.api) +} diff --git a/api/src/main/kotlin/app/simplecloud/npc/api/SimpleCloudNpcs.kt b/api/src/main/kotlin/app/simplecloud/npc/api/SimpleCloudNpcs.kt new file mode 100644 index 0000000..0855d8b --- /dev/null +++ b/api/src/main/kotlin/app/simplecloud/npc/api/SimpleCloudNpcs.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.api + +import org.bukkit.entity.Player + +interface SimpleCloudNpcs { + fun npcIds(): Set + fun menuIds(): Set + fun openMenu(player: Player, menuId: String): Boolean +} diff --git a/api/src/main/kotlin/app/simplecloud/npc/api/event/MenuClickEvent.kt b/api/src/main/kotlin/app/simplecloud/npc/api/event/MenuClickEvent.kt new file mode 100644 index 0000000..ab2884c --- /dev/null +++ b/api/src/main/kotlin/app/simplecloud/npc/api/event/MenuClickEvent.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.api.event + +import org.bukkit.entity.Player +import org.bukkit.event.Cancellable +import org.bukkit.event.HandlerList +import org.bukkit.event.player.PlayerEvent + +class MenuClickEvent( + player: Player, + val menuId: String, + val slot: Int, + val click: String, + async: Boolean, +) : PlayerEvent(player, async), Cancellable { + + private var cancelled = false + + override fun isCancelled(): Boolean = cancelled + + override fun setCancelled(cancel: Boolean) { + cancelled = cancel + } + + override fun getHandlers(): HandlerList = HANDLERS + + companion object { + private val HANDLERS = HandlerList() + + @JvmStatic + fun getHandlerList(): HandlerList = HANDLERS + } +} diff --git a/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcInteractEvent.kt b/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcInteractEvent.kt new file mode 100644 index 0000000..ea6d116 --- /dev/null +++ b/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcInteractEvent.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.api.event + +import org.bukkit.entity.Player +import org.bukkit.event.Cancellable +import org.bukkit.event.HandlerList +import org.bukkit.event.player.PlayerEvent + +class NpcInteractEvent( + player: Player, + val npcId: String, + val interaction: String, + val joinState: String, + async: Boolean, +) : PlayerEvent(player, async), Cancellable { + + private var cancelled = false + + override fun isCancelled(): Boolean = cancelled + + override fun setCancelled(cancel: Boolean) { + cancelled = cancel + } + + override fun getHandlers(): HandlerList = HANDLERS + + companion object { + private val HANDLERS = HandlerList() + + @JvmStatic + fun getHandlerList(): HandlerList = HANDLERS + } +} diff --git a/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcJoinEvent.kt b/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcJoinEvent.kt new file mode 100644 index 0000000..4f4a470 --- /dev/null +++ b/api/src/main/kotlin/app/simplecloud/npc/api/event/NpcJoinEvent.kt @@ -0,0 +1,31 @@ +package app.simplecloud.npc.api.event + +import org.bukkit.entity.Player +import org.bukkit.event.Cancellable +import org.bukkit.event.HandlerList +import org.bukkit.event.player.PlayerEvent + +class NpcJoinEvent( + player: Player, + val npcId: String?, + var destination: String, + async: Boolean, +) : PlayerEvent(player, async), Cancellable { + + private var cancelled = false + + override fun isCancelled(): Boolean = cancelled + + override fun setCancelled(cancel: Boolean) { + cancelled = cancel + } + + override fun getHandlers(): HandlerList = HANDLERS + + companion object { + private val HANDLERS = HandlerList() + + @JvmStatic + fun getHandlerList(): HandlerList = HANDLERS + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c1c5db0..a66cb40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,90 +1,53 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion - plugins { - alias(libs.plugins.kotlin) - alias(libs.plugins.shadow) + alias(libs.plugins.kotlin) apply false } -val baseVersion = "0.1.6" -val commitHash = System.getenv("COMMIT_HASH") -val snapshotVersion = "${baseVersion}-dev.$commitHash" +val baseVersion = "0.2.0" +val commitHash: String? = System.getenv("COMMIT_HASH") allprojects { - group = "app.simplecloud.plugin" - version = if (commitHash != null) snapshotVersion else baseVersion - - repositories { - mavenCentral() - mavenLocal() - maven("https://oss.sonatype.org/content/repositories/snapshots") - maven("https://libraries.minecraft.net") - maven("https://repo.papermc.io/repository/maven-public/") - maven("https://repo.pyr.lol/snapshots") - maven("https://maven.citizensnpcs.co/repo") - maven("https://repo.minebench.de") - maven("https://buf.build/gen/maven") - maven("https://repo.fancyinnovations.com/releases") - maven("https://mvn.lumine.io/repository/maven-public/") - maven("https://repo.ranull.com/maven/external") - maven("https://maven.noxcrew.com/public") - maven("https://repo.simplecloud.app/snapshots") - maven("https://buf.build/gen/maven") - } + group = "app.simplecloud.npc" + version = commitHash?.let { "$baseVersion-dev.$it" } ?: baseVersion } -subprojects { - apply(plugin = "org.jetbrains.kotlin.jvm") - apply(plugin = "com.gradleup.shadow") - - dependencies { - testImplementation(rootProject.libs.kotlin.test) - implementation(rootProject.libs.kotlin.jvm) - implementation(rootProject.libs.kotlin.coroutines) - - compileOnly(rootProject.libs.paper.api) - - compileOnly(rootProject.libs.simplecloud) - compileOnly(rootProject.libs.simplecloud.plugin) - } - - java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - } +val adventureFloor = libs.versions.adventure.floor.get() +val serverProvidedAdventure = setOf( + "adventure-api", + "adventure-key", + "adventure-text-minimessage", + "adventure-text-serializer-commons", + "adventure-text-serializer-gson", + "adventure-text-serializer-json", + "adventure-text-serializer-legacy", + "adventure-text-serializer-plain", + "adventure-text-logger-slf4j", +) - kotlin { - jvmToolchain(21) - compilerOptions { - apiVersion.set(KotlinVersion.KOTLIN_2_2) - jvmTarget.set(JvmTarget.JVM_21) +subprojects { + plugins.withId("org.jetbrains.kotlin.jvm") { + dependencies { + "implementation"(rootProject.libs.kotlin.coroutines) + "testImplementation"(rootProject.libs.kotlin.test) } - } - - tasks.test { - useJUnitPlatform() - } - - tasks.processResources { - expand( - "version" to project.version, - "name" to project.name - ) - } - /* TODO: fix here - tasks.shadowJar { - exclude("kotlin") - exclude("kotlinx") - mergeServiceFiles() + extensions.configure { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } - relocate("com.google.protobuf", "app.simplecloud.relocate.google.protobuf") - relocate("com.google.common", "app.simplecloud.relocate.google.common") - relocate("io.grpc", "app.simplecloud.relocate.io.grpc") + configurations.matching { + it.name == "compileClasspath" || it.name.endsWith("CompileClasspath") + }.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "net.kyori" && requested.name in serverProvidedAdventure) { + val target = findProperty("npc.adventureVersion")?.toString() ?: adventureFloor + useVersion(target) + because("the paper-api this module compiles against provides Adventure $target") + } + } + } - relocate("org.incendo", "app.simplecloud.npc.plugin.relocate.incendo") - relocate("org.spongepowered", "app.simplecloud.npc.plugin.relocate.spongepowered") - relocate("app.simplecloud.plugin.api", "app.simplecloud.npc.plugin.relocate.plugin.api") + tasks.withType().configureEach { + useJUnitPlatform() + } } - - */ } diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..97b86d1 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlin) + `java-test-fixtures` +} + +dependencies { + api(libs.configurate.yaml) + api(libs.simplecloud.plugin) + compileOnly(libs.simplecloud.api) + compileOnly(libs.adventure.api) + compileOnly(libs.adventure.text.minimessage) + + testImplementation(libs.simplecloud.api) + testImplementation(libs.bundles.adventure) + testFixturesImplementation(libs.simplecloud.api) + testFixturesImplementation(libs.kotlin.coroutines) +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/NpcLog.kt b/core/src/main/kotlin/app/simplecloud/npc/core/NpcLog.kt new file mode 100644 index 0000000..ca148a7 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/NpcLog.kt @@ -0,0 +1,7 @@ +package app.simplecloud.npc.core + +import java.util.logging.Logger + +object NpcLog { + val logger: Logger = Logger.getLogger("SimpleCloud-NPC") +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/BridgePlaceholders.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/BridgePlaceholders.kt new file mode 100644 index 0000000..b772370 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/BridgePlaceholders.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.plugin.api.shared.placeholder.PlaceholderProvider +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver + +object BridgePlaceholders { + suspend fun append(bridge: ServerBridge, text: String, prefix: String = "target"): Component = when (bridge) { + is ServerBridge.OfPersistentServer -> + PlaceholderProvider.persistentServerPlaceholderProvider.append(bridge.persistentServer, text, prefix) + + is ServerBridge.OfGroup -> PlaceholderProvider.groupPlaceholderProvider.append(bridge.group, text, prefix) + is ServerBridge.OfServer -> PlaceholderProvider.serverPlaceholderProvider.append(bridge.server, text, prefix) + } + + suspend fun resolver(bridge: ServerBridge, prefix: String = "target"): TagResolver = when (bridge) { + is ServerBridge.OfPersistentServer -> PlaceholderProvider.persistentServerPlaceholderProvider + .getTagResolver(listOf(bridge.persistentServer), prefix) + + is ServerBridge.OfGroup -> PlaceholderProvider.groupPlaceholderProvider.getTagResolver(listOf(bridge.group), prefix) + is ServerBridge.OfServer -> PlaceholderProvider.serverPlaceholderProvider.getTagResolver(listOf(bridge.server), prefix) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudExtensions.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudExtensions.kt new file mode 100644 index 0000000..62ed9b6 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudExtensions.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.base.ServerBase +import app.simplecloud.api.group.GroupServerType +import app.simplecloud.api.server.Server + +fun ServerBase.isProxy(): Boolean = type == GroupServerType.PROXY +fun Server.groupName(): String? = group?.name +fun Server.connectName(): String? = groupName()?.let { "$it-$numericalId" } + +fun Server.isFull(): Boolean { + val max = maxPlayers ?: return false + return max > 0 && (playerCount ?: 0) >= max +} + +fun serversOf(bridge: ServerBridge, servers: List): List = when (bridge) { + is ServerBridge.OfGroup -> servers.filter { it.groupName().equals(bridge.group.name, true) } + is ServerBridge.OfPersistentServer -> servers.filter { + it.persistentServerId != null && it.persistentServerId == bridge.persistentServer.persistentServerId + } + is ServerBridge.OfServer -> listOf(bridge.server) +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListCache.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListCache.kt new file mode 100644 index 0000000..80a3c58 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListCache.kt @@ -0,0 +1,46 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.CloudApi +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerQuery +import app.simplecloud.api.server.ServerState +import kotlinx.coroutines.future.await + +object CloudListCache : CloudLists, CloudSnapshots { + + val cloudApi: CloudApi by lazy { CloudApi.create() } + + private val store: CloudListStore by lazy { + CloudListStore( + fetchGroups = { cloudApi.group().allGroups.await().toList() }, + fetchPersistentServers = { cloudApi.persistentServer().allPersistentServers.await().toList() }, + fetchServers = { cloudApi.server().allServers.await() }, + fetchAvailableServers = { groupName -> + val query = ServerQuery.create() + .filterByServerGroupName(groupName) + .filterByState(ServerState.AVAILABLE) + cloudApi.server().getAllServers(query).await() + }, + ) + } + + override fun peek(): CloudSnapshot = store.peek() + + override fun requestRefresh() = store.requestRefresh() + + override suspend fun groups(): List = store.groups() + + override suspend fun persistentServers(): List = store.persistentServers() + + override suspend fun servers(): List = store.servers() + + override suspend fun availableServers(groupName: String): List = store.availableServers(groupName) + + suspend fun warmUp() = store.warmUp() + + suspend fun refreshNow() = store.refreshNow() + + fun shutdown() = store.shutdown() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListStore.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListStore.kt new file mode 100644 index 0000000..9718939 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudListStore.kt @@ -0,0 +1,245 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server +import app.simplecloud.npc.core.NpcLog +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.logging.Level + +class CloudListStore( + private val fetchGroups: suspend () -> List, + private val fetchPersistentServers: suspend () -> List, + private val fetchServers: suspend () -> List, + private val fetchAvailableServers: suspend (groupName: String) -> List, + private val now: () -> Long = System::currentTimeMillis, + private val ttlMillis: Long = TTL_MILLIS, + launchInBackground: ((suspend () -> Unit) -> Unit)? = null, +) : CloudLists, CloudSnapshots { + + private class Cached(val value: T, val fetchedAt: Long) + + private class Lists(val groups: List, val persistentServers: List) + + private val ownScope: CoroutineScope? = if (launchInBackground != null) null else CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + NpcLog.logger.log(Level.FINE, "Cloud cache revalidation failed", throwable) + }, + ) + + private val launchTask: (suspend () -> Unit) -> Unit = + launchInBackground ?: { block -> ownScope?.launch(start = CoroutineStart.ATOMIC) { block() } } + + @Volatile + private var lists: Cached? = null + + @Volatile + private var servers: Cached>? = null + + @Volatile + private var snapshot: CloudSnapshot = CloudSnapshot.EMPTY + + @Volatile + private var lastFailure: Pair? = null + + private val listsMutex = Mutex() + private val serversMutex = Mutex() + + override fun peek(): CloudSnapshot { + requestRefresh() + return snapshot + } + + override fun requestRefresh() { + if (isStale(lists)) revalidate(listsMutex, ::refreshLists) + if (isStale(servers)) revalidate(serversMutex, ::refreshServers) + } + + override suspend fun groups(): List = currentLists().groups + + override suspend fun persistentServers(): List = currentLists().persistentServers + + override suspend fun servers(): List { + val current = servers ?: return awaitServers() + if (isStale(current)) revalidate(serversMutex, ::refreshServers) + + return current.value + } + + override suspend fun availableServers(groupName: String): List = try { + failFast() + fetchAvailableServers(groupName) + } catch (exception: CloudUnavailableException) { + throw exception + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + CloudOutageLog.failed(exception) + throw CloudUnavailableException(exception) + } + + suspend fun warmUp() { + ignoringOutage { currentLists() } + ignoringOutage { servers() } + } + + suspend fun refreshNow() { + if (!mayProbe()) return + if (listsMutex.withLock { refreshLists(force = true) }) serversMutex.withLock { refreshServers(force = true) } + } + + fun shutdown() { + ownScope?.cancel() + } + + private suspend fun currentLists(): Lists { + val current = lists ?: return awaitLists() + if (isStale(current)) revalidate(listsMutex, ::refreshLists) + + return current.value + } + + private suspend fun awaitLists(): Lists = listsMutex.withLock { + lists?.let { return it.value } + failFast() + try { + val fetched = Lists(fetchGroups(), fetchPersistentServers()) + storeLists(Cached(fetched, now())) + recovered() + fetched + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + throw coldFailure(exception) + } + } + + private suspend fun awaitServers(): List = serversMutex.withLock { + servers?.let { return it.value } + failFast() + try { + val fetched = fetchServers() + storeServers(Cached(fetched, now())) + recovered() + fetched + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + throw coldFailure(exception) + } + } + + private suspend fun refreshLists(force: Boolean = false): Boolean { + val stale = lists + if (!force && !isStale(stale)) return true + + return try { + storeLists(Cached(Lists(fetchGroups(), fetchPersistentServers()), now())) + recovered() + true + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + backgroundFailure(exception, stale) { storeLists(Cached(it.value, now())) } + false + } + } + + private suspend fun refreshServers(force: Boolean = false): Boolean { + val stale = servers + if (!force && !isStale(stale)) return true + + return try { + storeServers(Cached(fetchServers(), now())) + recovered() + true + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + backgroundFailure(exception, stale) { storeServers(Cached(it.value, now())) } + false + } + } + + private fun revalidate(mutex: Mutex, refresh: suspend () -> Boolean) { + if (!mayProbe()) return + if (!mutex.tryLock()) return + launchTask { + try { + refresh() + } finally { + mutex.unlock() + } + } + } + + private fun storeLists(value: Cached) { + lists = value + publish() + } + + private fun storeServers(value: Cached>) { + servers = value + publish() + } + + @Synchronized + private fun publish() { + val lists = this.lists + val servers = this.servers + snapshot = CloudSnapshot( + groups = lists?.value?.groups.orEmpty(), + persistentServers = lists?.value?.persistentServers.orEmpty(), + servers = servers?.value.orEmpty(), + known = lists != null && servers != null, + ) + } + + private fun recovered() { + lastFailure = null + CloudOutageLog.recovered() + } + + private fun backgroundFailure(exception: Exception, stale: Cached?, renew: (Cached) -> Unit) { + CloudOutageLog.failed(exception) + lastFailure = now() to exception + stale?.let(renew) + } + + private fun coldFailure(exception: Exception): CloudUnavailableException { + CloudOutageLog.failed(exception) + lastFailure = now() to exception + + return CloudUnavailableException(exception) + } + + private fun failFast() { + val (at, cause) = lastFailure ?: return + if (now() - at <= ttlMillis) throw CloudUnavailableException(cause) + } + + private fun mayProbe(): Boolean { + val (at, _) = lastFailure ?: return true + return now() - at > ttlMillis + } + + private fun isStale(cached: Cached<*>?): Boolean = cached == null || now() - cached.fetchedAt > ttlMillis + + private inline fun ignoringOutage(read: () -> Unit) = try { + read() + } catch (_: CloudUnavailableException) { + } + + private companion object { + const val TTL_MILLIS = 2_500L + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudLists.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudLists.kt new file mode 100644 index 0000000..c4e9083 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudLists.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server + +interface CloudLists { + suspend fun groups(): List + suspend fun persistentServers(): List + suspend fun servers(): List + + suspend fun availableServers(groupName: String): List +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudNames.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudNames.kt new file mode 100644 index 0000000..828326a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudNames.kt @@ -0,0 +1,14 @@ +package app.simplecloud.npc.core.cloud + +object CloudNames { + fun servers(snapshot: CloudSnapshot = CloudListCache.peek()): List = buildList { + val gameServers = snapshot.servers.filterNot { (it.serverBase ?: it.group)?.isProxy() == true } + addAll(gameServers.mapNotNull { it.connectName() }) + addAll(snapshot.persistentServers.filterNot { it.isProxy() }.map { it.name }) + }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) + + fun targets(snapshot: CloudSnapshot = CloudListCache.peek()): List = buildList { + addAll(snapshot.groups.filterNot { it.isProxy() }.map { it.name }) + addAll(snapshot.persistentServers.filterNot { it.isProxy() }.map { it.name }) + }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudOutageLog.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudOutageLog.kt new file mode 100644 index 0000000..4a45503 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudOutageLog.kt @@ -0,0 +1,53 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.npc.core.NpcLog +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.logging.Level + +object CloudOutageLog { + private const val THROTTLE_MILLIS = 60_000L + + private val logger = NpcLog.logger + private val outage = AtomicBoolean(false) + private val lastLogged = AtomicLong(0) + private val throttled = ConcurrentHashMap() + + fun failed(cause: Throwable) { + val now = System.currentTimeMillis() + val first = outage.compareAndSet(false, true) + val previous = lastLogged.get() + + if (!first && now - previous < THROTTLE_MILLIS) return + if (!lastLogged.compareAndSet(previous, now)) return + + val summary = CloudUnavailableException(cause).message + val message = if (first) { + "$summary. NPC actions and holograms fall back to the last known cloud data until it is back." + } else { + "$summary (still unreachable)." + } + logger.warning(message) + logger.log(Level.FINE, "Controller lookup failure", cause) + } + + fun recovered() { + if (outage.compareAndSet(true, false)) { + lastLogged.set(0) + throttled.clear() + logger.info("SimpleCloud controller is reachable again.") + } + } + + fun warnThrottled(message: String, key: String = message) { + val now = System.currentTimeMillis() + var log = false + throttled.compute(key) { _, previous -> + if (previous != null && now - previous < THROTTLE_MILLIS) previous + else now.also { log = true } + } + + if (log) logger.warning(message) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshot.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshot.kt new file mode 100644 index 0000000..6d66f9a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshot.kt @@ -0,0 +1,26 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState + +class CloudSnapshot( + val groups: List = emptyList(), + val persistentServers: List = emptyList(), + val servers: List = emptyList(), + val known: Boolean = false, +) : CloudLists { + + override suspend fun groups(): List = groups + override suspend fun persistentServers(): List = persistentServers + override suspend fun servers(): List = servers + + override suspend fun availableServers(groupName: String): List = servers.filter { + it.groupName().equals(groupName, true) && it.state == ServerState.AVAILABLE + } + + companion object { + val EMPTY = CloudSnapshot() + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshots.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshots.kt new file mode 100644 index 0000000..e60f40f --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudSnapshots.kt @@ -0,0 +1,6 @@ +package app.simplecloud.npc.core.cloud + +interface CloudSnapshots { + fun peek(): CloudSnapshot + fun requestRefresh() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudUnavailableException.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudUnavailableException.kt new file mode 100644 index 0000000..9a9ce98 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/CloudUnavailableException.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.core.cloud + +import java.util.concurrent.CompletionException +import java.util.concurrent.ExecutionException + +private const val MAX_CAUSES = 16 + +class CloudUnavailableException(cause: Throwable) : + RuntimeException("The SimpleCloud controller is unreachable: ${cause.summary()}", cause) + +private fun Throwable.summary(): String { + val chain = generateSequence(this) { it.cause?.takeIf { cause -> cause !== it } }.take(MAX_CAUSES).toList() + val pick = chain.firstOrNull { throwable -> + val ownMessage = throwable.message?.trim().orEmpty() + val cause = throwable.cause + val wrapper = throwable is CompletionException || + throwable is ExecutionException || + throwable.javaClass == RuntimeException::class.java + val repeatsCause = wrapper && cause != null && ownMessage.endsWith(cause.message.orEmpty()) + ownMessage.isNotEmpty() && !repeatsCause + } ?: chain.last() + val message = pick.message?.lineSequence()?.map(String::trim)?.firstOrNull { it.isNotEmpty() }.orEmpty() + + return message.ifEmpty { pick::class.simpleName ?: "unknown error" } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/JoinStates.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/JoinStates.kt new file mode 100644 index 0000000..0492ad9 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/JoinStates.kt @@ -0,0 +1,92 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.config.NpcConfig +import java.util.concurrent.ConcurrentHashMap + +object JoinStateResolver { + const val OFFLINE = "offline" + + private const val OFFLINE_GRACE_MILLIS = 5_000L + + private class OfflineStreak(val since: Long, val lastSeen: Long) + + private val offline = ConcurrentHashMap() + + suspend fun of( + config: NpcConfig, + lists: CloudLists = CloudListCache, + now: Long = System.currentTimeMillis(), + ): String { + if (config.targetServers.isEmpty()) return NpcConfig.DEFAULT_JOIN_STATE + val state = try { + resolve(config, snapshotOf(lists)) + } catch (_: CloudUnavailableException) { + NpcConfig.DEFAULT_JOIN_STATE + } + + return settle(config.id, state, now) + } + + fun resolve(config: NpcConfig, snapshot: CloudSnapshot): String { + val bridges = config.targetServers.mapNotNull { ServerBridgeResolver.resolve(it, snapshot) } + bridges.firstNotNullOfOrNull(::property)?.let { return it } + if (!snapshot.known || bridges.isEmpty()) return NpcConfig.DEFAULT_JOIN_STATE + + return availability(bridges.flatMap { serversOf(it, snapshot.servers) }) + } + + fun resolve(target: String, snapshot: CloudSnapshot): String { + val bridge = ServerBridgeResolver.resolve(target, snapshot) ?: return NpcConfig.DEFAULT_JOIN_STATE + property(bridge)?.let { return it } + if (!snapshot.known) return NpcConfig.DEFAULT_JOIN_STATE + + return availability(serversOf(bridge, snapshot.servers)) + } + + private fun property(bridge: ServerBridge): String? { + val value = bridge.properties["join-state"] ?: bridge.properties["joinstate"] + return (value as? String)?.lowercase()?.takeUnless { it == NpcConfig.DEFAULT_JOIN_STATE } + } + + private fun availability(servers: List): String = + if (servers.none { it.state == ServerState.AVAILABLE }) OFFLINE else NpcConfig.DEFAULT_JOIN_STATE + + private fun settle(npcId: String, state: String, now: Long): String { + if (state != OFFLINE) { + offline.remove(npcId) + return state + } + val streak = offline.compute(npcId) { _, previous -> + val since = previous?.takeIf { now - it.lastSeen <= OFFLINE_GRACE_MILLIS }?.since ?: now + OfflineStreak(since, now) + }!! + + return if (now - streak.since >= OFFLINE_GRACE_MILLIS) OFFLINE else NpcConfig.DEFAULT_JOIN_STATE + } + + private suspend fun snapshotOf(lists: CloudLists): CloudSnapshot = + lists as? CloudSnapshot + ?: CloudSnapshot(lists.groups(), lists.persistentServers(), lists.servers(), known = true) +} + +object JoinStates { + private val WELL_KNOWN = listOf( + NpcConfig.DEFAULT_JOIN_STATE, + "maintenance", + JoinStateResolver.OFFLINE, + ) + + fun offered(config: NpcConfig, liveStates: Collection = emptyList()): List { + val layoutStates = config.hologram.layouts.map { it.joinState } + val actionStates = config.actions.map { it.joinState } + + return (WELL_KNOWN + liveStates + layoutStates + actionStates + config.joinStates) + .map { it.lowercase() } + .distinct() + } + + fun live(config: NpcConfig, snapshot: CloudSnapshot): List = + config.targetServers.map { target -> JoinStateResolver.resolve(target, snapshot) }.distinct() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/ServerBridge.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/ServerBridge.kt new file mode 100644 index 0000000..56e220a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/ServerBridge.kt @@ -0,0 +1,50 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server + +sealed interface ServerBridge { + val name: String + val properties: Map + + data class OfGroup(val group: Group) : ServerBridge { + override val name: String get() = group.name + override val properties: Map get() = group.properties + } + + data class OfPersistentServer(val persistentServer: PersistentServer) : ServerBridge { + override val name: String get() = persistentServer.name + override val properties: Map get() = persistentServer.properties + } + + data class OfServer(val server: Server) : ServerBridge { + override val name: String get() = server.connectName() ?: server.serverId + override val properties: Map get() = server.properties + } +} + +object ServerBridgeResolver { + private val SERVER_NAME = Regex("^(.+)-(\\d+)$") + + fun resolve(name: String, snapshot: CloudSnapshot): ServerBridge? = + when (val resolution = TargetResolver.resolve(name, snapshot, allowProxy = true)) { + is TargetResolution.Found -> resolution.target.bridge + TargetResolution.Ambiguous -> null + TargetResolution.NotFound -> serverByName(name, snapshot.servers)?.let(ServerBridge::OfServer) + } + + suspend fun resolve(name: String, lists: CloudLists = CloudListCache): ServerBridge? = + when (val resolution = TargetResolver.resolve(name, lists, allowProxy = true)) { + is TargetResolution.Found -> resolution.target.bridge + TargetResolution.Ambiguous -> null + TargetResolution.NotFound -> serverByName(name, lists.servers())?.let(ServerBridge::OfServer) + } + + private fun serverByName(name: String, servers: List): Server? { + val (groupName, id) = SERVER_NAME.matchEntire(name)?.destructured ?: return null + val numericalId = id.toIntOrNull() ?: return null + + return servers.firstOrNull { it.groupName().equals(groupName, true) && it.numericalId == numericalId } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/cloud/TargetResolver.kt b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/TargetResolver.kt new file mode 100644 index 0000000..e3f9dad --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/cloud/TargetResolver.kt @@ -0,0 +1,83 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.config.JoinStrategy + +enum class TargetType { + GROUP, + PERSISTENT_SERVER, +} + +data class ResolvedTarget( + val name: String, + val type: TargetType, + val bridge: ServerBridge, +) + +sealed interface TargetResolution { + data class Found(val target: ResolvedTarget) : TargetResolution + data object NotFound : TargetResolution + data object Ambiguous : TargetResolution +} + +object TargetResolver { + fun resolve( + name: String, + snapshot: CloudSnapshot, + allowProxy: Boolean = false, + ): TargetResolution { + val groups = snapshot.groups.filter { it.name.equals(name, true) && (allowProxy || !it.isProxy()) } + val persistentServers = + snapshot.persistentServers.filter { it.name.equals(name, true) && (allowProxy || !it.isProxy()) } + + return when { + groups.isNotEmpty() && persistentServers.isNotEmpty() -> TargetResolution.Ambiguous + groups.isNotEmpty() -> groups.first().let { + TargetResolution.Found(ResolvedTarget(it.name, TargetType.GROUP, ServerBridge.OfGroup(it))) + } + + persistentServers.isNotEmpty() -> persistentServers.first().let { + TargetResolution.Found( + ResolvedTarget(it.name, TargetType.PERSISTENT_SERVER, ServerBridge.OfPersistentServer(it)), + ) + } + + else -> TargetResolution.NotFound + } + } + + suspend fun resolve( + name: String, + lists: CloudLists = CloudListCache, + allowProxy: Boolean = false, + ): TargetResolution = resolve( + name, + lists as? CloudSnapshot ?: CloudSnapshot(lists.groups(), lists.persistentServers(), known = true), + allowProxy, + ) + + suspend fun findDestination( + targetName: String, + lists: CloudLists = CloudListCache, + strategy: JoinStrategy = JoinStrategy.LEAST_PLAYERS, + ): String? { + val target = (resolve(targetName, lists) as? TargetResolution.Found)?.target ?: return null + + return when (target.type) { + TargetType.PERSISTENT_SERVER -> target.name.takeIf { + serversOf(target.bridge, lists.servers()).any { it.state == ServerState.AVAILABLE } + } + TargetType.GROUP -> { + val available = lists.availableServers(target.name) + pick(available.filterNot(Server::isFull).ifEmpty { available }, strategy)?.connectName() + } + } + } + + private fun pick(servers: List, strategy: JoinStrategy): Server? = when (strategy) { + JoinStrategy.LEAST_PLAYERS -> servers.minByOrNull { it.playerCount ?: 0 } + JoinStrategy.MOST_PLAYERS -> servers.maxByOrNull { it.playerCount ?: 0 } + JoinStrategy.RANDOM -> servers.randomOrNull() + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/ActionFields.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/ActionFields.kt new file mode 100644 index 0000000..a3f01f3 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/ActionFields.kt @@ -0,0 +1,26 @@ +package app.simplecloud.npc.core.config + +object ActionFields { + const val JOIN_TARGET = "join-target" + const val OPEN_INVENTORY = "open-inventory" + const val PLAY_SOUND = "play-sound" + const val EXECUTE_COMMAND = "execute-command" + const val SEND_MESSAGE = "send-message" + const val TELEPORT = "teleport" + const val SEND_TITLE = "send-title" + const val SEND_TO_SERVER = "send-to-server" + const val TRANSFER_TO_SERVER = "transfer-to-server" + const val ACTION_BAR = "action-bar" + const val CLOSE_MENU = "close-menu" + const val PREVIOUS_MENU = "previous-menu" + const val NPC_ANIMATION = "npc-animation" + const val SPEECH = "speech" + + val NAMES: List = listOf( + JOIN_TARGET, OPEN_INVENTORY, PLAY_SOUND, EXECUTE_COMMAND, SEND_MESSAGE, + TELEPORT, SEND_TITLE, ACTION_BAR, SEND_TO_SERVER, TRANSFER_TO_SERVER, + ) + + val MENU_ONLY: List = listOf(CLOSE_MENU, PREVIOUS_MENU) + val NPC_ONLY: List = listOf(NPC_ANIMATION, SPEECH) +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/ConfigIds.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/ConfigIds.kt new file mode 100644 index 0000000..62c3e41 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/ConfigIds.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.core.config + +object ConfigIds { + private val PATTERN = Regex("^[A-Za-z0-9_-]{1,64}$") + + const val DESCRIPTION = "Letters, digits, - and _ only, up to 64 characters" + + fun isValid(id: String): Boolean = PATTERN.matches(id) +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/EquipmentSlot.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/EquipmentSlot.kt new file mode 100644 index 0000000..425d7c4 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/EquipmentSlot.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.core.config + +enum class EquipmentSlot { + MAIN_HAND, + OFF_HAND, + HELMET, + CHESTPLATE, + LEGGINGS, + BOOTS, + ; + + companion object { + private val HEAD_SUFFIXES = listOf("_HELMET", "_HEAD", "_SKULL", "CARVED_PUMPKIN", "TURTLE_HELMET") + private val CHEST_SUFFIXES = listOf("_CHESTPLATE", "ELYTRA") + + fun suitedFor(material: String): EquipmentSlot { + val name = material.uppercase() + + return when { + HEAD_SUFFIXES.any(name::endsWith) -> HELMET + CHEST_SUFFIXES.any(name::endsWith) -> CHESTPLATE + name.endsWith("_LEGGINGS") -> LEGGINGS + name.endsWith("_BOOTS") -> BOOTS + name == "SHIELD" -> OFF_HAND + else -> MAIN_HAND + } + } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/GlowColors.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/GlowColors.kt new file mode 100644 index 0000000..d36e2e7 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/GlowColors.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.core.config + +import net.kyori.adventure.text.format.NamedTextColor + +object GlowColors { + val NAMES: List = listOf( + "black", "dark_blue", "dark_green", "dark_aqua", "dark_red", "dark_purple", + "gold", "gray", "dark_gray", "blue", "green", "aqua", "red", "light_purple", + "yellow", "white", + ) + + fun resolve(name: String?): NamedTextColor? = name?.let { NamedTextColor.NAMES.value(it.lowercase()) } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/JoinStrategy.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/JoinStrategy.kt new file mode 100644 index 0000000..60792f7 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/JoinStrategy.kt @@ -0,0 +1,7 @@ +package app.simplecloud.npc.core.config + +enum class JoinStrategy { + LEAST_PLAYERS, + MOST_PLAYERS, + RANDOM, +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcAnimation.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcAnimation.kt new file mode 100644 index 0000000..d7b03a7 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcAnimation.kt @@ -0,0 +1,7 @@ +package app.simplecloud.npc.core.config + +enum class NpcAnimation { + SWING_MAIN_HAND, + SWING_OFF_HAND, + CROUCH, +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcConfig.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcConfig.kt new file mode 100644 index 0000000..652a488 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcConfig.kt @@ -0,0 +1,335 @@ +package app.simplecloud.npc.core.config + +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.render.Providers +import app.simplecloud.plugin.api.shared.config.VersionedConfig +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import org.spongepowered.configurate.objectmapping.meta.Setting + +@ConfigSerializable +data class NpcConfig( + override val version: Int = CURRENT_VERSION, + val id: String = "", + val entity: NpcEntityConfiguration = NpcEntityConfiguration(), + @Setting("target-servers") + val targetServers: MutableList = mutableListOf(), + @Setting("join-strategy") + val joinStrategy: JoinStrategy = JoinStrategy.LEAST_PLAYERS, + val hologram: HologramConfigurationRoot = HologramConfigurationRoot(), + val pushback: PushbackConfiguration = PushbackConfiguration(), + val actions: MutableList = mutableListOf(), + @Setting("join-states") + val joinStates: MutableList = mutableListOf(), +) : VersionedConfig { + + @ConfigSerializable + data class NpcEntityConfiguration( + val location: NpcLocation = NpcLocation(), + val skin: SkinConfiguration = SkinConfiguration(), + @Setting("look-at-player") + val lookAtPlayer: Boolean = true, + @Setting("look-at-player-distance") + val lookAtPlayerDistance: Double = 10.0, + @Setting("view-distance") + val viewDistance: Double = 48.0, + val glowing: Boolean = false, + @Setting("glow-color") + val glowColor: String? = null, + @Setting("custom-name") + val customName: String? = null, + val equipment: EquipmentConfiguration = EquipmentConfiguration(), + val pose: NpcPose = NpcPose.STANDING, + val scale: Double = 1.0, + val provider: String = Providers.STANDALONE, + @Setting("provider-reference") + val providerReference: String? = null, + @Setting("provider-linked") + val providerLinked: Boolean = false, + @Setting("needs-relocation") + val needsRelocation: Boolean = false, + @Setting("dormant-provider-references") + val dormantProviderReferences: Map = emptyMap(), + ) { + fun effectiveScale(): Double = scale.coerceIn(SCALE_RANGE) + + fun heightFactor(): Double = effectiveScale() * pose.heightFactor + + companion object { + val LOOK_AT_PLAYER_DISTANCE_RANGE = 0.5..32.0 + val VIEW_DISTANCE_RANGE = 16.0..128.0 + val SCALE_RANGE = 0.25..4.0 + } + } + + @ConfigSerializable + data class EquipmentConfiguration( + @Setting("main-hand") + val mainHand: EquipmentItem? = null, + @Setting("off-hand") + val offHand: EquipmentItem? = null, + val helmet: EquipmentItem? = null, + val chestplate: EquipmentItem? = null, + val leggings: EquipmentItem? = null, + val boots: EquipmentItem? = null, + ) { + fun of(slot: EquipmentSlot): EquipmentItem? = when (slot) { + EquipmentSlot.MAIN_HAND -> mainHand + EquipmentSlot.OFF_HAND -> offHand + EquipmentSlot.HELMET -> helmet + EquipmentSlot.CHESTPLATE -> chestplate + EquipmentSlot.LEGGINGS -> leggings + EquipmentSlot.BOOTS -> boots + } + + fun with(slot: EquipmentSlot, item: EquipmentItem?): EquipmentConfiguration = when (slot) { + EquipmentSlot.MAIN_HAND -> copy(mainHand = item) + EquipmentSlot.OFF_HAND -> copy(offHand = item) + EquipmentSlot.HELMET -> copy(helmet = item) + EquipmentSlot.CHESTPLATE -> copy(chestplate = item) + EquipmentSlot.LEGGINGS -> copy(leggings = item) + EquipmentSlot.BOOTS -> copy(boots = item) + } + + fun filled(): Map = + EquipmentSlot.entries.mapNotNull { slot -> of(slot)?.let { slot to it } }.toMap() + } + + @ConfigSerializable + data class EquipmentItem( + val material: String = "STONE", + val glowing: Boolean = false, + @Setting("head-texture") + val headTexture: String? = null, + val color: String? = null, + val trim: ArmorTrim? = null, + @Setting("custom-model-data") + val customModelData: Int? = null, + ) + + @ConfigSerializable + data class ArmorTrim( + val material: String = "minecraft:iron", + val pattern: String = "minecraft:coast", + ) + + @ConfigSerializable + data class SkinConfiguration( + val texture: String? = null, + val signature: String? = null, + @Setting("source-player") + val sourcePlayer: String? = null, + ) + + @ConfigSerializable + data class HologramConfigurationRoot( + var enabled: Boolean = true, + @Setting("start-height") + var startHeight: Double = 2.073, + val layouts: MutableList = mutableListOf(), + ) { + fun layoutShownFor(joinState: String?): HologramLayout? { + val normalizedState = joinState?.lowercase() ?: DEFAULT_JOIN_STATE + return layouts.firstOrNull { it.joinState.equals(normalizedState, true) } + ?: layouts.firstOrNull { it.joinState.equals(DEFAULT_JOIN_STATE, true) } + } + + fun findLayout(joinState: String): HologramLayout? = + layouts.firstOrNull { it.joinState.equals(joinState, true) } + + fun layoutOrCreate(joinState: String): HologramLayout { + val state = joinState.lowercase() + return findLayout(state) ?: HologramLayout(state).also(layouts::add) + } + + companion object { + val START_HEIGHT_RANGE = 0.0..10.0 + } + } + + @ConfigSerializable + data class HologramLayout( + @Setting("join-state") + var joinState: String = DEFAULT_JOIN_STATE, + val lines: MutableList = mutableListOf(), + ) + + @ConfigSerializable + data class PushbackConfiguration( + var enabled: Boolean = false, + var radius: Double = 1.2, + var strength: Double = 0.8, + var vertical: Double = 0.3, + var sound: String? = null, + @Setting("sound-options") + var soundOptions: SoundOptions = SoundOptions(), + ) { + companion object { + val RADIUS_RANGE = 0.5..20.0 + val STRENGTH_RANGE = 0.0..10.0 + val VERTICAL_RANGE = 0.0..5.0 + } + } + + @ConfigSerializable + data class SoundOptions( + val volume: Double = 1.0, + val pitch: Double = 1.0, + ) { + companion object { + val VOLUME_RANGE = 0.0..10.0 + val PITCH_RANGE = 0.5..2.0 + } + } + + @ConfigSerializable + data class ActionConfiguration( + @Setting("interaction-type") + val interactionType: PlayerInteraction = PlayerInteraction.RIGHT_CLICK, + @Setting("join-state") + var joinState: String = DEFAULT_JOIN_STATE, + @Setting("join-target") + var joinTarget: Boolean = false, + @Setting("open-inventory") + var openInventory: String? = null, + @Setting("play-sound") + var playSound: String? = null, + @Setting("play-sound-options") + var playSoundOptions: SoundOptions = SoundOptions(), + @Setting("execute-command") + var executeCommand: String? = null, + @Setting("send-message") + var sendMessage: String? = null, + var teleport: NpcLocation? = null, + @Setting("send-title") + var sendTitle: TitleConfiguration? = null, + @Setting("send-to-server") + var sendToServer: String? = null, + @Setting("transfer-to-server") + var transferToServer: String? = null, + @Setting("action-bar") + var actionBar: String? = null, + @Setting("close-menu") + var closeMenu: Boolean = false, + @Setting("previous-menu") + var previousMenu: Boolean = false, + @Setting("npc-animation") + var npcAnimation: NpcAnimation? = null, + var speech: String? = null, + var permission: String? = null, + @Setting("deny-message") + var denyMessage: String? = null, + var cooldown: Long = DEFAULT_COOLDOWN_MILLIS, + ) { + fun isEmpty(): Boolean = configuredTypes().isEmpty() && + permission == null && + denyMessage == null && + cooldown == DEFAULT_COOLDOWN_MILLIS + + fun configuredTypes(): List = buildList { + if (joinTarget) add(ActionFields.JOIN_TARGET) + if (openInventory != null) add(ActionFields.OPEN_INVENTORY) + if (playSound != null) add(ActionFields.PLAY_SOUND) + if (executeCommand != null) add(ActionFields.EXECUTE_COMMAND) + if (sendMessage != null) add(ActionFields.SEND_MESSAGE) + if (teleport != null) add(ActionFields.TELEPORT) + if (sendTitle != null) add(ActionFields.SEND_TITLE) + if (sendToServer != null) add(ActionFields.SEND_TO_SERVER) + if (transferToServer != null) add(ActionFields.TRANSFER_TO_SERVER) + if (actionBar != null) add(ActionFields.ACTION_BAR) + if (closeMenu) add(ActionFields.CLOSE_MENU) + if (previousMenu) add(ActionFields.PREVIOUS_MENU) + if (npcAnimation != null) add(ActionFields.NPC_ANIMATION) + if (speech != null) add(ActionFields.SPEECH) + } + + fun clearField(type: String): Boolean { + when (type.lowercase()) { + ActionFields.JOIN_TARGET -> joinTarget = false + ActionFields.OPEN_INVENTORY -> openInventory = null + ActionFields.PLAY_SOUND -> playSound = null + ActionFields.EXECUTE_COMMAND -> executeCommand = null + ActionFields.SEND_MESSAGE -> sendMessage = null + ActionFields.TELEPORT -> teleport = null + ActionFields.SEND_TITLE -> sendTitle = null + ActionFields.SEND_TO_SERVER -> sendToServer = null + ActionFields.TRANSFER_TO_SERVER -> transferToServer = null + ActionFields.ACTION_BAR -> actionBar = null + ActionFields.CLOSE_MENU -> closeMenu = false + ActionFields.PREVIOUS_MENU -> previousMenu = false + ActionFields.NPC_ANIMATION -> npcAnimation = null + ActionFields.SPEECH -> speech = null + else -> return false + } + + return true + } + } + + @ConfigSerializable + data class TitleConfiguration( + val title: String = "", + val subtitle: String = "", + @Setting("fade-in") + val fadeIn: Int = 20, + val stay: Int = 20, + @Setting("fade-out") + val fadeOut: Int = 20, + ) { + companion object { + val FADE_RANGE = 0..100 + val STAY_RANGE = 0..200 + } + } + + fun actionsFor(interaction: PlayerInteraction, joinState: String?): List { + val normalizedState = joinState?.lowercase() ?: DEFAULT_JOIN_STATE + val configured = actions.filter { it.interactionType == interaction && !it.isEmpty() } + val exact = configured.filter { it.joinState.equals(normalizedState, true) } + if (exact.isNotEmpty() || normalizedState.equals(DEFAULT_JOIN_STATE, true)) return exact + + return configured.filter { it.joinState.equals(DEFAULT_JOIN_STATE, true) } + } + + fun findAction(interaction: PlayerInteraction, joinState: String): ActionConfiguration? = + actions.firstOrNull { it.interactionType == interaction && it.joinState.equals(joinState, true) } + + fun actionOrCreate(interaction: PlayerInteraction, joinState: String): ActionConfiguration = + findAction(interaction, joinState) + ?: ActionConfiguration(interactionType = interaction, joinState = joinState.lowercase()).also(actions::add) + + fun dropActionIfEmpty(interaction: PlayerInteraction, joinState: String) { + val action = findAction(interaction, joinState) ?: return + + if (action.isEmpty()) actions.remove(action) + } + + fun normalize(): NpcConfig = apply { + hologram.layouts.forEach { it.joinState = it.joinState.lowercase() } + actions.forEach { it.joinState = it.joinState.lowercase() } + val normalizedJoinStates = joinStates.map { it.lowercase() }.distinct() + joinStates.clear() + joinStates.addAll(normalizedJoinStates) + } + + fun deepCopy(): NpcConfig = copy( + targetServers = targetServers.toMutableList(), + hologram = hologram.copy( + layouts = hologram.layouts.map { + it.copy(lines = it.lines.toMutableList()) + }.toMutableList(), + ), + pushback = pushback.copy(), + actions = actions.map { it.copy() }.toMutableList(), + joinStates = joinStates.toMutableList(), + ) + + companion object { + const val CURRENT_VERSION = 6 + const val DEFAULT_JOIN_STATE = "public" + const val DEFAULT_COOLDOWN_MILLIS = 1_000L + const val MAX_TARGETS = 8 + const val MAX_JOIN_STATES = 8 + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcPose.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcPose.kt new file mode 100644 index 0000000..d2c9314 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/NpcPose.kt @@ -0,0 +1,8 @@ +package app.simplecloud.npc.core.config + +enum class NpcPose(val heightFactor: Double) { + STANDING(1.0), + SNEAKING(0.83), + SWIMMING(0.33), + SLEEPING(0.33), +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/ConfigBackups.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/ConfigBackups.kt new file mode 100644 index 0000000..58d600a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/ConfigBackups.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.core.config.migration + +import org.spongepowered.configurate.yaml.YamlConfigurationLoader +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +object ConfigBackups { + fun backupOutdated(directory: Path, currentVersion: Int, fallbackVersion: Int) { + if (!directory.exists()) return + val backups = directory.resolve("backups") + directory.listDirectoryEntries() + .filter { it.isRegularFile() && it.isYaml() } + .forEach { path -> + val version = runCatching { + YamlConfigurationLoader.builder().path(path).build().load().node("version").getInt(fallbackVersion) + }.getOrDefault(fallbackVersion) + if (version >= currentVersion) return@forEach + + val backup = backups.resolve("${path.fileName}.v$version.bak") + if (backup.exists()) return@forEach + backups.createDirectories() + path.copyTo(backup) + } + } + + private fun Path.isYaml(): Boolean = name.lowercase().endsWith(".yml") +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigration.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigration.kt new file mode 100644 index 0000000..16ca4d2 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigration.kt @@ -0,0 +1,199 @@ +package app.simplecloud.npc.core.config.migration + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.migration.legacy.LegacyAction +import app.simplecloud.npc.core.config.migration.legacy.LegacyActionOptions +import app.simplecloud.npc.core.config.migration.legacy.LegacyNpcConfigV1 +import app.simplecloud.npc.core.config.migration.legacy.LegacyNpcConfigV4 +import app.simplecloud.npc.core.config.migration.legacy.LegacyNpcConfigV5 +import app.simplecloud.npc.core.config.migration.legacy.LegacyPlayerActionOptions +import app.simplecloud.npc.core.config.migration.legacy.LegacyProviderType +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.plugin.api.shared.config.ConfigMigration +import app.simplecloud.plugin.api.shared.config.ConfigMigrator +import org.spongepowered.configurate.CommentedConfigurationNode +import org.spongepowered.configurate.ConfigurationNode +import java.nio.file.Path + +object NpcConfigMigration { + private const val LEGACY_DEFAULT_JOIN_STATE = "default" + private const val FALLBACK_VERSION = 4 + private val CURRENT_LAYOUT_KEYS = listOf("target-servers", "entity") + + fun migrator(): ConfigMigrator = ConfigMigrator.builder(NpcConfig.CURRENT_VERSION) + .fallbackVersion(FALLBACK_VERSION) + .migrate(1, NpcConfig.CURRENT_VERSION, ConfigMigration(::migrateVersionOne)) + .migrate(2, NpcConfig.CURRENT_VERSION, ConfigMigration(::migrateLegacy)) + .migrate(3, NpcConfig.CURRENT_VERSION, ConfigMigration(::migrateLegacy)) + .migrate(4, NpcConfig.CURRENT_VERSION, ConfigMigration(::migrateLegacy)) + .migrate(5, NpcConfig.CURRENT_VERSION, ConfigMigration(::migrateVersionFive)) + .build() + + fun stampCurrentLayout(node: ConfigurationNode) { + val version = node.node("version") + if (!version.virtual()) return + if (CURRENT_LAYOUT_KEYS.any { !node.node(it).virtual() }) version.set(NpcConfig.CURRENT_VERSION) + } + + fun backupOutdated(directory: Path) = + ConfigBackups.backupOutdated(directory, NpcConfig.CURRENT_VERSION, FALLBACK_VERSION) + + private fun migrateVersionFive(node: CommentedConfigurationNode) { + val legacy = node.get(LegacyNpcConfigV5::class.java) ?: return + legacy.hologram.layouts.forEach { it.joinState = migrateJoinState(it.joinState) } + legacy.actions.forEach { it.joinState = migrateJoinState(it.joinState) } + + val linked = legacy.provider.ownership.equals("LINKED", true) || + legacy.provider.type == LegacyProviderType.MYTHIC_MOBS + val migrated = NpcConfig( + id = legacy.id, + entity = NpcConfig.NpcEntityConfiguration( + provider = legacy.provider.type.commandName, + providerReference = legacy.provider.reference.takeIf(String::isNotBlank), + providerLinked = linked, + needsRelocation = !linked, + ), + targetServers = legacy.targetServers, + hologram = legacy.hologram, + pushback = legacy.pushback, + actions = legacy.actions, + ) + + replaceWith(node, migrated) + } + + private fun migrateLegacy(node: CommentedConfigurationNode) { + val legacy = node.get(LegacyNpcConfigV4::class.java) ?: return + + val target = sequenceOf( + legacy.hologramConfiguration.placeholderServerBaseName, + legacy.hologramConfiguration.placeholderName, + legacy.hologramConfiguration.placeholderGroupName, + legacy.options[LegacyActionOptions.GROUP_NAME], + legacy.options[LegacyActionOptions.CONNECT_TO_SERVER_NAME], + ).filterNotNull().firstOrNull(String::isNotBlank) + + val layouts = legacy.hologramConfiguration.holograms.map { old -> + NpcConfig.HologramLayout( + joinState = migrateJoinState(old.joinState), + lines = old.lores.toMutableList(), + ) + }.toMutableList() + + val actions = legacy.actions.map { old -> + val options = legacy.options + old.options + val oldAction = old.action.uppercase() + if (oldAction == LegacyAction.RUN_CONSOLE_COMMAND.name) { + NpcLog.logger.warning( + "NPC '${legacy.id}': the console command '${options[LegacyActionOptions.EXECUTE_COMMAND_NAME]}' " + + "now runs as the clicking player, console commands are no longer supported. " + + "Check that players may run it, or remove the action.", + ) + } + + NpcConfig.ActionConfiguration( + interactionType = old.playerInteraction, + joinState = NpcConfig.DEFAULT_JOIN_STATE, + joinTarget = oldAction == LegacyAction.QUICK_JOIN.name, + openInventory = if (oldAction == LegacyAction.OPEN_INVENTORY.name) { + options[LegacyActionOptions.INVENTORY_NAME]?.takeIf(String::isNotBlank) + } else null, + playSound = options[LegacyPlayerActionOptions.PLAY_SOUND]?.takeIf(String::isNotBlank), + executeCommand = when (oldAction) { + LegacyAction.RUN_COMMAND.name, LegacyAction.RUN_CONSOLE_COMMAND.name -> + options[LegacyActionOptions.EXECUTE_COMMAND_NAME] + + else -> null + }?.takeIf(String::isNotBlank), + sendMessage = options[LegacyPlayerActionOptions.SEND_MESSAGE]?.takeIf(String::isNotBlank), + sendTitle = options[LegacyPlayerActionOptions.SEND_TITLE]?.takeIf(String::isNotBlank) + ?.let { title -> + NpcConfig.TitleConfiguration(title, options[LegacyPlayerActionOptions.SEND_SUBTITLE].orEmpty()) + }, + sendToServer = if (oldAction == LegacyAction.CONNECT_TO_SERVER.name) { + options[LegacyActionOptions.CONNECT_TO_SERVER_NAME]?.takeIf(String::isNotBlank) + } else null, + transferToServer = if (oldAction == LegacyAction.TRANSFER_TO_SERVER.name) { + options[LegacyActionOptions.TRANSFER_SERVER_IP]?.takeIf(String::isNotBlank)?.let { ip -> + val port = options[LegacyActionOptions.TRANSFER_SERVER_PORT]?.takeIf(String::isNotBlank) + ?: LegacyActionOptions.TRANSFER_SERVER_DEFAULT_PORT + "$ip:$port" + } + } else null, + ) + }.toMutableList() + + val migrated = NpcConfig( + id = legacy.id, + entity = NpcConfig.NpcEntityConfiguration(needsRelocation = true), + targetServers = listOfNotNull(target).toMutableList(), + hologram = NpcConfig.HologramConfigurationRoot( + enabled = layouts.isNotEmpty(), + startHeight = legacy.hologramConfiguration.holograms.firstOrNull()?.startHeight ?: 2.073, + layouts = layouts, + ), + actions = actions, + ) + + replaceWith(node, migrated) + } + + private fun migrateVersionOne(node: CommentedConfigurationNode) { + val old = node.get(LegacyNpcConfigV1::class.java) ?: return + val providerType = LegacyProviderType.getOrNull(old.provider) ?: LegacyProviderType.CITIZENS + val migrated = NpcConfig( + id = old.id, + entity = NpcConfig.NpcEntityConfiguration( + provider = providerType.commandName, + providerReference = old.id, + needsRelocation = true, + ), + targetServers = old.targetServers.toMutableList(), + hologram = NpcConfig.HologramConfigurationRoot( + old.hologram.enabled, + old.hologram.startHeight, + old.hologram.layouts.map { + NpcConfig.HologramLayout( + migrateJoinState(it.joinState), + mutableListOf(HologramLine(it.text)), + ) + }.toMutableList(), + ), + pushback = with(old.pushback) { + NpcConfig.PushbackConfiguration(enabled, radius, strength, vertical, playSound) + }, + actions = old.actions.map { action -> + NpcConfig.ActionConfiguration( + interactionType = action.interactionType, + joinState = migrateJoinState(action.joinState), + openInventory = action.openInventory, + playSound = action.playSound, + executeCommand = action.executeCommand, + sendMessage = action.sendMessage, + teleport = action.teleport?.takeIf { it.enabled }?.let { + NpcLocation(it.world, it.x, it.y, it.z, it.yaw, it.pitch) + }, + sendTitle = action.sendTitle?.takeIf { it.enabled }?.let { + NpcConfig.TitleConfiguration(it.title, it.text, it.fadeIn, it.stay, it.fadeOut) + }, + sendToServer = action.sendToServer, + transferToServer = action.transferToServer, + ) + }.toMutableList(), + ) + + replaceWith(node, migrated) + } + + private fun migrateJoinState(joinState: String): String { + val normalized = joinState.lowercase().ifBlank { NpcConfig.DEFAULT_JOIN_STATE } + return if (normalized == LEGACY_DEFAULT_JOIN_STATE) NpcConfig.DEFAULT_JOIN_STATE else normalized + } + + private fun replaceWith(node: CommentedConfigurationNode, migrated: NpcConfig) { + node.childrenMap().keys.toList().forEach(node::removeChild) + node.set(NpcConfig::class.java, migrated) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyAction.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyAction.kt new file mode 100644 index 0000000..820ad67 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyAction.kt @@ -0,0 +1,42 @@ +package app.simplecloud.npc.core.config.migration.legacy + +import app.simplecloud.npc.core.render.Providers + +enum class LegacyAction { + RUN_COMMAND, + RUN_CONSOLE_COMMAND, + CONNECT_TO_SERVER, + TRANSFER_TO_SERVER, + QUICK_JOIN, + OPEN_INVENTORY, +} + +object LegacyActionOptions { + const val EXECUTE_COMMAND_NAME = "command.name" + const val CONNECT_TO_SERVER_NAME = "server.name" + const val TRANSFER_SERVER_IP = "server.ip" + const val TRANSFER_SERVER_PORT = "server.port" + const val TRANSFER_SERVER_DEFAULT_PORT = "25565" + const val GROUP_NAME = "group.name" + const val INVENTORY_NAME = "inventory.name" +} + +object LegacyPlayerActionOptions { + const val SEND_MESSAGE = "send.message" + const val SEND_TITLE = "send.title" + const val SEND_SUBTITLE = "send.subtitle" + const val PLAY_SOUND = "play.sound" +} + +enum class LegacyProviderType(val commandName: String) { + CITIZENS(Providers.CITIZENS), + FANCY_NPCS(Providers.FANCYNPCS), + MYTHIC_MOBS(Providers.MYTHICMOBS), + ZNPCS_PLUS(Providers.ZNPCSPLUS); + + companion object { + fun getOrNull(value: String): LegacyProviderType? = entries.firstOrNull { + it.commandName.equals(value, true) || it.name.equals(value, true) + } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV1.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV1.kt new file mode 100644 index 0000000..aa434ce --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV1.kt @@ -0,0 +1,88 @@ +package app.simplecloud.npc.core.config.migration.legacy + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import org.spongepowered.configurate.objectmapping.meta.Setting + +@ConfigSerializable +data class LegacyNpcConfigV1( + val id: String = "", + val provider: String = "CITIZENS", + @Setting("target-servers") + val targetServers: List = emptyList(), + val hologram: LegacyHologramV1 = LegacyHologramV1(), + val pushback: LegacyPushbackV1 = LegacyPushbackV1(), + val actions: List = emptyList(), +) + +@ConfigSerializable +data class LegacyHologramV1( + val enabled: Boolean = true, + @Setting("start-height") + val startHeight: Double = 2.073, + val layouts: List = emptyList(), +) + +@ConfigSerializable +data class LegacyLayoutV1( + @Setting("joinstate") + val joinState: String = NpcConfig.DEFAULT_JOIN_STATE, + val text: String = "", +) + +@ConfigSerializable +data class LegacyPushbackV1( + val enabled: Boolean = false, + val radius: Double = 2.5, + val strength: Double = 1.2, + val vertical: Double = 0.3, + @Setting("playSound") + val playSound: String? = null, +) + +@ConfigSerializable +data class LegacyActionV1( + @Setting("interaction-type") + val interactionType: PlayerInteraction = PlayerInteraction.RIGHT_CLICK, + @Setting("joinstate") + val joinState: String = NpcConfig.DEFAULT_JOIN_STATE, + @Setting("openInventory") + val openInventory: String? = null, + @Setting("playSound") + val playSound: String? = null, + @Setting("executeCommand") + val executeCommand: String? = null, + @Setting("sendMessage") + val sendMessage: String? = null, + val teleport: LegacyTeleportV1? = null, + @Setting("sendTitle") + val sendTitle: LegacyTitleV1? = null, + @Setting("sendToServer") + val sendToServer: String? = null, + @Setting("transferToServer") + val transferToServer: String? = null, +) + +@ConfigSerializable +data class LegacyTeleportV1( + val enabled: Boolean = false, + val world: String = "world", + val x: Double = 0.0, + val y: Double = 0.0, + val z: Double = 0.0, + val yaw: Float = 0F, + val pitch: Float = 0F, +) + +@ConfigSerializable +data class LegacyTitleV1( + val enabled: Boolean = false, + val title: String = "", + val text: String = "", + @Setting("fadeIn") + val fadeIn: Int = 20, + val stay: Int = 20, + @Setting("fadeOut") + val fadeOut: Int = 20, +) diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV4.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV4.kt new file mode 100644 index 0000000..85dfea9 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV4.kt @@ -0,0 +1,35 @@ +package app.simplecloud.npc.core.config.migration.legacy + +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.interaction.PlayerInteraction +import org.spongepowered.configurate.objectmapping.ConfigSerializable + +@ConfigSerializable +data class LegacyNpcConfigV4( + val id: String = "", + val hologramConfiguration: LegacyHologramRootV4 = LegacyHologramRootV4(), + val actions: MutableList = mutableListOf(), + val options: HashMap = hashMapOf(), +) + +@ConfigSerializable +data class LegacyInteractionV4( + val playerInteraction: PlayerInteraction = PlayerInteraction.LEFT_CLICK, + val action: String = LegacyAction.RUN_COMMAND.name, + val options: HashMap = hashMapOf(), +) + +@ConfigSerializable +data class LegacyHologramRootV4( + val placeholderServerBaseName: String = "", + val placeholderName: String = "", + val placeholderGroupName: String = "", + val holograms: List = emptyList(), +) + +@ConfigSerializable +data class LegacyHologramV4( + val startHeight: Double = 2.073, + val joinState: String = "", + val lores: List = emptyList(), +) diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV5.kt b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV5.kt new file mode 100644 index 0000000..063f05d --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/config/migration/legacy/LegacyConfigV5.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.core.config.migration.legacy + +import app.simplecloud.npc.core.config.NpcConfig +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import org.spongepowered.configurate.objectmapping.meta.Setting + +@ConfigSerializable +data class LegacyNpcConfigV5( + val id: String = "", + val provider: LegacyProviderConfigurationV5 = LegacyProviderConfigurationV5(), + @Setting("target-servers") + val targetServers: MutableList = mutableListOf(), + val hologram: NpcConfig.HologramConfigurationRoot = NpcConfig.HologramConfigurationRoot(), + val pushback: NpcConfig.PushbackConfiguration = NpcConfig.PushbackConfiguration(), + val actions: MutableList = mutableListOf(), +) + +@ConfigSerializable +data class LegacyProviderConfigurationV5( + val type: LegacyProviderType = LegacyProviderType.CITIZENS, + val reference: String = "", + val ownership: String = "LINKED", +) diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLine.kt b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLine.kt new file mode 100644 index 0000000..8730412 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLine.kt @@ -0,0 +1,59 @@ +package app.simplecloud.npc.core.hologram + +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import org.spongepowered.configurate.objectmapping.meta.Setting + +@ConfigSerializable +data class HologramLine( + val text: String = "", + val billboard: HologramBillboard = HologramBillboard.CENTER, + val viewRange: Float? = null, + val shadowRadius: Float? = null, + val displayHeight: Float? = null, + val displayWidth: Float? = null, + val alignment: HologramAlignment = HologramAlignment.CENTER, + val lineWidth: Int? = null, + val shadow: Boolean? = null, + val frames: List? = null, + @Setting("frame-interval") + val frameInterval: Double? = null, + val refresh: Double? = null, +) { + val texts: List get() = frames?.takeIf { it.isNotEmpty() } ?: listOf(text) + + val rotating: Boolean get() = texts.size > 1 + + val shownText: String get() = texts.first() + + val interval: Double get() = (frameInterval ?: DEFAULT_FRAME_INTERVAL).coerceIn(FRAME_INTERVAL_RANGE) + + val refreshSeconds: Double get() = (refresh ?: DEFAULT_REFRESH).coerceIn(REFRESH_RANGE) + + fun frameIndex(millis: Long): Int { + if (!rotating) return 0 + + return ((millis / (interval * 1000).toLong()) % texts.size).toInt() + } + + fun textAt(millis: Long): String = texts[frameIndex(millis)] + + fun withFrames(updated: List): HologramLine = when { + updated.size > 1 -> copy(text = updated.first(), frames = updated) + else -> copy(text = updated.firstOrNull() ?: text, frames = null) + } + + companion object { + const val DEFAULT_FRAME_INTERVAL = 3.0 + val FRAME_INTERVAL_RANGE = 0.5..60.0 + const val DEFAULT_REFRESH = 1.0 + val REFRESH_RANGE = 0.25..60.0 + } +} + +enum class HologramBillboard { + FIXED, VERTICAL, HORIZONTAL, CENTER, +} + +enum class HologramAlignment { + CENTER, LEFT, RIGHT, +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLineResolver.kt b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLineResolver.kt new file mode 100644 index 0000000..7232a4a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/HologramLineResolver.kt @@ -0,0 +1,63 @@ +package app.simplecloud.npc.core.hologram + +import app.simplecloud.npc.core.cloud.BridgePlaceholders +import app.simplecloud.npc.core.cloud.CloudOutageLog +import app.simplecloud.npc.core.cloud.CloudUnavailableException +import app.simplecloud.npc.core.cloud.JoinStateResolver +import app.simplecloud.npc.core.cloud.ServerBridgeResolver +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.text.PlayerPlaceholders +import kotlinx.coroutines.CancellationException +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver + +object HologramLineResolver { + + private val miniMessage = MiniMessage.miniMessage() + + class ResolvedLine(val line: HologramLine, val text: Component, val personal: PersonalText?) + + sealed interface Result { + class Lines(val lines: List) : Result + + data object None : Result + data object Unavailable : Result + } + + suspend fun resolve(config: NpcConfig): Result { + if (!config.hologram.enabled) return Result.None + if (config.targetServers.isEmpty()) return Result.None + + val bridge = try { + config.targetServers.firstNotNullOfOrNull { ServerBridgeResolver.resolve(it) } ?: return Result.None + } catch (_: CloudUnavailableException) { + return Result.Unavailable + } + + val layout = config.hologram.layoutShownFor(JoinStateResolver.of(config)) ?: return Result.None + + val now = System.currentTimeMillis() + + return try { + val resolver = BridgePlaceholders.resolver(bridge) + Result.Lines(layout.lines.map { line -> resolveLine(line, line.textAt(now), resolver) }) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + val reason = if (exception is CloudUnavailableException) exception.message else exception.toString() + CloudOutageLog.warnThrottled( + "Hologram of NPC '${config.id}' not rendered: $reason", + key = "hologram:${config.id}", + ) + Result.Unavailable + } + } + + private fun resolveLine(line: HologramLine, text: String, resolver: TagResolver): ResolvedLine { + if (!PlayerPlaceholders.isPersonal(text)) return ResolvedLine(line, miniMessage.deserialize(text, resolver), null) + val personal = PersonalText.of(text, resolver) + + return ResolvedLine(line, personal.fallback(), personal) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/hologram/PersonalText.kt b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/PersonalText.kt new file mode 100644 index 0000000..fe3da4d --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/hologram/PersonalText.kt @@ -0,0 +1,51 @@ +package app.simplecloud.npc.core.hologram + +import app.simplecloud.npc.core.text.PlayerPlaceholders +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver +import java.security.SecureRandom +import java.util.UUID + +class PersonalText private constructor( + val template: String, + private val values: Map, +) { + val external: Boolean = PlayerPlaceholders.usesExternal(template) + + private val markers: TagResolver = + TagResolver.resolver(values.map { (marker, value) -> Placeholder.component(marker, value) }) + + fun resolve(uniqueId: UUID, name: String): String = PlayerPlaceholders.resolve(template, uniqueId, name) + + fun render(resolved: String): Component = MINI_MESSAGE.deserialize(resolved, markers) + + fun fallback(): Component = render(PlayerPlaceholders.strip(template)) + + override fun equals(other: Any?): Boolean = + other is PersonalText && other.template == template && other.values == values + + override fun hashCode(): Int = 31 * template.hashCode() + values.hashCode() + + companion object { + private val MINI_MESSAGE = MiniMessage.miniMessage() + private val TARGET_TAG = Regex("(?i)]*)?>") + private val SALT = SecureRandom().let { random -> + (1..8).map { 'a' + random.nextInt(26) }.joinToString("") + } + + fun of(text: String, resolver: TagResolver): PersonalText { + val markerOf = HashMap() + val values = LinkedHashMap() + val template = TARGET_TAG.replace(text) { match -> + val marker = markerOf.getOrPut(match.value) { + "scnpc_${SALT}_${markerOf.size}".also { values[it] = MINI_MESSAGE.deserialize(match.value, resolver) } + } + "<$marker>" + } + + return PersonalText(template, values) + } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/interaction/PlayerInteraction.kt b/core/src/main/kotlin/app/simplecloud/npc/core/interaction/PlayerInteraction.kt new file mode 100644 index 0000000..28b5790 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/interaction/PlayerInteraction.kt @@ -0,0 +1,18 @@ +package app.simplecloud.npc.core.interaction + +enum class PlayerInteraction { + RIGHT_CLICK, + LEFT_CLICK, + SHIFT_RIGHT_CLICK, + SHIFT_LEFT_CLICK; + + fun shifted(): PlayerInteraction = when (this) { + RIGHT_CLICK -> SHIFT_RIGHT_CLICK + LEFT_CLICK -> SHIFT_LEFT_CLICK + SHIFT_RIGHT_CLICK, SHIFT_LEFT_CLICK -> this + } + + companion object { + fun getOrNull(name: String): PlayerInteraction? = entries.firstOrNull { it.name.equals(name, true) } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryConfiguration.kt b/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryConfiguration.kt new file mode 100644 index 0000000..bb41b55 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryConfiguration.kt @@ -0,0 +1,151 @@ +package app.simplecloud.npc.core.inventory + +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.cloud.isFull +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.plugin.api.shared.config.VersionedConfig +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import org.spongepowered.configurate.objectmapping.meta.Setting + +@ConfigSerializable +data class InventoryConfiguration( + override val version: Int = CURRENT_VERSION, + val id: String = "", + val title: String = "<#0ea5e9>Menu", + val rows: Int = 3, + val items: MutableList = mutableListOf(), + @Setting("open-permission") + val openPermission: String? = null, + @Setting("open-sound") + val openSound: String? = null, +) : VersionedConfig { + fun size(): Int = rows.coerceIn(ROWS) * 9 + + fun liveGroups(): List = + items.filter { it.liveGroup != null }.groupBy { it.liveGroup!! }.map { (groupId, members) -> + LiveGroup( + id = groupId, + sourceType = members.firstNotNullOfOrNull { it.live }?.type, + contentSlots = members.count { it.pageControl == null }, + paginated = members.any { it.pageControl != null }, + ) + } + + fun deepCopy(): InventoryConfiguration = copy(items = items.map { it.deepCopy() }.toMutableList()) + + data class LiveGroup(val id: String, val sourceType: String?, val contentSlots: Int, val paginated: Boolean) + + object PageControl { + const val PREVIOUS = "previous" + const val NEXT = "next" + } + + companion object { + const val CURRENT_VERSION = 1 + const val MIN_ROWS = 1 + const val MAX_ROWS = 6 + val ROWS = MIN_ROWS..MAX_ROWS + } + + @ConfigSerializable + data class InventoryItemConfiguration( + val slot: Int = -1, + val material: String = "STONE", + @Setting("custom-model-data") + val customModelData: Int? = null, + val name: String? = null, + val lore: MutableList = mutableListOf(), + val amount: Int = 1, + val glowing: Boolean = false, + @Setting("hide-tooltip") + val hideTooltip: Boolean = false, + val head: HeadConfiguration? = null, + val actions: MutableList = mutableListOf(), + @Setting("view-permission") + val viewPermission: String? = null, + + @Setting("live-group") + val liveGroup: String? = null, + val live: SourceConfiguration? = null, + @Setting("when-empty") + val whenEmpty: InventoryItemConfiguration? = null, + @Setting("page-control") + val pageControl: String? = null, + ) { + fun deepCopy(): InventoryItemConfiguration = copy( + lore = lore.toMutableList(), + actions = actions.map { it.copy() }.toMutableList(), + live = live?.deepCopy(), + whenEmpty = whenEmpty?.deepCopy(), + ) + } + + @ConfigSerializable + data class HeadConfiguration( + val texture: String = "", + val signature: String? = null, + val viewer: Boolean? = null, + val owner: String? = null, + ) { + val showsViewer: Boolean get() = viewer == true + } + + @ConfigSerializable + data class SourceConfiguration( + val type: String = DEFAULT_TYPE, + val group: String? = null, + val states: MutableList = mutableListOf(), + val sort: String? = null, + val entries: MutableList = mutableListOf(), + val looks: MutableMap = mutableMapOf(), + ) { + fun deepCopy(): SourceConfiguration = copy( + states = states.toMutableList(), + entries = entries.map { it.deepCopy() }.toMutableList(), + looks = looks.mapValues { it.value.copy(lore = it.value.lore?.toMutableList()) }.toMutableMap(), + ) + + fun lookFor(state: String?): StateLook? = + state?.let { wanted -> looks.entries.firstOrNull { it.key.equals(wanted, true) }?.value } + + companion object { + const val DEFAULT_TYPE = "group-servers" + } + } + + @ConfigSerializable + data class StateLook( + val material: String? = null, + val name: String? = null, + val lore: MutableList? = null, + val glowing: Boolean? = null, + ) { + fun isEmpty(): Boolean = material == null && name == null && lore == null && glowing == null + } + + object EntryStates { + const val FULL = "FULL" + const val OFFLINE = "OFFLINE" + + val SERVER_STATES = listOf(ServerState.AVAILABLE.name, FULL, ServerState.STARTING.name, ServerState.INGAME.name) + val TARGET_STATES = listOf(ServerState.AVAILABLE.name, FULL, ServerState.STARTING.name, OFFLINE) + + fun of(servers: List): String { + val joinable = servers.filter { it.state == ServerState.AVAILABLE } + return when { + joinable.isNotEmpty() -> if (joinable.all(Server::isFull)) FULL else ServerState.AVAILABLE.name + servers.any { it.state == ServerState.STARTING || it.state == ServerState.PREPARING } -> + ServerState.STARTING.name + + else -> OFFLINE + } + } + + fun of(server: Server): String? = when { + server.state == ServerState.AVAILABLE && server.isFull() -> FULL + server.state == ServerState.PREPARING -> ServerState.STARTING.name + else -> server.state?.name + } + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryRepository.kt b/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryRepository.kt new file mode 100644 index 0000000..cf86be5 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/inventory/InventoryRepository.kt @@ -0,0 +1,14 @@ +package app.simplecloud.npc.core.inventory + +import app.simplecloud.npc.core.repository.WatchableYamlDirectoryRepository +import java.nio.file.Path + +class InventoryRepository( + directory: Path, +) : WatchableYamlDirectoryRepository( + directory, + InventoryConfiguration::class.java, +) { + override fun idOf(entity: InventoryConfiguration): String = entity.id + override fun detach(entity: InventoryConfiguration): InventoryConfiguration = entity.deepCopy() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/location/NpcLocation.kt b/core/src/main/kotlin/app/simplecloud/npc/core/location/NpcLocation.kt new file mode 100644 index 0000000..d8c1e12 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/location/NpcLocation.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.core.location + +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import kotlin.math.cos +import kotlin.math.sin + +@ConfigSerializable +data class NpcLocation( + val world: String = "world", + val x: Double = 0.0, + val y: Double = 0.0, + val z: Double = 0.0, + val yaw: Float = 0F, + val pitch: Float = 0F, +) { + fun inFrontLookingBack(distance: Double): NpcLocation { + val radians = Math.toRadians(yaw.toDouble()) + return copy( + x = x - sin(radians) * distance, + z = z + cos(radians) * distance, + yaw = ((yaw + 180f) % 360f + 360f) % 360f, + pitch = 0f, + ) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcCommandSender.kt b/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcCommandSender.kt new file mode 100644 index 0000000..7a8dc22 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcCommandSender.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.core.platform + +import net.kyori.adventure.text.Component + +interface NpcCommandSender { + fun sendMessage(component: Component) + fun asPlayer(): NpcPlayer? + fun hasPermission(permission: String): Boolean +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcPlayer.kt b/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcPlayer.kt new file mode 100644 index 0000000..fb5597b --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/platform/NpcPlayer.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.core.platform + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import net.kyori.adventure.text.Component +import java.util.UUID + +interface NpcPlayer : NpcCommandSender { + val uniqueId: UUID + val name: String + + fun location(): NpcLocation + fun performCommand(command: String) + fun playSound(key: String, options: NpcConfig.SoundOptions = NpcConfig.SoundOptions()) + fun showTitle(title: Component, subtitle: Component, fadeInTicks: Int, stayTicks: Int, fadeOutTicks: Int) + fun teleport(location: NpcLocation): Boolean + fun push(from: NpcLocation, strength: Double, vertical: Double) + fun sendToServer(serverName: String) + fun transferToServer(host: String, port: Int) + fun closeInventory() + fun wornArmor(): NpcConfig.EquipmentConfiguration + fun sendActionBar(component: Component) + + override fun asPlayer(): NpcPlayer = this +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/render/HologramRenderer.kt b/core/src/main/kotlin/app/simplecloud/npc/core/render/HologramRenderer.kt new file mode 100644 index 0000000..6874927 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/render/HologramRenderer.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.core.render + +import app.simplecloud.npc.core.config.NpcConfig + +interface HologramRenderer { + fun createOrUpdate(config: NpcConfig) + fun destroy(config: NpcConfig) + fun destroyAll() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/render/NpcRenderer.kt b/core/src/main/kotlin/app/simplecloud/npc/core/render/NpcRenderer.kt new file mode 100644 index 0000000..6ed29ce --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/render/NpcRenderer.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.core.render + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.platform.NpcPlayer +import java.util.UUID + +interface NpcRenderer { + + fun onEnable() + fun onDisable() + + fun isReady(): Boolean = true + + val supportsCreation: Boolean get() = true + val supportsFixedSkin: Boolean get() = false + val supportsLinking: Boolean get() = false + + fun linkableReferences(): List = emptyList() + fun spawn(config: NpcConfig): NpcConfig + fun despawn(config: NpcConfig) + fun refresh(config: NpcConfig) + fun teleport(config: NpcConfig) + fun locationOf(config: NpcConfig): NpcLocation? + fun snapshotOf(config: NpcConfig): ProviderNpcSnapshot? = null + fun canonicalReference(config: NpcConfig): String? = null + fun captureSkin(player: NpcPlayer): NpcConfig.SkinConfiguration = + NpcConfig.SkinConfiguration(sourcePlayer = player.name) + + suspend fun captureSkinByUsername(username: String): NpcConfig.SkinConfiguration? = null + suspend fun captureSkinByUuid(uuid: UUID): NpcConfig.SkinConfiguration? = null +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/render/ProviderNpcSnapshot.kt b/core/src/main/kotlin/app/simplecloud/npc/core/render/ProviderNpcSnapshot.kt new file mode 100644 index 0000000..1f6d578 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/render/ProviderNpcSnapshot.kt @@ -0,0 +1,10 @@ +package app.simplecloud.npc.core.render + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation + +data class ProviderNpcSnapshot( + val location: NpcLocation?, + val skin: NpcConfig.SkinConfiguration? = null, + val customName: String? = null, +) diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/render/Providers.kt b/core/src/main/kotlin/app/simplecloud/npc/core/render/Providers.kt new file mode 100644 index 0000000..16f1bf8 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/render/Providers.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.core.render + +object Providers { + const val STANDALONE = "standalone" + const val MANNEQUIN = "mannequin" + const val CITIZENS = "citizens" + const val FANCYNPCS = "fancynpcs" + const val ZNPCSPLUS = "znpcsplus" + const val MYTHICMOBS = "mythicmobs" + + val OWN: Set = setOf(STANDALONE, MANNEQUIN) + + fun displayName(provider: String): String = when (provider.lowercase()) { + STANDALONE -> "Standalone" + MANNEQUIN -> "Mannequin" + CITIZENS -> "Citizens" + FANCYNPCS -> "FancyNpcs" + ZNPCSPLUS -> "ZNPCsPlus" + MYTHICMOBS -> "MythicMobs" + else -> provider + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/repository/LenientEnumSerializer.kt b/core/src/main/kotlin/app/simplecloud/npc/core/repository/LenientEnumSerializer.kt new file mode 100644 index 0000000..13f6879 --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/repository/LenientEnumSerializer.kt @@ -0,0 +1,30 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.npc.core.NpcLog +import io.leangen.geantyref.GenericTypeReflector +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.serialize.TypeSerializer +import java.lang.reflect.Type + +object LenientEnumSerializer : TypeSerializer> { + + fun handles(type: Type): Boolean = GenericTypeReflector.erase(type).isEnum + + override fun deserialize(type: Type, node: ConfigurationNode): Enum<*>? { + val raw = node.raw()?.toString() ?: return null + val constants = GenericTypeReflector.erase(type).enumConstants.filterIsInstance>() + val key = raw.trim().replace('-', '_') + + return constants.firstOrNull { it.name.equals(key, ignoreCase = true) } ?: run { + NpcLog.logger.warning( + "Unknown value '$raw' at '${node.path().joinToString(".")}', using the default. " + + "Allowed: ${constants.joinToString { it.name }}", + ) + null + } + } + + override fun serialize(type: Type, obj: Enum<*>?, node: ConfigurationNode) { + node.raw(obj?.name) + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/repository/NpcRepository.kt b/core/src/main/kotlin/app/simplecloud/npc/core/repository/NpcRepository.kt new file mode 100644 index 0000000..db664aa --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/repository/NpcRepository.kt @@ -0,0 +1,41 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.migration.NpcConfigMigration +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +class NpcRepository( + directory: Path, +) : WatchableYamlDirectoryRepository( + directory, + NpcConfig::class.java, + NpcConfigMigration.migrator(), + NpcConfigMigration::stampCurrentLayout, +) { + private val byProviderReference = ConcurrentHashMap() + + override fun idOf(entity: NpcConfig): String = entity.id + + override fun detach(entity: NpcConfig): NpcConfig = entity.deepCopy() + + override fun beforeSave(entity: NpcConfig) { + entity.normalize() + } + + fun findByProvider(provider: String, reference: String): NpcConfig? = + byProviderReference[providerKey(provider, reference)]?.deepCopy() + + override fun onEntityChanged(entity: NpcConfig) { + byProviderReference.entries.removeIf { it.value.id.equals(entity.id, true) } + val reference = entity.entity.providerReference ?: return + byProviderReference[providerKey(entity.entity.provider, reference)] = entity + } + + override fun onEntityRemoved(entity: NpcConfig) { + val reference = entity.entity.providerReference ?: return + byProviderReference.remove(providerKey(entity.entity.provider, reference), entity) + } + + private fun providerKey(provider: String, reference: String) = "${provider.lowercase()}|$reference" +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepository.kt b/core/src/main/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepository.kt new file mode 100644 index 0000000..fe336ef --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepository.kt @@ -0,0 +1,316 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.plugin.api.shared.config.ConfigMigrator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.spongepowered.configurate.ConfigurationNode +import java.io.File +import java.nio.file.ClosedWatchServiceException +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE +import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE +import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY +import java.nio.file.WatchService +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.zip.CRC32 +import kotlin.time.Duration.Companion.milliseconds + +class ReloadDiff( + val added: List = emptyList(), + val changed: List> = emptyList(), + val removed: List = emptyList(), +) { + class Change(val previous: E, val current: E) +} + +abstract class WatchableYamlDirectoryRepository( + private val directory: Path, + clazz: Class, + migrator: ConfigMigrator? = null, + prepare: (ConfigurationNode) -> Unit = {}, +) { + private val configurator = YamlConfigurator(clazz, migrator, prepare) + private val cachedEntities = ConcurrentHashMap() + private val byId = ConcurrentHashMap() + private val changeListeners = ConcurrentHashMap.newKeySet<() -> Unit>() + private val recentOwnDeletes = ConcurrentHashMap() + private val ownContentHashes = ConcurrentHashMap() + private val pendingLoads = ConcurrentHashMap() + + @Volatile + private var externalChangeListener: (ReloadDiff) -> Unit = {} + + @Volatile + private var watchScope: CoroutineScope? = null + + @Volatile + private var watchJob: Job? = null + + @Volatile + private var watchService: WatchService? = null + + protected abstract fun idOf(entity: E): String + protected abstract fun detach(entity: E): E + + protected open fun beforeSave(entity: E) = Unit + + fun save(entity: E) { + val detached = detach(entity).also(::beforeSave) + save(fileNameOf(idOf(detached)) ?: fileNameFor(detached), detached) + } + + fun find(id: String): E? = byId[id.lowercase()]?.let(::detach) + fun findAll(): List = cachedEntities.values.map(::detach) + fun ids(): List = byId.values.map(::idOf).sortedWith(String.CASE_INSENSITIVE_ORDER) + + fun delete(id: String) { + val fileName = fileNameOf(id) ?: return + + recentOwnDeletes[fileName] = System.currentTimeMillis() + val file = directory.resolve(fileName).toFile() + if (!file.delete() && file.exists()) { + logger.warning("Could not delete ${file.name}; it will come back the next time the directory is read") + } + + removeEntity(fileName) + } + + fun load(): List { + val directoryFile = directory.toFile() + directoryFile.mkdirs() + directoryFile.listFiles { file -> file.isYamlFile() }?.forEach { load(it) } + + return findAll() + } + + private fun fileNameFor(entity: E): String = "${idOf(entity)}.yml" + + private fun fileNameOf(id: String): String? { + val entity = byId[id.lowercase()] ?: return null + return cachedEntities.entries.firstOrNull { it.value === entity }?.key + } + + private fun load(file: File): E? { + val entity = runCatching { configurator.load(file) } + .onFailure { logger.log(Level.WARNING, "Could not load ${file.name}", it) } + .getOrNull() ?: return null + + val expected = fileNameFor(entity) + if (!expected.equals(file.name, ignoreCase = true)) { + logger.warning( + "Skipping ${file.name}: it holds '$expected', so it would show up twice. " + + "Rename the file to $expected or change the id inside it.", + ) + return null + } + + ownContentHashes[file.name] = contentHash(file) + putEntity(file.name, entity) + + return entity + } + + fun reload(): ReloadDiff { + val before = cachedEntities.toMap() + val onDisk = directory.toFile().listFiles { file -> file.isYamlFile() }?.map { it.name }.orEmpty().toSet() + + val removed = before.filterKeys { it !in onDisk }.onEach { (fileName, _) -> removeEntity(fileName) }.values + load() + val after = cachedEntities.toMap() + + val added = after.filterKeys { it !in before }.values.map(::detach) + val changed = after.mapNotNull { (fileName, current) -> + before[fileName]?.takeIf { it != current }?.let { ReloadDiff.Change(detach(it), detach(current)) } + } + + return ReloadDiff(added, changed, removed.map(::detach)) + } + + private fun save(fileName: String, entity: E) { + val file = directory.resolve(fileName).toFile() + file.parentFile?.mkdirs() + configurator.save(file, entity) + ownContentHashes[fileName] = contentHash(file) + putEntity(fileName, entity) + } + + fun onAnyChange(listener: () -> Unit) { + changeListeners.add(listener) + } + + fun setExternalChangeListener(listener: (ReloadDiff) -> Unit) { + externalChangeListener = listener + } + + fun loadAndWatch(): List { + val entities = load() + watchUpdates() + + return entities + } + + private fun watchUpdates() { + if (watchJob?.isActive == true) return + + directory.toFile().mkdirs() + val service = FileSystems.getDefault().newWatchService() + try { + directory.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) + } catch (exception: Exception) { + service.close() + throw exception + } + watchService = service + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { watchScope = it } + + watchJob = scope.launch { + while (isActive) { + val watchKey = try { + service.take() + } catch (_: ClosedWatchServiceException) { + break + } catch (_: InterruptedException) { + break + } + watchKey.pollEvents().forEach { event -> + try { + val path = event.context() as? Path ?: return@forEach + val file = directory.resolve(path).toFile() + if (!file.isYamlFile()) return@forEach + + when (event.kind()) { + ENTRY_CREATE, ENTRY_MODIFY -> scheduleLoad(scope, file) + ENTRY_DELETE -> if (!isOwnDeleteEcho(file.name)) { + pendingLoads.remove(file.name)?.cancel() + cachedEntities[file.name]?.let { entity -> + removeEntity(file.name) + externalChangeListener(ReloadDiff(removed = listOf(detach(entity)))) + } + } + } + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to handle a config directory change in $directory", exception) + } + } + if (!watchKey.reset() && !watchAgain(service)) break + } + } + } + + private suspend fun CoroutineScope.watchAgain(service: WatchService): Boolean { + while (isActive) { + delay(REWATCH_DELAY_MILLIS.milliseconds) + if (!directory.toFile().isDirectory) continue + + try { + directory.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) + } catch (_: ClosedWatchServiceException) { + return false + } catch (exception: Exception) { + logger.log(Level.WARNING, "Could not watch $directory again", exception) + continue + } + + val diff = reload() + if (diff.added.isNotEmpty() || diff.changed.isNotEmpty() || diff.removed.isNotEmpty()) { + externalChangeListener(diff) + } + + return true + } + + return false + } + + fun stopWatching() { + watchJob?.cancel() + watchJob = null + pendingLoads.values.forEach { it.cancel() } + pendingLoads.clear() + watchService?.close() + watchService = null + watchScope?.cancel() + watchScope = null + } + + protected open fun onEntityChanged(entity: E) = Unit + protected open fun onEntityRemoved(entity: E) = Unit + + private fun putEntity(fileName: String, entity: E) { + cachedEntities.put(fileName, entity)?.let(::forget) + byId[idOf(entity).lowercase()] = entity + onEntityChanged(entity) + notifyChangeListeners() + } + + private fun removeEntity(fileName: String) { + ownContentHashes.remove(fileName) + cachedEntities.remove(fileName)?.let { + forget(it) + notifyChangeListeners() + } + } + + private fun forget(entity: E) { + byId.remove(idOf(entity).lowercase(), entity) + onEntityRemoved(entity) + } + + private fun notifyChangeListeners() { + changeListeners.forEach { listener -> + runCatching { listener() } + .onFailure { logger.log(Level.WARNING, "Repository change listener failed", it) } + } + } + + private fun isOwnDeleteEcho(fileName: String): Boolean { + val deletedAt = recentOwnDeletes[fileName] ?: return false + if (System.currentTimeMillis() - deletedAt <= OWN_DELETE_ECHO_WINDOW_MILLIS) return true + recentOwnDeletes.remove(fileName) + + return false + } + + private fun scheduleLoad(scope: CoroutineScope, file: File) { + pendingLoads.compute(file.name) { _, previous -> + previous?.cancel() + scope.launch { + delay(LOAD_DEBOUNCE_MILLIS.milliseconds) + pendingLoads.remove(file.name) + if (!file.exists()) return@launch + if (contentHash(file) == ownContentHashes[file.name]) return@launch + + val previous = cachedEntities[file.name] + val current = load(file) ?: return@launch + val diff = when { + previous == null -> ReloadDiff(added = listOf(detach(current))) + previous == current -> return@launch + else -> ReloadDiff(changed = listOf(ReloadDiff.Change(detach(previous), detach(current)))) + } + externalChangeListener(diff) + } + } + } + + private fun contentHash(file: File): Long = runCatching { + CRC32().apply { update(file.readBytes()) }.value + }.getOrDefault(-1L) + + private fun File.isYamlFile(): Boolean = extension == "yml" + + private companion object { + private const val OWN_DELETE_ECHO_WINDOW_MILLIS = 800L + private const val LOAD_DEBOUNCE_MILLIS = 150L + private const val REWATCH_DELAY_MILLIS = 1000L + private val logger = NpcLog.logger + } +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/repository/YamlConfigurator.kt b/core/src/main/kotlin/app/simplecloud/npc/core/repository/YamlConfigurator.kt new file mode 100644 index 0000000..528212a --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/repository/YamlConfigurator.kt @@ -0,0 +1,43 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.plugin.api.shared.config.ConfigMigrator +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.objectmapping.ObjectMapper +import org.spongepowered.configurate.yaml.NodeStyle +import org.spongepowered.configurate.yaml.YamlConfigurationLoader +import java.io.File + +class YamlConfigurator( + private val clazz: Class, + private val migrator: ConfigMigrator? = null, + private val prepare: (ConfigurationNode) -> Unit = {}, +) { + + fun load(file: File): E? { + val loader = loader(file) + val node = loader.load() + prepare(node) + if (migrator?.migrate(node) == true) loader.save(node) + + return node.get(clazz) + } + + fun save(file: File, entity: E) { + val loader = loader(file) + val node = loader.createNode() + node.set(clazz, entity) + loader.save(node) + } + + private fun loader(file: File): YamlConfigurationLoader = YamlConfigurationLoader.builder() + .nodeStyle(NodeStyle.BLOCK) + .defaultOptions { options -> + options.implicitInitialization(false).serializers { serializers -> + serializers + .register(LenientEnumSerializer::handles, LenientEnumSerializer) + .registerAnnotatedObjects(ObjectMapper.factory()) + } + } + .file(file) + .build() +} diff --git a/core/src/main/kotlin/app/simplecloud/npc/core/text/PlayerPlaceholders.kt b/core/src/main/kotlin/app/simplecloud/npc/core/text/PlayerPlaceholders.kt new file mode 100644 index 0000000..077b60d --- /dev/null +++ b/core/src/main/kotlin/app/simplecloud/npc/core/text/PlayerPlaceholders.kt @@ -0,0 +1,41 @@ +package app.simplecloud.npc.core.text + +import app.simplecloud.npc.core.platform.NpcPlayer +import java.util.UUID + +object PlayerPlaceholders { + + const val PLAYER_NAME = "" + const val PLAYER_UUID = "" + + private val EXTERNAL = Regex("%[^%\\s]+%") + + @Volatile + var external: ((UUID, String) -> String)? = null + + fun resolve(text: String, player: NpcPlayer): String = resolve(text, player.uniqueId, player.name) + + fun resolve(text: String, uniqueId: UUID, name: String): String { + val own = resolveOwn(text, uniqueId, name) + if (!usesExternal(own)) return own + val hook = external ?: return own + + return runCatching { hook(uniqueId, own) }.getOrDefault(own) + } + + fun resolveOwn(text: String, uniqueId: UUID, name: String): String { + if ('<' !in text) return text + + return text.replace(PLAYER_NAME, name).replace(PLAYER_UUID, uniqueId.toString()) + } + + fun usesExternal(text: String): Boolean = external != null && '%' in text && EXTERNAL.containsMatchIn(text) + + fun isPersonal(text: String): Boolean = PLAYER_NAME in text || PLAYER_UUID in text || usesExternal(text) + + fun strip(text: String): String { + val own = text.replace(PLAYER_NAME, "").replace(PLAYER_UUID, "") + + return if (external != null) EXTERNAL.replace(own, "") else own + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/cloud/CloudListStoreTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/CloudListStoreTest.kt new file mode 100644 index 0000000..9b6cf0c --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/CloudListStoreTest.kt @@ -0,0 +1,88 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CloudListStoreTest { + + private val lobby = CloudFakes.group("lobby") + private val hub = CloudFakes.persistentServer("hub") + private val lobbyOne = CloudFakes.server(lobby, 1) + + private var clock = 1_000L + private var groupCalls = 0 + private var serverCalls = 0 + private var fail = false + private var groupsOnController = listOf(lobby) + private var availableOnController = listOf(lobbyOne) + + private fun store(ttl: Long = 2_500L) = CloudListStore( + fetchGroups = { groupCalls++; if (fail) error("controller offline") else groupsOnController }, + fetchPersistentServers = { if (fail) error("controller offline") else listOf(hub) }, + fetchServers = { serverCalls++; if (fail) error("controller offline") else listOf(lobbyOne) }, + fetchAvailableServers = { availableOnController }, + now = { clock }, + ttlMillis = ttl, + launchInBackground = { block -> runBlocking { block() } }, + ) + + @Test + fun `a downed controller is probed at most once per ttl`() { + val store = store() + fail = true + assertFailsWith { runBlocking { store.groups() } } + val afterFirstAttempt = groupCalls + + repeat(10) { store.peek() } + + assertEquals(afterFirstAttempt, groupCalls, "no probe inside the failure window") + + clock += 3_000 + store.peek() + assertEquals(afterFirstAttempt + 1, groupCalls, "one probe once the window passed") + } + + @Test + fun `refreshNow keeps the data when the controller is down`() { + val store = store() + runBlocking { store.warmUp() } + fail = true + + runBlocking { store.refreshNow() } + + assertContentEquals(listOf(lobby), store.peek().groups, "a failed refresh must not blank the snapshot") + } + + @Test + fun `a failing revalidation neither loses the data nor hammers the controller`() { + val store = store() + runBlocking { store.warmUp() } + clock += 3_000 + fail = true + + val groups: List = store.peek().groups + val servers: List = store.peek().servers + val persistent: List = store.peek().persistentServers + + assertContentEquals(listOf(lobby), groups) + assertContentEquals(listOf(lobbyOne), servers) + assertContentEquals(listOf(hub), persistent) + assertEquals(2, groupCalls, "one failed attempt, then the failure window holds") + assertEquals( + 1, + serverCalls, + "the failure closed the window before the second slot was probed - the controller is " + + "down, so there is nothing to gain from asking it again in the same breath", + ) + + clock += 3_000 + store.peek() + assertEquals(3, groupCalls, "past the window it is probed again") + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/cloud/JoinStateResolverTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/JoinStateResolverTest.kt new file mode 100644 index 0000000..202adde --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/JoinStateResolverTest.kt @@ -0,0 +1,48 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.config.NpcConfig +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class JoinStateResolverTest { + + private val plain = CloudFakes.group("Plain") + private val running = listOf(CloudFakes.server(plain, 1, playerCount = 3, maxPlayers = 20)) + + @Test + fun `no joinable server reads as offline once the grace period is over`() = runBlocking { + val lists = FakeCloudLists( + groups = listOf(plain), + servers = listOf(CloudFakes.server(plain, 1, state = ServerState.STARTING)), + ) + val config = NpcConfig(id = "grace", targetServers = mutableListOf("Plain")) + + assertEquals(NpcConfig.DEFAULT_JOIN_STATE, JoinStateResolver.of(config, lists, now = 1_000)) + assertEquals(NpcConfig.DEFAULT_JOIN_STATE, JoinStateResolver.of(config, lists, now = 5_999)) + assertEquals(JoinStateResolver.OFFLINE, JoinStateResolver.of(config, lists, now = 6_000)) + assertEquals(JoinStateResolver.OFFLINE, JoinStateResolver.resolve(config, lists.asSnapshot())) + } + + @Test + fun `one joinable server on any target keeps the NPC public`() { + val farm = CloudFakes.group("Farm") + val snapshot = FakeCloudLists( + groups = listOf(plain, farm), + servers = listOf(CloudFakes.server(farm, 1, state = ServerState.AVAILABLE)), + ).asSnapshot() + val both = NpcConfig(id = "both", targetServers = mutableListOf("Plain", "Farm")) + + assertEquals(JoinStateResolver.OFFLINE, JoinStateResolver.resolve("Plain", snapshot)) + assertEquals(NpcConfig.DEFAULT_JOIN_STATE, JoinStateResolver.resolve(both, snapshot)) + } + + @Test + fun `an outage or a cold cache reads as the default state`() = runBlocking { + val config = NpcConfig(id = "outage", targetServers = mutableListOf("Plain")) + assertEquals(NpcConfig.DEFAULT_JOIN_STATE, JoinStateResolver.of(config, UnreachableCloud)) + val coldCache = CloudSnapshot(groups = listOf(plain)) + assertEquals(NpcConfig.DEFAULT_JOIN_STATE, JoinStateResolver.resolve(config, coldCache)) + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/cloud/NoBlockingCloudReadsTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/NoBlockingCloudReadsTest.kt new file mode 100644 index 0000000..8f55d32 --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/NoBlockingCloudReadsTest.kt @@ -0,0 +1,36 @@ +package app.simplecloud.npc.core.cloud + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertTrue + +class NoBlockingCloudReadsTest { + + @Test + fun `no production source under core or platform-common blocks on a coroutine`() { + val root = repositoryRoot() + val offenders = listOf("core/src/main", "platform/common/src/main") + .map(root::resolve) + .flatMap { directory -> + Files.walk(directory).use { paths -> + paths.filter { it.extension == "kt" } + .filter { it.readText().contains("runBlocking") } + .map(root::relativize) + .toList() + } + } + + assertTrue( + offenders.isEmpty(), + "these run a coroutine to completion on the calling thread, which may be the server " + + "thread: ${offenders.joinToString()}", + ) + } + + private fun repositoryRoot(): Path = generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { it.resolve("settings.gradle.kts").exists() } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/cloud/TargetResolverTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/TargetResolverTest.kt new file mode 100644 index 0000000..8df8c10 --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/cloud/TargetResolverTest.kt @@ -0,0 +1,77 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.GroupServerType +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.config.JoinStrategy +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TargetResolverTest { + + private val lobby = CloudFakes.group("Lobby") + private val proxy = CloudFakes.group("Proxy", GroupServerType.PROXY) + private val build = CloudFakes.persistentServer("Build") + private val lobbyServers = listOf( + CloudFakes.server(lobby, 1, playerCount = 5), + CloudFakes.server(lobby, 2, playerCount = 2), + CloudFakes.server(lobby, 3, state = ServerState.STARTING, playerCount = 0), + ) + private val lists = FakeCloudLists(listOf(lobby, proxy), listOf(build), lobbyServers) + + @Test + fun `groups and persistent servers resolve by name, case-insensitively`() = runBlocking { + val group = assertIs(TargetResolver.resolve("lobby", lists)) + assertEquals("Lobby", group.target.name) + assertEquals(TargetType.GROUP, group.target.type) + assertIs(group.target.bridge) + + val persistent = assertIs(TargetResolver.resolve("BUILD", lists)) + assertEquals(TargetType.PERSISTENT_SERVER, persistent.target.type) + assertEquals(TargetResolution.NotFound, TargetResolver.resolve("nope", lists)) + } + + @Test + fun `a name used by both a group and a persistent server is ambiguous`() = runBlocking { + val clash = FakeCloudLists(listOf(CloudFakes.group("Hub")), listOf(CloudFakes.persistentServer("hub"))) + + assertEquals(TargetResolution.Ambiguous, TargetResolver.resolve("Hub", clash)) + assertNull(ServerBridgeResolver.resolve("Hub", clash)) + } + + @Test + fun `strategies pick by player count and prefer servers that are not full`() = runBlocking { + val servers = listOf( + CloudFakes.server(lobby, 1, playerCount = 5, maxPlayers = 10), + CloudFakes.server(lobby, 2, playerCount = 2, maxPlayers = 10), + CloudFakes.server(lobby, 3, playerCount = 10, maxPlayers = 10), + ) + val withFull = FakeCloudLists(listOf(lobby), servers = servers) + + assertEquals("Lobby-2", TargetResolver.findDestination("Lobby", withFull, JoinStrategy.LEAST_PLAYERS)) + assertEquals("Lobby-1", TargetResolver.findDestination("Lobby", withFull, JoinStrategy.MOST_PLAYERS)) + repeat(20) { + val picked = TargetResolver.findDestination("Lobby", withFull, JoinStrategy.RANDOM) + assertTrue(picked in setOf("Lobby-1", "Lobby-2")) + } + val allFull = FakeCloudLists(listOf(lobby), servers = listOf(servers[2])) + assertEquals("Lobby-3", TargetResolver.findDestination("Lobby", allFull)) + } + + @Test + fun `a persistent server is only a destination while it is joinable`() = runBlocking { + assertNull(TargetResolver.findDestination("Build", lists)) + + val online = FakeCloudLists(persistentServers = listOf(build), servers = listOf(CloudFakes.server(build))) + assertEquals("Build", TargetResolver.findDestination("Build", online)) + + val full = FakeCloudLists( + persistentServers = listOf(build), + servers = listOf(CloudFakes.server(build, playerCount = 50, maxPlayers = 50)), + ) + assertEquals("Build", TargetResolver.findDestination("Build", full)) + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/config/ActionFieldsTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/config/ActionFieldsTest.kt new file mode 100644 index 0000000..d2853c0 --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/config/ActionFieldsTest.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.core.config + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActionFieldsTest { + + @Test + fun `an empty join state action falls back to the public one`() { + val config = NpcConfig( + actions = mutableListOf( + NpcConfig.ActionConfiguration(PlayerInteraction.LEFT_CLICK, sendMessage = "hi"), + NpcConfig.ActionConfiguration(PlayerInteraction.LEFT_CLICK, joinState = "offline"), + ), + ) + + assertEquals("hi", config.actionsFor(PlayerInteraction.LEFT_CLICK, "offline").single().sendMessage) + } + + @Test + fun `an action holding only a permission or cooldown is kept`() { + assertFalse(NpcConfig.ActionConfiguration(permission = "vip").isEmpty()) + assertFalse(NpcConfig.ActionConfiguration(cooldown = 3_000).isEmpty()) + assertTrue(NpcConfig.ActionConfiguration().isEmpty()) + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigrationTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigrationTest.kt new file mode 100644 index 0000000..92b71de --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/config/migration/NpcConfigMigrationTest.kt @@ -0,0 +1,233 @@ +package app.simplecloud.npc.core.config.migration + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.repository.NpcRepository +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NpcConfigMigrationTest { + + private fun load(yaml: String): NpcConfig { + val directory = createTempDirectory("npc-config-migration") + val trimmed = yaml.trimIndent() + val file = directory.resolve("${idOf(trimmed)}.yml").apply { writeText(trimmed) } + val config = assertNotNull(NpcRepository(directory).load().singleOrNull()) + + val written = file.readText() + assertTrue(written.contains("version: ${NpcConfig.CURRENT_VERSION}"), written) + assertFalse(written.contains("ownership"), written) + assertFalse(written.contains("hologram-configuration"), written) + + return config + } + + private fun idOf(yaml: String): String = + Regex("^id: '?([^'\n]+)'?$", RegexOption.MULTILINE).find(yaml)?.groupValues?.get(1) + ?: error("fixture has no top-level id") + + @Test + fun `migrates a version five config written by release 0_1_4`() { + val config = load( + """ + version: 5 + id: lobby + provider: + type: CITIZENS + reference: '42' + ownership: MANAGED + target-servers: + - Lobby + hologram: + enabled: true + start-height: 2.073 + layouts: + - join-state: default + lines: + - text: 'Lobby' + billboard: CENTER + alignment: CENTER + - join-state: maintenance + lines: + - text: 'Maintenance' + pushback: + enabled: true + radius: 1.2 + strength: 0.8 + vertical: 0.3 + actions: + - interaction-type: RIGHT_CLICK + join-state: DEFAULT + join-target: true + - interaction-type: LEFT_CLICK + join-state: maintenance + send-message: 'Closed' + """, + ) + + assertEquals(NpcConfig.CURRENT_VERSION, config.version) + assertEquals("citizens", config.entity.provider) + assertEquals("42", config.entity.providerReference) + assertTrue(config.entity.needsRelocation) + assertEquals(listOf("Lobby"), config.targetServers) + + assertEquals(listOf("public", "maintenance"), config.hologram.layouts.map { it.joinState }) + assertEquals("Lobby", config.hologram.layoutShownFor(null)?.lines?.single()?.text) + assertEquals("Lobby", config.hologram.layoutShownFor("public")?.lines?.single()?.text) + + assertEquals(listOf("public", "maintenance"), config.actions.map { it.joinState }) + assertTrue(config.actionsFor(PlayerInteraction.RIGHT_CLICK, null).single().joinTarget) + assertTrue(config.actionsFor(PlayerInteraction.RIGHT_CLICK, "public").single().joinTarget) + assertEquals("Closed", config.actionsFor(PlayerInteraction.LEFT_CLICK, "maintenance").single().sendMessage) + assertTrue(config.pushback.enabled) + } + + @Test + fun `keeps a linked version five npc linked`() { + val config = load( + """ + version: 5 + id: shop + provider: + type: FANCY_NPCS + reference: shopkeeper + ownership: LINKED + target-servers: + - Shop + """, + ) + + assertEquals("fancynpcs", config.entity.provider) + assertEquals("shopkeeper", config.entity.providerReference) + assertTrue(config.entity.providerLinked) + assertFalse(config.entity.needsRelocation) + } + + @Test + fun `migrates a version four config written by release 0_1_3 and older`() { + val config = load( + """ + version: '4' + id: '12' + hologram-configuration: + placeholder-group-name: Lobby + holograms: + - start-height: 2.2 + join-state: '' + lores: + - text: 'Lobby' + billboard: CENTER + alignment: CENTER + actions: + - player-interaction: RIGHT_CLICK + action: QUICK_JOIN + options: {} + options: + group.name: Lobby + """, + ) + + assertEquals(NpcConfig.CURRENT_VERSION, config.version) + assertEquals("standalone", config.entity.provider) + assertNull(config.entity.providerReference) + assertTrue(config.entity.needsRelocation) + assertEquals(listOf("Lobby"), config.targetServers) + assertEquals(2.2, config.hologram.startHeight) + assertEquals("public", config.hologram.layouts.single().joinState) + assertEquals("Lobby", config.hologram.layouts.single().lines.single().text) + assertTrue(config.actionsFor(PlayerInteraction.RIGHT_CLICK, null).single().joinTarget) + } + + @Test + fun `migrates a proposed version one config`() { + val config = load( + """ + version: 1 + id: lobby + provider: FANCY_NPCS + target-servers: + - Lobby + hologram: + enabled: true + start-height: 2.5 + layouts: + - joinstate: default + text: 'Lobby' + - joinstate: Maintenance + text: 'Closed' + pushback: + enabled: true + radius: 3.0 + strength: 1.5 + vertical: 0.4 + playSound: entity.player.hurt + actions: + - interaction-type: RIGHT_CLICK + joinstate: default + sendMessage: hi + teleport: + enabled: true + world: hub + x: 1.0 + y: 2.0 + z: 3.0 + sendTitle: + enabled: false + title: ignored + - interaction-type: LEFT_CLICK + joinstate: maintenance + sendToServer: Lobby-1 + """, + ) + + assertEquals(NpcConfig.CURRENT_VERSION, config.version) + assertEquals("fancynpcs", config.entity.provider) + assertEquals("lobby", config.entity.providerReference) + assertTrue(config.entity.needsRelocation) + assertEquals(listOf("Lobby"), config.targetServers) + assertEquals(2.5, config.hologram.startHeight) + assertEquals(listOf("public", "maintenance"), config.hologram.layouts.map { it.joinState }) + assertEquals("Closed", config.hologram.layoutShownFor("maintenance")?.lines?.single()?.text) + assertTrue(config.pushback.enabled) + assertEquals("entity.player.hurt", config.pushback.sound) + + val right = config.actionsFor(PlayerInteraction.RIGHT_CLICK, null).single() + assertEquals("hi", right.sendMessage) + assertEquals("hub", right.teleport?.world) + assertEquals(3.0, right.teleport?.z) + assertNull(right.sendTitle, "a disabled title is dropped") + assertEquals("Lobby-1", config.actionsFor(PlayerInteraction.LEFT_CLICK, "maintenance").single().sendToServer) + } + + @Test + fun `keeps current configs untouched and writes the current version back`() { + val directory = createTempDirectory("npc-config-roundtrip") + val repository = NpcRepository(directory) + repository.save( + NpcConfig( + id = "hub", + entity = NpcConfig.NpcEntityConfiguration(provider = "standalone"), + targetServers = mutableListOf("Hub"), + actions = mutableListOf( + NpcConfig.ActionConfiguration(PlayerInteraction.RIGHT_CLICK, "PUBLIC", joinTarget = true), + ), + ), + ) + + val yaml = directory.resolve("hub.yml").readText() + assertTrue(yaml.contains("version: ${NpcConfig.CURRENT_VERSION}")) + assertTrue(yaml.contains("join-state: public")) + + val reloaded = assertNotNull(NpcRepository(directory).load().singleOrNull()) + assertEquals("standalone", reloaded.entity.provider) + assertFalse(reloaded.entity.needsRelocation) + assertEquals(listOf("Hub"), reloaded.targetServers) + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/hologram/PersonalTextTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/hologram/PersonalTextTest.kt new file mode 100644 index 0000000..6b70fff --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/hologram/PersonalTextTest.kt @@ -0,0 +1,44 @@ +package app.simplecloud.npc.core.hologram + +import app.simplecloud.npc.core.text.PlayerPlaceholders +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class PersonalTextTest { + + private val viewer = UUID.fromString("00000000-0000-0000-0000-000000000001") + + private val target = TagResolver.resolver( + Placeholder.component("target_name", Component.text("Lobby")), + Placeholder.component("target_motd", Component.text("50% off for ")), + ) + + @AfterTest + fun reset() { + PlayerPlaceholders.external = null + } + + @Test + fun `target values reach the viewer untouched by player and PlaceholderAPI substitution`() { + PlayerPlaceholders.external = { _, text -> text.replace(Regex("%[^%\\s]+%"), "7") } + val text = PersonalText.of(" | has %level%", target) + + assertEquals("50% off for | Steve has 7", plain(text.render(text.resolve(viewer, "Steve")))) + } + + @Test + fun `the fallback leaves out everything personal`() { + PlayerPlaceholders.external = { _, text -> text } + val text = PersonalText.of("Hi , %level% on ", target) + + assertEquals("Hi , on Lobby", plain(text.fallback())) + } + + private fun plain(component: Component) = PlainTextComponentSerializer.plainText().serialize(component) +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/repository/NpcRepositoryFileNameTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/repository/NpcRepositoryFileNameTest.kt new file mode 100644 index 0000000..8e01f41 --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/repository/NpcRepositoryFileNameTest.kt @@ -0,0 +1,59 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.npc.core.config.JoinStrategy +import app.simplecloud.npc.core.config.NpcPose +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals + +class NpcRepositoryFileNameTest { + + private fun config(id: String) = """ + version: 6 + id: $id + target-servers: + - Lobby + """.trimIndent() + + @Test + fun `a file whose name does not match its id is skipped`() { + val directory = createTempDirectory("npc-file-name") + directory.resolve("hub.yml").writeText(config("lobby")) + + assertEquals(emptyList(), NpcRepository(directory).load().map { it.id }) + } + + @Test + fun `a file without a version in today's layout is not migrated away`() { + val directory = createTempDirectory("npc-file-unversioned") + directory.resolve("lobby.yml").writeText( + """ + id: lobby + target-servers: + - Lobby + """.trimIndent(), + ) + + assertEquals(listOf("Lobby"), NpcRepository(directory).load().single().targetServers) + } + + @Test + fun `an unknown enum value falls back to its default instead of dropping the NPC`() { + val directory = createTempDirectory("npc-file-enum") + directory.resolve("lobby.yml").writeText( + """ + version: 6 + id: lobby + join-strategy: FULLEST + entity: + pose: FLYING + """.trimIndent(), + ) + + val config = NpcRepository(directory).load().single() + + assertEquals(JoinStrategy.LEAST_PLAYERS, config.joinStrategy) + assertEquals(NpcPose.STANDING, config.entity.pose) + } +} diff --git a/core/src/test/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepositoryTest.kt b/core/src/test/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepositoryTest.kt new file mode 100644 index 0000000..b6e2c44 --- /dev/null +++ b/core/src/test/kotlin/app/simplecloud/npc/core/repository/WatchableYamlDirectoryRepositoryTest.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.core.repository + +import app.simplecloud.npc.core.config.NpcConfig +import java.nio.file.Path +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchableYamlDirectoryRepositoryTest { + + private val directory: Path = createTempDirectory("npc-watch") + private val repository = NpcRepository(directory) + private val events = CopyOnWriteArrayList() + + init { + repository.setExternalChangeListener { diff -> + diff.added.forEach { events += "added:${it.id}" } + diff.changed.forEach { change -> + val previous = change.previous.targetServers.single() + val current = change.current.targetServers.single() + events += "changed:$previous->$current" + } + diff.removed.forEach { events += "removed:${it.id}" } + } + } + + private fun yaml(id: String, target: String) = """ + version: ${NpcConfig.CURRENT_VERSION} + id: $id + target-servers: + - $target + """.trimIndent() + + private fun await(what: String, timeoutMillis: Long = 5_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMillis + while (!condition()) { + assertTrue(System.currentTimeMillis() < deadline, "timed out waiting for $what; events: $events") + Thread.sleep(25) + } + } + + private fun settle() = Thread.sleep(700) + + @Test + fun `an external edit reloads the config and reports an update`() { + directory.resolve("lobby.yml").writeText(yaml("lobby", "Lobby")) + repository.loadAndWatch() + try { + assertEquals(listOf("Lobby"), repository.find("lobby")!!.targetServers.toList()) + + settle() + directory.resolve("lobby.yml").writeText(yaml("lobby", "Hub")) + + await("change event") { events.contains("changed:Lobby->Hub") } + await("reloaded target") { repository.find("lobby")?.targetServers == listOf("Hub") } + } finally { + repository.stopWatching() + } + } + + @Test + fun `a new file appears and an external delete reports a deletion`() { + repository.loadAndWatch() + try { + settle() + directory.resolve("shop.yml").writeText(yaml("shop", "Shop")) + await("created config") { repository.find("shop") != null } + await("added event for the new file") { events.contains("added:shop") } + + directory.resolve("shop.yml").deleteIfExists() + await("removed event") { events.contains("removed:shop") } + assertNull(repository.find("shop")) + } finally { + repository.stopWatching() + } + } + + @Test + fun `the repository's own save does not echo back as an external update`() { + repository.loadAndWatch() + try { + settle() + repository.save(NpcConfig(id = "own", targetServers = mutableListOf("Own"))) + assertTrue(directory.resolve("own.yml").exists()) + + settle() + assertTrue(events.isEmpty(), "own writes must not reload: $events") + assertNotNull(repository.find("own")) + } finally { + repository.stopWatching() + } + } + + @Test + fun `a deleted and recreated directory is watched again`() { + directory.resolve("lobby.yml").writeText(yaml("lobby", "Lobby")) + repository.loadAndWatch() + try { + settle() + directory.toFile().deleteRecursively() + await("removed event") { events.contains("removed:lobby") } + + directory.toFile().mkdirs() + directory.resolve("lobby.yml").writeText(yaml("lobby", "Hub")) + await("reloaded after the directory came back") { repository.find("lobby")?.targetServers == listOf("Hub") } + + settle() + directory.resolve("lobby.yml").writeText(yaml("lobby", "Shop")) + await("edit after rewatch") { events.contains("changed:Hub->Shop") } + } finally { + repository.stopWatching() + } + } +} diff --git a/core/src/testFixtures/kotlin/app/simplecloud/npc/core/cloud/CloudFakes.kt b/core/src/testFixtures/kotlin/app/simplecloud/npc/core/cloud/CloudFakes.kt new file mode 100644 index 0000000..33e6cd2 --- /dev/null +++ b/core/src/testFixtures/kotlin/app/simplecloud/npc/core/cloud/CloudFakes.kt @@ -0,0 +1,112 @@ +package app.simplecloud.npc.core.cloud + +import app.simplecloud.api.group.Group +import app.simplecloud.api.group.GroupServerType +import app.simplecloud.api.persistentserver.PersistentServer +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState +import kotlinx.coroutines.runBlocking +import java.lang.reflect.Proxy + +object CloudFakes { + fun group( + name: String, + type: GroupServerType = GroupServerType.SERVER, + properties: Map = emptyMap(), + ): Group = fake(Group::class.java, mapOf("getName" to name, "getType" to type, "getProperties" to properties)) + + fun persistentServer( + name: String, + type: GroupServerType = GroupServerType.SERVER, + properties: Map = emptyMap(), + ): PersistentServer = fake( + PersistentServer::class.java, + mapOf( + "getName" to name, + "getType" to type, + "getProperties" to properties, + "getPersistentServerId" to "ps-$name", + ), + ) + + fun server( + group: Group, + numericalId: Int, + state: ServerState = ServerState.AVAILABLE, + playerCount: Int = 0, + maxPlayers: Int? = null, + ): Server = fake( + Server::class.java, + mapOf( + "getGroup" to group, + "getServerBase" to group, + "getServerId" to "${group.name}-$numericalId", + "getNumericalId" to numericalId, + "getState" to state, + "getPlayerCount" to playerCount, + "getMaxPlayers" to maxPlayers, + "getProperties" to emptyMap(), + ), + ) + + fun server( + persistentServer: PersistentServer, + state: ServerState = ServerState.AVAILABLE, + playerCount: Int = 0, + maxPlayers: Int? = null, + ): Server = fake( + Server::class.java, + mapOf( + "getPersistentServer" to persistentServer, + "getPersistentServerId" to persistentServer.persistentServerId, + "getServerBase" to persistentServer, + "getServerId" to persistentServer.name, + "getState" to state, + "getPlayerCount" to playerCount, + "getMaxPlayers" to maxPlayers, + "getProperties" to emptyMap(), + ), + ) + + @Suppress("UNCHECKED_CAST") + private fun fake(type: Class, values: Map): T = Proxy.newProxyInstance( + type.classLoader, + arrayOf(type), + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.get(0) + "hashCode" -> System.identityHashCode(proxy) + "toString" -> "${type.simpleName}(${values["getName"] ?: values["getServerId"]})" + in values -> values[method.name] + else -> when (method.returnType) { + Int::class.javaPrimitiveType -> 0 + Boolean::class.javaPrimitiveType -> false + else -> null + } + } + } as T +} + +class FakeCloudLists( + private val groups: List = emptyList(), + private val persistentServers: List = emptyList(), + private val servers: List = emptyList(), +) : CloudLists { + override suspend fun groups(): List = groups + override suspend fun persistentServers(): List = persistentServers + override suspend fun servers(): List = servers + override suspend fun availableServers(groupName: String): List = + servers.filter { it.groupName().equals(groupName, true) && it.state == ServerState.AVAILABLE } +} + +object UnreachableCloud : CloudLists { + private fun down(): Nothing = throw CloudUnavailableException(IllegalStateException("controller offline")) + override suspend fun groups(): List = down() + override suspend fun persistentServers(): List = down() + override suspend fun servers(): List = down() + override suspend fun availableServers(groupName: String): List = down() +} + +fun FakeCloudLists.asSnapshot(): CloudSnapshot = runBlocking { + CloudSnapshot(groups(), persistentServers(), servers(), known = true) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f767a26 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,116 @@ +# Architecture + +## Modules + +| Module | Package | Contents | +|---|---|---| +| `core` | `app.simplecloud.npc.core` | Config model, migrations, YAML repositories, SimpleCloud access, hologram text, renderer interfaces. No Bukkit. | +| `platform/common` | `app.simplecloud.npc.common` | Commands, managers, editors, inventories. No Bukkit. | +| `platform/paper` | `app.simplecloud.npc.paper` | The Paper plugin. Builds the jar. | +| `api` | `app.simplecloud.npc.api` | Public developer API: Bukkit events and the `SimpleCloudNpcs` service. | +| `provider/base` | `app.simplecloud.npc.bukkit` | Shared Bukkit code for providers: click packets, glow teams, skins, look-at-player. | +| `provider/standalone` | `app.simplecloud.npc.provider.standalone` | Packet NPCs (PacketEvents/EntityLib). | +| `provider/mannequin` | `app.simplecloud.npc.provider.mannequin` | Mannequin entities, 1.21.9 and newer. | +| `provider/citizens`, `fancynpcs`, `znpcsplus`, `mythicmobs` | `app.simplecloud.npc.provider.` | Wrappers for the other NPC plugins. | +| `libs/packetevents` | | PacketEvents and EntityLib, relocated, with their own Adventure. No code. | + +`core` and `platform/common` don't know Bukkit. They talk to the server through +interfaces in `platform/common/.../platform` and `core/.../platform`, which +`platform/paper` implements. + +## Build + +- The API floor is paper-api 1.20.6. `platform/paper` and `provider/base` compile + against it, so using newer API fails the build. `provider/mannequin` is the + only exception (Paper 1.21.11). It is loaded by reflection when + `ServerCompat.hasMannequin` is true, and `ReflectiveSeamsTest` checks its class name. +- Our code uses the server's Adventure. The compile classpath is pinned to the + floor version (4.17.0). +- PacketEvents and EntityLib can't use the server's Adventure: they break below + 4.25 and on Adventure 5. `libs/packetevents` ships them with Adventure 4.26.1, + relocated to `app.simplecloud.npc.relocate.packets.kyori`. Components going into + a packet are converted through `PacketComponents` (JSON). +- The plugin is one jar, `simplecloud-npc.jar`, built from `platform/paper`. All + shaded libraries are relocated under `app.simplecloud.npc.relocate`, because the + SimpleCloud API plugin brings its own Kotlin. The SimpleCloud API itself comes + from that plugin (`depend: simplecloud-api`). +- `PluginJarTest` checks the finished jar: allowed contents, that every + referenced class exists, and that it links against the Adventure of 1.20.6, + 1.21.8 and 26.x. +- The root `build.gradle.kts` holds the shared Kotlin setup and the version + (`baseVersion`). With `COMMIT_HASH` set, the version becomes + `-dev.` and is published to Modrinth as a beta. + +## Startup + +`PaperPlugin.onEnable` runs these steps in order: + +1. `bootstrap/PacketStack` starts PacketEvents and EntityLib, `bootstrap/Listeners` + registers the listeners. +2. `provider/ProviderWiring.load` loads the providers from `ProviderCatalog` and + skips plugins that aren't installed. `RoutingNpcRenderer` picks the provider + per NPC. New NPCs always use our own provider (Mannequin if available, + otherwise standalone). Other plugins' NPCs only come in through `link`. +3. `editor/EditorWiring.create` builds the NPC and inventory editors. +4. `NpcPluginContext` creates the managers, the interaction executor and the + inventory views, and starts watching the config folders. +5. Effects, API hooks, the PlaceholderAPI hook and the update checker are added. +6. `bootstrap/ProviderReadyWait` waits until all provider plugins have loaded + their NPCs. Then `NpcManager.reconcileOnBoot` spawns or re-attaches every NPC. + +## Providers + +A provider implements `NpcRenderer` from `core`. Wrappers for other plugins +extend `LinkedProviderRenderer`, which tracks whether an NPC is ours or linked +and runs every call on the main thread. + +Holograms don't go through providers. Every NPC gets a packet-only TextDisplay +hologram from `PacketHologramRenderer` in `provider/base`. Each line is sent per +player, so `` and PlaceholderAPI text go straight into that player's +packets (`ViewerTexts`). Holograms show and hide with the NPC's `view-distance` +through `PacketViewerTracker`. `LegacyHologramCleanup` removes the persisted +TextDisplays older versions left in the worlds. + +Clicks and glow colors work through packets: + +- `NpcInteractPacketListener` reads click packets and maps them to NPCs through + `InteractableEntities`. `ClickPackets` knows the format: up to 1.21.11 one + interact packet, from 26.1 a separate attack packet. The packet is cancelled, + so game mode, invulnerability or other plugins can't block the click. +- `GlowTeams` sends scoreboard teams per player, so glow colors also work when + another plugin gives the player its own scoreboard. + +## Configs + +- `NpcConfig` (`core/config`) is the format of `npcs/.yml`. + `NpcConfigMigration` upgrades old files on load and keeps a backup. The old + formats are in `migration/legacy`. +- Value ranges and `ActionFields` live next to the config classes, so commands + and editor check the same limits. +- `InventoryConfiguration` (`core/inventory`) is the format of the menus. +- `YamlConfigurator` reads and writes the files. Missing keys keep the default + of the data class. +- `WatchableYamlDirectoryRepository` watches a folder and reports changes made + outside the plugin. It ignores its own writes. + +## Editors + +Both editors are in `platform/common/.../editor`. + +- `editor/menu`: `EditorMenu` is a menu made of `Element`s (icon and click + handler). `Pane` builds one, `Paginator` splits lists into pages. +- `editor/core`: shared parts. `ScreenStackEditor` keeps a stack of screens per + player, back and Escape go one screen back. Also pickers, confirm dialogs and + the shared action editor (`ActionFieldsPane`), which NPC clicks and menu items + both use. +- `editor/npc`: the NPC editor. Each screen is a `*MenuBuilder` in + `editor/npc//`. Changes go through `NpcEditorContext.commit`, which + saves, refreshes the NPC and redraws the menu. +- `editor/inventory`: the menu editor. Changes go through + `InventoryEditorContext.change`, with undo/redo (`EditHistory`, 50 steps). +- `editor/inventory/canvas`: the canvas for laying out menus. On Paper, + `PacketCanvasSurface` handles it at packet level: the player's inventory shows + the toolbox and the cursor shows the carried item, but no editor item ever + ends up in a real inventory. + +Text input always happens in chat (`ChatInputPrompts`, `TextPrompts`). diff --git a/gradle.properties b/gradle.properties index 7fc6f1f..9e34be6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,4 @@ kotlin.code.style=official +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2ac2fb..96aba0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,35 +1,65 @@ [versions] kotlin = "2.4.0" -kotlinX = "1.11.0" +kotlinx = "1.11.0" shadow = "9.4.3" -paper = "1.21.11-R0.1-SNAPSHOT" -cloud-commands = "2.0.0" -cloud-annotation-commands = "2.0.0" +minotaur = "2.9.0" +# The plugin's API floor: every module but provider/mannequin compiles against this, so +# the compiler rejects anything a supported server would not have. It is deliberately the +# oldest version we publish (see the Modrinth gameVersions list), NOT the newest release. +paper = "1.20.6-R0.1-SNAPSHOT" +# Mannequin entity API (1.21.9+); only provider/mannequin compiles against this. +paper-mannequin = "1.21.11-R0.1-SNAPSHOT" +# The Adventure that paper-mannequin ships; provider/mannequin compiles against it instead of the floor. +adventure-mannequin = "4.26.1" +# cloud-core and cloud-annotations share a version line; cloud-minecraft (cloud-paper) +# is versioned separately. +cloud = "2.1.0" +cloud-minecraft = "2.0.1" simplecloud = "0.1.0-platform.33-dev.52.1-9490b37" simplecloud-plugin = "0.0.1-platform.1778966459069-b1e45e2" -minotaur = "2.9.0" configurate = "4.2.0" +# What paper-api provides on our floor. The compile classpath is held to it in +# the root build file, so the compiler rejects Adventure API a 1.20.6 server lacks. +adventure-floor = "4.17.0" +# The Adventure PacketEvents and EntityLib were built against. libs/packetevents ships it, +# relocated, next to them. Our plugin code never runs on it, only some tests do. +adventure = "4.26.1" +packetevents = "2.14.0" +# EntityLib only publishes snapshots; this is the 3.3.7 build, pinned so a release jar can be +# rebuilt. libs/packetevents holds its api and common artifacts to the same build. +entitylib = "3.3.7-20260731.141813-1" citizens = "2.0.43-SNAPSHOT" fancynpcs = "2.9.2" mythic-dist = "5.12.1" znpcsplus = "2.1.0-SNAPSHOT" [libraries] -kotlin-jvm = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinX" } +kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper" } +paper-mannequin-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-mannequin" } -cloud-minecraft-extras = { module = "org.incendo:cloud-minecraft-extras", version.ref = "cloud-commands" } -cloud-paper = { module = "org.incendo:cloud-paper", version.ref = "cloud-commands" } -cloud-annotation = { module = "org.incendo:cloud-annotations", version.ref = "cloud-annotation-commands" } +cloud-core = { module = "org.incendo:cloud-core", version.ref = "cloud" } +cloud-annotations = { module = "org.incendo:cloud-annotations", version.ref = "cloud" } +cloud-paper = { module = "org.incendo:cloud-paper", version.ref = "cloud-minecraft" } -simplecloud = { module = "app.simplecloud.api:api", version.ref = "simplecloud" } +simplecloud-api = { module = "app.simplecloud.api:api", version.ref = "simplecloud" } simplecloud-plugin = { module = "app.simplecloud.plugin:plugin-shared", version.ref = "simplecloud-plugin" } -configurate = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate" } -configurate-kotlin = { module = "org.spongepowered:configurate-extra-kotlin", version.ref = "configurate" } +configurate-yaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate" } + +adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" } +adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" } +adventure-text-serializer-legacy = { module = "net.kyori:adventure-text-serializer-legacy", version.ref = "adventure" } +adventure-text-serializer-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" } +adventure-text-serializer-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure" } +adventure-nbt = { module = "net.kyori:adventure-nbt", version.ref = "adventure" } + +packetevents-spigot = { module = "com.github.retrooper:packetevents-spigot", version.ref = "packetevents" } +entitylib-spigot = { module = "io.github.tofaa2:spigot", version.ref = "entitylib" } +entitylib-api = { module = "io.github.tofaa2:api", version.ref = "entitylib" } +entitylib-common = { module = "io.github.tofaa2:common", version.ref = "entitylib" } citizens-main = { module = "net.citizensnpcs:citizens-main", version.ref = "citizens" } fancynpcs = { module = "de.oliver:FancyNpcs", version.ref = "fancynpcs" } @@ -37,8 +67,19 @@ mythic-dist = { module = "io.lumine:Mythic-Dist", version.ref = "mythic-dist" } znpcsplus-api = { module = "lol.pyr:znpcsplus-api", version.ref = "znpcsplus" } [bundles] -cloud = ["cloud-paper", "cloud-minecraft-extras", "cloud-annotation"] -configurate = ["configurate", "configurate-kotlin"] +adventure = [ + "adventure-api", + "adventure-text-minimessage", + "adventure-text-serializer-legacy", + "adventure-text-serializer-plain", +] +# What libs/packetevents bundles: everything PacketEvents and EntityLib reference. +packet-adventure = [ + "adventure-api", + "adventure-nbt", + "adventure-text-serializer-gson", + "adventure-text-serializer-legacy", +] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/libs/packetevents/build.gradle.kts b/libs/packetevents/build.gradle.kts new file mode 100644 index 0000000..edab865 --- /dev/null +++ b/libs/packetevents/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + `java-library` + alias(libs.plugins.shadow) +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +dependencies { + implementation(libs.packetevents.spigot) + implementation(libs.entitylib.spigot) + implementation(libs.bundles.packet.adventure) + + constraints { + implementation(libs.entitylib.api) { version { strictly(libs.versions.entitylib.get()) } } + implementation(libs.entitylib.common) { version { strictly(libs.versions.entitylib.get()) } } + } +} + +configurations.runtimeClasspath { + exclude(group = "com.google.code.gson") +} + +tasks.shadowJar { + archiveClassifier.set("") + mergeServiceFiles() + relocate("net.kyori", "app.simplecloud.npc.relocate.packets.kyori") + dependencies { + exclude(dependency("org.jetbrains:annotations")) + } + exclude("**/module-info.class") + exclude("META-INF/maven/**") +} + +tasks.named("jar") { enabled = false } diff --git a/npc-namespace/build.gradle.kts b/npc-namespace/build.gradle.kts deleted file mode 100644 index e69de29..0000000 diff --git a/npc-namespace/npc-namespace-citizens/build.gradle.kts b/npc-namespace/npc-namespace-citizens/build.gradle.kts deleted file mode 100644 index fd2886f..0000000 --- a/npc-namespace/npc-namespace-citizens/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencies { - compileOnly(rootProject.libs.citizens.main) - - implementation(project(":npc-shared")) -} diff --git a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/CitizensNamespace.kt b/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/CitizensNamespace.kt deleted file mode 100644 index 77213d7..0000000 --- a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/CitizensNamespace.kt +++ /dev/null @@ -1,78 +0,0 @@ -package app.simplecloud.npc.namespace.citizens - -import app.simplecloud.npc.namespace.citizens.listener.NpcLeftClickListener -import app.simplecloud.npc.namespace.citizens.listener.NpcRightClickListener -import app.simplecloud.npc.shared.provider.NpcProvider -import app.simplecloud.npc.shared.provider.NpcCreationDefaults -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import net.citizensnpcs.api.CitizensAPI -import net.citizensnpcs.api.event.NPCDespawnEvent -import net.citizensnpcs.api.event.NPCRemoveEvent -import net.citizensnpcs.api.event.NPCSpawnEvent -import net.citizensnpcs.api.npc.NPC -import net.citizensnpcs.trait.LookClose -import net.citizensnpcs.trait.MirrorTrait -import org.bukkit.Location -import org.bukkit.entity.EntityType -import org.bukkit.entity.Player -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class CitizensNamespace : NpcProvider { - - override val type = NpcProviderType.CITIZENS - override val pluginName = "Citizens" - override val supportsCreation = true - - override fun registerListeners(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) { - pluginManager.registerEvents(NpcRightClickListener(events), plugin) - pluginManager.registerEvents(NpcLeftClickListener(events), plugin) - pluginManager.registerEvents(LifecycleListener(events), plugin) - } - - override fun exists(reference: String): Boolean = find(reference) != null - - override fun references(): List = CitizensAPI.getNPCRegistry() - .map { it.id.toString() } - - override fun location(reference: String): Location? = find(reference)?.storedLocation - - override fun create(id: String, player: Player, location: Location): String { - val registry = CitizensAPI.getNPCRegistry() - val npc = registry.createNPC(EntityType.PLAYER, id, location) - npc.data().setPersistent(NPC.Metadata.NAMEPLATE_VISIBLE, false) - npc.getOrAddTrait(LookClose::class.java).apply { - lookClose(true) - setPerPlayer(true) - setRange(NpcCreationDefaults.LOOK_AT_PLAYER_DISTANCE.toDouble()) - } - npc.getOrAddTrait(MirrorTrait::class.java).setEnabled(true) - registry.saveToStore() - return npc.id.toString() - } - - override fun delete(reference: String): Boolean { - val npc = find(reference) ?: return false - npc.destroy() - CitizensAPI.getNPCRegistry().saveToStore() - return true - } - - private fun find(reference: String): NPC? { - return reference.toIntOrNull()?.let(CitizensAPI.getNPCRegistry()::getById) - } - - private class LifecycleListener(private val events: NpcProviderEvents) : Listener { - @EventHandler - fun onSpawn(event: NPCSpawnEvent) = events.spawn(NpcProviderType.CITIZENS, event.npc.id.toString()) - - @EventHandler - fun onDespawn(event: NPCDespawnEvent) = events.remove(NpcProviderType.CITIZENS, event.npc.id.toString()) - - @EventHandler - fun onRemove(event: NPCRemoveEvent) = events.remove(NpcProviderType.CITIZENS, event.npc.id.toString()) - } -} diff --git a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcLeftClickListener.kt b/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcLeftClickListener.kt deleted file mode 100644 index 76d7411..0000000 --- a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcLeftClickListener.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.namespace.citizens.listener - -import app.simplecloud.npc.namespace.citizens.option.CitizensOptionProviders -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import net.citizensnpcs.api.event.NPCLeftClickEvent -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class NpcLeftClickListener( - private val events: NpcProviderEvents, -) : Listener { - - @EventHandler - fun handleNpcLeftClick(event: NPCLeftClickEvent) { - events.interact( - NpcProviderType.CITIZENS, - event.npc.id.toString(), - event.clicker, - PlayerInteraction.LEFT_CLICK, - CitizensOptionProviders.createInteractOptionProviders(event.npc), - ) - } -} diff --git a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcRightClickListener.kt b/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcRightClickListener.kt deleted file mode 100644 index 95b3e84..0000000 --- a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/listener/NpcRightClickListener.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.namespace.citizens.listener - -import app.simplecloud.npc.namespace.citizens.option.CitizensOptionProviders -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import net.citizensnpcs.api.event.NPCRightClickEvent -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class NpcRightClickListener( - private val events: NpcProviderEvents, -) : Listener { - - @EventHandler - fun handleNpcRightClick(event: NPCRightClickEvent) { - events.interact( - NpcProviderType.CITIZENS, - event.npc.id.toString(), - event.clicker, - PlayerInteraction.RIGHT_CLICK, - CitizensOptionProviders.createInteractOptionProviders(event.npc), - ) - } -} diff --git a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/option/CitizensOptionProviders.kt b/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/option/CitizensOptionProviders.kt deleted file mode 100644 index 859cd67..0000000 --- a/npc-namespace/npc-namespace-citizens/src/main/kotlin/app/simplecloud/npc/namespace/citizens/option/CitizensOptionProviders.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.namespace.citizens.option - -import app.simplecloud.npc.shared.option.DefaultOptions -import app.simplecloud.npc.shared.option.Option -import app.simplecloud.npc.shared.option.OptionProvider -import net.citizensnpcs.api.npc.NPC - -/** - * @author Niklas Nieberler - */ - -object CitizensOptionProviders { - - /** - * Creates a [OptionProvider] for the interacting usage of npcs - * @param npc the npc - */ - fun createInteractOptionProviders(npc: NPC) = OptionProvider.with( - Option.of(DefaultOptions.NPC_ID, npc.id.toString()), - Option.of(DefaultOptions.NPC_NAME, npc.name), - Option.of(DefaultOptions.NPC_UUID, npc.uniqueId.toString()), - Option.of(DefaultOptions.NPC_ENTITY_ID, npc.entity.entityId) - ) - -} \ No newline at end of file diff --git a/npc-namespace/npc-namespace-fancynpcs/build.gradle.kts b/npc-namespace/npc-namespace-fancynpcs/build.gradle.kts deleted file mode 100644 index 9fa62ac..0000000 --- a/npc-namespace/npc-namespace-fancynpcs/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencies { - compileOnly(rootProject.libs.fancynpcs) - - implementation(project(":npc-shared")) -} diff --git a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/FancyNpcsNamespace.kt b/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/FancyNpcsNamespace.kt deleted file mode 100644 index 150d0f7..0000000 --- a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/FancyNpcsNamespace.kt +++ /dev/null @@ -1,80 +0,0 @@ -package app.simplecloud.npc.namespace.fancynpcs - -import app.simplecloud.npc.namespace.fancynpcs.listener.NpcInteractListener -import app.simplecloud.npc.shared.provider.NpcProvider -import app.simplecloud.npc.shared.provider.NpcCreationDefaults -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import de.oliver.fancynpcs.api.FancyNpcsPlugin -import de.oliver.fancynpcs.api.Npc -import de.oliver.fancynpcs.api.NpcData -import de.oliver.fancynpcs.api.events.NpcCreateEvent -import de.oliver.fancynpcs.api.events.NpcRemoveEvent -import de.oliver.fancynpcs.api.events.NpcSpawnEvent -import org.bukkit.Location -import org.bukkit.entity.Player -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class FancyNpcsNamespace : NpcProvider { - - override val type = NpcProviderType.FANCY_NPCS - override val pluginName = "FancyNpcs" - override val supportsCreation = true - - private lateinit var fancyNpcPlugin: FancyNpcsPlugin - - override fun onEnable() { - fancyNpcPlugin = FancyNpcsPlugin.get() - } - - override fun registerListeners(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) { - pluginManager.registerEvents(NpcInteractListener(events), plugin) - pluginManager.registerEvents(LifecycleListener(events), plugin) - } - - override fun exists(reference: String): Boolean = find(reference) != null - - override fun references(): List = fancyNpcPlugin.npcManager.allNpcs - .map { it.data.id } - - override fun location(reference: String): Location? = find(reference)?.data?.location - - override fun create(id: String, player: Player, location: Location): String { - require(fancyNpcPlugin.npcManager.getNpc(id) == null) { "FancyNPC $id already exists" } - - val npcData = NpcData(id, player.uniqueId, location).apply { - setDisplayName("") - setTurnToPlayer(true) - setTurnToPlayerDistance(NpcCreationDefaults.LOOK_AT_PLAYER_DISTANCE) - setMirrorSkin(true) - } - val npc = fancyNpcPlugin.npcAdapter.apply(npcData) - check(NpcCreateEvent(npc, player).callEvent()) { "FancyNPC creation was cancelled" } - npc.create() - fancyNpcPlugin.npcManager.registerNpc(npc) - npc.spawnForAll() - fancyNpcPlugin.npcManager.saveNpcs(true) - return npc.data.id - } - - override fun delete(reference: String): Boolean { - val npc = find(reference) ?: return false - npc.removeForAll() - fancyNpcPlugin.npcManager.removeNpc(npc) - fancyNpcPlugin.npcManager.saveNpcs(true) - return true - } - - private fun find(reference: String): Npc? = fancyNpcPlugin.npcManager.getNpcById(reference) - - private class LifecycleListener(private val events: NpcProviderEvents) : Listener { - @EventHandler - fun onSpawn(event: NpcSpawnEvent) = events.spawn(NpcProviderType.FANCY_NPCS, event.npc.data.id) - - @EventHandler - fun onRemove(event: NpcRemoveEvent) = events.remove(NpcProviderType.FANCY_NPCS, event.npc.data.id) - } -} diff --git a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/listener/NpcInteractListener.kt b/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/listener/NpcInteractListener.kt deleted file mode 100644 index 240dc97..0000000 --- a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/listener/NpcInteractListener.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.simplecloud.npc.namespace.fancynpcs.listener - -import app.simplecloud.npc.namespace.fancynpcs.option.FancyNpcsOptionProviders -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import de.oliver.fancynpcs.api.actions.ActionTrigger -import de.oliver.fancynpcs.api.events.NpcInteractEvent -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class NpcInteractListener( - private val events: NpcProviderEvents, -) : Listener { - - @EventHandler - fun handleInteract(event: NpcInteractEvent) { - val interaction = if (event.interactionType == ActionTrigger.RIGHT_CLICK) { - PlayerInteraction.RIGHT_CLICK - } else { - PlayerInteraction.LEFT_CLICK - } - events.interact( - NpcProviderType.FANCY_NPCS, - event.npc.data.id, - event.player, - interaction, - FancyNpcsOptionProviders.createInteractOptionProviders(event.npc), - ) - } -} diff --git a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/option/FancyNpcsOptionProviders.kt b/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/option/FancyNpcsOptionProviders.kt deleted file mode 100644 index c044994..0000000 --- a/npc-namespace/npc-namespace-fancynpcs/src/main/kotlin/app/simplecloud/npc/namespace/fancynpcs/option/FancyNpcsOptionProviders.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.namespace.fancynpcs.option - -import app.simplecloud.npc.shared.option.DefaultOptions -import app.simplecloud.npc.shared.option.Option -import app.simplecloud.npc.shared.option.OptionProvider -import de.oliver.fancynpcs.api.Npc - -/** - * @author Niklas Nieberler - */ - -object FancyNpcsOptionProviders { - - /** - * Creates a [OptionProvider] for the interacting usage of npcs - * @param npc the npc - */ - fun createInteractOptionProviders(npc: Npc) = OptionProvider.with( - Option.of(DefaultOptions.NPC_ID, npc.data.name), - Option.of(DefaultOptions.NPC_NAME, npc.data.name), - Option.of(DefaultOptions.NPC_UUID, npc.data.id), - Option.of(DefaultOptions.NPC_ENTITY_ID, npc.entityId), - ) - -} \ No newline at end of file diff --git a/npc-namespace/npc-namespace-mythicmobs/build.gradle.kts b/npc-namespace/npc-namespace-mythicmobs/build.gradle.kts deleted file mode 100644 index cb064b9..0000000 --- a/npc-namespace/npc-namespace-mythicmobs/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencies { - compileOnly(rootProject.libs.mythic.dist) - - implementation(project(":npc-shared")) -} diff --git a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MobIdFetcher.kt b/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MobIdFetcher.kt deleted file mode 100644 index cc658fa..0000000 --- a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MobIdFetcher.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.simplecloud.npc.namespace.mythicmobs - -import io.lumine.mythic.core.mobs.ActiveMob - -/** - * @author Niklas Nieberler - */ - -object MobIdFetcher { - - fun fetch(mob: ActiveMob): String { - val id = mob.uniqueId.toString().take(6) - return "${mob.mobType}-$id" - } - -} \ No newline at end of file diff --git a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MythicMobsNamespace.kt b/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MythicMobsNamespace.kt deleted file mode 100644 index 251997b..0000000 --- a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/MythicMobsNamespace.kt +++ /dev/null @@ -1,50 +0,0 @@ -package app.simplecloud.npc.namespace.mythicmobs - -import app.simplecloud.npc.namespace.mythicmobs.listener.MythicMobInteractListener -import app.simplecloud.npc.shared.provider.NpcProvider -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.event.listener.listenEvent -import io.lumine.mythic.bukkit.MythicBukkit -import io.lumine.mythic.bukkit.events.MythicMobDespawnEvent -import io.lumine.mythic.bukkit.events.MythicMobSpawnEvent -import org.bukkit.Bukkit -import org.bukkit.Location -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager - -class MythicMobsNamespace : NpcProvider { - override val type = NpcProviderType.MYTHIC_MOBS - override val pluginName = "MythicMobs" - - override fun registerListeners(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) { - pluginManager.registerEvents(MythicMobInteractListener(events), plugin) - listenEvent(plugin) - .addAction { events.spawn(type, MobIdFetcher.fetch(it.mob)) } - listenEvent(plugin) - .addAction { events.remove(type, MobIdFetcher.fetch(it.mob)) } - } - - override fun exists(reference: String): Boolean = find(reference) != null - - override fun references(): List = MythicBukkit.inst().mobManager.activeMobs - .filterNot { it.isDead } - .map(MobIdFetcher::fetch) - .distinct() - - override fun location(reference: String): Location? { - val abstractLocation = find(reference)?.location ?: return null - val world = Bukkit.getWorld(abstractLocation.world.name) ?: return null - return Location( - world, - abstractLocation.x, - abstractLocation.y, - abstractLocation.z, - abstractLocation.yaw, - abstractLocation.pitch, - ) - } - - private fun find(reference: String) = MythicBukkit.inst().mobManager.activeMobs - .firstOrNull { !it.isDead && MobIdFetcher.fetch(it) == reference } -} diff --git a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/listener/MythicMobInteractListener.kt b/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/listener/MythicMobInteractListener.kt deleted file mode 100644 index 5129839..0000000 --- a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/listener/MythicMobInteractListener.kt +++ /dev/null @@ -1,26 +0,0 @@ -package app.simplecloud.npc.namespace.mythicmobs.listener - -import app.simplecloud.npc.namespace.mythicmobs.MobIdFetcher -import app.simplecloud.npc.namespace.mythicmobs.option.MythicMobsOptionProviders -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import io.lumine.mythic.bukkit.events.MythicMobInteractEvent -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class MythicMobInteractListener( - private val events: NpcProviderEvents, -) : Listener { - - @EventHandler - fun handleInteract(event: MythicMobInteractEvent) { - events.interact( - NpcProviderType.MYTHIC_MOBS, - MobIdFetcher.fetch(event.activeMob), - event.player, - PlayerInteraction.RIGHT_CLICK, - MythicMobsOptionProviders.createInteractOptionProviders(event.activeMob), - ) - } -} diff --git a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/option/MythicMobsOptionProviders.kt b/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/option/MythicMobsOptionProviders.kt deleted file mode 100644 index 3064e76..0000000 --- a/npc-namespace/npc-namespace-mythicmobs/src/main/kotlin/app/simplecloud/npc/namespace/mythicmobs/option/MythicMobsOptionProviders.kt +++ /dev/null @@ -1,26 +0,0 @@ -package app.simplecloud.npc.namespace.mythicmobs.option - -import app.simplecloud.npc.namespace.mythicmobs.MobIdFetcher -import app.simplecloud.npc.shared.option.DefaultOptions -import app.simplecloud.npc.shared.option.Option -import app.simplecloud.npc.shared.option.OptionProvider -import io.lumine.mythic.core.mobs.ActiveMob - -/** - * @author Niklas Nieberler - */ - -object MythicMobsOptionProviders { - - /** - * Creates a [OptionProvider] for the interacting usage of npcs - * @param mob the active mob - */ - fun createInteractOptionProviders(mob: ActiveMob) = OptionProvider.with( - Option.of(DefaultOptions.NPC_ID, MobIdFetcher.fetch(mob)), - Option.of(DefaultOptions.NPC_NAME, mob.name), - Option.of(DefaultOptions.NPC_UUID, mob.uniqueId), - Option.of(DefaultOptions.NPC_ENTITY_ID, mob.entity.entityId), - ) - -} \ No newline at end of file diff --git a/npc-namespace/npc-namespace-standalone/build.gradle.kts b/npc-namespace/npc-namespace-standalone/build.gradle.kts deleted file mode 100644 index 02314fa..0000000 --- a/npc-namespace/npc-namespace-standalone/build.gradle.kts +++ /dev/null @@ -1,3 +0,0 @@ -dependencies { - implementation(project(":npc-shared")) -} \ No newline at end of file diff --git a/npc-namespace/npc-namespace-znpcs-plus/build.gradle.kts b/npc-namespace/npc-namespace-znpcs-plus/build.gradle.kts deleted file mode 100644 index 794de57..0000000 --- a/npc-namespace/npc-namespace-znpcs-plus/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencies { - compileOnly(rootProject.libs.znpcsplus.api) - - implementation(project(":npc-shared")) -} diff --git a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/ZNpcsPlusNamespace.kt b/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/ZNpcsPlusNamespace.kt deleted file mode 100644 index ab6b3d3..0000000 --- a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/ZNpcsPlusNamespace.kt +++ /dev/null @@ -1,86 +0,0 @@ -package app.simplecloud.npc.namespace.znpcsplus - -import app.simplecloud.npc.namespace.znpcsplus.listener.NpcInteractListener -import app.simplecloud.npc.shared.provider.NpcCreationDefaults -import app.simplecloud.npc.shared.provider.NpcProvider -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.event.listener.listenEvent -import lol.pyr.znpcsplus.api.NpcApiProvider -import lol.pyr.znpcsplus.api.event.NpcDespawnEvent -import lol.pyr.znpcsplus.api.event.NpcSpawnEvent -import lol.pyr.znpcsplus.api.skin.SkinDescriptor -import lol.pyr.znpcsplus.util.LookType -import lol.pyr.znpcsplus.util.NpcLocation -import org.bukkit.Location -import org.bukkit.entity.Player -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager - -class ZNpcsPlusNamespace : NpcProvider { - override val type = NpcProviderType.ZNPCS_PLUS - override val pluginName = "ZNPCsPlus" - override val supportsCreation = true - - override fun registerListeners(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) { - pluginManager.registerEvents(NpcInteractListener(events), plugin) - listenEvent(plugin).addAction { events.spawn(type, it.entry.id) } - listenEvent(plugin).addAction { events.remove(type, it.entry.id) } - } - - override fun exists(reference: String): Boolean = NpcApiProvider.get().npcRegistry.getById(reference) != null - - override fun references(): Collection = NpcApiProvider.get().npcRegistry.allIds - - override fun location(reference: String): Location? { - val npc = NpcApiProvider.get().npcRegistry.getById(reference)?.npc ?: return null - return npc.location.toBukkitLocation(npc.world) - } - - override fun create(id: String, player: Player, location: Location): String { - val api = NpcApiProvider.get() - val registry = api.npcRegistry - require(registry.getById(id) == null) { "ZNPCsPlus NPC $id already exists" } - - val playerType = checkNotNull(api.npcTypeRegistry.getByName("player")) { - "ZNPCsPlus player NPC type is unavailable" - } - val entry = registry.create(id, location.world, playerType, NpcLocation(location)) - - return try { - val npc = entry.npc - npc.setProperty( - checkNotNull(api.propertyRegistry.getByName("look", LookType::class.java)) { - "ZNPCsPlus look property is unavailable" - }, - LookType.PER_PLAYER, - ) - npc.setProperty( - checkNotNull(api.propertyRegistry.getByName("look_distance", Double::class.java)) { - "ZNPCsPlus look-distance property is unavailable" - }, - NpcCreationDefaults.LOOK_AT_PLAYER_DISTANCE.toDouble(), - ) - npc.setProperty( - checkNotNull(api.propertyRegistry.getByName("skin", SkinDescriptor::class.java)) { - "ZNPCsPlus skin property is unavailable" - }, - api.skinDescriptorFactory.createMirrorDescriptor(), - ) - entry.enableEverything() - registry.save() - entry.id - } catch (exception: Exception) { - registry.delete(entry.id) - throw exception - } - } - - override fun delete(reference: String): Boolean { - val registry = NpcApiProvider.get().npcRegistry - if (registry.getById(reference) == null) return false - registry.delete(reference) - registry.save() - return registry.getById(reference) == null - } -} diff --git a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/listener/NpcInteractListener.kt b/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/listener/NpcInteractListener.kt deleted file mode 100644 index 14ddc37..0000000 --- a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/listener/NpcInteractListener.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.simplecloud.npc.namespace.znpcsplus.listener - -import app.simplecloud.npc.namespace.znpcsplus.option.ZNpcsPlusOptionProviders -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderType -import lol.pyr.znpcsplus.api.event.NpcInteractEvent -import lol.pyr.znpcsplus.api.interaction.InteractionType -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class NpcInteractListener( - private val events: NpcProviderEvents, -) : Listener { - - @EventHandler - fun handleInteract(event: NpcInteractEvent) { - val interaction = if (event.clickType == InteractionType.RIGHT_CLICK) { - PlayerInteraction.RIGHT_CLICK - } else { - PlayerInteraction.LEFT_CLICK - } - events.interact( - NpcProviderType.ZNPCS_PLUS, - event.entry.id, - event.player, - interaction, - ZNpcsPlusOptionProviders.createInteractOptionProviders(event.entry), - ) - } -} diff --git a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/option/ZNpcsPlusOptionProviders.kt b/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/option/ZNpcsPlusOptionProviders.kt deleted file mode 100644 index 80bd975..0000000 --- a/npc-namespace/npc-namespace-znpcs-plus/src/main/kotlin/app/simplecloud/npc/namespace/znpcsplus/option/ZNpcsPlusOptionProviders.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.namespace.znpcsplus.option - -import app.simplecloud.npc.shared.option.DefaultOptions -import app.simplecloud.npc.shared.option.Option -import app.simplecloud.npc.shared.option.OptionProvider -import lol.pyr.znpcsplus.api.npc.NpcEntry - -/** - * @author Niklas Nieberler - */ - -object ZNpcsPlusOptionProviders { - - /** - * Creates a [OptionProvider] for the interacting usage of npcs - * @param npc the npc - */ - fun createInteractOptionProviders(npc: NpcEntry) = OptionProvider.with( - Option.of(DefaultOptions.NPC_ID, npc.id), - Option.of(DefaultOptions.NPC_NAME, ""), - Option.of(DefaultOptions.NPC_UUID, npc.npc.uuid), - Option.of(DefaultOptions.NPC_ENTITY_ID, 0), - ) - -} \ No newline at end of file diff --git a/npc-paper/build.gradle.kts b/npc-paper/build.gradle.kts deleted file mode 100644 index 77412c8..0000000 --- a/npc-paper/build.gradle.kts +++ /dev/null @@ -1,60 +0,0 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -plugins { - alias(libs.plugins.minotaur) -} - -dependencies { - implementation(rootProject.libs.bundles.cloud) - implementation(rootProject.libs.bundles.configurate) - - implementation(rootProject.libs.simplecloud.plugin) - - implementation(project(":npc-shared")) - implementation(project(":npc-namespace:npc-namespace-citizens")) - implementation(project(":npc-namespace:npc-namespace-fancynpcs")) - implementation(project(":npc-namespace:npc-namespace-mythicmobs")) - implementation(project(":npc-namespace:npc-namespace-znpcs-plus")) -} - -tasks.named("shadowJar", ShadowJar::class) { - mergeServiceFiles() - archiveFileName.set("simplecloud-npc.jar") -} - -modrinth { - token.set(project.findProperty("modrinthToken") as String? ?: System.getenv("MODRINTH_TOKEN")) - projectId.set("eyNPY9oJ") - versionNumber.set(rootProject.version.toString()) - versionType.set("release") - uploadFile.set(tasks.shadowJar) - gameVersions.addAll( - "1.20", - "1.20.1", - "1.20.2", - "1.20.3", - "1.20.4", - "1.20.5", - "1.20.6", - "1.21", - "1.21.1", - "1.21.2", - "1.21.3", - "1.21.4", - "1.21.5", - "1.21.6", - "1.21.7", - "1.21.8", - "1.21.9", - "1.21.10", - "1.21.11", - "26.1", - "26.1.1", - "26.1.2", - "26.2", - "26.3", - ) - loaders.addAll("paper", "purpur") - changelog.set("https://docs.simplecloud.app/changelog") - syncBodyFrom.set(rootProject.file("README.md").readText()) -} diff --git a/npc-paper/src/main/java/org/incendo/cloud/bukkit/parser/ItemStackParser.java b/npc-paper/src/main/java/org/incendo/cloud/bukkit/parser/ItemStackParser.java deleted file mode 100644 index 294445c..0000000 --- a/npc-paper/src/main/java/org/incendo/cloud/bukkit/parser/ItemStackParser.java +++ /dev/null @@ -1,358 +0,0 @@ -// -// MIT License -// -// Copyright (c) 2024 Incendo -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// -// Backport for Paper 26.3 from https://github.com/Incendo/cloud-minecraft/issues/174. -// Based on cloud-bukkit 2.0.0; remove when upstream ships the fallback. -package org.incendo.cloud.bukkit.parser; - -import com.google.common.base.Suppliers; -import com.mojang.brigadier.arguments.ArgumentType; -import com.mojang.brigadier.exceptions.CommandSyntaxException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import org.apiguardian.api.API; -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.inventory.ItemStack; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.incendo.cloud.brigadier.parser.WrappedBrigadierParser; -import org.incendo.cloud.bukkit.BukkitCommandManager; -import org.incendo.cloud.bukkit.data.ProtoItemStack; -import org.incendo.cloud.bukkit.internal.CommandBuildContextSupplier; -import org.incendo.cloud.bukkit.internal.CraftBukkitReflection; -import org.incendo.cloud.bukkit.internal.MinecraftArgumentTypes; -import org.incendo.cloud.component.CommandComponent; -import org.incendo.cloud.context.CommandContext; -import org.incendo.cloud.context.CommandInput; -import org.incendo.cloud.parser.ArgumentParseResult; -import org.incendo.cloud.parser.ArgumentParser; -import org.incendo.cloud.parser.ParserDescriptor; -import org.incendo.cloud.suggestion.BlockingSuggestionProvider; -import org.incendo.cloud.suggestion.SuggestionProvider; - -import static java.util.Objects.requireNonNull; - -/** - * Parser for a {@link Material} and optional extra NBT data into a {@link ProtoItemStack}. - * - *

This parser only provides basic suggestions by default. On Minecraft 1.13 and newer, enabling Brigadier - * compatibility through {@link BukkitCommandManager#registerBrigadier()} will allow client side validation and - * suggestions to be utilized.

- * - * @param Command sender type - * @since 1.5.0 - */ -public class ItemStackParser implements ArgumentParser.FutureArgumentParser { - - /** - * Creates a new item stack parser. - * - * @param command sender type - * @return the created parser - * @since 2.0.0 - */ - @API(status = API.Status.STABLE, since = "2.0.0") - public static @NonNull ParserDescriptor itemStackParser() { - return ParserDescriptor.of(new ItemStackParser<>(), ProtoItemStack.class); - } - - /** - * Returns a {@link CommandComponent.Builder} using {@link #itemStackParser()} as the parser. - * - * @param the command sender type - * @return the component builder - * @since 2.0.0 - */ - @API(status = API.Status.STABLE, since = "2.0.0") - public static CommandComponent.@NonNull Builder itemStackComponent() { - return CommandComponent.builder().parser(itemStackParser()); - } - - private final ArgumentParser parser; - - private static @Nullable Class findItemInputClass() { - final Class[] classes = new Class[]{ - CraftBukkitReflection.findNMSClass("ArgumentPredicateItemStack"), - CraftBukkitReflection.findMCClass("commands.arguments.item.ArgumentPredicateItemStack"), - CraftBukkitReflection.findMCClass("commands.arguments.item.ItemInput") - }; - for (final Class clazz : classes) { - if (clazz != null) { - return clazz; - } - } - return null; - } - - /** - * Create a new {@link ItemStackParser}. - * - * @since 1.5.0 - */ - public ItemStackParser() { - if (findItemInputClass() != null) { - this.parser = new ModernParser<>(); - } else { - this.parser = new LegacyParser<>(); - } - } - - @Override - public final @NonNull CompletableFuture<@NonNull ArgumentParseResult> parseFuture( - final @NonNull CommandContext commandContext, - final @NonNull CommandInput commandInput - ) { - return this.parser.parseFuture(commandContext, commandInput); - } - - @Override - public final @NonNull SuggestionProvider suggestionProvider() { - return this.parser.suggestionProvider(); - } - - - private static final class ModernParser implements ArgumentParser.FutureArgumentParser { - - private static final Class NMS_ITEM_STACK_CLASS = CraftBukkitReflection.needNMSClassOrElse( - "ItemStack", - "net.minecraft.world.item.ItemStack" - ); - private static final Class CRAFT_ITEM_STACK_CLASS = - CraftBukkitReflection.needOBCClass("inventory.CraftItemStack"); - private static final Supplier> ARGUMENT_ITEM_STACK_CLASS = - Suppliers.memoize(() -> MinecraftArgumentTypes.getClassByKey(NamespacedKey.minecraft("item_stack"))); - private static final Class ITEM_INPUT_CLASS = requireNonNull(findItemInputClass(), "ItemInput class"); - private static final Class NMS_ITEM_CLASS = CraftBukkitReflection.needNMSClassOrElse( - "Item", - "net.minecraft.world.item.Item" - ); - private static final Supplier GET_MATERIAL_METHOD = Suppliers.memoize(() -> CraftBukkitReflection - .needMethod(CraftBukkitReflection.needOBCClass("util.CraftMagicNumbers"), "getMaterial", NMS_ITEM_CLASS)); - private static final Method CREATE_ITEM_STACK_METHOD = CraftBukkitReflection.firstNonNullOrThrow( - () -> "Couldn't find createItemStack method on ItemInput", - CraftBukkitReflection.findMethod(ITEM_INPUT_CLASS, "a", int.class, boolean.class), - CraftBukkitReflection.findMethod(ITEM_INPUT_CLASS, "createItemStack", int.class, boolean.class), - CraftBukkitReflection.findMethod(ITEM_INPUT_CLASS, "createItemStack", int.class) - ); - private static final Method AS_BUKKIT_STACK_METHOD = CraftBukkitReflection.firstNonNullOrThrow( - () -> "Couldn't find asBukkitCopy or asCraftMirror method on CraftItemStack", - CraftBukkitReflection.findMethod(CRAFT_ITEM_STACK_CLASS, "asBukkitCopy", NMS_ITEM_STACK_CLASS), - CraftBukkitReflection.findMethod(CRAFT_ITEM_STACK_CLASS, "asCraftMirror", NMS_ITEM_STACK_CLASS), - CraftBukkitReflection.findMethod(CRAFT_ITEM_STACK_CLASS, "asBukkitMirror", NMS_ITEM_STACK_CLASS) - ); - private static final Field ITEM_FIELD = CraftBukkitReflection.firstNonNullOrThrow( - () -> "Couldn't find item field on ItemInput", - CraftBukkitReflection.findField(ITEM_INPUT_CLASS, "b"), - CraftBukkitReflection.findField(ITEM_INPUT_CLASS, "item") - ); - private static final Field EXTRA_DATA_FIELD = CraftBukkitReflection.firstNonNullOrThrow( - () -> "Couldn't find tag field on ItemInput", - CraftBukkitReflection.findField(ITEM_INPUT_CLASS, "c"), - CraftBukkitReflection.findField(ITEM_INPUT_CLASS, "tag"), - CraftBukkitReflection.findField(ITEM_INPUT_CLASS, "components") - ); - private static final Class HOLDER_CLASS = CraftBukkitReflection.findMCClass("core.Holder"); - private static final @Nullable Method VALUE_METHOD = HOLDER_CLASS == null - ? null - : CraftBukkitReflection.firstNonNullOrThrow( - () -> "Couldn't find Holder#value", - CraftBukkitReflection.findMethod(HOLDER_CLASS, "value"), - CraftBukkitReflection.findMethod(HOLDER_CLASS, "a") - ); - private static final Class NBT_TAG_CLASS = CraftBukkitReflection.firstNonNullOrThrow( - () -> "Cloud not find net.minecraft.nbt.Tag", - CraftBukkitReflection.findClass("net.minecraft.nbt.Tag"), - CraftBukkitReflection.findClass("net.minecraft.nbt.NBTBase"), - CraftBukkitReflection.findNMSClass("NBTBase") - ); - - private final ArgumentParser parser; - - ModernParser() { - this.parser = this.createParser(); - } - - @SuppressWarnings("unchecked") - private ArgumentParser createParser() { - final Supplier> inst = () -> { - final Constructor ctr = ARGUMENT_ITEM_STACK_CLASS.get().getDeclaredConstructors()[0]; - try { - if (ctr.getParameterCount() == 0) { - return (ArgumentType) ctr.newInstance(); - } else { - // 1.19+ - return (ArgumentType) ctr.newInstance(CommandBuildContextSupplier.commandBuildContext()); - } - } catch (final ReflectiveOperationException e) { - throw new RuntimeException("Failed to initialize modern ItemStack parser.", e); - } - }; - return new WrappedBrigadierParser(inst) - .flatMapSuccess((ctx, itemInput) -> ArgumentParseResult.successFuture( - new ModernProtoItemStack(itemInput))); - } - - @Override - public @NonNull CompletableFuture<@NonNull ArgumentParseResult<@NonNull ProtoItemStack>> parseFuture( - final @NonNull CommandContext<@NonNull C> commandContext, - final @NonNull CommandInput commandInput - ) { - // Minecraft has a parser for this - just use it - return this.parser.parseFuture(commandContext, commandInput); - } - - @Override - public @NonNull SuggestionProvider suggestionProvider() { - return this.parser.suggestionProvider(); - } - - - private static final class ModernProtoItemStack implements ProtoItemStack { - - private final Object itemInput; - private final Material material; - private final boolean hasExtraData; - - ModernProtoItemStack(final @NonNull Object itemInput) { - this.itemInput = itemInput; - try { - Object item = ITEM_FIELD.get(itemInput); - if (HOLDER_CLASS != null && HOLDER_CLASS.isInstance(item)) { - item = VALUE_METHOD.invoke(item); - } - this.material = (Material) GET_MATERIAL_METHOD.get().invoke(null, item); - final Object extraData = EXTRA_DATA_FIELD.get(itemInput); - if (NBT_TAG_CLASS.isInstance(extraData) || extraData == null) { - this.hasExtraData = extraData != null; - } else { - final List isEmptyMethod = Arrays.stream(extraData.getClass().getMethods()) - .filter(it -> it.getParameterCount() == 0 && it.getReturnType().equals(boolean.class)) - .collect(Collectors.toList()); - if (isEmptyMethod.size() != 1) { - throw new IllegalStateException( - "Failed to locate DataComponentMap/Patch#isEmpty; size=" + isEmptyMethod.size()); - } - this.hasExtraData = !(boolean) isEmptyMethod.get(0).invoke(extraData); - } - } catch (final ReflectiveOperationException ex) { - throw new RuntimeException(ex); - } - } - - @Override - public @NonNull Material material() { - return this.material; - } - - @Override - public boolean hasExtraData() { - return this.hasExtraData; - } - - @Override - public @NonNull ItemStack createItemStack(final int stackSize) { - try { - final Object nmsItemStack = CREATE_ITEM_STACK_METHOD.getParameterCount() == 1 - ? CREATE_ITEM_STACK_METHOD.invoke(this.itemInput, stackSize) - : CREATE_ITEM_STACK_METHOD.invoke(this.itemInput, stackSize, true); - return (ItemStack) AS_BUKKIT_STACK_METHOD.invoke( - null, - nmsItemStack - ); - } catch (final InvocationTargetException ex) { - final Throwable cause = ex.getCause(); - if (cause instanceof CommandSyntaxException) { - throw new IllegalArgumentException(cause.getMessage(), cause); - } - throw new RuntimeException(ex); - } catch (final ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - } - } - - private static final class LegacyParser implements ArgumentParser.FutureArgumentParser, - BlockingSuggestionProvider.Strings { - - private final ArgumentParser parser = new MaterialParser() - .mapSuccess((ctx, material) -> CompletableFuture.completedFuture(new LegacyProtoItemStack(material))); - - @Override - public @NonNull CompletableFuture<@NonNull ArgumentParseResult<@NonNull ProtoItemStack>> parseFuture( - final @NonNull CommandContext<@NonNull C> commandContext, - final @NonNull CommandInput commandInput - ) { - return this.parser.parseFuture(commandContext, commandInput); - } - - @Override - public @NonNull Iterable<@NonNull String> stringSuggestions(final @NonNull CommandContext commandContext, - final @NonNull CommandInput input) { - return Arrays.stream(Material.values()) - .filter(Material::isItem) - .map(value -> value.name().toLowerCase(Locale.ROOT)) - .collect(Collectors.toList()); - } - - private static final class LegacyProtoItemStack implements ProtoItemStack { - - private final Material material; - - private LegacyProtoItemStack(final @NonNull Material material) { - this.material = material; - } - - @Override - public @NonNull Material material() { - return this.material; - } - - @Override - public boolean hasExtraData() { - return false; - } - - @Override - public @NonNull ItemStack createItemStack(final int stackSize) throws IllegalArgumentException { - if (stackSize > this.material.getMaxStackSize()) { - throw new IllegalArgumentException(String.format( - "The maximum stack size for %s is %d", - this.material, - this.material.getMaxStackSize() - )); - } - return new ItemStack(this.material, stackSize); - } - } - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PaperPlugin.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PaperPlugin.kt deleted file mode 100644 index 90323f0..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PaperPlugin.kt +++ /dev/null @@ -1,71 +0,0 @@ -package app.simplecloud.npc.plugin.paper - -import app.simplecloud.npc.plugin.paper.command.CommandHandler -import app.simplecloud.npc.plugin.paper.command.message.CommandMessages -import app.simplecloud.npc.plugin.paper.namespace.NamespaceService -import app.simplecloud.npc.shared.cloud.CloudService -import app.simplecloud.npc.shared.config.NpcConfigMigration -import app.simplecloud.npc.shared.namespace.NpcNamespace -import org.bukkit.plugin.java.JavaPlugin -import java.util.logging.Level - -class PaperPlugin : JavaPlugin() { - - private var namespace: NpcNamespace? = null - - override fun onEnable() { - try { - when (val result = PluginDataFolderMigration.migrate(dataFolder.toPath())) { - PluginDataFolderMigration.Result.None -> Unit - PluginDataFolderMigration.Result.Moved -> logger.info( - "Migrated the plugin data folder from ${PluginDataFolderMigration.LEGACY_FOLDER_NAME} to ${dataFolder.name}." - ) - PluginDataFolderMigration.Result.Merged -> logger.info( - "Merged the legacy ${PluginDataFolderMigration.LEGACY_FOLDER_NAME} data into ${dataFolder.name}." - ) - is PluginDataFolderMigration.Result.MergedWithConflicts -> logger.warning( - "Merged legacy plugin data, but kept conflicting files in ${PluginDataFolderMigration.LEGACY_FOLDER_NAME}: " + - result.paths.joinToString() - ) - } - } catch (exception: Exception) { - logger.log(Level.SEVERE, "Could not migrate the legacy plugin data folder; disabling to protect its data.", exception) - server.pluginManager.disablePlugin(this) - return - } - - server.messenger.registerOutgoingPluginChannel(this, "BungeeCord") - CommandMessages.initialize(this) - - val registry = NamespaceService.createProviderRegistry() - val availableProviders = registry.availableProviders(server.pluginManager) - if (availableProviders.isEmpty()) { - logger.warning("No supported NPC provider is installed. Install Citizens, FancyNPCs, ZNPCsPlus, or MythicMobs.") - server.pluginManager.disablePlugin(this) - return - } - - logger.info("Available NPC providers: ${availableProviders.joinToString { it.type.commandName }}") - - val npcDirectory = dataFolder.toPath().resolve("npcs") - val namespace = NpcNamespace(registry, npcDirectory) - this.namespace = namespace - namespace.onEnable(this) - NpcConfigMigration.backupOutdated(npcDirectory) - namespace.npcRepository.loadAndWatch() - namespace.hologramManager.registerFileRequest() - CloudService.eventHandler.registerEvents(namespace) - CommandHandler(namespace, this).parseCommands() - - namespace.npcRepository.findAll().forEach(namespace.hologramManager::createOrUpdate) - } - - override fun onDisable() { - CloudService.eventHandler.unregisterEvents() - namespace?.apply { - npcRepository.stopWatching() - hologramManager.destroyAllHolograms() - onDisable() - } - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigration.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigration.kt deleted file mode 100644 index e416a59..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigration.kt +++ /dev/null @@ -1,105 +0,0 @@ -package app.simplecloud.npc.plugin.paper - -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes -import java.util.UUID - -internal object PluginDataFolderMigration { - const val LEGACY_FOLDER_NAME = "SimpleCloud-NPC" - - fun migrate(dataFolder: Path): Result { - val parent = dataFolder.parent ?: return Result.None - val legacyFolder = findExactEntry(parent, LEGACY_FOLDER_NAME) ?: return Result.None - - if (Files.exists(dataFolder) && Files.isSameFile(legacyFolder, dataFolder)) { - renameCaseOnly(legacyFolder, dataFolder) - return Result.Moved - } - - if (!Files.exists(dataFolder)) { - Files.move(legacyFolder, dataFolder) - return Result.Moved - } - - val conflicts = mergeDirectories(legacyFolder, dataFolder) - return if (conflicts.isEmpty()) Result.Merged else Result.MergedWithConflicts(conflicts) - } - - internal fun mergeDirectories(source: Path, target: Path): List { - require(Files.isDirectory(source)) { "Legacy plugin data path is not a directory: $source" } - require(Files.isDirectory(target)) { "Plugin data path is not a directory: $target" } - - val conflicts = mutableListOf() - Files.walkFileTree(source, object : SimpleFileVisitor() { - override fun preVisitDirectory(directory: Path, attributes: BasicFileAttributes): FileVisitResult { - val relative = source.relativize(directory) - val destination = target.resolve(relative) - if (Files.exists(destination) && !Files.isDirectory(destination)) { - conflicts.add(relative) - return FileVisitResult.SKIP_SUBTREE - } - Files.createDirectories(destination) - return FileVisitResult.CONTINUE - } - - override fun visitFile(file: Path, attributes: BasicFileAttributes): FileVisitResult { - val relative = source.relativize(file) - val destination = target.resolve(relative) - when { - !Files.exists(destination) -> Files.move(file, destination) - filesMatch(file, destination) -> Files.delete(file) - else -> conflicts.add(relative) - } - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(directory: Path, exception: java.io.IOException?): FileVisitResult { - exception?.let { throw it } - if (isEmpty(directory)) Files.delete(directory) - return FileVisitResult.CONTINUE - } - }) - return conflicts.sortedBy(Path::toString) - } - - private fun findExactEntry(parent: Path, name: String): Path? { - if (!Files.isDirectory(parent)) return null - return Files.newDirectoryStream(parent).use { entries -> - entries.firstOrNull { it.fileName.toString() == name } - } - } - - private fun renameCaseOnly(source: Path, target: Path) { - if (source.fileName.toString() == target.fileName.toString()) return - val temporary = source.parent.resolve(".simplecloud-npc-migration-${UUID.randomUUID()}") - Files.move(source, temporary) - try { - Files.move(temporary, target) - } catch (exception: Exception) { - try { - Files.move(temporary, source) - } catch (rollbackException: Exception) { - exception.addSuppressed(rollbackException) - } - throw exception - } - } - - private fun filesMatch(first: Path, second: Path): Boolean { - return Files.isRegularFile(first) && Files.isRegularFile(second) && Files.mismatch(first, second) == -1L - } - - private fun isEmpty(directory: Path): Boolean { - return Files.newDirectoryStream(directory).use { entries -> !entries.iterator().hasNext() } - } - - sealed interface Result { - data object None : Result - data object Moved : Result - data object Merged : Result - data class MergedWithConflicts(val paths: List) : Result - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/AbstractNpcCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/AbstractNpcCommand.kt deleted file mode 100644 index 4ba0ef3..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/AbstractNpcCommand.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command - -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.plugin.api.shared.extension.text -import org.bukkit.command.CommandSender - -abstract class AbstractNpcCommand( - namespace: NpcNamespace, -) : CommandSuggestions(namespace) { - - fun findPlayerInteraction(sender: CommandSender, value: String): PlayerInteraction? { - return PlayerInteraction.getOrNull(value) ?: run { - sender.sendMessage(text("$PREFIX <#dc2626>Unknown interaction <#f8fafc>$value<#dc2626>.")) - null - } - } - - fun findNpcConfig(sender: CommandSender, id: String): NpcConfig? { - return namespace.npcRepository.findBySelector(id) ?: run { - sender.sendMessage(text("$PREFIX <#dc2626>NPC <#f8fafc>$id <#dc2626>was not found.")) - null - } - } - - protected fun saveConfig( - sender: CommandSender, - config: NpcConfig, - successMessage: String, - refreshHologram: Boolean = true, - ) { - namespace.npcRepository.save(config) - if (refreshHologram) { - namespace.hologramManager.createOrUpdate(config) - } - sender.sendMessage(text("$PREFIX <#a3e635>$successMessage")) - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandExtension.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandExtension.kt deleted file mode 100644 index 3ba2f94..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandExtension.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command - -/** - * @author Niklas Nieberler - */ - -const val commandName = "simplecloudnpcs|scnpcs|scnpc" - -const val PREFIX = "<#0ea5e9>NPCs <#475569>|" -const val COMMAND_PERMISSION = "simplecloud.npcs.command" diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandHandler.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandHandler.kt deleted file mode 100644 index bdcfcc5..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandHandler.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command - -import app.simplecloud.npc.plugin.paper.command.global.NpcCommand -import app.simplecloud.npc.plugin.paper.command.edit.NpcActionCommand -import app.simplecloud.npc.plugin.paper.command.edit.NpcHologramCommand -import app.simplecloud.npc.plugin.paper.command.edit.NpcPushbackCommand -import app.simplecloud.npc.plugin.paper.command.edit.NpcTargetCommand -import app.simplecloud.npc.shared.namespace.NpcNamespace -import io.papermc.paper.command.brigadier.CommandSourceStack -import org.bukkit.plugin.java.JavaPlugin -import org.incendo.cloud.annotations.AnnotationParser -import org.incendo.cloud.execution.ExecutionCoordinator -import org.incendo.cloud.paper.PaperCommandManager -import org.incendo.cloud.parser.standard.StringParser - -/** - * @author Niklas Nieberler - */ - -class CommandHandler( - private val namespace: NpcNamespace, - private val javaPlugin: JavaPlugin -) { - - private val annotationParser = AnnotationParser(createCommandManager(), CommandSourceStack::class.java) - - fun parseCommands() { - this.annotationParser.parse(listOf( - NpcCommand(this.namespace), - NpcTargetCommand(this.namespace), - NpcHologramCommand(this.namespace), - NpcPushbackCommand(this.namespace), - NpcActionCommand(this.namespace), - )) - } - - private fun createCommandManager(): PaperCommandManager { - val commandManager = PaperCommandManager.builder() - .executionCoordinator(ExecutionCoordinator.simpleCoordinator()) - .buildOnEnable(this.javaPlugin) - commandManager.parserRegistry().registerNamedParser( - "greedyString", - StringParser.greedyStringParser(), - ) - return commandManager - } - -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandSuggestions.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandSuggestions.kt deleted file mode 100644 index c5816f6..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/CommandSuggestions.kt +++ /dev/null @@ -1,100 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command - -import app.simplecloud.npc.plugin.paper.command.message.CommandMessages -import app.simplecloud.npc.shared.bridge.TargetResolver -import app.simplecloud.npc.shared.cloud.CloudService -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.provider.NpcProviderType -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.future.await -import org.bukkit.Bukkit -import org.bukkit.Registry -import io.papermc.paper.command.brigadier.CommandSourceStack -import org.incendo.cloud.context.CommandContext -import org.incendo.cloud.annotations.suggestion.Suggestions - -open class CommandSuggestions( - protected val namespace: NpcNamespace, -) { - @Suggestions("npcIds") - fun suggestNpcId(): List = namespace.npcRepository.selectors() - - @Suggestions("targets") - fun suggestTargets(): List = runBlocking { TargetResolver.suggestions() } - - @Suggestions("providers") - fun suggestProviders(): List { - return namespace.providerRegistry.availableProviders(Bukkit.getPluginManager()) - .map { it.type.commandName } - .sorted() - } - - @Suggestions("creationProviders") - fun suggestCreationProviders(): List { - return namespace.providerRegistry.availableProviders(Bukkit.getPluginManager()) - .filter { it.supportsCreation } - .map { it.type.commandName } - .sorted() - } - - @Suggestions("providerReferences") - fun suggestProviderReferences(context: CommandContext): List { - val providerName = context.optional("provider").orElse(null) ?: return emptyList() - val providerType = NpcProviderType.getOrNull(providerName) ?: return emptyList() - val provider = namespace.providerRegistry.getAvailable(providerType, Bukkit.getPluginManager()) - ?: return emptyList() - return provider.references() - .filter { namespace.npcRepository.findByProvider(providerType, it) == null } - .sorted() - } - - @Suggestions("configuredTargets") - fun suggestConfiguredTargets(context: CommandContext): List { - val selector = context.optional("id").orElse(null) ?: return emptyList() - return namespace.npcRepository.findBySelector(selector) - ?.targetServers - ?.sortedWith(String.CASE_INSENSITIVE_ORDER) - .orEmpty() - } - - @Suggestions("servers") - fun suggestServers(): List = runBlocking { - buildList { - addAll(CloudService.cloudApi.server().allServers.await().map { "${it.group.name}-${it.numericalId}" }) - addAll(CloudService.cloudApi.persistentServer().allPersistentServers.await().map { it.name }) - }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) - } - - @Suggestions("joinStates") - fun suggestJoinStates(context: CommandContext): List { - val selector = context.optional("id").orElse(null) - val config = selector?.let(namespace.npcRepository::findBySelector) - return buildList { - add("default") - add("maintenance") - config?.hologram?.layouts?.mapTo(this) { it.joinState.lowercase() } - config?.actions?.mapTo(this) { it.joinState.lowercase() } - }.distinct().sorted() - } - - @Suggestions("helpTopics") - fun suggestHelpTopics(): List = CommandMessages.helpTopics - - @Suggestions("playerInteractions") - fun suggestPlayerInteraction(): List = namespace.getAvailablePlayerInteractions().map { it.name.lowercase() } - - @Suggestions("actionTypes") - fun suggestActionTypes(): List = listOf( - "join-target", - "play-sound", - "execute-command", - "send-message", - "teleport", - "send-title", - "send-to-server", - "transfer-to-server", - ) - - @Suggestions("sounds") - fun suggestSounds(): List = Registry.SOUNDS.mapNotNull { Registry.SOUNDS.getKey(it)?.asString() }.sorted() -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcActionCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcActionCommand.kt deleted file mode 100644 index 2f372be..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcActionCommand.kt +++ /dev/null @@ -1,303 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.edit - -import app.simplecloud.npc.plugin.paper.command.AbstractNpcCommand -import app.simplecloud.npc.plugin.paper.command.COMMAND_PERMISSION -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.plugin.paper.command.commandName -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.plugin.api.shared.extension.text -import io.papermc.paper.command.brigadier.CommandSourceStack -import net.kyori.adventure.key.Key -import org.bukkit.entity.Player -import org.incendo.cloud.annotations.Argument -import org.incendo.cloud.annotations.Command -import org.incendo.cloud.annotations.Permission - -class NpcActionCommand(namespace: NpcNamespace) : AbstractNpcCommand(namespace) { - - @Command("$commandName edit action") - @Permission(COMMAND_PERMISSION) - fun info(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - val visibleActions = config.actions.filter { it.configuredTypes().isNotEmpty() } - if (visibleActions.isEmpty()) { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>NPC <#f8fafc>$id <#f59e0b>has no actions.")) - return - } - var message = text("$PREFIX <#0ea5e9>Actions for <#f8fafc>$id") - visibleActions.forEach { - message = message.appendNewline().append( - text(" <#475569>- <#f8fafc>${it.interactionType.name.lowercase()} <#475569>(${it.joinState}) -> <#e2e8f0>${it.configuredTypes().joinToString()}") - ) - } - sender.sender.sendMessage(message) - } - - @Command("$commandName edit action join-target ") - @Permission(COMMAND_PERMISSION) - fun joinTarget( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interactionName: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("enabled") enabled: Boolean, - ) = update(sender, id, interactionName, joinState, "join-target") { it.joinTarget = enabled } - - @Command("$commandName edit action play-sound ") - @Permission(COMMAND_PERMISSION) - fun playSound( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("sound", suggestions = "sounds") sound: String, - ) { - runCatching { Key.key(sound) }.getOrElse { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Sound <#f8fafc>$sound <#dc2626>is invalid.")) - return - } - update(sender, id, interaction, joinState, "play-sound") { it.playSound = sound } - } - - @Command("$commandName edit action execute-command ") - @Permission(COMMAND_PERMISSION) - fun executeCommand( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("command", parserName = "greedyString") command: String, - ) = update(sender, id, interaction, joinState, "execute-command") { - it.executeCommand = command.removePrefix("/") - } - - @Command("$commandName edit action send-message ") - @Permission(COMMAND_PERMISSION) - fun sendMessage( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("message", parserName = "greedyString") message: String, - ) = update(sender, id, interaction, joinState, "send-message") { it.sendMessage = message } - - @Command("$commandName edit action teleport-here ") - @Permission(COMMAND_PERMISSION) - fun teleportHere( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - ) { - val player = sender.sender as? Player ?: run { - sender.sender.sendMessage(text("$PREFIX <#dc2626>This command can only be used by a player.")) - return - } - val location = player.location - update(sender, id, interaction, joinState, "teleport") { - it.teleport = NpcConfig.TeleportConfiguration( - location.world.name, - location.x, - location.y, - location.z, - location.yaw, - location.pitch, - ) - } - } - - @Command("$commandName edit action send-title <subtitle>") - @Permission(COMMAND_PERMISSION) - fun sendTitle( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("title") title: String, - @Argument("subtitle") subtitle: String, - ) = update(sender, id, interaction, joinState, "send-title") { - it.sendTitle = NpcConfig.TitleConfiguration(title, subtitle) - } - - @Command("$commandName edit <id> action title <interaction> <joinState> <title>") - @Permission(COMMAND_PERMISSION) - fun title( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("title", parserName = "greedyString") title: String, - ) = update(sender, id, interaction, joinState, "send-title") { - it.sendTitle = (it.sendTitle ?: NpcConfig.TitleConfiguration()).copy(title = title) - } - - @Command("$commandName edit <id> action subtitle <interaction> <joinState> <subtitle>") - @Permission(COMMAND_PERMISSION) - fun subtitle( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("subtitle", parserName = "greedyString") subtitle: String, - ) = update(sender, id, interaction, joinState, "send-title") { - it.sendTitle = (it.sendTitle ?: NpcConfig.TitleConfiguration()).copy(subtitle = subtitle) - } - - @Command("$commandName edit <id> action title-duration <interaction> <joinState> <fadeIn> <stay> <fadeOut>") - @Permission(COMMAND_PERMISSION) - fun titleDuration( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("fadeIn") fadeIn: Int, - @Argument("stay") stay: Int, - @Argument("fadeOut") fadeOut: Int, - ) { - if (fadeIn < 0 || stay < 0 || fadeOut < 0) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Title durations cannot be negative.")) - return - } - update(sender, id, interaction, joinState, "title-duration") { - val current = it.sendTitle ?: NpcConfig.TitleConfiguration() - it.sendTitle = current.copy(fadeIn = fadeIn, stay = stay, fadeOut = fadeOut) - } - } - - @Command("$commandName edit <id> action send-to-server <interaction> <joinState> <server>") - @Permission(COMMAND_PERMISSION) - fun sendToServer( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("server", suggestions = "servers") server: String, - ) = update(sender, id, interaction, joinState, "send-to-server") { it.sendToServer = server } - - @Command("$commandName edit <id> action transfer-to-server <interaction> <joinState> <address>") - @Permission(COMMAND_PERMISSION) - fun transferToServer( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interaction: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("address") address: String, - ) { - if (!ADDRESS_PATTERN.matches(address)) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Address <#f8fafc>$address <#dc2626>is invalid.")) - return - } - update(sender, id, interaction, joinState, "transfer-to-server") { it.transferToServer = address } - } - - @Command("$commandName edit <id> action clear <interaction> <joinState> <actionType>") - @Permission(COMMAND_PERMISSION) - fun clear( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interactionName: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("actionType", suggestions = "actionTypes") actionType: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val interaction = findPlayerInteraction(sender.sender, interactionName) ?: return - val action = config.actions.firstOrNull { - it.interactionType == interaction && it.joinState.equals(joinState, true) - } ?: run { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>No action is configured for that interaction and join state.")) - return - } - if (!clear(action, actionType)) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Unknown action type <#f8fafc>$actionType<#dc2626>.")) - return - } - if (action.isEmpty()) config.actions.remove(action) - saveConfig(sender.sender, config, "Cleared <#f8fafc>$actionType <#a3e635>for NPC <#f8fafc>$id<#a3e635>.", false) - } - - @Command("$commandName edit <id> action clear <interaction> <joinState>") - @Permission(COMMAND_PERMISSION) - fun clearInteraction( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("interaction", suggestions = "playerInteractions") interactionName: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val interaction = findPlayerInteraction(sender.sender, interactionName) ?: return - val removed = config.actions.removeIf { - it.interactionType == interaction && it.joinState.equals(joinState, true) - } - if (!removed) { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>No action is configured for that interaction and join state.")) - return - } - saveConfig(sender.sender, config, "Cleared actions for NPC <#f8fafc>$id<#a3e635>.", false) - } - - private fun update( - sender: CommandSourceStack, - id: String, - interactionName: String, - joinState: String, - actionName: String, - update: (NpcConfig.ActionConfiguration) -> Unit, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val interaction = findPlayerInteraction(sender.sender, interactionName) ?: return - val state = joinState.lowercase() - if (state.isBlank()) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Join state cannot be empty.")) - return - } - val action = config.actions.firstOrNull { - it.interactionType == interaction && it.joinState.equals(state, true) - } ?: NpcConfig.ActionConfiguration(interactionType = interaction, joinState = state) - .also(config.actions::add) - update(action) - saveConfig( - sender.sender, - config, - "Set <#f8fafc>$actionName <#a3e635>for NPC <#f8fafc>$id <#a3e635>on <#f8fafc>${interaction.name.lowercase()}<#a3e635>.", - false, - ) - } - - private fun clear(action: NpcConfig.ActionConfiguration, type: String): Boolean { - return when (type.lowercase()) { - "join-target" -> action.joinTarget = false - "open-inventory" -> action.openInventory = null - "play-sound" -> action.playSound = null - "execute-command" -> action.executeCommand = null - "send-message" -> action.sendMessage = null - "teleport" -> action.teleport = null - "send-title" -> action.sendTitle = null - "send-to-server" -> action.sendToServer = null - "transfer-to-server" -> action.transferToServer = null - else -> return false - }.let { true } - } - - private fun NpcConfig.ActionConfiguration.isEmpty(): Boolean { - return !joinTarget && openInventory == null && playSound == null && executeCommand == null && - sendMessage == null && teleport == null && sendTitle == null && sendToServer == null && - transferToServer == null - } - - private fun NpcConfig.ActionConfiguration.configuredTypes(): List<String> = buildList { - if (joinTarget) add("join-target") - if (playSound != null) add("play-sound") - if (executeCommand != null) add("execute-command") - if (sendMessage != null) add("send-message") - if (teleport != null) add("teleport") - if (sendTitle != null) add("send-title") - if (sendToServer != null) add("send-to-server") - if (transferToServer != null) add("transfer-to-server") - } - - companion object { - private val ADDRESS_PATTERN = Regex("^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$") - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcHologramCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcHologramCommand.kt deleted file mode 100644 index 59d83c6..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcHologramCommand.kt +++ /dev/null @@ -1,119 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.edit - -import app.simplecloud.npc.plugin.paper.command.AbstractNpcCommand -import app.simplecloud.npc.plugin.paper.command.COMMAND_PERMISSION -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.plugin.paper.command.commandName -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.plugin.api.shared.extension.text -import io.papermc.paper.command.brigadier.CommandSourceStack -import org.incendo.cloud.annotations.Argument -import org.incendo.cloud.annotations.Command -import org.incendo.cloud.annotations.Permission - -class NpcHologramCommand(namespace: NpcNamespace) : AbstractNpcCommand(namespace) { - - @Command("$commandName edit <id> hologram") - @Permission(COMMAND_PERMISSION) - fun info(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - val states = config.hologram.layouts.joinToString { "${it.joinState}:${it.lines.size}" }.ifBlank { "none" } - sender.sender.sendMessage( - text("$PREFIX <#0ea5e9>Hologram <#f8fafc>$id<#475569>: <#e2e8f0>${if (config.hologram.enabled) "enabled" else "disabled"}, height ${config.hologram.startHeight}, layouts $states") - ) - } - - @Command("$commandName edit <id> hologram enable") - @Permission(COMMAND_PERMISSION) - fun enable(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - setEnabled(sender, id, true) - } - - @Command("$commandName edit <id> hologram disable") - @Permission(COMMAND_PERMISSION) - fun disable(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - setEnabled(sender, id, false) - } - - @Command("$commandName edit <id> hologram height <height>") - @Permission(COMMAND_PERMISSION) - fun height( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("height") height: Double, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - if (height !in 0.0..10.0) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Hologram height must be between 0 and 10.")) - return - } - config.hologram.startHeight = height - saveConfig(sender.sender, config, "Set hologram height for NPC <#f8fafc>$id <#a3e635>to <#f8fafc>$height<#a3e635>.") - } - - @Command("$commandName edit <id> hologram line add <joinState> <content>") - @Permission(COMMAND_PERMISSION) - fun addLine( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("content", parserName = "greedyString") content: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val layout = layout(config, joinState) - layout.lines += HologramConfiguration(content) - saveConfig(sender.sender, config, "Added hologram line <#f8fafc>${layout.lines.size} <#a3e635>for NPC <#f8fafc>$id<#a3e635>.") - } - - @Command("$commandName edit <id> hologram line set <joinState> <line> <content>") - @Permission(COMMAND_PERMISSION) - fun setLine( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("line") line: Int, - @Argument("content", parserName = "greedyString") content: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val layout = config.hologram.layouts.firstOrNull { it.joinState.equals(joinState, true) } - if (layout == null || line !in 1..layout.lines.size) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Hologram line <#f8fafc>$line <#dc2626>does not exist.")) - return - } - layout.lines[line - 1] = layout.lines[line - 1].copy(text = content) - saveConfig(sender.sender, config, "Set hologram line <#f8fafc>$line <#a3e635>for NPC <#f8fafc>$id<#a3e635>.") - } - - @Command("$commandName edit <id> hologram line remove <joinState> <line>") - @Permission(COMMAND_PERMISSION) - fun removeLine( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("joinState", suggestions = "joinStates") joinState: String, - @Argument("line") line: Int, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val layout = config.hologram.layouts.firstOrNull { it.joinState.equals(joinState, true) } - if (layout == null || line !in 1..layout.lines.size) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Hologram line <#f8fafc>$line <#dc2626>does not exist.")) - return - } - layout.lines.removeAt(line - 1) - saveConfig(sender.sender, config, "Removed hologram line <#f8fafc>$line <#a3e635>from NPC <#f8fafc>$id<#a3e635>.") - } - - private fun setEnabled(sender: CommandSourceStack, id: String, enabled: Boolean) { - val config = findNpcConfig(sender.sender, id) ?: return - config.hologram.enabled = enabled - val state = if (enabled) "enabled" else "disabled" - saveConfig(sender.sender, config, "Hologram for NPC <#f8fafc>$id <#a3e635>was $state.") - } - - private fun layout(config: NpcConfig, joinState: String): NpcConfig.HologramLayout { - val state = joinState.lowercase() - return config.hologram.layouts.firstOrNull { it.joinState.equals(state, true) } - ?: NpcConfig.HologramLayout(state).also(config.hologram.layouts::add) - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcPushbackCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcPushbackCommand.kt deleted file mode 100644 index a8ee6ef..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcPushbackCommand.kt +++ /dev/null @@ -1,99 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.edit - -import app.simplecloud.npc.plugin.paper.command.AbstractNpcCommand -import app.simplecloud.npc.plugin.paper.command.COMMAND_PERMISSION -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.plugin.paper.command.commandName -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.plugin.api.shared.extension.text -import io.papermc.paper.command.brigadier.CommandSourceStack -import net.kyori.adventure.key.Key -import org.incendo.cloud.annotations.Argument -import org.incendo.cloud.annotations.Command -import org.incendo.cloud.annotations.Permission - -class NpcPushbackCommand(namespace: NpcNamespace) : AbstractNpcCommand(namespace) { - - @Command("$commandName edit <id> pushback") - @Permission(COMMAND_PERMISSION) - fun info(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - val pushback = config.pushback - sender.sender.sendMessage( - text("$PREFIX <#0ea5e9>Pushback <#f8fafc>$id<#475569>: <#e2e8f0>${if (pushback.enabled) "enabled" else "disabled"}, radius ${pushback.radius}, strength ${pushback.strength}, vertical ${pushback.vertical}") - ) - } - - @Command("$commandName edit <id> pushback enable") - @Permission(COMMAND_PERMISSION) - fun enable(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, true) - - @Command("$commandName edit <id> pushback disable") - @Permission(COMMAND_PERMISSION) - fun disable(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, false) - - @Command("$commandName edit <id> pushback radius <value>") - @Permission(COMMAND_PERMISSION) - fun radius(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) { - updateNumber(sender, id, "radius", value, 0.1..20.0) { it.pushback.radius = value } - } - - @Command("$commandName edit <id> pushback strength <value>") - @Permission(COMMAND_PERMISSION) - fun strength(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) { - updateNumber(sender, id, "strength", value, 0.0..10.0) { it.pushback.strength = value } - } - - @Command("$commandName edit <id> pushback vertical <value>") - @Permission(COMMAND_PERMISSION) - fun vertical(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) { - updateNumber(sender, id, "vertical boost", value, 0.0..5.0) { it.pushback.vertical = value } - } - - @Command("$commandName edit <id> pushback sound <sound>") - @Permission(COMMAND_PERMISSION) - fun sound( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("sound", suggestions = "sounds") sound: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - if (runCatching { Key.key(sound) }.isFailure) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Sound <#f8fafc>$sound <#dc2626>is invalid.")) - return - } - config.pushback.sound = sound - saveConfig(sender.sender, config, "Set pushback sound for NPC <#f8fafc>$id <#a3e635>to <#f8fafc>$sound<#a3e635>.", false) - } - - @Command("$commandName edit <id> pushback sound clear") - @Permission(COMMAND_PERMISSION) - fun clearSound(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - config.pushback.sound = null - saveConfig(sender.sender, config, "Cleared the pushback sound for NPC <#f8fafc>$id<#a3e635>.", false) - } - - private fun setEnabled(sender: CommandSourceStack, id: String, enabled: Boolean) { - val config = findNpcConfig(sender.sender, id) ?: return - config.pushback.enabled = enabled - saveConfig(sender.sender, config, "Pushback for NPC <#f8fafc>$id <#a3e635>was ${if (enabled) "enabled" else "disabled"}.", false) - } - - private fun updateNumber( - sender: CommandSourceStack, - id: String, - property: String, - value: Double, - range: ClosedFloatingPointRange<Double>, - update: (app.simplecloud.npc.shared.config.NpcConfig) -> Unit, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - if (value !in range) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Pushback $property must be between ${range.start} and ${range.endInclusive}.")) - return - } - update(config) - saveConfig(sender.sender, config, "Set pushback $property for NPC <#f8fafc>$id <#a3e635>to <#f8fafc>$value<#a3e635>.", false) - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcTargetCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcTargetCommand.kt deleted file mode 100644 index 74e5ff4..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/edit/NpcTargetCommand.kt +++ /dev/null @@ -1,78 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.edit - -import app.simplecloud.npc.plugin.paper.command.AbstractNpcCommand -import app.simplecloud.npc.plugin.paper.command.COMMAND_PERMISSION -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.plugin.paper.command.commandName -import app.simplecloud.npc.plugin.paper.command.message.CommandMessages -import app.simplecloud.npc.shared.bridge.TargetResolution -import app.simplecloud.npc.shared.bridge.TargetResolver -import app.simplecloud.npc.shared.manager.NpcFailure -import app.simplecloud.npc.shared.manager.NpcOperationResult -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.plugin.api.shared.extension.text -import io.papermc.paper.command.brigadier.CommandSourceStack -import kotlinx.coroutines.runBlocking -import org.incendo.cloud.annotations.Argument -import org.incendo.cloud.annotations.Command -import org.incendo.cloud.annotations.Permission - -class NpcTargetCommand(namespace: NpcNamespace) : AbstractNpcCommand(namespace) { - - @Command("$commandName edit <id> target list") - @Permission(COMMAND_PERMISSION) - fun list(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - sender.sender.sendMessage( - text("$PREFIX <#0ea5e9>Targets for <#f8fafc>${config.id}<#475569>: <#e2e8f0>${config.targetServers.joinToString()}") - ) - } - - @Command("$commandName edit <id> target add <target>") - @Permission(COMMAND_PERMISSION) - fun add( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("target", suggestions = "targets") targetName: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val target = when (val resolution = runBlocking { TargetResolver.resolve(targetName) }) { - is TargetResolution.Found -> resolution.target.name - TargetResolution.Ambiguous -> { - CommandMessages.sendResult(sender.sender, NpcOperationResult.Failure(NpcFailure.TARGET_AMBIGUOUS, targetName), "") - return - } - TargetResolution.NotFound -> { - CommandMessages.sendResult(sender.sender, NpcOperationResult.Failure(NpcFailure.TARGET_NOT_FOUND, targetName), "") - return - } - } - if (config.targetServers.any { it.equals(target, true) }) { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>NPC <#f8fafc>$id <#f59e0b>already uses target <#f8fafc>$target<#f59e0b>.")) - return - } - config.targetServers += target - saveConfig(sender.sender, config, "Added target <#f8fafc>$target <#a3e635>to NPC <#f8fafc>$id<#a3e635>.") - } - - @Command("$commandName edit <id> target remove <target>") - @Permission(COMMAND_PERMISSION) - fun remove( - sender: CommandSourceStack, - @Argument("id", suggestions = "npcIds") id: String, - @Argument("target", suggestions = "configuredTargets") targetName: String, - ) { - val config = findNpcConfig(sender.sender, id) ?: return - val target = config.targetServers.firstOrNull { it.equals(targetName, true) } - if (target == null) { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>NPC <#f8fafc>$id <#f59e0b>does not use target <#f8fafc>$targetName<#f59e0b>.")) - return - } - if (config.targetServers.size == 1) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>NPC <#f8fafc>$id <#dc2626>must keep at least one target.")) - return - } - config.targetServers.remove(target) - saveConfig(sender.sender, config, "Removed target <#f8fafc>$target <#a3e635>from NPC <#f8fafc>$id<#a3e635>.") - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/global/NpcCommand.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/global/NpcCommand.kt deleted file mode 100644 index 5554b5e..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/global/NpcCommand.kt +++ /dev/null @@ -1,182 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.global - -import app.simplecloud.npc.plugin.paper.command.AbstractNpcCommand -import app.simplecloud.npc.plugin.paper.command.COMMAND_PERMISSION -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.plugin.paper.command.commandName -import app.simplecloud.npc.plugin.paper.command.message.CommandMessages -import app.simplecloud.npc.shared.manager.NpcOperationResult -import app.simplecloud.npc.shared.bridge.TargetResolution -import app.simplecloud.npc.shared.bridge.TargetResolver -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.plugin.api.shared.extension.text -import io.papermc.paper.command.brigadier.CommandSourceStack -import kotlinx.coroutines.runBlocking -import org.bukkit.Bukkit -import org.bukkit.entity.Player -import org.incendo.cloud.annotations.Argument -import org.incendo.cloud.annotations.Command -import org.incendo.cloud.annotations.Permission - -class NpcCommand( - namespace: NpcNamespace, -) : AbstractNpcCommand(namespace) { - - @Command(commandName) - @Permission(COMMAND_PERMISSION) - fun root(sender: CommandSourceStack) = CommandMessages.sendHelp(sender.sender) - - @Command("$commandName help") - @Permission(COMMAND_PERMISSION) - fun help(sender: CommandSourceStack) = CommandMessages.sendHelp(sender.sender) - - @Command("$commandName help <topic>") - @Permission(COMMAND_PERMISSION) - fun helpTopic( - sender: CommandSourceStack, - @Argument("topic", suggestions = "helpTopics") topic: String, - ) = CommandMessages.sendHelp(sender.sender, topic) - - @Command("$commandName placeholders") - @Permission(COMMAND_PERMISSION) - fun placeholders(sender: CommandSourceStack) { - sender.sender.sendMessage( - text("$PREFIX <#0ea5e9>Target placeholders").appendNewline() - .append(text("<#e2e8f0><target_name>, <target_type>, <target_online_players>, <target_max_players>")).appendNewline() - .append(text("<#e2e8f0><target_min_memory>, <target_max_memory>, <target_property:key>")).appendNewline() - .append(text("<#94a3b8>Persistent server: <target_id>, <target_pretty_name>, <target_motd>")) - ) - } - - @Command("$commandName edit <id>") - @Permission(COMMAND_PERMISSION) - fun edit(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - sender.sender.sendMessage(text("$PREFIX <#0ea5e9>Edit <#f8fafc>${config.id}<#475569>: <#e2e8f0>target, hologram, action, or pushback")) - } - - @Command("$commandName create <id> <target>") - @Permission(COMMAND_PERMISSION) - fun create( - sender: CommandSourceStack, - @Argument("id") id: String, - @Argument("target", suggestions = "targets") target: String, - ) = create(sender, id, target, null) - - @Command("$commandName create <id> <target> <provider>") - @Permission(COMMAND_PERMISSION) - fun createWithProvider( - sender: CommandSourceStack, - @Argument("id") id: String, - @Argument("target", suggestions = "targets") target: String, - @Argument("provider", suggestions = "creationProviders") provider: String, - ) = create(sender, id, target, provider) - - private fun create(sender: CommandSourceStack, id: String, target: String, providerName: String?) { - val player = sender.sender as? Player ?: run { - sender.sender.sendMessage(text("$PREFIX <#dc2626>This command can only be used by a player.")) - return - } - val provider = providerName?.let(NpcProviderType::getOrNull) - if (providerName != null && provider == null) { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Unknown provider <#f8fafc>$providerName<#dc2626>.")) - return - } - val result = runBlocking { namespace.npcManager.create(id, target, provider, player) } - CommandMessages.sendResult(player, result, "Created NPC") - } - - @Command("$commandName link <id> <provider> <reference> <target>") - @Permission(COMMAND_PERMISSION) - fun link( - sender: CommandSourceStack, - @Argument("id") id: String, - @Argument("provider", suggestions = "providers") providerName: String, - @Argument("reference", suggestions = "providerReferences") reference: String, - @Argument("target", suggestions = "targets") target: String, - ) { - val provider = NpcProviderType.getOrNull(providerName) ?: run { - sender.sender.sendMessage(text("$PREFIX <#dc2626>Unknown provider <#f8fafc>$providerName<#dc2626>.")) - return - } - CommandMessages.sendResult( - sender.sender, - runBlocking { namespace.npcManager.link(id, target, provider, reference) }, - "Linked NPC", - ) - } - - @Command("$commandName unlink <id>") - @Permission(COMMAND_PERMISSION) - fun unlink(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - CommandMessages.sendResult(sender.sender, namespace.npcManager.unlink(config.id), "Unlinked NPC") - } - - @Command("$commandName delete <id>") - @Permission(COMMAND_PERMISSION) - fun delete(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - CommandMessages.sendResult(sender.sender, namespace.npcManager.delete(config.id), "Deleted NPC") - } - - @Command("$commandName list") - @Permission(COMMAND_PERMISSION) - fun list(sender: CommandSourceStack) { - val configs = namespace.npcRepository.findAll() - if (configs.isEmpty()) { - sender.sender.sendMessage(text("$PREFIX <#f59e0b>No NPCs were found.")) - return - } - var message = text("$PREFIX <#0ea5e9>Configured NPCs <#475569>(${configs.size})") - configs.sortedBy { it.id }.forEach { - message = message.appendNewline().append( - text(" <#475569>- <#f8fafc>${it.id} <#475569>(${it.provider.type.commandName})") - ) - } - sender.sender.sendMessage(message) - } - - @Command("$commandName info <id>") - @Permission(COMMAND_PERMISSION) - fun info(sender: CommandSourceStack, @Argument("id", suggestions = "npcIds") id: String) { - val config = findNpcConfig(sender.sender, id) ?: return - val provider = namespace.providerRegistry.getAvailable(config.provider.type, Bukkit.getPluginManager()) - val targetStatus = runBlocking { - config.targetServers.map { TargetResolver.resolve(it) } - } - val status = when { - provider == null -> "provider unavailable" - !provider.exists(config.provider.reference) -> "provider NPC missing" - config.targetServers.isEmpty() -> "target missing" - targetStatus.any { it == TargetResolution.Ambiguous } -> "target ambiguous" - targetStatus.any { it == TargetResolution.NotFound } -> "target missing" - else -> "complete" - } - sender.sender.sendMessage( - text("$PREFIX <#0ea5e9>NPC <#f8fafc>${config.id}").appendNewline() - .append(text(" <#94a3b8>Status<#475569>: <#e2e8f0>$status")).appendNewline() - .append(text(" <#94a3b8>Provider<#475569>: <#e2e8f0>${config.provider.type.commandName} (${config.provider.reference})")).appendNewline() - .append(text(" <#94a3b8>Ownership<#475569>: <#e2e8f0>${config.provider.ownership.name.lowercase()}")).appendNewline() - .append(text(" <#94a3b8>Targets<#475569>: <#e2e8f0>${config.targetServers.joinToString()}")).appendNewline() - .append(text(" <#94a3b8>Hologram<#475569>: <#e2e8f0>${if (config.hologram.enabled) "enabled" else "disabled"}")).appendNewline() - .append(text(" <#94a3b8>Actions<#475569>: <#e2e8f0>${config.actions.size}")) - ) - } - - @Command("$commandName reload") - @Permission(COMMAND_PERMISSION) - fun reload(sender: CommandSourceStack) { - val result = runCatching { - check(CommandMessages.reload()) - namespace.npcRepository.reload().forEach(namespace.hologramManager::createOrUpdate) - } - CommandMessages.send( - sender.sender, - if (result.isSuccess) "command.reload.success" else "command.reload.failed", - if (result.isSuccess) "$PREFIX <#a3e635>SimpleCloud NPCs was reloaded." - else "$PREFIX <#dc2626>SimpleCloud NPCs could not be reloaded.", - ) - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/message/CommandMessages.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/message/CommandMessages.kt deleted file mode 100644 index e2116ba..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/command/message/CommandMessages.kt +++ /dev/null @@ -1,207 +0,0 @@ -package app.simplecloud.npc.plugin.paper.command.message - -import app.simplecloud.npc.plugin.paper.command.PREFIX -import app.simplecloud.npc.shared.manager.NpcFailure -import app.simplecloud.npc.shared.manager.NpcOperationResult -import app.simplecloud.plugin.api.shared.extension.text -import org.bukkit.command.CommandSender -import org.bukkit.plugin.java.JavaPlugin -import org.spongepowered.configurate.ConfigurationNode -import org.spongepowered.configurate.yaml.YamlConfigurationLoader -import java.nio.file.Path - -object CommandMessages { - val helpTopics = listOf("create", "link", "edit", "target", "hologram", "action", "pushback", "admin") - - private var messagesPath: Path? = null - private var root: ConfigurationNode? = null - private var variables: Map<String, String> = emptyMap() - - fun initialize(plugin: JavaPlugin) { - plugin.saveResource("messages.yml", false) - messagesPath = plugin.dataFolder.toPath().resolve("messages.yml") - check(reload()) { "Could not load messages.yml" } - } - - fun reload(): Boolean { - val path = messagesPath ?: return false - return runCatching { - root = YamlConfigurationLoader.builder().path(path).build().load() - variables = root?.node("variables")?.childrenMap()?.mapNotNull { (key, node) -> - node.string?.let { key.toString() to it } - }?.toMap().orEmpty() - }.isSuccess - } - - fun send( - sender: CommandSender, - path: String, - fallback: String, - replacements: Map<String, Any?> = emptyMap(), - ) { - sender.sendMessage(text(render(path, fallback, replacements))) - } - - fun render(path: String, fallback: String, replacements: Map<String, Any?> = emptyMap()): String { - val configured = root?.node(*path.split('.').toTypedArray())?.string ?: fallback - return (variables + replacements.mapValues { it.value?.toString().orEmpty() }) - .entries - .fold(configured) { message, (key, value) -> message.replace("<$key>", value) } - } - - fun sendResult(sender: CommandSender, result: NpcOperationResult, success: String) { - when (result) { - is NpcOperationResult.Success -> { - val path = when (success.substringBefore(' ').lowercase()) { - "created" -> "command.npc.create.success" - "linked" -> "command.npc.link.success" - "unlinked" -> "command.npc.unlink.success" - "deleted" -> "command.npc.delete.success" - else -> "command.npc.update.success" - } - send( - sender, - path, - "$PREFIX <#a3e635>$success <#f8fafc><id><#a3e635>.", - mapOf("id" to result.config.id), - ) - } - is NpcOperationResult.Failure -> sender.sendMessage(text(failureMessage(result))) - } - } - - fun sendHelp(sender: CommandSender, topic: String? = null) { - val normalizedTopic = topic?.lowercase() - val fallbackLines = helpPage(normalizedTopic) - if (fallbackLines == null) { - send( - sender, - "command.help.unknown", - "$PREFIX <#dc2626>Unknown help topic. <#94a3b8>Try: <#f8fafc>${helpTopics.joinToString()}", - ) - return - } - - val page = normalizedTopic ?: "main" - val title = if (normalizedTopic == null) { - render("command.help.title", "$PREFIX <#0ea5e9>Commands") - } else { - render( - "command.help.topic-title", - "$PREFIX <#0ea5e9>Help <#475569>— <#f8fafc><topic>", - mapOf("topic" to normalizedTopic), - ) - } - var message = text(title) - renderLines("command.help.pages.$page", fallbackLines).forEach { line -> - message = message.appendNewline().append(text(line)) - } - sender.sendMessage(message) - } - - private fun renderLines(path: String, fallback: List<String>): List<String> { - val configured = root?.node(*path.split('.').toTypedArray()) - ?.childrenList() - ?.mapNotNull(ConfigurationNode::getString) - .orEmpty() - .ifEmpty { fallback } - return configured.map { line -> - variables.entries.fold(line) { message, (key, value) -> message.replace("<$key>", value) } - } - } - - private fun helpPage(topic: String?): List<String>? = when (topic) { - null -> listOf( - "<#38bdf8>/scnpcs create <#f8fafc><id> <target> <#475569>— <#94a3b8>Create an NPC", - "<#38bdf8>/scnpcs edit <#f8fafc><id> <#475569>— <#94a3b8>Edit an NPC", - "<#38bdf8>/scnpcs list <#475569>— <#94a3b8>List all NPCs", - "<#38bdf8>/scnpcs info <#f8fafc><id> <#475569>— <#94a3b8>View details", - "<#38bdf8>/scnpcs delete <#f8fafc><id> <#475569>— <#94a3b8>Delete an NPC", - "<#94a3b8>More: <#38bdf8>/scnpcs help <#f8fafc><topic>", - "<#94a3b8>Topics: <#f8fafc>create, link, edit, target", - "<#f8fafc>hologram, action, pushback, admin", - ) - "create" -> listOf( - "<#38bdf8>/scnpcs create <#f8fafc><id> <target> [provider]", - "<#94a3b8>Creates the provider NPC at your position and links it.", - "<#94a3b8>Example: <#f8fafc>/scnpcs create lobby Lobby", - ) - "link" -> listOf( - "<#38bdf8>/scnpcs link <#f8fafc><id> <provider>", - " <#f8fafc><reference> <target>", - "<#94a3b8>Links an NPC that already exists in a provider.", - "<#94a3b8>Example: <#f8fafc>/scnpcs link lobby citizens 12 Lobby", - ) - "edit" -> listOf( - "<#38bdf8>/scnpcs edit <#f8fafc><id> target", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram", - "<#38bdf8>/scnpcs edit <#f8fafc><id> action", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback", - "<#94a3b8>Use <#38bdf8>/scnpcs help <#f8fafc><topic> <#94a3b8>for details.", - ) - "target" -> listOf( - "<#38bdf8>/scnpcs edit <#f8fafc><id> target list", - "<#38bdf8>/scnpcs edit <#f8fafc><id> target add <target>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> target remove <target>", - ) - "hologram" -> listOf( - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram enable", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram disable", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram height <value>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram line add", - " <#f8fafc><state> <text>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram line set", - " <#f8fafc><state> <line> <text>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> hologram line remove", - " <#f8fafc><state> <line>", - ) - "action" -> listOf( - "<#38bdf8>/scnpcs edit <#f8fafc><id> action <type>", - " <#f8fafc><interaction> <state> [value]", - "<#94a3b8>Types: <#f8fafc>join-target, play-sound, send-message,", - " <#f8fafc>execute-command, teleport-here, title, subtitle,", - " <#f8fafc>send-title, title-duration,", - " <#f8fafc>send-to-server, transfer-to-server", - "<#38bdf8>/scnpcs edit <#f8fafc><id> action clear", - " <#f8fafc><interaction> <state> [type]", - ) - "pushback" -> listOf( - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback enable", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback disable", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback radius <value>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback strength <value>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback vertical <value>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback sound <sound>", - "<#38bdf8>/scnpcs edit <#f8fafc><id> pushback sound clear", - ) - "admin" -> listOf( - "<#38bdf8>/scnpcs list <#475569>— <#94a3b8>List NPCs", - "<#38bdf8>/scnpcs info <#f8fafc><id> <#475569>— <#94a3b8>View details", - "<#38bdf8>/scnpcs unlink <#f8fafc><id> <#475569>— <#94a3b8>Keep provider NPC", - "<#38bdf8>/scnpcs delete <#f8fafc><id> <#475569>— <#94a3b8>Delete completely", - "<#38bdf8>/scnpcs reload <#475569>— <#94a3b8>Reload configs", - "<#38bdf8>/scnpcs placeholders <#475569>— <#94a3b8>List placeholders", - ) - else -> null - } - - private fun failureMessage(result: NpcOperationResult.Failure): String { - val detail = result.detail.orEmpty() - val (path, fallback) = when (result.failure) { - NpcFailure.INVALID_ID -> "command.npc.error.invalid-id" to "$PREFIX <#dc2626>NPC id <#f8fafc><detail> <#dc2626>is invalid." - NpcFailure.ALREADY_EXISTS -> "command.npc.error.already-exists" to "$PREFIX <#dc2626>NPC <#f8fafc><detail> <#dc2626>already exists." - NpcFailure.NOT_FOUND -> "command.npc.error.not-found" to "$PREFIX <#dc2626>NPC <#f8fafc><detail> <#dc2626>was not found." - NpcFailure.TARGET_NOT_FOUND -> "command.target.error.not-found" to "$PREFIX <#dc2626>Target <#f8fafc><detail> <#dc2626>was not found." - NpcFailure.TARGET_AMBIGUOUS -> "command.target.error.ambiguous" to "$PREFIX <#dc2626>Target <#f8fafc><detail> <#dc2626>exists as both a group and persistent server. Rename one of them." - NpcFailure.PROVIDER_REQUIRED -> "command.provider.error.required" to "$PREFIX <#dc2626>More than one creation provider is installed; specify one." - NpcFailure.CREATION_PROVIDER_UNAVAILABLE -> "command.provider.error.creation-unavailable" to "$PREFIX <#dc2626>Install Citizens, FancyNPCs, or ZNPCsPlus to create NPCs." - NpcFailure.PROVIDER_UNAVAILABLE -> "command.provider.error.unavailable" to "$PREFIX <#dc2626>Provider <#f8fafc><detail> <#dc2626>is unavailable." - NpcFailure.PROVIDER_NPC_NOT_FOUND -> "command.provider.error.external-npc-missing" to "$PREFIX <#dc2626>Provider NPC <#f8fafc><detail> <#dc2626>was not found." - NpcFailure.PROVIDER_NPC_ALREADY_LINKED -> "command.provider.error.already-linked" to "$PREFIX <#dc2626>Provider NPC <#f8fafc><detail> <#dc2626>is already linked." - NpcFailure.CREATE_FAILED -> "command.npc.create.failed" to "$PREFIX <#dc2626>Could not create the NPC<#f8fafc><detail-suffix>" - NpcFailure.DELETE_FAILED -> "command.npc.delete.failed" to "$PREFIX <#dc2626>Could not delete NPC <#f8fafc><detail><#dc2626>." - } - val suffix = if (detail.isBlank()) "." else ": $detail" - return render(path, fallback, mapOf("detail" to detail, "detail-suffix" to suffix)) - } -} diff --git a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/namespace/NamespaceService.kt b/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/namespace/NamespaceService.kt deleted file mode 100644 index 8455402..0000000 --- a/npc-paper/src/main/kotlin/app/simplecloud/npc/plugin/paper/namespace/NamespaceService.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.simplecloud.npc.plugin.paper.namespace - -import app.simplecloud.npc.namespace.citizens.CitizensNamespace -import app.simplecloud.npc.namespace.fancynpcs.FancyNpcsNamespace -import app.simplecloud.npc.namespace.mythicmobs.MythicMobsNamespace -import app.simplecloud.npc.namespace.znpcsplus.ZNpcsPlusNamespace -import app.simplecloud.npc.shared.provider.NpcProviderRegistry - -object NamespaceService { - fun createProviderRegistry() = NpcProviderRegistry( - listOf( - CitizensNamespace(), - FancyNpcsNamespace(), - MythicMobsNamespace(), - ZNpcsPlusNamespace(), - ) - ) -} diff --git a/npc-paper/src/main/resources/messages.yml b/npc-paper/src/main/resources/messages.yml deleted file mode 100644 index b45da36..0000000 --- a/npc-paper/src/main/resources/messages.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '1' - -variables: - prefix: '<#0EA5E9><bold>NPCs</bold> <#475569>|' - -command: - help: - title: '<prefix> <#0EA5E9>Commands' - topic-title: '<prefix> <#0EA5E9>Help <#475569>— <#F8FAFC><topic>' - unknown: '<prefix> <#DC2626>Unknown help topic. <#94A3B8>Try: <#F8FAFC>create, link, edit, target, hologram, action, pushback, admin' - reload: - success: '<prefix> <#A3E635>SimpleCloud NPCs was reloaded.' - failed: '<prefix> <#DC2626>SimpleCloud NPCs could not be reloaded.' - npc: - create: - success: '<prefix> <#A3E635>Created NPC <#F8FAFC><id><#A3E635>.' - failed: '<prefix> <#DC2626>Could not create the NPC<#F8FAFC><detail-suffix>' - link: - success: '<prefix> <#A3E635>Linked NPC <#F8FAFC><id><#A3E635>.' - unlink: - success: '<prefix> <#A3E635>Unlinked NPC <#F8FAFC><id><#A3E635>.' - delete: - success: '<prefix> <#A3E635>Deleted NPC <#F8FAFC><id><#A3E635>.' - failed: '<prefix> <#DC2626>Could not delete NPC <#F8FAFC><detail><#DC2626>.' - error: - not-found: '<prefix> <#DC2626>NPC <#F8FAFC><detail> <#DC2626>was not found.' - already-exists: '<prefix> <#DC2626>NPC <#F8FAFC><detail> <#DC2626>already exists.' - invalid-id: '<prefix> <#DC2626>NPC id <#F8FAFC><detail> <#DC2626>is invalid.' - provider: - error: - required: '<prefix> <#DC2626>More than one creation provider is installed; specify one.' - creation-unavailable: '<prefix> <#DC2626>Install Citizens, FancyNPCs, or ZNPCsPlus to create NPCs.' - unavailable: '<prefix> <#DC2626>Provider <#F8FAFC><detail> <#DC2626>is unavailable.' - external-npc-missing: '<prefix> <#DC2626>Provider NPC <#F8FAFC><detail> <#DC2626>was not found.' - already-linked: '<prefix> <#DC2626>Provider NPC <#F8FAFC><detail> <#DC2626>is already linked.' - target: - error: - not-found: '<prefix> <#DC2626>Target <#F8FAFC><detail> <#DC2626>was not found.' - ambiguous: '<prefix> <#DC2626>Target <#F8FAFC><detail> <#DC2626>exists as both a group and persistent server. Rename one of them.' diff --git a/npc-paper/src/main/resources/plugin.yml b/npc-paper/src/main/resources/plugin.yml deleted file mode 100644 index f4ca25a..0000000 --- a/npc-paper/src/main/resources/plugin.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: simplecloud-npc -prefix: SimpleCloud-NPC -provides: - - SimpleCloud-NPC -version: '${version}' -author: MrManHD -main: app.simplecloud.npc.plugin.paper.PaperPlugin -api-version: 1.21 - -libraries: - - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0" - - "org.jetbrains.kotlin:kotlin-stdlib:2.4.0" - - "org.jetbrains.kotlin:kotlin-reflect:2.4.0" - -softdepend: - - Citizens - - FancyNpcs - - MythicMobs - - ZNPCsPlus - -permissions: - simplecloud.npcs.command: - description: Manage SimpleCloud NPCs - default: op diff --git a/npc-paper/src/test/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigrationTest.kt b/npc-paper/src/test/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigrationTest.kt deleted file mode 100644 index b776100..0000000 --- a/npc-paper/src/test/kotlin/app/simplecloud/npc/plugin/paper/PluginDataFolderMigrationTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package app.simplecloud.npc.plugin.paper - -import java.nio.file.Files -import kotlin.io.path.createDirectories -import kotlin.io.path.createTempDirectory -import kotlin.io.path.readText -import kotlin.io.path.writeText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class PluginDataFolderMigrationTest { - - @Test - fun `renames the legacy data folder without losing files`() { - val pluginsDirectory = createTempDirectory("npc-folder-migration") - val legacy = pluginsDirectory.resolve(PluginDataFolderMigration.LEGACY_FOLDER_NAME) - legacy.resolve("npcs").createDirectories() - legacy.resolve("messages.yml").writeText("message: legacy") - legacy.resolve("npcs/lobby.yml").writeText("id: lobby") - val current = pluginsDirectory.resolve("simplecloud-npc") - - val result = PluginDataFolderMigration.migrate(current) - - assertIs<PluginDataFolderMigration.Result.Moved>(result) - assertEquals("message: legacy", current.resolve("messages.yml").readText()) - assertEquals("id: lobby", current.resolve("npcs/lobby.yml").readText()) - assertTrue(hasExactEntry(pluginsDirectory, "simplecloud-npc")) - assertFalse(hasExactEntry(pluginsDirectory, PluginDataFolderMigration.LEGACY_FOLDER_NAME)) - } - - @Test - fun `merges files without replacing conflicting data`() { - val directory = createTempDirectory("npc-folder-merge") - val source = directory.resolve("legacy").createDirectories() - val target = directory.resolve("current").createDirectories() - source.resolve("npcs").createDirectories() - target.resolve("npcs").createDirectories() - source.resolve("npcs/legacy.yml").writeText("legacy npc") - source.resolve("messages.yml").writeText("legacy messages") - target.resolve("messages.yml").writeText("current messages") - - val conflicts = PluginDataFolderMigration.mergeDirectories(source, target) - - assertEquals(listOf("messages.yml"), conflicts.map { it.toString() }) - assertEquals("legacy npc", target.resolve("npcs/legacy.yml").readText()) - assertEquals("current messages", target.resolve("messages.yml").readText()) - assertEquals("legacy messages", source.resolve("messages.yml").readText()) - } - - @Test - fun `removes duplicate legacy files after merging`() { - val directory = createTempDirectory("npc-folder-duplicates") - val source = directory.resolve("legacy").createDirectories() - val target = directory.resolve("current").createDirectories() - source.resolve("messages.yml").writeText("same") - target.resolve("messages.yml").writeText("same") - - val conflicts = PluginDataFolderMigration.mergeDirectories(source, target) - - assertTrue(conflicts.isEmpty()) - assertFalse(Files.exists(source)) - assertEquals("same", target.resolve("messages.yml").readText()) - } - - private fun hasExactEntry(parent: java.nio.file.Path, name: String): Boolean { - return Files.newDirectoryStream(parent).use { entries -> - entries.any { it.fileName.toString() == name } - } - } -} diff --git a/npc-shared/build.gradle.kts b/npc-shared/build.gradle.kts deleted file mode 100644 index 49a7aae..0000000 --- a/npc-shared/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -dependencies { - compileOnly(rootProject.libs.bundles.configurate) - - testImplementation(rootProject.libs.bundles.configurate) - testImplementation(rootProject.libs.paper.api) - testImplementation(rootProject.libs.simplecloud) - testImplementation(rootProject.libs.simplecloud.plugin) -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/Extension.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/Extension.kt deleted file mode 100644 index 087f787..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/Extension.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.simplecloud.npc.shared - -import org.bukkit.Bukkit -import org.bukkit.NamespacedKey -import java.util.concurrent.Callable - -/** - * @author Niklas Nieberler - */ - -val hologramNamespacedKey = NamespacedKey("simplecloud", "npc.hologram") -val createAtNamespacedKey = NamespacedKey("simplecloud", "npc.hologram.createat") -val hologramLineNamespacedKey = NamespacedKey("simplecloud", "npc.hologram.line") -const val pluginName = "simplecloud-npc" - -fun <T> sync(function: () -> T): T { - if (Bukkit.isPrimaryThread()) return function() - val plugin = Bukkit.getPluginManager().getPlugin(pluginName)!! - return Bukkit.getScheduler().callSyncMethod(plugin, Callable(function)).get() -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/Action.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/Action.kt deleted file mode 100644 index 0ecd3bf..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/Action.kt +++ /dev/null @@ -1,29 +0,0 @@ -package app.simplecloud.npc.shared.action - -import app.simplecloud.npc.shared.action.handler.* - -/** - * @author Niklas Nieberler - */ - -enum class Action( - val actionHandler: ActionHandler -) { - - RUN_COMMAND(RunCommandActionHandler()), - - RUN_CONSOLE_COMMAND(RunConsoleCommandActionHandler()), - - CONNECT_TO_SERVER(ConnectToServerActionHandler()), - - TRANSFER_TO_SERVER(TransferToServerActionHandler()), - - QUICK_JOIN(QuickJoinActionHandler()); - - companion object { - fun getOrNull(name: String): Action? { - return entries.firstOrNull { it.name.equals(name, true) } - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionHandler.kt deleted file mode 100644 index fdecb62..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionHandler.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.simplecloud.npc.shared.action - -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -interface ActionHandler { - - fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) - - fun getOptions(): List<Pair<String, Any>> - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionOptions.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionOptions.kt deleted file mode 100644 index e907c52..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/ActionOptions.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.simplecloud.npc.shared.action - -/** - * @author Niklas Nieberler - */ - -object ActionOptions { - - val EXECUTE_COMMAND_NAME = Pair("command.name", "") - - val CONNECT_TO_SERVER_NAME = Pair("server.name", "") - - val TRANSFER_SERVER_IP = Pair("server.ip", "") - - val TRANSFER_SERVER_PORT = Pair("server.port", "25565") - - val SERVER_NAME_PATTERN = Pair("server.name.pattern", "<group_name>-<numerical_id>") - - val FAILED_SERVER_FINDER_MESSAGE = Pair("message.failed.server.finder", "") - - val GROUP_NAME = Pair("group.name", "") - - val QUICK_JOIN_FILTER_PLAYERS = Pair("filter.player.count", "LEAST") - - val QUICK_JOIN_FILTER_STATE = Pair("filter.server.state", "AVAILABLE") - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/TargetConnectionService.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/TargetConnectionService.kt deleted file mode 100644 index e35b133..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/TargetConnectionService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package app.simplecloud.npc.shared.action - -import app.simplecloud.api.server.ServerQuery -import app.simplecloud.api.server.ServerState -import app.simplecloud.npc.shared.bridge.TargetResolution -import app.simplecloud.npc.shared.bridge.TargetResolver -import app.simplecloud.npc.shared.bridge.TargetType -import app.simplecloud.npc.shared.cloud.CloudService -import kotlinx.coroutines.future.await - -object TargetConnectionService { - suspend fun findDestination(targetName: String): String? { - val target = (TargetResolver.resolve(targetName) as? TargetResolution.Found)?.target ?: return null - return when (target.type) { - TargetType.PERSISTENT_SERVER -> target.name - TargetType.GROUP -> { - val query = ServerQuery.create() - .filterByServerGroupName(target.name) - .filterByState(ServerState.AVAILABLE) - CloudService.cloudApi.server().getAllServers(query).await() - .minByOrNull { it.playerCount } - ?.let { "${it.group.name}-${it.numericalId}" } - } - } - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/ConnectToServerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/ConnectToServerActionHandler.kt deleted file mode 100644 index 8ddb97c..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/ConnectToServerActionHandler.kt +++ /dev/null @@ -1,32 +0,0 @@ -package app.simplecloud.npc.shared.action.handler - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionHandler -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.utils.MessageHelper -import app.simplecloud.npc.shared.utils.PlayerConnectionHelper -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class ConnectToServerActionHandler : ActionHandler { - - override fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) { - val action = ActionOptions.CONNECT_TO_SERVER_NAME - val serverName = optionProvider.getOption(action) - if (serverName.isBlank()) { - MessageHelper.printOptionNotFoundMessage(Action.CONNECT_TO_SERVER, action, optionProvider) - return - } - PlayerConnectionHelper.sendPlayerToServer(player, serverName) - } - - override fun getOptions() = listOf( - ActionOptions.CONNECT_TO_SERVER_NAME - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/QuickJoinActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/QuickJoinActionHandler.kt deleted file mode 100644 index a8385f9..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/QuickJoinActionHandler.kt +++ /dev/null @@ -1,90 +0,0 @@ -package app.simplecloud.npc.shared.action.handler - -import app.simplecloud.api.server.Server -import app.simplecloud.api.server.ServerQuery -import app.simplecloud.api.server.ServerState -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionHandler -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.cloud.CloudService -import app.simplecloud.npc.shared.enums.QuantityType -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.utils.MessageHelper -import app.simplecloud.npc.shared.utils.PlayerConnectionHelper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch -import org.bukkit.Bukkit -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class QuickJoinActionHandler : ActionHandler { - - override fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) { - if (!optionProvider.hasOption(ActionOptions.GROUP_NAME)) { - MessageHelper.printOptionNotFoundMessage(Action.QUICK_JOIN, ActionOptions.GROUP_NAME, optionProvider) - return - } - - CoroutineScope(Dispatchers.IO).launch { - val server = getQuickJoinServer(optionProvider) - if (server == null) { - sendFailedServerFinderMessage(player, optionProvider) - return@launch - } - - val serverNamePattern = optionProvider.getOption(ActionOptions.SERVER_NAME_PATTERN) - .replace("<group_name>", server.group.name) - .replace("<numerical_id>", server.numericalId.toString()) - - PlayerConnectionHelper.sendPlayerToServer(player, serverNamePattern) - } - } - - private suspend fun getQuickJoinServer(optionProvider: OptionProvider): Server? { - val groupName = optionProvider.getOption(ActionOptions.GROUP_NAME) - val serverState = optionProvider.getOption(ActionOptions.QUICK_JOIN_FILTER_STATE) - val quantityType = optionProvider.getOption(ActionOptions.QUICK_JOIN_FILTER_PLAYERS) - - val logger = Bukkit.getLogger() - if (!QuantityType.exist(quantityType)) { - logger.warning("[SimpleCloud-NPC] No possible quantity type was found in filter.player.state! Please use ${QuantityType.entries.joinToString(", ") { it.name.lowercase() }}") - return null - } - - if (!ServerState.entries.any { it.name.equals(serverState, ignoreCase = true) }) { - logger.warning("[SimpleCloud-NPC] No possible server state was found in filter.server.state! Please use ${ServerState.entries.joinToString(", ") { it.name.lowercase() }}") - return null - } - - val groupQuery = ServerQuery.create() - .filterByServerGroupName(groupName) - .filterByState(ServerState.valueOf(serverState)) - - val servers = CloudService.cloudApi.server().getAllServers(groupQuery).await() - return when (QuantityType.valueOf(quantityType.uppercase())) { - QuantityType.MOST -> servers.maxByOrNull { it.playerCount } - QuantityType.LEAST -> servers.minByOrNull { it.playerCount } - } - } - - private fun sendFailedServerFinderMessage(player: Player, optionProvider: OptionProvider) { - val serverFinderMessage = ActionOptions.FAILED_SERVER_FINDER_MESSAGE - if (optionProvider.hasOption(serverFinderMessage)) { - player.sendMessage(optionProvider.getOption(serverFinderMessage)) - } - } - - override fun getOptions() = listOf( - ActionOptions.GROUP_NAME, - ActionOptions.FAILED_SERVER_FINDER_MESSAGE, - ActionOptions.SERVER_NAME_PATTERN, - ActionOptions.QUICK_JOIN_FILTER_STATE, - ActionOptions.QUICK_JOIN_FILTER_PLAYERS, - ) -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunCommandActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunCommandActionHandler.kt deleted file mode 100644 index ec666ca..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunCommandActionHandler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.simplecloud.npc.shared.action.handler - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionHandler -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.utils.MessageHelper -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class RunCommandActionHandler : ActionHandler { - - private val placeholders = hashMapOf<String, (Player) -> Any>( - "<playername>" to { it.name }, - "<playeruuid>" to { it.uniqueId } - ) - - override fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) { - val action = ActionOptions.EXECUTE_COMMAND_NAME - val commandOption = optionProvider.getOption(action) - if (commandOption.isBlank()) { - MessageHelper.printOptionNotFoundMessage(Action.RUN_COMMAND, action, optionProvider) - return - } - val resolvedCommand = this.placeholders.entries.fold(commandOption) { it, entry -> - it.replace(entry.key, entry.value(player).toString()) - } - player.performCommand(resolvedCommand) - } - - override fun getOptions() = listOf( - ActionOptions.EXECUTE_COMMAND_NAME - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunConsoleCommandActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunConsoleCommandActionHandler.kt deleted file mode 100644 index bf88772..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/RunConsoleCommandActionHandler.kt +++ /dev/null @@ -1,40 +0,0 @@ -package app.simplecloud.npc.shared.action.handler - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionHandler -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.utils.MessageHelper -import org.bukkit.Bukkit -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class RunConsoleCommandActionHandler : ActionHandler { - - private val placeholders = hashMapOf<String, (Player) -> Any>( - "<playername>" to { it.name }, - "<playeruuid>" to { it.uniqueId } - ) - - override fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) { - val action = ActionOptions.EXECUTE_COMMAND_NAME - val commandOption = optionProvider.getOption(action) - if (commandOption.isBlank()) { - MessageHelper.printOptionNotFoundMessage(Action.RUN_CONSOLE_COMMAND, action, optionProvider) - return - } - val resolvedCommand = this.placeholders.entries.fold(commandOption) { it, entry -> - it.replace(entry.key, entry.value(player).toString()) - } - Bukkit.dispatchCommand(Bukkit.getConsoleSender(), resolvedCommand) - } - - override fun getOptions() = listOf( - ActionOptions.EXECUTE_COMMAND_NAME - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/TransferToServerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/TransferToServerActionHandler.kt deleted file mode 100644 index 471d666..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/handler/TransferToServerActionHandler.kt +++ /dev/null @@ -1,34 +0,0 @@ -package app.simplecloud.npc.shared.action.handler - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionHandler -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.utils.MessageHelper -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class TransferToServerActionHandler : ActionHandler { - - override fun handle(player: Player, namespace: NpcNamespace, optionProvider: OptionProvider) { - val serverIpAction = ActionOptions.TRANSFER_SERVER_IP - val serverPortAction = ActionOptions.TRANSFER_SERVER_PORT - val serverIp = optionProvider.getOption(serverIpAction) - val serverPort = optionProvider.getOption(serverPortAction) - if (serverIp.isBlank()) { - MessageHelper.printOptionNotFoundMessage(Action.TRANSFER_TO_SERVER, serverIpAction, optionProvider) - return - } - player.transfer(serverIp, serverPort.toInt()) - } - - override fun getOptions() = listOf( - ActionOptions.TRANSFER_SERVER_IP, - ActionOptions.TRANSFER_SERVER_PORT - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/InteractionExecutor.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/InteractionExecutor.kt deleted file mode 100644 index 63e47df..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/InteractionExecutor.kt +++ /dev/null @@ -1,93 +0,0 @@ -package app.simplecloud.npc.shared.action.interaction - -import app.simplecloud.npc.shared.action.TargetConnectionService -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.hologram.JoinStateHelper -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.sync -import app.simplecloud.npc.shared.utils.PlayerConnectionHelper -import app.simplecloud.plugin.api.shared.extension.text -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.kyori.adventure.key.Key -import net.kyori.adventure.sound.Sound -import net.kyori.adventure.title.Title -import org.bukkit.Bukkit -import org.bukkit.Location -import org.bukkit.entity.Player -import java.time.Duration - -class InteractionExecutor( - private val namespace: NpcNamespace, -) { - fun execute( - id: String, - player: Player, - playerInteraction: PlayerInteraction, - optionProvider: OptionProvider = OptionProvider(), - ) { - val config = namespace.npcRepository.find(id) ?: return - val interaction = if (player.isSneaking) { - PlayerInteraction.getOrNull("SHIFT_$playerInteraction") ?: playerInteraction - } else { - playerInteraction - } - - CoroutineScope(Dispatchers.IO).launch { - val joinState = JoinStateHelper.getJoinState(config) - val actions = config.actionsFor(interaction, joinState) - actions.forEach { executeAction(config, it, player) } - } - } - - private suspend fun executeAction(config: NpcConfig, action: NpcConfig.ActionConfiguration, player: Player) { - if (action.joinTarget) { - var destination: String? = null - for (target in config.targetServers) { - destination = TargetConnectionService.findDestination(target) - if (destination != null) break - } - destination?.let { sync { PlayerConnectionHelper.sendPlayerToServer(player, it) } } - } - - sync { - action.playSound?.let { - player.playSound(Sound.sound(Key.key(it), Sound.Source.MASTER, 1F, 1F)) - } - action.executeCommand?.let { player.performCommand(resolvePlayerPlaceholders(it, player)) } - action.sendMessage?.let { player.sendMessage(text(resolvePlayerPlaceholders(it, player))) } - action.teleport?.let { teleport(player, it) } - action.sendTitle?.let { showTitle(player, it) } - action.sendToServer?.let { PlayerConnectionHelper.sendPlayerToServer(player, it) } - action.transferToServer?.let { transfer(player, it) } - } - } - - private fun teleport(player: Player, config: NpcConfig.TeleportConfiguration) { - val world = Bukkit.getWorld(config.world) ?: return - player.teleport(Location(world, config.x, config.y, config.z, config.yaw, config.pitch)) - } - - private fun showTitle(player: Player, config: NpcConfig.TitleConfiguration) { - val times = Title.Times.times( - Duration.ofMillis(config.fadeIn * 50L), - Duration.ofMillis(config.stay * 50L), - Duration.ofMillis(config.fadeOut * 50L), - ) - player.showTitle(Title.title(text(config.title), text(config.subtitle), times)) - } - - private fun transfer(player: Player, address: String) { - val host = address.substringBefore(':') - val port = address.substringAfter(':', "25565").toIntOrNull() ?: return - player.transfer(host, port) - } - - private fun resolvePlayerPlaceholders(value: String, player: Player): String { - return value - .replace("<playername>", player.name) - .replace("<playeruuid>", player.uniqueId.toString()) - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/PlayerInteraction.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/PlayerInteraction.kt deleted file mode 100644 index 9bc7618..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/action/interaction/PlayerInteraction.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.simplecloud.npc.shared.action.interaction - -/** - * @author Niklas Nieberler - */ - -enum class PlayerInteraction { - - RIGHT_CLICK, - - LEFT_CLICK, - - SHIFT_RIGHT_CLICK, - - SHIFT_LEFT_CLICK; - - companion object { - fun getOrNull(name: String): PlayerInteraction? { - return entries.firstOrNull { it.name.equals(name, true) } - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridge.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridge.kt deleted file mode 100644 index 73d84a5..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridge.kt +++ /dev/null @@ -1,36 +0,0 @@ -package app.simplecloud.npc.shared.bridge - -import app.simplecloud.api.group.Group -import app.simplecloud.api.persistentserver.PersistentServer -import app.simplecloud.api.server.Server -import app.simplecloud.npc.shared.cloud.CloudService -import kotlinx.coroutines.future.await - -/** - * @author Niklas Nieberler - */ - -data class ServerBridge( - val name: String, - val serverId: String, - val numericalId: Int, - val properties: Map<String, Any>, - val isServer: Boolean, - val isGroup: Boolean, - val isPersistentServer: Boolean -) { - - suspend fun getGroup(): Group? { - return CloudService.cloudApi.group().getGroupByName(this.name).await() - } - - suspend fun getPersistentServer(): PersistentServer? { - return CloudService.cloudApi.persistentServer().getPersistentServerByName(this.name).await() - } - - suspend fun getServer(): Server? { - return CloudService.cloudApi.server().allServers.await() - .firstOrNull { it.group.name == this.name && it.numericalId == this.numericalId } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridgeFinder.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridgeFinder.kt deleted file mode 100644 index 0636850..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/ServerBridgeFinder.kt +++ /dev/null @@ -1,69 +0,0 @@ -package app.simplecloud.npc.shared.bridge - -import app.simplecloud.api.server.Server -import app.simplecloud.npc.shared.cloud.CloudService -import app.simplecloud.plugin.api.shared.pattern.ServerPatternIdentifier -import kotlinx.coroutines.future.await - -/** - * @author Niklas Nieberler - */ - -object ServerBridgeFinder { - - private val serverPatternIdentifier = ServerPatternIdentifier() - private val cloudApi = CloudService.cloudApi - - suspend fun find(name: String): ServerBridge? { - val persistentServer = this.cloudApi.persistentServer().allPersistentServers.await() - .firstOrNull { it.name.equals(name, true) } - val group = this.cloudApi.group().allGroups.await() - .firstOrNull { it.name.equals(name, true) } - val server = getServerByName(name) - - return when { - persistentServer != null && group != null -> null - persistentServer != null -> ServerBridge( - persistentServer.name, - persistentServer.persistentServerId, - 0, - persistentServer.properties, - isServer = false, - isGroup = false, - isPersistentServer = true - ) - - group != null -> ServerBridge( - group.name, - group.serverGroupId, - 0, - group.properties, - isServer = false, - isGroup = true, - isPersistentServer = false - ) - - server != null -> ServerBridge( - server.group.name, - server.serverGroupId, - server.numericalId, - server.properties, - isServer = true, - isGroup = false, - isPersistentServer = false - ) - - else -> null - } - } - - private suspend fun getServerByName(name: String): Server? { - return try { - val (groupName, numericalId) = this.serverPatternIdentifier.parse(name) - this.cloudApi.server().allServers.await() - .firstOrNull { it.group.name == groupName && it.numericalId == numericalId } - } catch (_: Exception) { - null - } - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/TargetResolver.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/TargetResolver.kt deleted file mode 100644 index 6a33169..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/bridge/TargetResolver.kt +++ /dev/null @@ -1,43 +0,0 @@ -package app.simplecloud.npc.shared.bridge - -import app.simplecloud.npc.shared.cloud.CloudService -import kotlinx.coroutines.future.await - -enum class TargetType { - GROUP, - PERSISTENT_SERVER, -} - -data class ResolvedTarget( - val name: String, - val type: TargetType, -) - -sealed interface TargetResolution { - data class Found(val target: ResolvedTarget) : TargetResolution - data object NotFound : TargetResolution - data object Ambiguous : TargetResolution -} - -object TargetResolver { - suspend fun resolve(name: String): TargetResolution { - val groups = CloudService.cloudApi.group().allGroups.await() - .filter { it.name.equals(name, true) } - val persistentServers = CloudService.cloudApi.persistentServer().allPersistentServers.await() - .filter { it.name.equals(name, true) } - - return when { - groups.isNotEmpty() && persistentServers.isNotEmpty() -> TargetResolution.Ambiguous - groups.isNotEmpty() -> TargetResolution.Found(ResolvedTarget(groups.first().name, TargetType.GROUP)) - persistentServers.isNotEmpty() -> TargetResolution.Found( - ResolvedTarget(persistentServers.first().name, TargetType.PERSISTENT_SERVER) - ) - else -> TargetResolution.NotFound - } - } - - suspend fun suggestions(): List<String> = buildList { - addAll(CloudService.cloudApi.group().allGroups.await().map { it.name }) - addAll(CloudService.cloudApi.persistentServer().allPersistentServers.await().map { it.name }) - }.distinct().sorted() -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudEventHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudEventHandler.kt deleted file mode 100644 index 88a23b7..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudEventHandler.kt +++ /dev/null @@ -1,81 +0,0 @@ -package app.simplecloud.npc.shared.cloud - -import app.simplecloud.api.CloudApi -import app.simplecloud.api.event.Subscription -import app.simplecloud.npc.shared.hologram.JoinStateHelper -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.utils.Debouncer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.bukkit.Bukkit - -class CloudEventHandler( - private val cloudApi: CloudApi, -) { - private var scope = createScope() - private var debouncer = Debouncer(EVENT_DEBOUNCE_MILLIS) - private val subscriptions = mutableListOf<Subscription>() - private val refreshMutex = Mutex() - private var periodicRefreshJob: Job? = null - - fun registerEvents(namespace: NpcNamespace) { - unregisterEvents() - scope = createScope() - debouncer = Debouncer(EVENT_DEBOUNCE_MILLIS) - - val event = cloudApi.event() - subscriptions += event.server().onUpdated { requestRefresh(namespace) } - subscriptions += event.group().onUpdated { requestRefresh(namespace) } - - periodicRefreshJob = scope.launch { - while (isActive) { - delay(PERIODIC_REFRESH_MILLIS) - updateHolograms(namespace) - } - } - } - - fun unregisterEvents() { - periodicRefreshJob?.cancel() - periodicRefreshJob = null - subscriptions.forEach { runCatching { it.unsubscribe() } } - subscriptions.clear() - debouncer.cancel() - scope.cancel() - } - - private fun requestRefresh(namespace: NpcNamespace) { - debouncer.debounce(scope) { updateHolograms(namespace) } - } - - private suspend fun updateHolograms(namespace: NpcNamespace) = refreshMutex.withLock { - namespace.npcRepository.findAll().forEach { config -> - try { - val joinState = JoinStateHelper.getJoinState(config) - namespace.hologramManager.updateTextHologram(config, joinState) - } catch (exception: CancellationException) { - throw exception - } catch (exception: Exception) { - Bukkit.getLogger().warning( - "[SimpleCloud-NPC] Could not refresh hologram ${config.id}: ${exception.message}" - ) - } - } - } - - private fun createScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - companion object { - private const val EVENT_DEBOUNCE_MILLIS = 1_000L - private const val PERIODIC_REFRESH_MILLIS = 5_000L - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudService.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudService.kt deleted file mode 100644 index 817dfbf..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/cloud/CloudService.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.simplecloud.npc.shared.cloud - -import app.simplecloud.api.CloudApi - -/** - * @author Niklas Nieberler - */ - -object CloudService { - - val cloudApi = CloudApi.create() - - val eventHandler = CloudEventHandler(cloudApi) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfig.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfig.kt deleted file mode 100644 index 66c80e1..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfig.kt +++ /dev/null @@ -1,147 +0,0 @@ -package app.simplecloud.npc.shared.config - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import app.simplecloud.npc.shared.option.Option -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.provider.ProviderOwnership -import app.simplecloud.npc.shared.utils.ConfigVersion -import app.simplecloud.plugin.api.shared.config.VersionedConfig -import org.spongepowered.configurate.objectmapping.ConfigSerializable -import org.spongepowered.configurate.objectmapping.meta.Setting - -/** - * The user-facing NPC configuration. - * - * Provider type and reference intentionally live together. The SimpleCloud id is - * stable and friendly while [ProviderConfiguration.reference] is free to use the - * native identifier of the selected provider. - */ -@ConfigSerializable -data class NpcConfig( - override val version: Int = ConfigVersion.VERSION, - val id: String = "", - val provider: ProviderConfiguration = ProviderConfiguration(), - @Setting("target-servers") - val targetServers: MutableList<String> = mutableListOf(), - val hologram: HologramConfigurationRoot = HologramConfigurationRoot(), - val pushback: PushbackConfiguration = PushbackConfiguration(), - val actions: MutableList<ActionConfiguration> = mutableListOf(), -) : VersionedConfig { - - @ConfigSerializable - data class ProviderConfiguration( - val type: NpcProviderType = NpcProviderType.CITIZENS, - val reference: String = "", - val ownership: ProviderOwnership = ProviderOwnership.LINKED, - ) - - @ConfigSerializable - data class HologramConfigurationRoot( - var enabled: Boolean = true, - @Setting("start-height") - var startHeight: Double = 2.073, - val layouts: MutableList<HologramLayout> = mutableListOf(), - ) { - fun getLayout(joinState: String?): HologramLayout? { - val normalizedState = joinState?.lowercase() ?: DEFAULT_JOIN_STATE - return layouts.firstOrNull { it.joinState.equals(normalizedState, true) } - ?: layouts.firstOrNull { it.joinState.equals(DEFAULT_JOIN_STATE, true) } - } - } - - @ConfigSerializable - data class HologramLayout( - @Setting("join-state") - var joinState: String = DEFAULT_JOIN_STATE, - val lines: MutableList<HologramConfiguration> = mutableListOf(), - ) - - @ConfigSerializable - data class PushbackConfiguration( - var enabled: Boolean = false, - var radius: Double = 1.2, - var strength: Double = 0.8, - var vertical: Double = 0.3, - var sound: String? = null, - ) - - @ConfigSerializable - data class ActionConfiguration( - @Setting("interaction-type") - var interactionType: PlayerInteraction = PlayerInteraction.RIGHT_CLICK, - @Setting("join-state") - var joinState: String = DEFAULT_JOIN_STATE, - @Setting("join-target") - var joinTarget: Boolean = false, - @Setting("open-inventory") - var openInventory: String? = null, - @Setting("play-sound") - var playSound: String? = null, - @Setting("execute-command") - var executeCommand: String? = null, - @Setting("send-message") - var sendMessage: String? = null, - var teleport: TeleportConfiguration? = null, - @Setting("send-title") - var sendTitle: TitleConfiguration? = null, - @Setting("send-to-server") - var sendToServer: String? = null, - @Setting("transfer-to-server") - var transferToServer: String? = null, - ) - - @ConfigSerializable - data class TeleportConfiguration( - val world: String = "world", - val x: Double = 0.0, - val y: Double = 0.0, - val z: Double = 0.0, - val yaw: Float = 0F, - val pitch: Float = 0F, - ) - - @ConfigSerializable - data class TitleConfiguration( - val title: String = "", - val subtitle: String = "", - @Setting("fade-in") - val fadeIn: Int = 20, - val stay: Int = 20, - @Setting("fade-out") - val fadeOut: Int = 20, - ) - - fun actionsFor(interaction: PlayerInteraction, joinState: String?): List<ActionConfiguration> { - val normalizedState = joinState?.lowercase() ?: DEFAULT_JOIN_STATE - return actions.filter { - it.interactionType == interaction && it.joinState.equals(normalizedState, true) - } - } - - fun normalize(): NpcConfig { - hologram.layouts.forEach { it.joinState = it.joinState.lowercase() } - actions.forEach { it.joinState = it.joinState.lowercase() } - return this - } - - /* - * Kept as a source-compatible bridge for the old handler classes while the - * version-4 option based command surface is retired. - */ - @Deprecated("Version-4 compatibility type") - @ConfigSerializable - data class NpcInteraction( - val playerInteraction: PlayerInteraction = PlayerInteraction.LEFT_CLICK, - var action: Action = Action.RUN_COMMAND, - val options: HashMap<String, String> = hashMapOf(), - ) { - fun getOption(key: String): Option? = options[key]?.let { Option(key, it) } - fun getOptions(): List<Option> = options.map { Option(it.key, it.value) } - } - - companion object { - const val DEFAULT_JOIN_STATE = "default" - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigration.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigration.kt deleted file mode 100644 index fd1cb50..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigration.kt +++ /dev/null @@ -1,288 +0,0 @@ -package app.simplecloud.npc.shared.config - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.action.ActionOptions -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import app.simplecloud.npc.shared.player.PlayerActionOptions -import app.simplecloud.npc.shared.provider.NpcProviderRegistry -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.provider.ProviderOwnership -import app.simplecloud.npc.shared.utils.ConfigVersion -import app.simplecloud.plugin.api.shared.config.ConfigMigration -import app.simplecloud.plugin.api.shared.config.ConfigMigrator -import org.bukkit.Bukkit -import org.spongepowered.configurate.CommentedConfigurationNode -import org.spongepowered.configurate.objectmapping.ConfigSerializable -import org.spongepowered.configurate.yaml.YamlConfigurationLoader -import java.nio.file.Files -import java.nio.file.Path - -object NpcConfigMigration { - fun migrator(providerRegistry: NpcProviderRegistry): ConfigMigrator { - return ConfigMigrator.builder(ConfigVersion.VERSION) - .fallbackVersion(4) - .migrate(1, ConfigVersion.VERSION, ConfigMigration { node -> migrateProposedVersionOne(node, providerRegistry) }) - .migrate(2, ConfigVersion.VERSION, ConfigMigration { node -> migrateLegacy(node, providerRegistry) }) - .migrate(3, ConfigVersion.VERSION, ConfigMigration { node -> migrateLegacy(node, providerRegistry) }) - .migrate(4, ConfigVersion.VERSION, ConfigMigration { node -> migrateVersionFour(node, providerRegistry) }) - .build() - } - - fun backupOutdated(directory: Path) { - if (!Files.exists(directory)) return - Files.createDirectories(directory.resolve("backups")) - Files.list(directory).use { paths -> - paths.filter { Files.isRegularFile(it) && it.isYaml() }.forEach { path -> - val version = runCatching { - YamlConfigurationLoader.builder().path(path).build().load().node("version").getInt(4) - }.getOrDefault(4) - if (version >= ConfigVersion.VERSION) return@forEach - val backup = directory.resolve("backups").resolve("${path.fileName}.v$version.bak") - if (!Files.exists(backup)) Files.copy(path, backup) - } - } - } - - private fun migrateVersionFour(node: CommentedConfigurationNode, registry: NpcProviderRegistry) { - migrateLegacy(node, registry) - } - - private fun migrateLegacy(node: CommentedConfigurationNode, registry: NpcProviderRegistry) { - val legacy = node.get(LegacyNpcConfig::class.java) ?: return - val provider = registry.availableProviders(Bukkit.getPluginManager()) - .firstOrNull { runCatching { it.exists(legacy.id) }.getOrDefault(false) } - ?: registry.availableProviders(Bukkit.getPluginManager()).firstOrNull() - - val target = sequenceOf( - legacy.hologramConfiguration.placeholderServerBaseName, - legacy.hologramConfiguration.placeholderName, - legacy.hologramConfiguration.placeholderGroupName, - legacy.options[ActionOptions.GROUP_NAME.first], - legacy.options[ActionOptions.CONNECT_TO_SERVER_NAME.first], - ).filterNotNull().firstOrNull(String::isNotBlank) - - val layouts = legacy.hologramConfiguration.holograms.map { old -> - NpcConfig.HologramLayout( - joinState = old.joinState.ifBlank { NpcConfig.DEFAULT_JOIN_STATE }.lowercase(), - lines = old.lores.toMutableList(), - ) - }.toMutableList() - - val actions = legacy.actions.map { old -> - val options = legacy.options + old.options - val oldAction = old.action.uppercase() - NpcConfig.ActionConfiguration( - interactionType = old.playerInteraction, - joinState = NpcConfig.DEFAULT_JOIN_STATE, - joinTarget = oldAction == Action.QUICK_JOIN.name, - openInventory = if (oldAction == "OPEN_INVENTORY") { - options["inventory.name"]?.takeIf(String::isNotBlank) - } else null, - playSound = options[PlayerActionOptions.PLAY_SOUND.first]?.takeIf(String::isNotBlank), - executeCommand = when (oldAction) { - Action.RUN_COMMAND.name, Action.RUN_CONSOLE_COMMAND.name -> options[ActionOptions.EXECUTE_COMMAND_NAME.first] - else -> null - }?.takeIf(String::isNotBlank), - sendMessage = options[PlayerActionOptions.SEND_MESSAGE.first]?.takeIf(String::isNotBlank), - sendTitle = options[PlayerActionOptions.SEND_TITLE.first]?.takeIf(String::isNotBlank)?.let { title -> - NpcConfig.TitleConfiguration(title, options[PlayerActionOptions.SEND_SUBTITLE.first].orEmpty()) - }, - sendToServer = if (oldAction == Action.CONNECT_TO_SERVER.name) { - options[ActionOptions.CONNECT_TO_SERVER_NAME.first]?.takeIf(String::isNotBlank) - } else null, - transferToServer = if (oldAction == Action.TRANSFER_TO_SERVER.name) { - options[ActionOptions.TRANSFER_SERVER_IP.first]?.takeIf(String::isNotBlank)?.let { ip -> - "$ip:${options[ActionOptions.TRANSFER_SERVER_PORT.first] ?: "25565"}" - } - } else null, - ) - }.toMutableList() - - val migrated = NpcConfig( - id = legacy.id, - provider = NpcConfig.ProviderConfiguration( - type = provider?.type ?: NpcProviderType.CITIZENS, - reference = legacy.id, - ownership = ProviderOwnership.LINKED, - ), - targetServers = target?.let { mutableListOf(it) } ?: mutableListOf(), - hologram = NpcConfig.HologramConfigurationRoot( - enabled = layouts.isNotEmpty(), - startHeight = legacy.hologramConfiguration.holograms.firstOrNull()?.startHeight ?: 2.073, - layouts = layouts, - ), - actions = actions, - ) - node.set(NpcConfig::class.java, migrated) - } - - private fun migrateProposedVersionOne(node: CommentedConfigurationNode, registry: NpcProviderRegistry) { - val old = node.get(ProposedNpcConfig::class.java) ?: return - val type = NpcProviderType.getOrNull(old.provider) - ?: registry.availableProviders(Bukkit.getPluginManager()).firstOrNull()?.type - ?: NpcProviderType.CITIZENS - val migrated = NpcConfig( - id = old.id, - provider = NpcConfig.ProviderConfiguration(type, old.id, ProviderOwnership.LINKED), - targetServers = old.targetServers.toMutableList(), - hologram = NpcConfig.HologramConfigurationRoot( - old.hologram.enabled, - old.hologram.startHeight, - old.hologram.layouts.map { - NpcConfig.HologramLayout( - it.joinState.ifBlank { NpcConfig.DEFAULT_JOIN_STATE }.lowercase(), - mutableListOf(HologramConfiguration(it.text)), - ) - }.toMutableList(), - ), - pushback = NpcConfig.PushbackConfiguration( - old.pushback.enabled, - old.pushback.radius, - old.pushback.strength, - old.pushback.vertical, - old.pushback.playSound, - ), - actions = old.actions.map { action -> - NpcConfig.ActionConfiguration( - interactionType = action.interactionType, - joinState = action.joinState.ifBlank { NpcConfig.DEFAULT_JOIN_STATE }.lowercase(), - openInventory = action.openInventory, - playSound = action.playSound, - executeCommand = action.executeCommand, - sendMessage = action.sendMessage, - teleport = action.teleport?.takeIf { it.enabled }?.let { - NpcConfig.TeleportConfiguration(it.world, it.x, it.y, it.z, it.yaw, it.pitch) - }, - sendTitle = action.sendTitle?.takeIf { it.enabled }?.let { - NpcConfig.TitleConfiguration(it.title, it.text, it.fadeIn, it.stay, it.fadeOut) - }, - sendToServer = action.sendToServer, - transferToServer = action.transferToServer, - ) - }.toMutableList(), - ) - node.set(NpcConfig::class.java, migrated) - } - - private fun Path.isYaml(): Boolean { - val name = fileName.toString().lowercase() - return name.endsWith(".yml") || name.endsWith(".yaml") - } - - @ConfigSerializable - data class LegacyNpcConfig( - val version: String = "4", - val id: String = "", - val hologramConfiguration: LegacyHologramRoot = LegacyHologramRoot(), - val actions: MutableList<LegacyInteraction> = mutableListOf(), - val options: HashMap<String, String> = hashMapOf(), - ) - - @ConfigSerializable - data class LegacyInteraction( - val playerInteraction: PlayerInteraction = PlayerInteraction.LEFT_CLICK, - val action: String = Action.RUN_COMMAND.name, - val options: HashMap<String, String> = hashMapOf(), - ) - - @ConfigSerializable - data class LegacyHologramRoot( - val placeholderServerBaseName: String = "", - val placeholderName: String = "", - val placeholderGroupName: String = "", - val holograms: List<LegacyHologram> = emptyList(), - ) - - @ConfigSerializable - data class LegacyHologram( - val startHeight: Double = 2.073, - val joinState: String = "", - val lores: List<HologramConfiguration> = emptyList(), - ) - - @ConfigSerializable - data class ProposedNpcConfig( - val version: String = "1", - val id: String = "", - val provider: String = "CITIZENS", - @org.spongepowered.configurate.objectmapping.meta.Setting("target-servers") - val targetServers: List<String> = emptyList(), - val hologram: ProposedHologram = ProposedHologram(), - val pushback: ProposedPushback = ProposedPushback(), - val actions: List<ProposedAction> = emptyList(), - ) - - @ConfigSerializable - data class ProposedHologram( - val enabled: Boolean = true, - @org.spongepowered.configurate.objectmapping.meta.Setting("start-height") - val startHeight: Double = 2.073, - val layouts: List<ProposedLayout> = emptyList(), - ) - - @ConfigSerializable - data class ProposedLayout( - val joinstate: String = NpcConfig.DEFAULT_JOIN_STATE, - val text: String = "", - ) { - val joinState: String get() = joinstate - } - - @ConfigSerializable - data class ProposedPushback( - val enabled: Boolean = false, - val radius: Double = 2.5, - val strength: Double = 1.2, - val vertical: Double = 0.3, - @org.spongepowered.configurate.objectmapping.meta.Setting("playSound") - val playSound: String? = null, - ) - - @ConfigSerializable - data class ProposedAction( - @org.spongepowered.configurate.objectmapping.meta.Setting("interaction-type") - val interactionType: PlayerInteraction = PlayerInteraction.RIGHT_CLICK, - val joinstate: String = NpcConfig.DEFAULT_JOIN_STATE, - @org.spongepowered.configurate.objectmapping.meta.Setting("openInventory") - val openInventory: String? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("playSound") - val playSound: String? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("executeCommand") - val executeCommand: String? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("sendMessage") - val sendMessage: String? = null, - val teleport: ProposedTeleport? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("sendTitle") - val sendTitle: ProposedTitle? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("sendToServer") - val sendToServer: String? = null, - @org.spongepowered.configurate.objectmapping.meta.Setting("transferToServer") - val transferToServer: String? = null, - ) { - val joinState: String get() = joinstate - } - - @ConfigSerializable - data class ProposedTeleport( - val enabled: Boolean = false, - val world: String = "world", - val x: Double = 0.0, - val y: Double = 0.0, - val z: Double = 0.0, - val yaw: Float = 0F, - val pitch: Float = 0F, - ) - - @ConfigSerializable - data class ProposedTitle( - val enabled: Boolean = false, - val title: String = "", - val text: String = "", - @org.spongepowered.configurate.objectmapping.meta.Setting("fadeIn") - val fadeIn: Int = 20, - val stay: Int = 20, - @org.spongepowered.configurate.objectmapping.meta.Setting("fadeOut") - val fadeOut: Int = 20, - ) -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/enums/QuantityType.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/enums/QuantityType.kt deleted file mode 100644 index 093f5df..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/enums/QuantityType.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.simplecloud.npc.shared.enums - -/** - * @author Niklas Nieberler - */ - -enum class QuantityType { - - MOST, - - LEAST; - - companion object { - fun exist(name: String): Boolean { - return entries.firstOrNull { it.name == name.uppercase() } != null - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionHandler.kt deleted file mode 100644 index 102cbf3..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.simplecloud.npc.shared.event - -import app.simplecloud.npc.shared.namespace.NpcNamespace - -/** - * @author Niklas Nieberler - */ - -fun interface EventActionHandler { - - /** - * Executes this method when an action is called - * @param namespace of the npc plugin - * @param id of the npc - */ - fun handle(namespace: NpcNamespace, id: String) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionType.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionType.kt deleted file mode 100644 index 238065b..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventActionType.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.simplecloud.npc.shared.event - -import app.simplecloud.npc.shared.event.action.* - -/** - * @author Niklas Nieberler - */ - -enum class EventActionType( - val eventActionHandler: EventActionHandler -) { - - SPAWN(SpawnNpcActionHandler()), - - REMOVE(RemoveNpcActionHandler()), - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventManager.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventManager.kt deleted file mode 100644 index 4eea7f3..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/EventManager.kt +++ /dev/null @@ -1,53 +0,0 @@ -package app.simplecloud.npc.shared.event - -import app.simplecloud.npc.shared.event.listener.FancyEventListener -import app.simplecloud.npc.shared.namespace.NpcNamespace -import org.bukkit.event.Event -import org.bukkit.event.EventPriority -import org.bukkit.plugin.Plugin - -/** - * @author Niklas Nieberler - */ - -class EventManager( - private val namespace: NpcNamespace -) { - - /** - * Creates the npc listeners for the interaction of the npcs - * @param event to register - * @param javaPlugin the npc plugin - * @param eventActionType the action - * @param function where to get the npc id - * @param condition for the event - */ - fun <E : Event> registerActionEvent( - event: Class<E>, - javaPlugin: Plugin, - eventActionType: EventActionType, - function: (E) -> String, - condition: (E) -> Boolean = { true } - ) { - FancyEventListener(event, EventPriority.NORMAL, javaPlugin) - .addCondition(condition) - .addAction { eventActionType.eventActionHandler.handle(this.namespace, function(it)) } - } - -} - -/** - * Creates the npc listeners for the interaction of the npcs - * @param javaPlugin the npc plugin - * @param eventActionType the action - * @param function where to get the npc id - * @param condition for the event - */ -inline fun <reified E : Event> EventManager.registerActionEvent( - javaPlugin: Plugin, - eventActionType: EventActionType, - noinline function: (E) -> String, - noinline condition: (E) -> Boolean = { true } -) { - registerActionEvent(E::class.java, javaPlugin, eventActionType, function, condition) -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/RemoveNpcActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/RemoveNpcActionHandler.kt deleted file mode 100644 index 6e49d37..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/RemoveNpcActionHandler.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.simplecloud.npc.shared.event.action - -import app.simplecloud.npc.shared.event.EventActionHandler -import app.simplecloud.npc.shared.namespace.NpcNamespace - -/** - * @author Niklas Nieberler - */ - -class RemoveNpcActionHandler : EventActionHandler { - - override fun handle(namespace: NpcNamespace, id: String) { - namespace.npcManager.delete(id) - - val hologramManager = namespace.hologramManager - hologramManager.destroyHolograms(id) - hologramManager.destroyLegacyHolograms(id) - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/SpawnNpcActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/SpawnNpcActionHandler.kt deleted file mode 100644 index 11296ce..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/action/SpawnNpcActionHandler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.simplecloud.npc.shared.event.action - -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.event.EventActionHandler -import app.simplecloud.npc.shared.hologram.HologramManager -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.utils.Debouncer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** - * @author Niklas Nieberler - */ - -class SpawnNpcActionHandler : EventActionHandler { - - private val debouncer = Debouncer(1500L) - - override fun handle(namespace: NpcNamespace, id: String) { - val npcConfig = namespace.npcRepository.find(id) ?: return - CoroutineScope(Dispatchers.IO).launch { - debouncer.debounceSync(this) { updateHolograms(namespace.hologramManager, npcConfig) } - } - } - - private fun updateHolograms( - hologramManager: HologramManager, - npcConfig: NpcConfig, - ) { - val coroutineScope = CoroutineScope(Dispatchers.IO) - if (hologramManager.getTextDisplays(npcConfig.id).isEmpty()) { - coroutineScope.launch { hologramManager.updateHolograms(npcConfig) } - return - } - coroutineScope.launch { hologramManager.updateTextHologram(npcConfig) } - } - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/FancyEventListener.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/FancyEventListener.kt deleted file mode 100644 index 2c4d081..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/FancyEventListener.kt +++ /dev/null @@ -1,57 +0,0 @@ -package app.simplecloud.npc.shared.event.listener - -import org.bukkit.event.Event -import org.bukkit.event.EventPriority -import org.bukkit.event.HandlerList -import org.bukkit.event.Listener -import org.bukkit.plugin.Plugin - -/** - * @authors Niklas Nieberler, Wetterbericht - */ - -class FancyEventListener<E : Event>( - private val listenerClass: Class<E>, - private val eventPriority: EventPriority = EventPriority.NORMAL, - private val javaPlugin: Plugin -) { - - private var conditions: MutableList<((E) -> Boolean)> = ArrayList() - private var actions: MutableList<((E) -> Unit)> = ArrayList() - private val listener = createListener() - - fun addCondition(predicate: (E) -> Boolean): FancyEventListener<E> { - this.conditions.add(predicate) - return this - } - - fun addAction(predicate: (E) -> Unit): FancyEventListener<E> { - this.actions.add(predicate) - return this - } - - fun unregisterWhen(predicate: (Unit) -> FancyEventListener<*>): FancyEventListener<E> { - val advancedListener = predicate(Unit) - advancedListener.addAction { stopListening() } - advancedListener.addAction { advancedListener.stopListening() } - return this - } - - fun stopListening() { - HandlerList.unregisterAll(this.listener) - } - - fun unregisterAfterCall(): FancyEventListener<E> { - addAction { stopListening() } - return this - } - - private fun createListener(): Listener { - return listen(this.javaPlugin, this.listenerClass, this.eventPriority) { event -> - if (this.conditions.all { it(event) }) { - this.actions.forEach { it(event) } - } - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/ListenerExtension.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/ListenerExtension.kt deleted file mode 100644 index b2957ba..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/event/listener/ListenerExtension.kt +++ /dev/null @@ -1,47 +0,0 @@ -package app.simplecloud.npc.shared.event.listener - -import org.bukkit.Bukkit -import org.bukkit.event.Event -import org.bukkit.event.EventPriority -import org.bukkit.event.Listener -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.java.JavaPlugin - -/** - * @authors Niklas Nieberler, Wetterbericht - */ - -inline fun <reified E : Event> listener( - javaPlugin: JavaPlugin, - eventPriority: EventPriority, - noinline eventFunction: (E) -> Unit -): Listener { - return listen(javaPlugin, E::class.java, eventPriority, eventFunction) -} - -@Suppress("UNCHECKED_CAST") -fun <E : Event> listen( - javaPlugin: Plugin, - eventClass: Class<E>, - eventPriority: EventPriority, - eventFunction: (E) -> Unit -): Listener { - val emptyListenerInstance = object : Listener {} - val pluginManager = Bukkit.getPluginManager() - pluginManager.registerEvent( - eventClass, - emptyListenerInstance, - eventPriority, - { _, event -> eventFunction(event as E) }, - javaPlugin, - false - ) - return emptyListenerInstance -} - -inline fun <reified E : Event> listenEvent( - javaPlugin: Plugin, - priority: EventPriority = EventPriority.NORMAL -): FancyEventListener<E> { - return FancyEventListener(E::class.java, priority, javaPlugin) -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramEditor.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramEditor.kt deleted file mode 100644 index 7811f49..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramEditor.kt +++ /dev/null @@ -1,44 +0,0 @@ -package app.simplecloud.npc.shared.hologram - -import app.simplecloud.npc.shared.createAtNamespacedKey -import app.simplecloud.npc.shared.hologramLineNamespacedKey -import app.simplecloud.npc.shared.hologramNamespacedKey -import net.kyori.adventure.text.Component -import org.bukkit.Location -import org.bukkit.entity.Display.Billboard -import org.bukkit.entity.TextDisplay -import org.bukkit.persistence.PersistentDataType - -/** - * @author Niklas Nieberler - */ - -class HologramEditor( - private val npcId: String, - private val location: Location, - private val lineIndex: Int, -) { - val textDisplay = createTextDisplay() - - /** - * Sets a custom name to the text display - * @param name the custom name - * @return this editor instance - */ - fun withCustomName(name: Component?): HologramEditor { - this.textDisplay.text(name) - return this - } - - private fun createTextDisplay(): TextDisplay { - val textDisplay = this.location.world.spawn(this.location, TextDisplay::class.java) - textDisplay.billboard = Billboard.CENTER - - val persistentDataContainer = textDisplay.persistentDataContainer - persistentDataContainer.set(hologramNamespacedKey, PersistentDataType.STRING, this.npcId) - persistentDataContainer.set(createAtNamespacedKey, PersistentDataType.LONG, System.currentTimeMillis()) - persistentDataContainer.set(hologramLineNamespacedKey, PersistentDataType.INTEGER, this.lineIndex) - return textDisplay - } - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramManager.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramManager.kt deleted file mode 100644 index 5546214..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramManager.kt +++ /dev/null @@ -1,227 +0,0 @@ -package app.simplecloud.npc.shared.hologram - -import app.simplecloud.npc.shared.bridge.ServerBridge -import app.simplecloud.npc.shared.bridge.ServerBridgeFinder -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.createAtNamespacedKey -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import app.simplecloud.npc.shared.hologramLineNamespacedKey -import app.simplecloud.npc.shared.hologramNamespacedKey -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.sync -import app.simplecloud.npc.shared.utils.NpcFileUpdater -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import net.kyori.adventure.text.Component -import org.bukkit.Bukkit -import org.bukkit.Location -import org.bukkit.entity.TextDisplay -import org.bukkit.persistence.PersistentDataType -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong - -class HologramManager( - private val namespace: NpcNamespace, -) { - private val textDisplays = hashMapOf<UUID, String>() - private val refreshScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val refreshJobs = ConcurrentHashMap<String, Job>() - private val refreshLocks = ConcurrentHashMap<String, Mutex>() - private val generations = ConcurrentHashMap<String, AtomicLong>() - - fun registerFileRequest() { - NpcFileUpdater.updateFileRequest("hologram", ::createOrUpdate) - } - - fun createOrUpdate(config: NpcConfig) { - if (!config.hologram.enabled) { - destroyHolograms(config.id) - return - } - - refreshJobs.remove(config.id)?.cancel() - refreshJobs[config.id] = refreshScope.launchRefresh(config.id) - } - - suspend fun updateHolograms(config: NpcConfig): Boolean { - return refreshLock(config.id).withLock { rebuildHologram(config) } - } - - private suspend fun rebuildHologram(config: NpcConfig): Boolean { - if (!config.hologram.enabled) { - removeHolograms(config.id) - return true - } - val layout = config.hologram.getLayout(JoinStateHelper.getJoinState(config)) - if (layout == null) { - removeHolograms(config.id) - return true - } - val generation = generation(config.id) - val location = sync { namespace.findLocationByNpc(config.id)?.clone() } ?: return false - val serverBridge = getServerBridge(config) - val renderedLines = layout.lines.map { - it to HologramPlaceholderHelper.appendPlaceholder(serverBridge, it.text) - } - return sync { - if (generation != generation(config.id)) return@sync false - replaceHologram(config.id, location, config.hologram.startHeight, renderedLines) - true - } - } - - private fun replaceHologram( - id: String, - npcLocation: Location, - startHeight: Double, - lines: List<Pair<HologramConfiguration, Component>>, - ) { - val created = mutableListOf<TextDisplay>() - try { - lines.reversed().forEachIndexed { index, (line, component) -> - val location = npcLocation.clone().add(0.0, startHeight + LINE_SPACING * index, 0.0) - val editor = HologramEditor(id, location, index).withCustomName(component) - HologramModifier.modify(line, editor.textDisplay) - created += editor.textDisplay - } - } catch (exception: Exception) { - created.forEach(TextDisplay::remove) - throw exception - } - - removeHolograms(id, created.mapTo(hashSetOf(), TextDisplay::getUniqueId)) - created.forEach { textDisplays[it.uniqueId] = id } - } - - suspend fun updateTextHologram(config: NpcConfig, state: String? = null) { - refreshLock(config.id).withLock { updateTextHologramLocked(config, state) } - } - - private suspend fun updateTextHologramLocked(config: NpcConfig, state: String?) { - if (!config.hologram.enabled) { - removeHolograms(config.id) - return - } - val layout = config.hologram.getLayout(state ?: JoinStateHelper.getJoinState(config)) - if (layout == null) { - removeHolograms(config.id) - return - } - val displays = sync { - getTextDisplays(config.id).sortedWith(textDisplayComparator) - } - if (displays.size != layout.lines.size) { - rebuildHologram(config) - return - } - val serverBridge = getServerBridge(config) - layout.lines.reversed().forEachIndexed { index, line -> - val component = HologramPlaceholderHelper.appendPlaceholder(serverBridge, line.text) - sync { displays.getOrNull(index)?.text(component) } - } - } - - fun destroyHolograms(id: String) { - refreshJobs.remove(id)?.cancel() - generations.computeIfAbsent(id) { AtomicLong() }.incrementAndGet() - removeHolograms(id) - } - - private fun removeHolograms(id: String, excluding: Set<UUID> = emptySet()) { - sync { - getTextDisplays(id).filterNot { it.uniqueId in excluding }.forEach(TextDisplay::remove) - textDisplays.entries.removeIf { it.value == id && it.key !in excluding } - - val location = namespace.findLocationByNpc(id) ?: return@sync - location.world.entities - .filterIsInstance<TextDisplay>() - .filter { it.uniqueId !in excluding } - .filter { it.persistentDataContainer.get(hologramNamespacedKey, PersistentDataType.STRING) == id } - .forEach(TextDisplay::remove) - } - } - - fun destroyLegacyHolograms(id: String) { - sync { - val location = namespace.findLocationByNpc(id) ?: return@sync - location.world.entities - .filter { it.persistentDataContainer.get(hologramNamespacedKey, PersistentDataType.STRING) == id } - .forEach { it.remove() } - } - } - - fun destroyAllHolograms() { - refreshJobs.values.forEach(Job::cancel) - refreshJobs.clear() - refreshScope.cancel() - sync { - namespace.findAllNpcs() - .mapNotNull(namespace::findLocationByNpc) - .map { it.world } - .distinct() - .flatMap { it.entities } - .filterIsInstance<TextDisplay>() - .filter { it.persistentDataContainer.has(hologramNamespacedKey) } - .forEach { it.remove() } - textDisplays.clear() - } - } - - fun getTextDisplays(id: String): List<TextDisplay> { - return sync { - textDisplays.filterValues { it == id } - .keys - .mapNotNull(Bukkit::getEntity) - .filterIsInstance<TextDisplay>() - } - } - - private fun CoroutineScope.launchRefresh(id: String): Job = launch { - var lastFailure: Exception? = null - repeat(CREATE_ATTEMPTS) { attempt -> - val config = namespace.npcRepository.find(id) ?: return@launch - try { - if (updateHolograms(config)) return@launch - } catch (exception: CancellationException) { - throw exception - } catch (exception: Exception) { - lastFailure = exception - } - - if (attempt < CREATE_ATTEMPTS - 1) delay(CREATE_RETRY_MILLIS) - } - - val reason = lastFailure?.message ?: "provider NPC location is not available" - Bukkit.getLogger().warning("[SimpleCloud-NPC] Could not create hologram $id: $reason") - } - - private fun refreshLock(id: String): Mutex = refreshLocks.computeIfAbsent(id) { Mutex() } - - private fun generation(id: String): Long = generations.computeIfAbsent(id) { AtomicLong() }.get() - - private suspend fun getServerBridge(config: NpcConfig): ServerBridge { - val target = requireNotNull(config.targetServers.firstOrNull()) { "NPC ${config.id} has no target server" } - return requireNotNull(ServerBridgeFinder.find(target)) { "Failed to find target $target for NPC ${config.id}" } - } - - companion object { - private const val LINE_SPACING = 0.3 - private const val CREATE_ATTEMPTS = 6 - private const val CREATE_RETRY_MILLIS = 1_000L - - private val textDisplayComparator = compareBy<TextDisplay> { - it.persistentDataContainer.get(hologramLineNamespacedKey, PersistentDataType.INTEGER) ?: Int.MAX_VALUE - }.thenBy { - it.persistentDataContainer.get(createAtNamespacedKey, PersistentDataType.LONG) ?: Long.MAX_VALUE - }.thenBy { it.uniqueId } - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramModifier.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramModifier.kt deleted file mode 100644 index 4ba5970..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramModifier.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.simplecloud.npc.shared.hologram - -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import org.bukkit.entity.TextDisplay - -/** - * @author Niklas Nieberler - */ - -object HologramModifier { - - fun modify(configuration: HologramConfiguration, textDisplay: TextDisplay) { - textDisplay.billboard = configuration.billboard - textDisplay.alignment = configuration.alignment - configuration.viewRange?.let { textDisplay.viewRange = it } - configuration.shadowRadius?.let { textDisplay.shadowRadius = it } - configuration.displayHeight?.let { textDisplay.displayHeight = it } - configuration.displayWidth?.let { textDisplay.displayWidth = it } - configuration.shadow?.let { textDisplay.isShadowed = it } - configuration.lineWidth?.let { textDisplay.lineWidth = it } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramPlaceholderHelper.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramPlaceholderHelper.kt deleted file mode 100644 index a262c19..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/HologramPlaceholderHelper.kt +++ /dev/null @@ -1,33 +0,0 @@ -package app.simplecloud.npc.shared.hologram - -import app.simplecloud.npc.shared.bridge.ServerBridge -import app.simplecloud.plugin.api.shared.placeholder.PlaceholderProvider -import net.kyori.adventure.text.Component - -/** - * @author Niklas Nieberler - */ - -object HologramPlaceholderHelper { - - suspend fun appendPlaceholder(serverBridge: ServerBridge, text: String): Component { - return when { - serverBridge.isPersistentServer -> { - val persistentServer = requireNotNull(serverBridge.getPersistentServer()) { "Failed to find persistent server for $serverBridge" } - PlaceholderProvider.persistentServerPlaceholderProvider.append(persistentServer, text, "target") - } - - serverBridge.isGroup -> { - val group = requireNotNull(serverBridge.getGroup()) { "Failed to find group for $serverBridge" } - PlaceholderProvider.groupPlaceholderProvider.append(group, text, "target") - } - - serverBridge.isServer -> { - val server = requireNotNull(serverBridge.getServer()) { "Failed to find server for $serverBridge" } - PlaceholderProvider.serverPlaceholderProvider.append(server, text, "target") - } - - else -> throw IllegalStateException("no server placeholder found for $serverBridge") - } - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/JoinStateHelper.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/JoinStateHelper.kt deleted file mode 100644 index 126c44c..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/JoinStateHelper.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.simplecloud.npc.shared.hologram - -import app.simplecloud.npc.shared.bridge.ServerBridgeFinder -import app.simplecloud.npc.shared.config.NpcConfig - -object JoinStateHelper { - suspend fun getJoinState(config: NpcConfig): String { - val target = config.targetServers.firstOrNull() ?: return NpcConfig.DEFAULT_JOIN_STATE - return getJoinState(target) - } - - suspend fun getJoinState(target: String): String { - val serverBridge = ServerBridgeFinder.find(target) - return ((serverBridge?.properties?.get("join-state") - ?: serverBridge?.properties?.get("joinstate")) as? String) - ?.lowercase() - ?: NpcConfig.DEFAULT_JOIN_STATE - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/config/HologramConfiguration.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/config/HologramConfiguration.kt deleted file mode 100644 index e5d226b..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/hologram/config/HologramConfiguration.kt +++ /dev/null @@ -1,22 +0,0 @@ -package app.simplecloud.npc.shared.hologram.config - -import org.bukkit.entity.Display.Billboard -import org.bukkit.entity.TextDisplay -import org.spongepowered.configurate.objectmapping.ConfigSerializable - -/** - * @author Niklas Nieberler - */ - -@ConfigSerializable -data class HologramConfiguration( - val text: String = "", - val billboard: Billboard = Billboard.CENTER, - val viewRange: Float? = null, - val shadowRadius: Float? = null, - val displayHeight: Float? = null, - val displayWidth: Float? = null, - val alignment: TextDisplay.TextAlignment = TextDisplay.TextAlignment.CENTER, - val lineWidth: Int? = null, - val shadow: Boolean? = null, -) \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/manager/NpcManager.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/manager/NpcManager.kt deleted file mode 100644 index 04b99b2..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/manager/NpcManager.kt +++ /dev/null @@ -1,190 +0,0 @@ -package app.simplecloud.npc.shared.manager - -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.bridge.TargetResolution -import app.simplecloud.npc.shared.bridge.TargetResolver -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import app.simplecloud.npc.shared.namespace.NpcNamespace -import app.simplecloud.npc.shared.provider.NpcProvider -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.provider.ProviderOwnership -import org.bukkit.Bukkit -import org.bukkit.entity.Player - -class NpcManager( - private val namespace: NpcNamespace, -) { - private val repository = namespace.npcRepository - - fun exist(id: String): Boolean = repository.find(id) != null - - suspend fun create( - id: String, - targetName: String, - requestedProvider: NpcProviderType?, - player: Player, - ): NpcOperationResult { - validateId(id)?.let { return it } - if (exist(id)) return NpcOperationResult.Failure(NpcFailure.ALREADY_EXISTS, id) - - val target = when (val resolution = TargetResolver.resolve(targetName)) { - is TargetResolution.Found -> resolution.target - TargetResolution.Ambiguous -> return NpcOperationResult.Failure(NpcFailure.TARGET_AMBIGUOUS, targetName) - TargetResolution.NotFound -> return NpcOperationResult.Failure(NpcFailure.TARGET_NOT_FOUND, targetName) - } - val creationProviders = namespace.providerRegistry.availableProviders(Bukkit.getPluginManager()) - .filter { it.supportsCreation } - val provider = when { - requestedProvider != null -> creationProviders.firstOrNull { it.type == requestedProvider } - ?: return NpcOperationResult.Failure(NpcFailure.PROVIDER_UNAVAILABLE, requestedProvider.commandName) - creationProviders.isEmpty() -> return NpcOperationResult.Failure(NpcFailure.CREATION_PROVIDER_UNAVAILABLE) - creationProviders.size > 1 -> return NpcOperationResult.Failure(NpcFailure.PROVIDER_REQUIRED) - else -> creationProviders.single() - } - - var reference: String? = null - return try { - reference = provider.create(id, player, player.location.clone()) - val config = createDefaultConfig(id, target.name, provider, reference) - repository.save(config) - namespace.hologramManager.createOrUpdate(config) - NpcOperationResult.Success(config) - } catch (exception: Exception) { - namespace.hologramManager.destroyHolograms(id) - reference?.let { runCatching { provider.delete(it) } } - repository.find(id)?.let { runCatching { repository.delete(it) } } - NpcOperationResult.Failure(NpcFailure.CREATE_FAILED, exception.message) - } - } - - suspend fun link( - id: String, - targetName: String, - providerType: NpcProviderType, - reference: String, - ): NpcOperationResult { - validateId(id)?.let { return it } - if (exist(id)) return NpcOperationResult.Failure(NpcFailure.ALREADY_EXISTS, id) - - val target = when (val resolution = TargetResolver.resolve(targetName)) { - is TargetResolution.Found -> resolution.target - TargetResolution.Ambiguous -> return NpcOperationResult.Failure(NpcFailure.TARGET_AMBIGUOUS, targetName) - TargetResolution.NotFound -> return NpcOperationResult.Failure(NpcFailure.TARGET_NOT_FOUND, targetName) - } - val provider = namespace.providerRegistry.getAvailable(providerType, Bukkit.getPluginManager()) - ?: return NpcOperationResult.Failure(NpcFailure.PROVIDER_UNAVAILABLE, providerType.commandName) - if (!provider.exists(reference)) { - return NpcOperationResult.Failure(NpcFailure.PROVIDER_NPC_NOT_FOUND, reference) - } - if (repository.findByProvider(providerType, reference) != null) { - return NpcOperationResult.Failure(NpcFailure.PROVIDER_NPC_ALREADY_LINKED, reference) - } - - return try { - val config = createDefaultConfig(id, target.name, provider, reference, ProviderOwnership.LINKED) - repository.save(config) - namespace.hologramManager.createOrUpdate(config) - NpcOperationResult.Success(config) - } catch (exception: Exception) { - namespace.hologramManager.destroyHolograms(id) - repository.find(id)?.let { runCatching { repository.delete(it) } } - NpcOperationResult.Failure(NpcFailure.CREATE_FAILED, exception.message) - } - } - - fun unlink(id: String): NpcOperationResult { - val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) - namespace.hologramManager.destroyHolograms(id) - repository.delete(config) - return NpcOperationResult.Success(config) - } - - fun delete(id: String): NpcOperationResult { - val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) - val provider = namespace.providerRegistry.getAvailable(config.provider.type, Bukkit.getPluginManager()) - if (config.provider.ownership == ProviderOwnership.MANAGED) { - if (provider == null) { - return NpcOperationResult.Failure(NpcFailure.PROVIDER_UNAVAILABLE, config.provider.type.commandName) - } - if (provider.exists(config.provider.reference) && !provider.delete(config.provider.reference)) { - return NpcOperationResult.Failure(NpcFailure.DELETE_FAILED, id) - } - } - namespace.hologramManager.destroyHolograms(id) - repository.delete(config) - return NpcOperationResult.Success(config) - } - - private fun validateId(id: String): NpcOperationResult.Failure? { - return if (ID_PATTERN.matches(id)) null else NpcOperationResult.Failure(NpcFailure.INVALID_ID, id) - } - - private fun createDefaultConfig( - id: String, - target: String, - provider: NpcProvider, - reference: String, - ownership: ProviderOwnership = ProviderOwnership.MANAGED, - ): NpcConfig { - return NpcConfig( - id = id, - provider = NpcConfig.ProviderConfiguration(provider.type, reference, ownership), - targetServers = mutableListOf(target), - hologram = NpcConfig.HologramConfigurationRoot( - layouts = mutableListOf( - NpcConfig.HologramLayout( - joinState = "default", - lines = mutableListOf( - HologramConfiguration("<#0ea5e9><bold><target_name>"), - HologramConfiguration("<#94a3b8><target_online_players>/<target_max_players> online"), - ), - ), - NpcConfig.HologramLayout( - joinState = "maintenance", - lines = mutableListOf( - HologramConfiguration("<#dc2626><bold><target_name>"), - HologramConfiguration("<#94a3b8>Currently under maintenance"), - ), - ), - ) - ), - actions = mutableListOf( - NpcConfig.ActionConfiguration( - interactionType = PlayerInteraction.RIGHT_CLICK, - joinState = "default", - joinTarget = true, - ), - NpcConfig.ActionConfiguration( - interactionType = PlayerInteraction.RIGHT_CLICK, - joinState = "maintenance", - sendMessage = "<#dc2626>This server is currently under maintenance.", - ), - ), - ) - } - - companion object { - private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,64}$") - } -} - -sealed interface NpcOperationResult { - data class Success(val config: NpcConfig) : NpcOperationResult - data class Failure(val failure: NpcFailure, val detail: String? = null) : NpcOperationResult -} - -enum class NpcFailure { - INVALID_ID, - ALREADY_EXISTS, - NOT_FOUND, - TARGET_NOT_FOUND, - TARGET_AMBIGUOUS, - PROVIDER_REQUIRED, - CREATION_PROVIDER_UNAVAILABLE, - PROVIDER_UNAVAILABLE, - PROVIDER_NPC_NOT_FOUND, - PROVIDER_NPC_ALREADY_LINKED, - CREATE_FAILED, - DELETE_FAILED, -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/namespace/NpcNamespace.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/namespace/NpcNamespace.kt deleted file mode 100644 index 2347828..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/namespace/NpcNamespace.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.simplecloud.npc.shared.namespace - -import app.simplecloud.npc.shared.action.interaction.InteractionExecutor -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.event.EventManager -import app.simplecloud.npc.shared.hologram.HologramManager -import app.simplecloud.npc.shared.manager.NpcManager -import app.simplecloud.npc.shared.provider.NpcProviderEvents -import app.simplecloud.npc.shared.provider.NpcProviderRegistry -import app.simplecloud.npc.shared.pushback.PushbackListener -import app.simplecloud.npc.shared.repository.NpcRepository -import org.bukkit.Bukkit -import org.bukkit.Location -import org.bukkit.plugin.Plugin -import java.nio.file.Path - -/** - * Shared runtime for every configured NPC and every installed provider. - * - * Provider integrations no longer own repositories or core managers. This lets - * Citizens and FancyNPCs configurations coexist on the same server. - */ -class NpcNamespace( - val providerRegistry: NpcProviderRegistry, - configDirectory: Path, -) { - - val npcRepository = NpcRepository(configDirectory, providerRegistry) - val eventManager = EventManager(this) - val npcManager = NpcManager(this) - val interactionExecutor = InteractionExecutor(this) - val hologramManager = HologramManager(this) - - init { - npcRepository.externalDeleteListener = { - hologramManager.destroyHolograms(it.id) - hologramManager.destroyLegacyHolograms(it.id) - } - } - - fun onEnable(plugin: Plugin) { - providerRegistry.enable(Bukkit.getPluginManager(), plugin, providerEvents()) - Bukkit.getPluginManager().registerEvents(PushbackListener(this), plugin) - } - - fun onDisable() { - providerRegistry.disable(Bukkit.getPluginManager()) - } - - fun findAllNpcs(): List<String> = npcRepository.findAll().map { it.id } - - fun getAvailablePlayerInteractions(): List<PlayerInteraction> = PlayerInteraction.entries - - fun existNpc(id: String): Boolean = npcRepository.find(id) != null - - fun findLocationByNpc(id: String): Location? { - val config = npcRepository.find(id) ?: return null - val provider = providerRegistry.getAvailable(config.provider.type, Bukkit.getPluginManager()) ?: return null - return provider.location(config.provider.reference) - } - - private fun providerEvents() = NpcProviderEvents( - interact = { provider, reference, player, interaction, options -> - val config = npcRepository.findByProvider(provider, reference) ?: return@NpcProviderEvents - interactionExecutor.execute(config.id, player, interaction, options) - }, - spawn = { provider, reference -> - val config = npcRepository.findByProvider(provider, reference) ?: return@NpcProviderEvents - hologramManager.createOrUpdate(config) - }, - remove = { provider, reference -> - val config = npcRepository.findByProvider(provider, reference) ?: return@NpcProviderEvents - hologramManager.destroyHolograms(config.id) - hologramManager.destroyLegacyHolograms(config.id) - }, - ) -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/DefaultOptions.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/DefaultOptions.kt deleted file mode 100644 index 83837ab..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/DefaultOptions.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.simplecloud.npc.shared.option - -/** - * @author Niklas Nieberler - */ - -object DefaultOptions { - - val NPC_ID = Pair("npc.id", "") - - val NPC_UUID = Pair("npc.uuid", "") - - val NPC_ENTITY_ID = Pair("npc.entity.id", 0) - - val NPC_NAME = Pair("npc.name", "") - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/Option.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/Option.kt deleted file mode 100644 index f3e7427..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/Option.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.simplecloud.npc.shared.option - -/** - * @author Niklas Nieberler - */ - -data class Option( - val key: String = "", - val value: Any = "", -) { - - companion object { - fun <T> of(pair: Pair<String, Any>, value: T): Option { - return Option(pair.first, value!!) - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/OptionProvider.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/OptionProvider.kt deleted file mode 100644 index 06cd7b7..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/option/OptionProvider.kt +++ /dev/null @@ -1,36 +0,0 @@ -package app.simplecloud.npc.shared.option - -/** - * @author Niklas Nieberler - */ - -@Suppress("UNCHECKED_CAST") -open class OptionProvider( - private val options: MutableList<Option> = arrayListOf() -) { - - fun <T : Any> hasOption(pair: Pair<String, T>): Boolean { - return getOptions().firstOrNull { it.key == pair.first } != null - } - - fun <T : Any> getOption(key: String, defaultValue: T): T { - return (getOptions().firstOrNull { it.key.equals(key, true) }?.value ?: defaultValue) as T - } - - fun <T : Any> getOption(pair: Pair<String, T>): T { - return (getOptions().firstOrNull { it.key.equals(pair.first, true) }?.value ?: pair.second) as T - } - - fun add(vararg option: Option) { - this.options.addAll(option) - } - - open fun getOptions(): List<Option> = this.options - - companion object { - fun with(vararg option: Option): OptionProvider { - return OptionProvider(option.toMutableList()) - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionHandler.kt deleted file mode 100644 index 4fbe6d7..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.simplecloud.npc.shared.player - -import app.simplecloud.npc.shared.option.OptionProvider -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -interface PlayerActionHandler { - - fun handle(player: Player, optionProvider: OptionProvider) - - fun getOptions(): List<Pair<String, Any>> - - fun getSuggestions(): Map<String, List<String>> = emptyMap() - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionOptions.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionOptions.kt deleted file mode 100644 index 5f7aba6..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActionOptions.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.simplecloud.npc.shared.player - -/** - * @author Niklas Nieberler - */ - -object PlayerActionOptions { - - val SEND_MESSAGE = Pair("send.message", "") - - val SEND_ACTIONBAR = Pair("send.actionbar", "") - - val SEND_TITLE = Pair("send.title", "") - - val SEND_SUBTITLE = Pair("send.subtitle", "") - - val PLAY_SOUND = Pair("play.sound", "") - - val SOUND_VOLUME = Pair("sound.volume", "3F") - - val SOUND_PITCH = Pair("sound.pitch", "2F") - - val SOUND_SOURCE = Pair("sound.source", "master") - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActions.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActions.kt deleted file mode 100644 index 72bbc6c..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/PlayerActions.kt +++ /dev/null @@ -1,49 +0,0 @@ -package app.simplecloud.npc.shared.player - -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.player.handler.* - -/** - * @author Niklas Nieberler - */ - -object PlayerActions { - - private val actions = listOf( - SendMessagePlayerActionHandler(), - SendActionbarPlayerActionHandler(), - SendTitlePlayerActionHandler(), - PlaySoundPlayerActionHandler() - ) - - /** - * Returns a list with all available [PlayerActionHandler] - * @param npcInteraction of the npc action - */ - fun findPossibleActions(npcInteraction: NpcConfig.NpcInteraction): List<PlayerActionHandler> { - return this.actions.filter { - it.getOptions().any { (key, _) -> npcInteraction.getOption(key) != null } - } - } - - /** - * Gets you the suggestion value for a key - * @param key of the suggestion key - */ - fun getSuggestionActionByKey(key: String): List<String> { - return this.actions.map { it.getSuggestions() } - .map { it[key] ?: listOf() } - .flatten() - } - - /** - * Returns list of all option keys - */ - fun getAllOptionKeys(): List<String> { - return this.actions - .map { it.getOptions() } - .flatten() - .map { it.first } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/PlaySoundPlayerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/PlaySoundPlayerActionHandler.kt deleted file mode 100644 index 2a9ae57..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/PlaySoundPlayerActionHandler.kt +++ /dev/null @@ -1,38 +0,0 @@ -package app.simplecloud.npc.shared.player.handler - -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.player.PlayerActionHandler -import app.simplecloud.npc.shared.player.PlayerActionOptions -import net.kyori.adventure.key.Key -import net.kyori.adventure.sound.Sound -import org.bukkit.Registry -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class PlaySoundPlayerActionHandler : PlayerActionHandler { - - override fun handle(player: Player, optionProvider: OptionProvider) { - val soundName = optionProvider.getOption(PlayerActionOptions.PLAY_SOUND) - val soundVolume = optionProvider.getOption(PlayerActionOptions.SOUND_VOLUME).toFloat() - val soundPitch = optionProvider.getOption(PlayerActionOptions.SOUND_PITCH).toFloat() - val soundSource = optionProvider.getOption(PlayerActionOptions.SOUND_SOURCE) - val sound = Sound.sound(Key.key(soundName), Sound.Source.valueOf(soundSource.uppercase()), soundVolume, soundPitch) - player.playSound(sound) - } - - override fun getSuggestions() = mapOf( - "play.sound" to Registry.SOUNDS.mapNotNull { Registry.SOUNDS.getKey(it)?.asString() }, - "sound.source" to Sound.Source.NAMES.keys().map { it.lowercase() } - ) - - override fun getOptions() = listOf( - PlayerActionOptions.PLAY_SOUND, - PlayerActionOptions.SOUND_PITCH, - PlayerActionOptions.SOUND_VOLUME, - PlayerActionOptions.SOUND_SOURCE - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendActionbarPlayerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendActionbarPlayerActionHandler.kt deleted file mode 100644 index 4d69536..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendActionbarPlayerActionHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.simplecloud.npc.shared.player.handler - -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.player.PlayerActionHandler -import app.simplecloud.npc.shared.player.PlayerActionOptions -import app.simplecloud.plugin.api.shared.extension.text -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class SendActionbarPlayerActionHandler : PlayerActionHandler { - - override fun handle(player: Player, optionProvider: OptionProvider) { - val message = optionProvider.getOption(PlayerActionOptions.SEND_ACTIONBAR) - player.sendActionBar(text(message)) - } - - override fun getOptions() = listOf( - PlayerActionOptions.SEND_ACTIONBAR - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendMessagePlayerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendMessagePlayerActionHandler.kt deleted file mode 100644 index 388cc2a..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendMessagePlayerActionHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.simplecloud.npc.shared.player.handler - -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.player.PlayerActionHandler -import app.simplecloud.npc.shared.player.PlayerActionOptions -import app.simplecloud.plugin.api.shared.extension.text -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class SendMessagePlayerActionHandler : PlayerActionHandler { - - override fun handle(player: Player, optionProvider: OptionProvider) { - val message = optionProvider.getOption(PlayerActionOptions.SEND_MESSAGE) - player.sendMessage(text(message)) - } - - override fun getOptions() = listOf( - PlayerActionOptions.SEND_MESSAGE - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendTitlePlayerActionHandler.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendTitlePlayerActionHandler.kt deleted file mode 100644 index a55b1ac..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/player/handler/SendTitlePlayerActionHandler.kt +++ /dev/null @@ -1,29 +0,0 @@ -package app.simplecloud.npc.shared.player.handler - -import app.simplecloud.npc.shared.option.OptionProvider -import app.simplecloud.npc.shared.player.PlayerActionHandler -import app.simplecloud.npc.shared.player.PlayerActionOptions -import app.simplecloud.plugin.api.shared.extension.text -import net.kyori.adventure.title.Title -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -class SendTitlePlayerActionHandler : PlayerActionHandler { - - override fun handle(player: Player, optionProvider: OptionProvider) { - val titleOption = optionProvider.getOption(PlayerActionOptions.SEND_TITLE) - val subtitleOption = optionProvider.getOption(PlayerActionOptions.SEND_SUBTITLE) - - val title = Title.title(text(titleOption), text(subtitleOption)) - player.showTitle(title) - } - - override fun getOptions() = listOf( - PlayerActionOptions.SEND_TITLE, - PlayerActionOptions.SEND_SUBTITLE - ) - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProvider.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProvider.kt deleted file mode 100644 index d9bf08b..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProvider.kt +++ /dev/null @@ -1,50 +0,0 @@ -package app.simplecloud.npc.shared.provider - -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.option.OptionProvider -import org.bukkit.Location -import org.bukkit.entity.Player -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager - -interface NpcProvider { - val type: NpcProviderType - val pluginName: String - val supportsCreation: Boolean - get() = false - - fun isAvailable(pluginManager: PluginManager): Boolean { - return pluginManager.getPlugin(pluginName)?.isEnabled == true - } - - fun onEnable() {} - fun onDisable() {} - - fun registerListeners(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) - - fun exists(reference: String): Boolean - fun location(reference: String): Location? - fun references(): Collection<String> = emptyList() - - fun create(id: String, player: Player, location: Location): String { - throw UnsupportedOperationException("Provider ${type.commandName} cannot create NPCs") - } - - fun delete(reference: String): Boolean = false -} - -object NpcCreationDefaults { - const val LOOK_AT_PLAYER_DISTANCE = 10 -} - -class NpcProviderEvents( - val interact: ( - provider: NpcProviderType, - reference: String, - player: Player, - interaction: PlayerInteraction, - options: OptionProvider, - ) -> Unit, - val spawn: (provider: NpcProviderType, reference: String) -> Unit, - val remove: (provider: NpcProviderType, reference: String) -> Unit, -) diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderRegistry.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderRegistry.kt deleted file mode 100644 index af26c3d..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderRegistry.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.simplecloud.npc.shared.provider - -import org.bukkit.plugin.Plugin -import org.bukkit.plugin.PluginManager - -class NpcProviderRegistry( - providers: Collection<NpcProvider>, -) { - private val providers = providers.associateBy { it.type } - - fun availableProviders(pluginManager: PluginManager): List<NpcProvider> { - return providers.values.filter { it.isAvailable(pluginManager) } - } - - fun get(type: NpcProviderType): NpcProvider? = providers[type] - - fun getAvailable(type: NpcProviderType, pluginManager: PluginManager): NpcProvider? { - return providers[type]?.takeIf { it.isAvailable(pluginManager) } - } - - fun enable(pluginManager: PluginManager, plugin: Plugin, events: NpcProviderEvents) { - availableProviders(pluginManager).forEach { - it.onEnable() - it.registerListeners(pluginManager, plugin, events) - } - } - - fun disable(pluginManager: PluginManager) { - availableProviders(pluginManager).forEach(NpcProvider::onDisable) - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderType.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderType.kt deleted file mode 100644 index 927c3ab..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/NpcProviderType.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.simplecloud.npc.shared.provider - -enum class NpcProviderType(val commandName: String) { - CITIZENS("citizens"), - FANCY_NPCS("fancynpcs"), - MYTHIC_MOBS("mythicmobs"), - ZNPCS_PLUS("znpcsplus"); - - companion object { - fun getOrNull(value: String): NpcProviderType? = entries.firstOrNull { - it.commandName.equals(value, true) || it.name.equals(value, true) - } - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/ProviderOwnership.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/ProviderOwnership.kt deleted file mode 100644 index fa3716c..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/provider/ProviderOwnership.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.simplecloud.npc.shared.provider - -enum class ProviderOwnership { - MANAGED, - LINKED, -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/pushback/PushbackListener.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/pushback/PushbackListener.kt deleted file mode 100644 index 9a6b939..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/pushback/PushbackListener.kt +++ /dev/null @@ -1,67 +0,0 @@ -package app.simplecloud.npc.shared.pushback - -import app.simplecloud.npc.shared.namespace.NpcNamespace -import net.kyori.adventure.key.Key -import net.kyori.adventure.sound.Sound -import org.bukkit.entity.Player -import org.bukkit.event.EventHandler -import org.bukkit.event.EventPriority -import org.bukkit.event.Listener -import org.bukkit.event.player.PlayerMoveEvent -import org.bukkit.event.player.PlayerQuitEvent -import org.bukkit.util.Vector -import java.util.UUID - -class PushbackListener( - private val namespace: NpcNamespace, -) : Listener { - private val cooldowns = hashMapOf<UUID, Long>() - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - fun onMove(event: PlayerMoveEvent) { - val destination = event.to - if (event.from.x == destination.x && event.from.y == destination.y && event.from.z == destination.z) return - - val player = event.player - val now = System.currentTimeMillis() - if ((cooldowns[player.uniqueId] ?: 0L) > now) return - - namespace.npcRepository.findAll() - .asSequence() - .filter { it.pushback.enabled } - .forEach { config -> - val npcLocation = namespace.findLocationByNpc(config.id) ?: return@forEach - if (npcLocation.world != destination.world) return@forEach - val radius = config.pushback.radius - if (npcLocation.distanceSquared(destination) > radius * radius) return@forEach - - push(player, npcLocation.toVector(), config.pushback.strength, config.pushback.vertical) - config.pushback.sound?.let { playSound(player, it) } - cooldowns[player.uniqueId] = now + COOLDOWN_MILLIS - return - } - } - - @EventHandler - fun onQuit(event: PlayerQuitEvent) { - cooldowns.remove(event.player.uniqueId) - } - - private fun push(player: Player, npc: Vector, strength: Double, vertical: Double) { - val direction = player.location.toVector().subtract(npc).setY(0) - if (direction.lengthSquared() < 0.0001) { - direction.copy(player.location.direction.multiply(-1).setY(0)) - } - player.velocity = direction.normalize().multiply(strength).setY(vertical) - } - - private fun playSound(player: Player, sound: String) { - runCatching { - player.playSound(Sound.sound(Key.key(sound), Sound.Source.MASTER, 1F, 1F)) - } - } - - companion object { - private const val COOLDOWN_MILLIS = 500L - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/NpcRepository.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/NpcRepository.kt deleted file mode 100644 index 44d7210..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/NpcRepository.kt +++ /dev/null @@ -1,88 +0,0 @@ -package app.simplecloud.npc.shared.repository - -import app.simplecloud.npc.shared.config.NpcConfig -import app.simplecloud.npc.shared.config.NpcConfigMigration -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.provider.NpcProviderRegistry -import app.simplecloud.npc.shared.utils.NpcFileUpdater -import java.io.File -import java.nio.file.Path - -/** - * @author Niklas Nieberler - */ - -class NpcRepository( - private val directory: Path, - providerRegistry: NpcProviderRegistry, -) : WatchableYamlDirectoryRepository<NpcConfig, String>( - directory, - NpcConfig::class.java, - NpcConfigMigration.migrator(providerRegistry), -) { - var externalDeleteListener: (NpcConfig) -> Unit = {} - - override fun save(entity: NpcConfig) { - save("${entity.id}.yml", entity.normalize()) - } - - /** - * Gets the [NpcConfig] by a npc id - * @param identifier of the npc - */ - override fun find(identifier: String): NpcConfig? { - return findAll().firstOrNull { it.id.equals(identifier, true) } - } - - /** - * Resolves the user-facing SimpleCloud id or an unambiguous provider reference. - * This lets commands accept familiar provider names such as a ZNPCsPlus id. - */ - fun findBySelector(selector: String): NpcConfig? { - find(selector)?.let { return it } - return findAll() - .filter { it.provider.reference.equals(selector, true) } - .singleOrNull() - } - - fun selectors(): List<String> { - val configs = findAll() - val uniqueReferences = configs - .groupBy { it.provider.reference.lowercase() } - .values - .filter { it.size == 1 } - .map { it.single().provider.reference } - return (configs.map { it.id } + uniqueReferences) - .distinctBy { it.lowercase() } - .sortedWith(String.CASE_INSENSITIVE_ORDER) - } - - fun findByProvider(type: NpcProviderType, reference: String): NpcConfig? { - return findAll().firstOrNull { - it.provider.type == type && it.provider.reference.equals(reference, true) - } - } - - fun reload(): List<NpcConfig> { - findAll().filter { config -> - !directory.resolve("${config.id}.yml").toFile().exists() && - !directory.resolve("${config.id}.yaml").toFile().exists() - }.forEach { - externalDeleteListener(it) - delete(it) - } - return load() - } - - override fun watchUpdateEvent(file: File) { - find(file.nameWithoutExtension)?.let(NpcFileUpdater::invokeFile) - } - - override fun watchDeleteEvent(file: File) { - find(file.nameWithoutExtension)?.let { - externalDeleteListener(it) - delete(it) - } - } - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/WatchableYamlDirectoryRepository.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/WatchableYamlDirectoryRepository.kt deleted file mode 100644 index 0948482..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/repository/WatchableYamlDirectoryRepository.kt +++ /dev/null @@ -1,93 +0,0 @@ -package app.simplecloud.npc.shared.repository - -import app.simplecloud.plugin.api.shared.config.YamlDirectoryRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.io.File -import java.nio.file.ClosedWatchServiceException -import java.nio.file.FileSystems -import java.nio.file.Path -import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE -import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE -import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY -import java.nio.file.WatchService - -abstract class WatchableYamlDirectoryRepository<E, I>( - private val directory: Path, - clazz: Class<E>, - migrator: app.simplecloud.plugin.api.shared.config.ConfigMigrator? = null, -) : YamlDirectoryRepository<E, I>(directory, clazz, migrator) { - - private val watchScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var watchJob: Job? = null - private var watchService: WatchService? = null - - fun loadAndWatch(): List<E> { - val entities = load() - watchUpdates() - return entities - } - - fun watchUpdates() { - if (watchJob?.isActive == true) { - return - } - - directory.toFile().mkdirs() - val service = FileSystems.getDefault().newWatchService() - directory.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) - watchService = service - - watchJob = watchScope.launch { - runCatching { - while (isActive) { - val watchKey = service.take() - watchKey.pollEvents().forEach { event -> - val path = event.context() as? Path ?: return@forEach - val file = directory.resolve(path).toFile() - if (!file.isYamlFile()) { - return@forEach - } - - when (event.kind()) { - ENTRY_CREATE -> load(file) - ENTRY_MODIFY -> { - load(file) - watchUpdateEvent(file) - } - ENTRY_DELETE -> watchDeleteEvent(file) - } - } - - if (!watchKey.reset()) { - break - } - } - }.onFailure { - if (it !is ClosedWatchServiceException) { - throw it - } - } - } - } - - fun stopWatching() { - watchJob?.cancel() - watchJob = null - watchService?.close() - watchService = null - } - - protected open fun watchUpdateEvent(file: File) {} - - protected open fun watchDeleteEvent(file: File) {} - - private fun File.isYamlFile(): Boolean { - return extension == "yml" || extension == "yaml" - } - -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/ConfigVersion.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/ConfigVersion.kt deleted file mode 100644 index 13cb3ac..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/ConfigVersion.kt +++ /dev/null @@ -1,5 +0,0 @@ -package app.simplecloud.npc.shared.utils - -object ConfigVersion { - const val VERSION = 5 -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/Debouncer.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/Debouncer.kt deleted file mode 100644 index 06d6ae3..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/Debouncer.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.simplecloud.npc.shared.utils - -import app.simplecloud.npc.shared.sync -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * @author Niklas Nieberler - */ - -class Debouncer( - private val delayMillis: Long -) { - - private var job: Job? = null - - fun debounce(scope: CoroutineScope, action: suspend () -> Unit) { - this.job?.cancel() - this.job = scope.launch { - delay(delayMillis) - action() - } - } - - fun debounceSync(scope: CoroutineScope, action: () -> Unit) { - this.job?.cancel() - this.job = scope.launch { - delay(delayMillis) - sync { action() } - } - } - - fun cancel() { - job?.cancel() - job = null - } -} diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/MessageHelper.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/MessageHelper.kt deleted file mode 100644 index e473544..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/MessageHelper.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.simplecloud.npc.shared.utils - -import app.simplecloud.npc.shared.action.Action -import app.simplecloud.npc.shared.option.DefaultOptions -import app.simplecloud.npc.shared.option.OptionProvider -import org.bukkit.Bukkit - -/** - * @author Niklas Nieberler - */ - -object MessageHelper { - - fun printOptionNotFoundMessage(action: Action, actionOption: Pair<String, String>, optionProvider: OptionProvider) { - val id = optionProvider.getOption(DefaultOptions.NPC_ID) - Bukkit.getLogger().warning("[SimpleCloud-NPC] No option was found for the Action $action for the NPC $id. Please create the option \"${actionOption.first}\"") - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/NpcFileUpdater.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/NpcFileUpdater.kt deleted file mode 100644 index b50ca99..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/NpcFileUpdater.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.simplecloud.npc.shared.utils - -import app.simplecloud.npc.shared.config.NpcConfig -import kotlin.collections.set - -/** - * @author Niklas Nieberler - */ - -object NpcFileUpdater { - - private val lastUpdateTimes = mutableMapOf<String, Long>() - - private val functions = hashMapOf<String, (NpcConfig) -> Unit>() - - fun updateFileRequest(key: String, function: (NpcConfig) -> Unit) { - this.functions[key] = function - } - - fun invokeFile(entry: NpcConfig) { - val id = entry.id - val currentTime = System.currentTimeMillis() - val lastUpdateTime = this.lastUpdateTimes[id] ?: 0 - - if (currentTime - lastUpdateTime >= 500) { - this.lastUpdateTimes[id] = currentTime - this.functions.forEach { it.value(entry) } - } - } - -} \ No newline at end of file diff --git a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/PlayerConnectionHelper.kt b/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/PlayerConnectionHelper.kt deleted file mode 100644 index d6cc3ab..0000000 --- a/npc-shared/src/main/kotlin/app/simplecloud/npc/shared/utils/PlayerConnectionHelper.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.simplecloud.npc.shared.utils - -import app.simplecloud.npc.shared.pluginName -import com.google.common.io.ByteStreams -import org.bukkit.Bukkit -import org.bukkit.entity.Player - -/** - * @author Niklas Nieberler - */ - -object PlayerConnectionHelper { - - fun sendPlayerToServer(player: Player, serverName: String) { - val out = ByteStreams.newDataOutput() - out.writeUTF("Connect") - out.writeUTF(serverName) - val plugin = Bukkit.getPluginManager().getPlugin(pluginName) - ?: throw NullPointerException("failed to find $pluginName plugin") - player.sendPluginMessage(plugin, "BungeeCord", out.toByteArray()) - } - -} diff --git a/npc-shared/src/test/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigrationTest.kt b/npc-shared/src/test/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigrationTest.kt deleted file mode 100644 index 39530a2..0000000 --- a/npc-shared/src/test/kotlin/app/simplecloud/npc/shared/config/NpcConfigMigrationTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -package app.simplecloud.npc.shared.config - -import app.simplecloud.npc.shared.provider.NpcProviderRegistry -import app.simplecloud.npc.shared.provider.NpcProviderType -import app.simplecloud.npc.shared.provider.ProviderOwnership -import app.simplecloud.npc.shared.repository.NpcRepository -import app.simplecloud.npc.shared.utils.ConfigVersion -import app.simplecloud.npc.shared.action.interaction.PlayerInteraction -import app.simplecloud.npc.shared.hologram.config.HologramConfiguration -import kotlin.io.path.createTempDirectory -import kotlin.io.path.readText -import kotlin.io.path.writeText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class NpcConfigMigrationTest { - - @Test - fun `uses safe pushback defaults for new npcs`() { - val pushback = NpcConfig.PushbackConfiguration() - - assertEquals(1.2, pushback.radius) - assertEquals(0.8, pushback.strength) - } - - @Test - fun `writes the provider binding beside lowercase join states`() { - val directory = createTempDirectory("npc-config-write") - val repository = NpcRepository(directory, NpcProviderRegistry(emptyList())) - repository.save( - NpcConfig( - id = "lobby", - provider = NpcConfig.ProviderConfiguration( - NpcProviderType.CITIZENS, - "42", - ProviderOwnership.MANAGED, - ), - targetServers = mutableListOf("Lobby"), - hologram = NpcConfig.HologramConfigurationRoot( - layouts = mutableListOf( - NpcConfig.HologramLayout("MAINTENANCE", mutableListOf(HologramConfiguration("Hello"))) - ) - ), - actions = mutableListOf( - NpcConfig.ActionConfiguration(PlayerInteraction.RIGHT_CLICK, "DEFAULT", joinTarget = true) - ), - ) - ) - - val yaml = directory.resolve("lobby.yml").readText() - assertTrue(yaml.contains("provider:")) - assertTrue(yaml.contains("reference: '42'") || yaml.contains("reference: 42")) - assertTrue(yaml.contains("target-servers:")) - assertTrue(yaml.contains("join-state: maintenance")) - assertTrue(yaml.contains("join-state: default")) - } - - @Test - fun `resolves and suggests unambiguous provider references as npc selectors`() { - val directory = createTempDirectory("npc-selectors") - val repository = NpcRepository(directory, NpcProviderRegistry(emptyList())) - repository.save( - NpcConfig( - id = "lobby", - provider = NpcConfig.ProviderConfiguration( - NpcProviderType.ZNPCS_PLUS, - "server-selector", - ProviderOwnership.LINKED, - ), - ) - ) - - assertEquals("lobby", repository.findBySelector("server-selector")?.id) - assertEquals(listOf("lobby", "server-selector"), repository.selectors()) - } - - @Test - fun `does not expose ambiguous provider references as npc selectors`() { - val directory = createTempDirectory("ambiguous-npc-selectors") - val repository = NpcRepository(directory, NpcProviderRegistry(emptyList())) - repository.save( - NpcConfig( - id = "first", - provider = NpcConfig.ProviderConfiguration(NpcProviderType.CITIZENS, "42"), - ) - ) - repository.save( - NpcConfig( - id = "second", - provider = NpcConfig.ProviderConfiguration(NpcProviderType.ZNPCS_PLUS, "42"), - ) - ) - - assertEquals(null, repository.findBySelector("42")) - assertEquals(listOf("first", "second"), repository.selectors()) - } - - @Test - fun `migrates the proposed version one structure and normalizes join states`() { - val directory = createTempDirectory("npc-config-migration") - directory.resolve("test.yml").writeText( - """ - version: '1' - id: test - provider: CITIZENS - target-servers: - - lobby - hologram: - enabled: true - start-height: 2.5 - layouts: - - joinstate: MAINTENANCE - text: '<red>Maintenance' - pushback: - enabled: true - radius: 3.0 - strength: 1.5 - vertical: 0.4 - playSound: minecraft:entity.enderman.teleport - actions: - - interaction-type: RIGHT_CLICK - joinstate: DEFAULT - sendMessage: '<green>Hello' - """.trimIndent() - ) - - val repository = NpcRepository(directory, NpcProviderRegistry(emptyList())) - val config = assertNotNull(repository.load().singleOrNull()) - - assertEquals(ConfigVersion.VERSION, config.version) - assertEquals("test", config.provider.reference) - assertEquals(listOf("lobby"), config.targetServers) - assertEquals("maintenance", config.hologram.layouts.single().joinState) - assertEquals("<red>Maintenance", config.hologram.layouts.single().lines.single().text) - assertEquals("default", config.actions.single().joinState) - assertEquals("<green>Hello", config.actions.single().sendMessage) - assertTrue(config.pushback.enabled) - } -} diff --git a/platform/common/build.gradle.kts b/platform/common/build.gradle.kts new file mode 100644 index 0000000..aea9c5d --- /dev/null +++ b/platform/common/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + api(project(":core")) + api(libs.cloud.core) + api(libs.cloud.annotations) + compileOnly(libs.simplecloud.api) + compileOnly(libs.bundles.adventure) + + testImplementation(libs.simplecloud.api) + testImplementation(libs.bundles.adventure) + testImplementation(testFixtures(project(":core"))) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/ClickCooldowns.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/ClickCooldowns.kt new file mode 100644 index 0000000..5a17ea4 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/ClickCooldowns.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.common.action + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class ClickCooldowns(private val now: () -> Long = System::currentTimeMillis) { + + private val until = ConcurrentHashMap<Pair<UUID, String>, Long>() + + fun tryAcquire(playerId: UUID, key: String, millis: Long): Boolean { + if (millis <= 0) return true + val time = now() + until.values.removeIf { it <= time } + + return until.putIfAbsent(playerId to key, time + millis) == null + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/InteractionExecutor.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/InteractionExecutor.kt new file mode 100644 index 0000000..ee81b9f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/action/InteractionExecutor.kt @@ -0,0 +1,215 @@ +package app.simplecloud.npc.common.action + +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.inventory.view.InventoryOpener +import app.simplecloud.npc.common.platform.InteractionHooks +import app.simplecloud.npc.common.platform.NpcEffects +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.text.PlayerMessages +import app.simplecloud.npc.core.text.PlayerPlaceholders +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.CloudLists +import app.simplecloud.npc.core.cloud.CloudOutageLog +import app.simplecloud.npc.core.cloud.CloudUnavailableException +import app.simplecloud.npc.core.cloud.JoinStateResolver +import app.simplecloud.npc.core.cloud.TargetResolver +import app.simplecloud.npc.core.config.ActionFields +import app.simplecloud.npc.core.config.JoinStrategy +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.repository.NpcRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.kyori.adventure.text.Component +import java.util.logging.Level + +class InteractionExecutor( + private val npcRepository: NpcRepository, + private val inventoryOpener: InventoryOpener, + private val effects: NpcEffects = NpcEffects.NONE, + private val hooks: () -> InteractionHooks = { InteractionHooks.NONE }, + private val lists: CloudLists = CloudListCache, +) { + + private val interactionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val cooldowns = ClickCooldowns() + + fun execute(id: String, player: NpcPlayer, playerInteraction: PlayerInteraction) { + val config = npcRepository.find(id) ?: return + + interactionScope.launch { + try { + val joinState = JoinStateResolver.of(config) + val actions = config.actionsFor(playerInteraction, joinState) + if (!passesCooldown(player, "npc:$id:$playerInteraction", actions)) return@launch + if (!hooks().interact(config.id, player, playerInteraction, joinState)) return@launch + + actions.forEach { executeAction(config.targetServers, it, player, config.joinStrategy, config) } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to handle interaction with NPC $id", exception) + } + } + } + + fun passesCooldown(player: NpcPlayer, key: String, actions: List<NpcConfig.ActionConfiguration>): Boolean = + cooldowns.tryAcquire(player.uniqueId, key, actions.maxOfOrNull { it.cooldown } ?: 0) + + fun shutdown() { + interactionScope.cancel() + } + + suspend fun executeAction( + targetServers: List<String>, + action: NpcConfig.ActionConfiguration, + player: NpcPlayer, + strategy: JoinStrategy = JoinStrategy.LEAST_PLAYERS, + npc: NpcConfig? = null, + ) { + val permission = action.permission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) { + PlayerMessages.denied(player, action.denyMessage?.let { PlayerPlaceholders.resolve(it, player) }) + return + } + + action.playSound?.let { sound -> + runCatching { player.playSound(sound, action.playSoundOptions) } + .onFailure { logFailure(ActionFields.PLAY_SOUND, it) } + } + + if (npc != null) { + action.npcAnimation?.let { animation -> + runCatching { effects.animate(npc, player, animation) } + .onFailure { logFailure(ActionFields.NPC_ANIMATION, it) } + } + action.speech?.let { text -> + runCatching { effects.speak(npc, player, render(text, player)) } + .onFailure { logFailure(ActionFields.SPEECH, it) } + } + } + + action.executeCommand?.let { command -> + runCatching { player.performCommand(PlayerPlaceholders.resolve(command, player)) } + .onFailure { logFailure(ActionFields.EXECUTE_COMMAND, it) } + } + + action.sendMessage?.let { message -> + runCatching { player.sendMessage(render(message, player)) } + .onFailure { logFailure(ActionFields.SEND_MESSAGE, it) } + } + + action.teleport?.let { location -> + runCatching { player.teleport(location) }.onFailure { logFailure(ActionFields.TELEPORT, it) } + } + + action.sendTitle?.let { config -> + runCatching { showTitle(player, config) }.onFailure { logFailure(ActionFields.SEND_TITLE, it) } + } + + action.actionBar?.let { text -> + runCatching { player.sendActionBar(render(text, player)) } + .onFailure { logFailure(ActionFields.ACTION_BAR, it) } + } + + action.openInventory?.let { inventoryId -> + runCatching { inventoryOpener.open(player, inventoryId, InventoryOpenContext(targetServers, strategy)) } + .onFailure { logFailure(ActionFields.OPEN_INVENTORY, it) } + } + + if (action.previousMenu) { + runCatching { inventoryOpener.back(player) }.onFailure { logFailure(ActionFields.PREVIOUS_MENU, it) } + } else if (action.closeMenu) { + runCatching { player.closeInventory() }.onFailure { logFailure(ActionFields.CLOSE_MENU, it) } + } + + switchServer(targetServers, action, player, strategy, npc?.id) + } + + private suspend fun switchServer( + targetServers: List<String>, + action: NpcConfig.ActionConfiguration, + player: NpcPlayer, + strategy: JoinStrategy, + npcId: String?, + ) { + val sendTo = action.sendToServer + val transferTo = action.transferToServer + val configured = listOfNotNull( + ActionFields.JOIN_TARGET.takeIf { action.joinTarget }, + ActionFields.SEND_TO_SERVER.takeIf { sendTo != null }, + ActionFields.TRANSFER_TO_SERVER.takeIf { transferTo != null }, + ) + if (configured.isEmpty()) return + if (configured.size > 1) { + CloudOutageLog.warnThrottled( + "NPC action sets ${configured.joinToString(", ")}; only '${configured.first()}' is used.", + key = "action-switch:${configured.joinToString()}", + ) + } + + when { + action.joinTarget -> runCatching { + val found = targetServers.firstNotNullOfOrNull { TargetResolver.findDestination(it, lists, strategy) } + ?: return@runCatching + hooks().join(npcId, player, found)?.let(player::sendToServer) + }.onFailure { + if (it is CancellationException) throw it + logFailure(ActionFields.JOIN_TARGET, it) + } + + sendTo != null -> runCatching { player.sendToServer(sendTo) } + .onFailure { logFailure(ActionFields.SEND_TO_SERVER, it) } + + transferTo != null -> runCatching { transfer(player, transferTo) } + .onFailure { logFailure(ActionFields.TRANSFER_TO_SERVER, it) } + } + } + + private fun logFailure(actionType: String, throwable: Throwable) { + if (throwable is CancellationException) throw throwable + if (throwable is CloudUnavailableException) { + CloudOutageLog.warnThrottled( + "NPC action '$actionType' skipped: ${throwable.message}", + key = "action:$actionType", + ) + return + } + + logger.log(Level.WARNING, "NPC action '$actionType' failed", throwable) + } + + private fun render(text: String, player: NpcPlayer): Component = + Msg.miniMessage.deserialize(PlayerPlaceholders.resolve(text, player)) + + private fun showTitle(player: NpcPlayer, config: NpcConfig.TitleConfiguration) = player.showTitle( + render(config.title, player), + render(config.subtitle, player), + config.fadeIn, + config.stay, + config.fadeOut, + ) + + private fun transfer(player: NpcPlayer, address: String) { + val (host, port) = InputChecks.parseHostPort(address) ?: run { + CloudOutageLog.warnThrottled( + "NPC action '${ActionFields.TRANSFER_TO_SERVER}' has an unusable address '$address'.", + key = "transfer:$address", + ) + return + } + + player.transferToServer(host, port) + } + + private companion object { + private val logger = NpcLog.logger + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/CloudEventHandler.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/CloudEventHandler.kt new file mode 100644 index 0000000..6c87f20 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/CloudEventHandler.kt @@ -0,0 +1,136 @@ +package app.simplecloud.npc.common.cloud + +import app.simplecloud.api.CloudApi +import app.simplecloud.api.event.Subscription +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.render.HologramRotation +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudListCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.logging.Level +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class CloudEventHandler( + private val cloudApi: CloudApi, +) { + private var scope: CoroutineScope? = null + private var debouncer: Debouncer? = null + private val subscriptions = mutableListOf<Subscription>() + + private val refreshMutex = Mutex() + private var periodicRefreshJob: Job? = null + private var rotationJob: Job? = null + private val rotation = HologramRotation() + + fun registerEvents(pluginContext: NpcPluginContext) { + unregisterEvents() + val scope = createScope().also { this.scope = it } + debouncer = Debouncer(scope, EVENT_DEBOUNCE) + + val event = cloudApi.event() + subscriptions += event.server().onUpdated { requestRefresh(pluginContext) } + subscriptions += event.group().onUpdated { requestRefresh(pluginContext) } + subscriptions += event.server().onStarted { requestRefresh(pluginContext) } + subscriptions += event.server().onStopped { requestRefresh(pluginContext) } + subscriptions += event.server().onStateChanged { requestRefresh(pluginContext) } + subscriptions += event.server().onDeleted { requestRefresh(pluginContext) } + + periodicRefreshJob = scope.launch { + while (isActive) { + delay(PERIODIC_REFRESH) + try { + CloudListCache.refreshNow() + refresh(pluginContext) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.log(Level.WARNING, "Periodic hologram/inventory refresh failed", exception) + } + } + } + rotationJob = scope.launch { + while (isActive) { + delay(ROTATION_TICK) + try { + rotate(pluginContext) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.log(Level.WARNING, "Rotating hologram lines failed", exception) + } + } + } + } + + fun unregisterEvents() { + periodicRefreshJob?.cancel() + periodicRefreshJob = null + rotationJob?.cancel() + rotationJob = null + subscriptions.forEach { runCatching { it.unsubscribe() } } + subscriptions.clear() + debouncer?.cancel() + debouncer = null + scope?.cancel() + scope = null + } + + private fun requestRefresh(pluginContext: NpcPluginContext) { + debouncer?.debounce { + CloudListCache.refreshNow() + refresh(pluginContext) + } + } + + private suspend fun refresh(pluginContext: NpcPluginContext) = refreshMutex.withLock { + pluginContext.npcRepository.findAll() + .filter { it.hologram.enabled && !it.entity.needsRelocation } + .forEach { config -> + try { + pluginContext.hologramRenderer.createOrUpdate(config) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.warning("Could not refresh hologram ${config.id}: ${exception.message}") + } + } + + pluginContext.inventoryViews.refreshOpenViews() + pluginContext.editors.refreshOpenEditors(pluginContext.playerDirectory.onlinePlayers()) + } + + private suspend fun rotate(pluginContext: NpcPluginContext) { + val visible = pluginContext.npcRepository.findAll() + .filter { it.hologram.enabled && !it.entity.needsRelocation } + val due = rotation.due(visible, System.currentTimeMillis()) + if (due.isEmpty()) return + + refreshMutex.withLock { due.forEach { pluginContext.hologramRenderer.createOrUpdate(it) } } + } + + private fun createScope(): CoroutineScope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.log(Level.WARNING, "Hologram/inventory refresh failed", throwable) + }, + ) + + companion object { + private val EVENT_DEBOUNCE = 300.milliseconds + + private val PERIODIC_REFRESH = 3.seconds + private val ROTATION_TICK = 250.milliseconds + private val logger = NpcLog.logger + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/Debouncer.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/Debouncer.kt new file mode 100644 index 0000000..cb61c96 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/cloud/Debouncer.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.common.cloud + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration + +class Debouncer( + private val scope: CoroutineScope, + private val delayDuration: Duration, +) { + + private val job = AtomicReference<Job?>(null) + + fun debounce(action: suspend () -> Unit) { + val newJob = scope.launch { + delay(delayDuration) + action() + } + + job.getAndSet(newJob)?.cancel() + } + + fun cancel() { + job.getAndSet(null)?.cancel() + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/AbstractNpcCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/AbstractNpcCommand.kt new file mode 100644 index 0000000..6763b6e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/AbstractNpcCommand.kt @@ -0,0 +1,49 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcCommandSender +import app.simplecloud.npc.core.platform.NpcPlayer + +abstract class AbstractNpcCommand<C : NpcCommandSender>( + protected val pluginContext: NpcPluginContext, +) { + protected fun findPlayerInteraction(sender: C, value: String): PlayerInteraction? = + PlayerInteraction.getOrNull(value) ?: run { + sender.sendError("Unknown interaction {}.", value) + null + } + + protected fun findNpc(sender: C, id: String): NpcConfig? = + pluginContext.npcRepository.find(id) ?: run { + sender.sendError("NPC {} was not found.", id) + null + } + + protected fun findInventory(sender: C, id: String): InventoryConfiguration? = + pluginContext.inventoryRepository.find(id) ?: run { + sender.sendError("Inventory {} was not found.", id) + null + } + + protected fun requirePlayer(sender: C): NpcPlayer? = + sender.asPlayer() ?: run { + sender.sendError("This command can only be used by a player.") + null + } + + protected fun saveConfig( + sender: C, + config: NpcConfig, + successMessage: String, + vararg args: Any?, + refreshHologram: Boolean = true, + ) { + pluginContext.npcManager.save(config, refreshHologram) + sender.sendSuccess(successMessage, *args) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/ChatPixels.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/ChatPixels.kt new file mode 100644 index 0000000..0ff07d4 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/ChatPixels.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.common.command + +object ChatPixels { + private const val SPACE = 4 + + fun width(text: String): Int = text.sumOf { char -> + when (char) { + 'i', '!', '.', ',', ':', ';', '|', '\'' -> 2 + 'l', '`' -> 3 + 't', 'I', ' ', '[', ']' -> 4 + 'f', 'k', '<', '>', '(', ')', '"', '*' -> 5 + '@', '~' -> 7 + else -> 6 + } + } + + fun pad(text: String, target: Int): String { + val missing = target - width(text) + return if (missing <= 0) text else text + " ".repeat((missing + SPACE - 1) / SPACE) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandConstants.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandConstants.kt new file mode 100644 index 0000000..a00786f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandConstants.kt @@ -0,0 +1,7 @@ +package app.simplecloud.npc.common.command + +const val COMMAND_SYNTAX = "simplecloudnpcs|scnpcs|scnpc" +const val COMMAND_LABEL = "scnpcs" +const val COMMAND_PERMISSION = "simplecloud.npcs.command" +const val INVENTORY_OPEN_PERMISSION = "simplecloud.npcs.inventory.open" +const val INVENTORY_OPEN_OTHERS_PERMISSION = "simplecloud.npcs.inventory.open.others" diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandMessages.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandMessages.kt new file mode 100644 index 0000000..1fc7722 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandMessages.kt @@ -0,0 +1,341 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.manager.NpcFailure +import app.simplecloud.npc.common.manager.NpcOperationResult +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.text.PlayerMessages +import app.simplecloud.npc.common.text.substitute +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.ActionFields +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.yaml.YamlConfigurationLoader +import java.nio.file.Path +import java.util.logging.Level +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +enum class NpcSuccessMessage(val path: String, val verb: String) { + CREATED("command.npc.create.success", "Created"), + DELETED("command.npc.delete.success", "Deleted"), + UPDATED("command.npc.update.success", "Updated"), + LINKED("command.npc.link.success", "Linked"), + UNLINKED("command.npc.unlink.success", "Unlinked"), + DUPLICATED("command.npc.duplicate.success", "Duplicated"), +} + +object CommandMessages { + val helpTopics = listOf("create", "link", "edit", "target", "hologram", "action", "pushback", "admin", "inventory") + + private const val CMD = "/$COMMAND_LABEL" + + @Volatile + private var messagesPath: Path? = null + + private class Loaded(val root: ConfigurationNode, val variables: Map<String, String>) + + @Volatile + private var loaded: Loaded? = null + + fun resourcePaths(): Set<String> = buildSet { + addAll(FIXED_PATHS) + addAll(PlayerMessages.PATHS) + NpcSuccessMessage.entries.forEach { add(it.path) } + NpcFailure.entries.forEach { add(failure(it).first) } + } + + private const val DEFAULT_PREFIX = "<#0EA5E9>⚡ NPC <#475569>|" + private val OLD_DEFAULT_PREFIXES = listOf( + "<#0EA5E9><bold>NPCs</bold> <#475569>|", + "<#0EA5E9>SimpleCloud NPC <#475569>|", + ) + + private val FIXED_PATHS = setOf( + "command.help.title", + "command.help.topic-title", + "command.help.unknown", + "command.reload.success", + "command.reload.failed", + ) + + fun initialize(path: Path) { + messagesPath = path + runCatching { updateDefaultPrefix(path) } + .onFailure { NpcLog.logger.log(Level.WARNING, "Could not update the prefix in messages.yml", it) } + reload() + } + + internal fun updateDefaultPrefix(path: Path) { + if (!path.exists()) return + val text = path.readText() + val updated = OLD_DEFAULT_PREFIXES.fold(text) { acc, old -> + acc.replace("prefix: '$old'", "prefix: '$DEFAULT_PREFIX'") + } + if (updated != text) path.writeText(updated) + } + + fun reload(): Boolean { + val path = messagesPath ?: return false + + return runCatching { + val root = YamlConfigurationLoader.builder().path(path).build().load() + val variables = root.node("variables").childrenMap().mapNotNull { (key, node) -> + node.string?.let { key.toString() to it } + }.toMap() + loaded = Loaded(root, variables) + }.onFailure { NpcLog.logger.log(Level.WARNING, "Could not load messages.yml", it) }.isSuccess + } + + fun send( + sender: NpcCommandSender, + path: String, + fallback: String, + replacements: Map<String, Any?> = emptyMap(), + ) { + sender.sendMessage(Msg.miniMessage.deserialize(render(path, fallback, replacements))) + } + + private fun render(path: String, fallback: String, replacements: Map<String, Any?> = emptyMap()): String { + val current = loaded + val configured = current?.root?.node(*path.split('.').toTypedArray())?.string ?: fallback + + val escaped = replacements.mapValues { Msg.miniMessage.escapeTags(it.value?.toString().orEmpty()) } + + return configured.substitute((current?.variables.orEmpty() + escaped).mapKeys { "<${it.key}>" }) + } + + fun sendResult( + sender: NpcCommandSender, + result: NpcOperationResult, + success: NpcSuccessMessage = NpcSuccessMessage.UPDATED, + ) { + when (result) { + is NpcOperationResult.Success -> send( + sender, + success.path, + "${Msg.successPrefix()}${success.verb} NPC ${Msg.ACCENT}<id>${Msg.SUCCESS}.", + mapOf("id" to result.config.id), + ) + + is NpcOperationResult.Failure -> sender.sendMessage(Msg.miniMessage.deserialize(failureMessage(result))) + } + } + + fun sendHelp(sender: NpcCommandSender, topic: String? = null) { + val normalizedTopic = topic?.lowercase() + val fallbackLines = helpPage(normalizedTopic) ?: run { + send( + sender, + "command.help.unknown", + "${Msg.errorPrefix()}Unknown help topic. ${Msg.MUTED}Try: ${Msg.ACCENT}<topics>", + mapOf("topics" to helpTopics.joinToString()), + ) + return + } + + val page = normalizedTopic ?: "main" + val title = if (normalizedTopic == null) { + render("command.help.title", "${Msg.infoPrefix()}Commands") + } else { + render( + "command.help.topic-title", + "${Msg.infoPrefix()}Help ${Msg.SUBTLE}- ${Msg.ACCENT}<topic>", + mapOf("topic" to normalizedTopic), + ) + } + + val lines = listOf(title) + renderLines("command.help.pages.$page", fallbackLines) + + sender.sendMessage(Msg.miniMessage.deserialize(lines.joinToString("\n"))) + } + + private fun renderLines(path: String, fallback: List<String>): List<String> { + val current = loaded + val configured = current?.root?.node(*path.split('.').toTypedArray()) + ?.childrenList() + ?.mapNotNull(ConfigurationNode::getString) + .orEmpty() + .ifEmpty { fallback } + + val variables = current?.variables.orEmpty().mapKeys { "<${it.key}>" } + + return configured.map { it.substitute(variables) } + } + + private fun helpPage(topic: String?): List<String>? = when (topic) { + null -> listOf( + clickable("$CMD create", "<#38bdf8>$CMD create <#475569>- <#94a3b8>Create an NPC"), + clickable( + "$CMD link ", + "<#38bdf8>$CMD link <#475569>- <#94a3b8>Attach an existing Citizens/FancyNpcs/ZNPCsPlus NPC", + ), + clickable( + "$CMD edit", + "<#38bdf8>$CMD edit <#f8fafc>[id] <#475569>- <#94a3b8>Edit an NPC (or the one you're looking at)", + ), + clickable("$CMD list", "<#38bdf8>$CMD list <#475569>- <#94a3b8>List all NPCs"), + clickable( + "$CMD info", + "<#38bdf8>$CMD info <#f8fafc>[id] <#475569>- <#94a3b8>View details (or the one you're looking at)", + ), + clickable( + "$CMD delete", + "<#38bdf8>$CMD delete <#f8fafc>[id] <#475569>- <#94a3b8>Delete an NPC (or the one you're looking at)", + ), + "<#94a3b8>More: <#38bdf8>$CMD help <#f8fafc><topic>", + "<#94a3b8>Topics: <#f8fafc>${helpTopics.joinToString()}", + ) + + "create" -> listOf( + clickable("$CMD create ", "<#38bdf8>$CMD create <#f8fafc><id> <target>"), + "<#94a3b8>Spawns a native NPC at your position, links it, and opens its editor.", + "<#94a3b8>Example: <#f8fafc>$CMD create lobby Lobby", + ) + + "link" -> listOf( + clickable("$CMD link ", "<#38bdf8>$CMD link <#f8fafc><id> <provider> <reference> <target>"), + "<#94a3b8>Attaches hologram and click handling to an NPC you created with", + "<#94a3b8>Citizens, FancyNpcs, ZNPCsPlus or MythicMobs. The NPC itself is never changed.", + "<#94a3b8>Example: <#f8fafc>$CMD link lobby citizens 42 Lobby", + clickable( + "$CMD unlink ", + "<#38bdf8>$CMD unlink <#f8fafc><id> <#475569>- <#94a3b8>Detach again, the NPC stays", + ), + ) + + "edit" -> listOf( + clickable("$CMD edit", "<#38bdf8>$CMD edit <#475569>- <#94a3b8>Edit the NPC you're looking at"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> target"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> action"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> teleport"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> skin <#f8fafc><player>"), + "<#94a3b8>Use <#38bdf8>$CMD help <#f8fafc><topic> <#94a3b8>for details.", + ) + + "target" -> listOf( + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> target list"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> target add <target>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> target remove <target>"), + ) + + "hologram" -> listOf( + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram enable"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram disable"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram height <value>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram line add <state> <text>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram line set <state> <line> <text>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> hologram line remove <state> <line>"), + ) + + "action" -> listOf( + "<#38bdf8>$CMD edit <#f8fafc><id> action <type> <interaction> <state> [value]", + "<#94a3b8>Types: <#f8fafc>join-target, open-inventory, play-sound,", + " <#f8fafc>send-message, execute-command, teleport-here, title,", + " <#f8fafc>subtitle, title-duration, action-bar, send-to-server, transfer-to-server", + "<#38bdf8>$CMD edit <#f8fafc><id> action clear <interaction> <state> [field]", + "<#94a3b8>Fields: <#f8fafc>${ActionFields.NAMES.joinToString()}", + ) + + "pushback" -> listOf( + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback enable"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback disable"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback radius <value>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback strength <value>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback vertical <value>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback sound <sound>"), + clickable("$CMD edit ", "<#38bdf8>$CMD edit <#f8fafc><id> pushback sound clear"), + ) + + "admin" -> listOf( + clickable("$CMD list", "<#38bdf8>$CMD list <#475569>- <#94a3b8>List NPCs"), + clickable( + "$CMD info", + "<#38bdf8>$CMD info <#f8fafc>[id] <#475569>- <#94a3b8>View details (or the one you're looking at)", + ), + clickable("$CMD tp ", "<#38bdf8>$CMD tp <#f8fafc><id> <#475569>- <#94a3b8>Teleport to an NPC"), + clickable( + "$CMD duplicate ", + "<#38bdf8>$CMD duplicate <#f8fafc><id> <newId> <#475569>- <#94a3b8>Copy an NPC to where you stand", + ), + clickable("$CMD delete", "<#38bdf8>$CMD delete <#f8fafc>[id] <#475569>- <#94a3b8>Delete completely"), + clickable("$CMD reload", "<#38bdf8>$CMD reload <#475569>- <#94a3b8>Reload configs"), + clickable("$CMD placeholders", "<#38bdf8>$CMD placeholders <#475569>- <#94a3b8>List placeholders"), + ) + + "inventory" -> listOf( + clickable("$CMD inventory create", "<#38bdf8>$CMD inventory create <#475569>- <#94a3b8>Create wizard"), + clickable("$CMD inventory create ", "<#38bdf8>$CMD inventory create <#f8fafc><id> <rows>"), + clickable( + "$CMD inventory edit ", + "<#38bdf8>$CMD inventory edit <#f8fafc><id> <#475569>- <#94a3b8>Visual editor", + ), + clickable("$CMD inventory delete ", "<#38bdf8>$CMD inventory delete <#f8fafc><id>"), + clickable( + "$CMD inventory open ", + "<#38bdf8>$CMD inventory open <#f8fafc><id> [player] " + + "<#475569>- <#94a3b8>Open it, e.g. from a compass item", + ), + clickable("$CMD inventory list", "<#38bdf8>$CMD inventory list"), + "<#94a3b8>Reference from an NPC via <#f8fafc>$CMD edit <id> action open-inventory", + ) + + else -> null + } + + private fun clickable(suggestion: String, text: String): String { + val escaped = suggestion.replace("'", "\\'") + val hover = "<hover:show_text:'<#94a3b8>Click to fill in chat'>$text</hover>" + + return "<click:suggest_command:'$escaped'>$hover</click>" + } + + private fun failure(failure: NpcFailure): Pair<String, String> = when (failure) { + NpcFailure.INVALID_ID -> "command.npc.error.invalid-id" to + "${Msg.errorPrefix()}NPC id ${Msg.ACCENT}<detail> ${Msg.ERROR}is invalid." + + NpcFailure.ALREADY_EXISTS -> "command.npc.error.already-exists" to + "${Msg.errorPrefix()}NPC ${Msg.ACCENT}<detail> ${Msg.ERROR}already exists." + + NpcFailure.NOT_FOUND -> "command.npc.error.not-found" to + "${Msg.errorPrefix()}NPC ${Msg.ACCENT}<detail> ${Msg.ERROR}was not found." + + NpcFailure.TARGET_NOT_FOUND -> "command.target.error.not-found" to + "${Msg.errorPrefix()}Target ${Msg.ACCENT}<detail> ${Msg.ERROR}was not found." + + NpcFailure.TARGET_AMBIGUOUS -> "command.target.error.ambiguous" to + "${Msg.errorPrefix()}Target ${Msg.ACCENT}<detail> ${Msg.ERROR}" + + "exists as both a group and persistent server. Rename one of them." + + NpcFailure.TARGET_ALREADY_USED -> "command.target.error.already-used" to + "${Msg.errorPrefix()}Target ${Msg.ACCENT}<detail> ${Msg.ERROR}is already used by this NPC." + + NpcFailure.TARGET_LIMIT -> "command.target.error.limit" to + "${Msg.errorPrefix()}This NPC already has the maximum of ${Msg.ACCENT}<detail> ${Msg.ERROR}targets." + + NpcFailure.CREATE_FAILED -> "command.npc.create.failed" to + "${Msg.errorPrefix()}Could not create the NPC${Msg.ACCENT}<detail-suffix>" + + NpcFailure.PROVIDER_UNAVAILABLE -> "command.provider.error.unavailable" to + "${Msg.errorPrefix()}Provider ${Msg.ACCENT}<detail> ${Msg.ERROR}is unavailable." + + NpcFailure.PROVIDER_NPC_NOT_FOUND -> "command.provider.error.external-npc-missing" to + "${Msg.errorPrefix()}Provider NPC ${Msg.ACCENT}<detail> ${Msg.ERROR}was not found." + + NpcFailure.PROVIDER_NPC_ALREADY_LINKED -> "command.provider.error.already-linked" to + "${Msg.errorPrefix()}Provider NPC ${Msg.ACCENT}<detail> ${Msg.ERROR}is already linked." + + NpcFailure.NOT_LINKED -> "command.npc.error.not-linked" to + "${Msg.errorPrefix()}NPC ${Msg.ACCENT}<detail> ${Msg.ERROR}is not a linked NPC, use delete." + } + + private fun failureMessage(result: NpcOperationResult.Failure): String { + val detail = result.detail.orEmpty() + val (path, fallback) = failure(result.failure) + val suffix = if (detail.isBlank()) "." else ": $detail" + + return render(path, fallback, mapOf("detail" to detail, "detail-suffix" to suffix)) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandSuggestions.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandSuggestions.kt new file mode 100644 index 0000000..b619606 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/CommandSuggestions.kt @@ -0,0 +1,69 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.CloudNames +import app.simplecloud.npc.core.cloud.JoinStates +import app.simplecloud.npc.core.config.ActionFields +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.suggestion.Suggestions +import org.incendo.cloud.context.CommandContext +import kotlin.jvm.optionals.getOrNull + +class CommandSuggestions<C : NpcCommandSender>( + private val pluginContext: NpcPluginContext, +) { + @Suggestions("npcIds") + fun suggestNpcId(): List<String> = pluginContext.npcRepository.ids() + + @Suggestions("targets") + fun suggestTargets(): List<String> = CloudNames.targets() + + @Suggestions("configuredTargets") + fun suggestConfiguredTargets(context: CommandContext<C>): List<String> = + context.optional<String>("id").getOrNull() + ?.let(pluginContext.npcRepository::find) + ?.targetServers + ?.sortedWith(String.CASE_INSENSITIVE_ORDER) + .orEmpty() + + @Suggestions("linkProviders") + fun suggestLinkProviders(): List<String> = pluginContext.providers.linkingProviders.sorted() + + @Suggestions("linkReferences") + fun suggestLinkReferences(context: CommandContext<C>): List<String> { + val provider = context.optional<String>("provider").getOrNull() ?: return emptyList() + return runCatching { pluginContext.providers.linkableReferences(provider) }.getOrDefault(emptyList()) + } + + @Suggestions("servers") + fun suggestServers(): List<String> = CloudNames.servers() + + @Suggestions("joinStates") + fun suggestJoinStates(context: CommandContext<C>): List<String> { + val selector = context.optional<String>("id").getOrNull() ?: return emptyList() + val config = pluginContext.npcRepository.find(selector) ?: return emptyList() + val live = JoinStates.live(config, CloudListCache.peek()) + + return JoinStates.offered(config, live).sorted() + } + + @Suggestions("helpTopics") + fun suggestHelpTopics(): List<String> = CommandMessages.helpTopics + + @Suggestions("placeholderTopics") + fun suggestPlaceholderTopics(): List<String> = PlaceholderPage.topics + + @Suggestions("playerInteractions") + fun suggestPlayerInteraction(): List<String> = PlayerInteraction.entries.map { it.name.lowercase() } + + @Suggestions("actionTypes") + fun suggestActionTypes(): List<String> = ActionFields.NAMES + + @Suggestions("onlinePlayers") + fun suggestOnlinePlayers(): List<String> = pluginContext.playerDirectory.onlinePlayers().map { it.name } + + @Suggestions("inventoryIds") + fun suggestInventoryIds(): List<String> = pluginContext.inventoryRepository.ids() +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandExceptionHandler.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandExceptionHandler.kt new file mode 100644 index 0000000..6b5a894 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandExceptionHandler.kt @@ -0,0 +1,91 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudUnavailableException +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.CommandManager +import org.incendo.cloud.exception.ArgumentParseException +import org.incendo.cloud.exception.CommandExecutionException +import org.incendo.cloud.exception.InvalidCommandSenderException +import org.incendo.cloud.exception.InvalidSyntaxException +import org.incendo.cloud.exception.NoPermissionException +import org.incendo.cloud.exception.NoSuchCommandException +import org.incendo.cloud.exception.parsing.NumberParseException +import java.util.logging.Level + +object NpcCommandExceptionHandler { + private val logger = NpcLog.logger + + fun <C : NpcCommandSender> registerTo(commandManager: CommandManager<C>) { + val controller = commandManager.exceptionController() + + controller.registerHandler(NoSuchCommandException::class.java) { context -> + context.context().sender().sendError( + "Unknown command {}. Run {} for a list of commands.", + context.exception().suppliedCommand(), + "/$COMMAND_LABEL help", + ) + } + + controller.registerHandler(InvalidSyntaxException::class.java) { context -> + context.context().sender().sendError( + "Wrong syntax. Usage: {}", + "/${context.exception().correctSyntax()}", + ) + } + + controller.registerHandler(NoPermissionException::class.java) { context -> + context.context().sender().sendError("You don't have permission to do that.") + } + + controller.registerHandler(InvalidCommandSenderException::class.java) { context -> + context.context().sender().sendError(context.exception().message ?: "This command can't be used here.") + } + + controller.registerHandler(ArgumentParseException::class.java) { context -> + val cause = context.exception().cause + + if (cause is NumberParseException) { + context.context().sender().sendError("{} is not a valid {}.", cause.input(), cause.numberType()) + } else { + context.context().sender().sendError(cause.message ?: "Invalid argument.") + } + } + + controller.registerHandler(CommandExecutionException::class.java) { context -> + generateSequence(context.exception().cause) { it.cause?.takeIf { cause -> cause !== it } } + .filterIsInstance<CloudUnavailableException>() + .firstOrNull() + ?.let { cloudOutage -> + context.context().sender().sendError( + "{}. Try again once the controller is back.", + cloudOutage.message ?: "The SimpleCloud controller is unreachable", + ) + return@registerHandler + } + + reportUnexpected( + context.context().sender(), + "Command failed: /${context.context().rawInput().input()}", + context.exception().cause, + ) + } + + controller.registerHandler(Throwable::class.java) { context -> + val exceptionType = context.exception()::class.qualifiedName + reportUnexpected( + context.context().sender(), + "Unhandled $exceptionType for command: /${context.context().rawInput().input()}", + context.exception(), + ) + } + } + + private fun reportUnexpected(sender: NpcCommandSender, summary: String, cause: Throwable?) { + val reference = java.lang.Long.toHexString(System.nanoTime()).takeLast(6) + + logger.log(Level.SEVERE, "[$reference] $summary", cause) + sender.sendError("An unexpected error occurred (ref: {}). Please notify an administrator.", reference) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandRegistrar.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandRegistrar.kt new file mode 100644 index 0000000..f169c4c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcCommandRegistrar.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.command.edit.NpcActionCommand +import app.simplecloud.npc.common.command.edit.NpcHologramCommand +import app.simplecloud.npc.common.command.edit.NpcPushbackCommand +import app.simplecloud.npc.common.command.edit.NpcTargetCommand +import app.simplecloud.npc.common.command.global.InventoryCommand +import app.simplecloud.npc.common.command.global.NpcCommand +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.core.platform.NpcCommandSender + +object NpcCommandRegistrar { + fun <C : NpcCommandSender> commands(pluginContext: NpcPluginContext): List<Any> = listOf( + CommandSuggestions<C>(pluginContext), + NpcCommand<C>(pluginContext), + NpcTargetCommand<C>(pluginContext), + NpcHologramCommand<C>(pluginContext), + NpcPushbackCommand<C>(pluginContext), + NpcActionCommand<C>(pluginContext), + InventoryCommand<C>(pluginContext), + ) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcInfoPage.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcInfoPage.kt new file mode 100644 index 0000000..11ab555 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/NpcInfoPage.kt @@ -0,0 +1,237 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.manager.NpcStatus +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.cloud.CloudSnapshot +import app.simplecloud.npc.core.cloud.TargetResolution +import app.simplecloud.npc.core.cloud.TargetResolver +import app.simplecloud.npc.core.cloud.TargetType +import app.simplecloud.npc.core.cloud.groupName +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.render.Providers +import java.util.Locale + +object NpcInfoPage { + + private const val TEXT = "<#e2e8f0>" + private const val KEY = "<#38bdf8>" + private const val OK = "<#a3e635>" + + fun render(config: NpcConfig, status: NpcStatus, snapshot: CloudSnapshot): String { + val cmd = "/$COMMAND_LABEL" + val id = config.id + val badge = statusBadge(config, status, snapshot) + val editButton = button("Edit", "$cmd edit $id", "Open the editor", run = true) + val moveButton = button("Move here", "$cmd edit $id teleport", "Moves the NPC to where you stand", run = true) + + return buildList { + add("${Msg.infoPrefix()}${Msg.ACCENT}${esc(id)}${nameSuffix(config)} $badge $editButton $moveButton") + add(row("Target", targetsValue(config, snapshot, cmd))) + add(row("Provider", providerValue(config))) + add(row("Location", locationValue(config, cmd))) + hologramLines(config).forEachIndexed { index, line -> add(row(if (index == 0) "Hologram" else "", line)) } + actionLines(config).forEachIndexed { index, line -> add(row(if (index == 0) "Actions" else "", line)) } + add(row("Pushback", pushbackValue(config))) + }.joinToString("\n") + } + + private fun nameSuffix(config: NpcConfig): String = + NpcFormat.displayName(config).takeIf { it != config.id }?.let { " ${Msg.MUTED}(${esc(it)})" }.orEmpty() + + private fun statusBadge(config: NpcConfig, status: NpcStatus, snapshot: CloudSnapshot): String { + val targetDerived = status == NpcStatus.TARGET_MISSING || status == NpcStatus.TARGET_AMBIGUOUS + if (targetDerived && config.targetServers.isNotEmpty() && !snapshot.known) { + return hover("${Msg.MUTED}● Checking…", "The cloud lists have not been read yet.") + } + + return when (status) { + NpcStatus.COMPLETE -> + hover("$OK● FUNCTIONAL", "Everything is fine.") + + NpcStatus.NEEDS_RELOCATION -> + hover( + "${Msg.WARNING}● Needs relocation", + "Its old position could not be taken over. Stand where it should appear and click Move here.", + ) + + NpcStatus.TARGET_AMBIGUOUS -> { + val names = namesWith(config, snapshot) { it == TargetResolution.Ambiguous } + hover( + "${Msg.ERROR}● Ambiguous target: ${esc(names)}", + "A group and a persistent server share this name. Rename one of them.", + ) + } + + NpcStatus.TARGET_MISSING -> if (config.targetServers.isEmpty()) { + hover( + "${Msg.ERROR}● No target", + "Clicks have nowhere to send players. " + + "Add one with /$COMMAND_LABEL edit ${config.id} target add <target>.", + escape = true, + ) + } else { + val names = namesWith(config, snapshot) { it == TargetResolution.NotFound } + hover( + "${Msg.ERROR}● Target missing: ${esc(names)}", + "No group or persistent server with this name exists in the cloud.", + ) + } + } + } + + private fun namesWith( + config: NpcConfig, + snapshot: CloudSnapshot, + predicate: (TargetResolution) -> Boolean, + ): String = config.targetServers.filter { predicate(TargetResolver.resolve(it, snapshot)) }.joinToString() + + private fun targetsValue(config: NpcConfig, snapshot: CloudSnapshot, cmd: String): String { + if (config.targetServers.isEmpty()) return "${Msg.MUTED}none" + val value = config.targetServers.joinToString("${Msg.SUBTLE}, ") { target -> + "$TEXT${esc(target)} ${targetLive(target, snapshot)}" + } + + return click(value, "$cmd edit ${config.id} target list", "Show the targets", run = true) + } + + private fun targetLive(target: String, snapshot: CloudSnapshot): String { + if (!snapshot.known) return "${Msg.SUBTLE}loading…" + val resolution = TargetResolver.resolve(target, snapshot) + if (resolution !is TargetResolution.Found) return "${Msg.ERROR}not found" + val servers = snapshot.servers.filter { server -> + server.groupName().equals(target, ignoreCase = true) || + server.persistentServer?.name.equals(target, ignoreCase = true) + } + val players = servers.sumOf { it.playerCount ?: 0 } + + return when (resolution.target.type) { + TargetType.GROUP -> + "${Msg.SUBTLE}group · ${Msg.MUTED}${servers.size} online · ${plural(players, "player")}" + + TargetType.PERSISTENT_SERVER -> if (servers.isEmpty()) { + "${Msg.SUBTLE}server · ${Msg.WARNING}offline" + } else { + "${Msg.SUBTLE}server · ${Msg.MUTED}online · ${plural(players, "player")}" + } + } + } + + private fun providerValue(config: NpcConfig): String { + val entity = config.entity + val name = providerName(entity.provider) + + return if (entity.providerLinked) { + "$TEXT$name${Msg.SUBTLE}, ${Msg.MUTED}linked ${Msg.SUBTLE}(${esc(entity.providerReference.orEmpty())})" + } else { + "$TEXT$name" + } + } + + private fun providerName(key: String): String = esc(Providers.displayName(key)) + + private fun locationValue(config: NpcConfig, cmd: String): String { + if (config.entity.needsRelocation) return "${Msg.WARNING}not placed yet" + val location = config.entity.location + val coordinates = "${location.x.toInt()} ${location.y.toInt()} ${location.z.toInt()}" + val value = "$TEXT${esc(location.world)} ${Msg.MUTED}$coordinates" + + return click(value, "$cmd tp ${config.id}", "Teleport to the NPC", run = true) + } + + private fun hologramLines(config: NpcConfig): List<String> { + val hologram = config.hologram + if (!hologram.enabled) return listOf("${Msg.MUTED}off") + val layouts = hologram.layouts.filter { it.lines.isNotEmpty() } + .sortedBy { if (NpcFormat.isDefaultJoinState(it.joinState)) 0 else 1 } + if (layouts.isEmpty()) return listOf("${Msg.MUTED}on, but no lines yet") + val labelWidth = layouts.maxOf { ChatPixels.width(it.joinState.lowercase()) } + + return layouts.map { layout -> + val state = layout.joinState.lowercase() + val preview = layout.lines.joinToString("\n") { it.shownText } + "${Msg.MUTED}${ChatPixels.pad(esc(state), labelWidth)} " + + hover("$TEXT${plural(layout.lines.size, "line")}", preview, formatted = true) + } + } + + private fun actionLines(config: NpcConfig): List<String> { + val byClick = NpcFormat.CLICK_ROWS.flatMap { click -> + config.actions.filter { it.interactionType == click && !it.isEmpty() } + .sortedBy { if (NpcFormat.isDefaultJoinState(it.joinState)) 0 else 1 } + } + if (byClick.isEmpty()) return listOf("${Msg.MUTED}none") + val labelWidth = byClick.maxOf { ChatPixels.width(Clicks.short(it.interactionType)) } + + return byClick.map { action -> + val label = Clicks.short(action.interactionType) + val state = if (NpcFormat.isDefaultJoinState(action.joinState)) { + "" + } else { + " ${Msg.SUBTLE}(${esc(action.joinState.lowercase())})" + } + "${Msg.MUTED}${ChatPixels.pad(label, labelWidth)} $TEXT${describe(action)}$state" + } + } + + private fun describe(action: NpcConfig.ActionConfiguration): String = buildList { + if (action.joinTarget) add("join target") + action.sendToServer?.let { add("send to ${esc(it)}") } + action.transferToServer?.let { add("transfer to ${esc(it)}") } + action.openInventory?.let { add("open menu ${esc(it)}") } + action.teleport?.let { add("teleport to ${esc(NpcFormat.position(it))}") } + action.sendMessage?.let { add(hover("message", it, formatted = true)) } + action.sendTitle?.let { title -> + val text = Msg.miniMessage.stripTags(title.title.ifBlank { title.subtitle }) + add("title \"${esc(text)}\"") + } + action.executeCommand?.let { add("command /${esc(it)}") } + action.playSound?.let { add("sound ${esc(it.lowercase())}") } + action.actionBar?.let { add(hover("action bar", it, formatted = true)) } + action.speech?.let { add(hover("speech", it, formatted = true)) } + action.npcAnimation?.let { add("animation ${it.name.lowercase()}") } + action.permission?.takeIf { it.isNotBlank() }?.let { add("needs ${esc(it)}") } + }.joinToString("${Msg.SUBTLE} + $TEXT") + + private fun pushbackValue(config: NpcConfig): String { + val pushback = config.pushback + if (!pushback.enabled) return "${Msg.MUTED}off" + val sound = pushback.sound?.let { " ${Msg.SUBTLE}· ${Msg.MUTED}sound ${esc(it.lowercase())}" }.orEmpty() + val radius = "${TEXT}radius ${number(pushback.radius)}" + val strength = "${TEXT}strength ${number(pushback.strength)}" + + return "$radius ${Msg.SUBTLE}· $strength$sound" + } + + private val LABEL_WIDTH = + listOf("Target", "Provider", "Location", "Hologram", "Actions", "Pushback").maxOf(ChatPixels::width) + + private fun row(label: String, value: String) = " $KEY${ChatPixels.pad(label, LABEL_WIDTH)} $value" + + private fun button(label: String, command: String, tip: String, run: Boolean): String = + click("${Msg.INFO}[$label]", command, tip, run) + + private fun click(content: String, command: String, tip: String, run: Boolean): String { + val action = if (run) "run_command" else "suggest_command" + return "<click:$action:'${arg(command)}'>${hover(content, tip)}</click>" + } + + private fun hover(content: String, tip: String, escape: Boolean = false, formatted: Boolean = false): String { + val text = when { + escape -> "${Msg.MUTED}${esc(tip)}" + formatted -> "$TEXT$tip" + else -> "${Msg.MUTED}$tip" + } + + return "<hover:show_text:'${arg(text)}'>$content</hover>" + } + + private fun arg(text: String) = text.replace("\\", "\\\\").replace("'", "\\'") + + private fun esc(text: String) = Msg.miniMessage.escapeTags(text) + + private fun plural(count: Int, word: String) = "$count $word${if (count == 1) "" else "s"}" + + private fun number(value: Double) = String.format(Locale.ROOT, "%.1f", value) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/PlaceholderPage.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/PlaceholderPage.kt new file mode 100644 index 0000000..aec136d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/PlaceholderPage.kt @@ -0,0 +1,196 @@ +package app.simplecloud.npc.common.command + +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.text.PlayerPlaceholders +import java.util.Locale + +object PlaceholderPage { + + private class Token(val token: String, val description: String) + + private class Section(val title: String, val tokens: List<Token>) + + private class Category( + val key: String, + val title: String, + val usedIn: String, + val sections: () -> List<Section>, + val note: String? = null, + ) + + private const val TEXT = "<#e2e8f0>" + private const val KEY = "<#38bdf8>" + private const val STATES = "STATE: all, AVAILABLE, STARTING, INGAME, STOPPING" + + private val CATEGORIES = listOf( + Category( + key = "target", + title = "Target", + usedIn = "Holograms and menus, filled with the NPC's group or server", + sections = { + listOf( + Section( + "Every target", + listOf( + Token("<target_name>", "Name"), + Token("<target_online_players>", "Players online"), + Token("<target_max_players>", "Player slots"), + Token("<target_type>", "Server type"), + Token("<target_min_memory>", "Minimum memory"), + Token("<target_max_memory>", "Maximum memory"), + Token("<target_property:key>", "Any cloud property"), + ), + ), + Section( + "Groups", + listOf( + Token("<target_server_count:STATE>", "Servers in STATE"), + Token("<target_player_count:STATE>", "Players in STATE"), + ), + ), + Section( + "Persistent servers", + listOf( + Token("<target_id>", "Server id"), + Token("<target_pretty_name>", "Display name"), + Token("<target_motd>", "MOTD"), + ), + ), + ) + }, + note = STATES, + ), + Category( + key = "player", + title = "Player", + usedIn = "Holograms, action texts, commands and menus", + sections = { + listOfNotNull( + Section( + "The player", + listOf( + Token("<playername>", "Name"), + Token("<playeruuid>", "UUID"), + ), + ), + Section( + "PlaceholderAPI", + listOf(Token("%player_level%", "Any PlaceholderAPI placeholder")), + ).takeIf { PlayerPlaceholders.external != null }, + ) + }, + note = "Holograms show them per player.", + ), + Category( + key = "menu", + title = "Menu", + usedIn = "Menu titles, item names and lore", + sections = { + listOf( + Section( + "Pages", + listOf( + Token("<page>", "Current page"), + Token("<max_page>", "Number of pages"), + ), + ), + ) + }, + note = "Player and target placeholders work here too.", + ), + Category( + key = "entry", + title = "Live entry", + usedIn = "Items in live slots, filled once per listed server", + sections = { + listOf( + Section( + "Every entry", + listOf( + Token("<entry_name>", "Name"), + Token("<entry_online_players>", "Players online"), + Token("<entry_max_players>", "Player slots"), + Token("<entry_type>", "Server type"), + Token("<entry_property:key>", "Any cloud property"), + ), + ), + Section( + "Servers of a group", + listOf( + Token("<entry_server>", "Join destination"), + Token("<entry_group>", "Group name"), + Token("<entry_numerical_id>", "Number, e.g. 2"), + Token("<entry_state>", "Server state"), + Token("<entry_motd>", "MOTD"), + Token("<entry_ip>", "Address"), + Token("<entry_port>", "Port"), + ), + ), + Section( + "Groups", + listOf( + Token("<entry_group>", "Group name"), + Token("<entry_server_count:STATE>", "Servers in STATE"), + Token("<entry_player_count:STATE>", "Players in STATE"), + ), + ), + Section( + "Persistent servers", + listOf( + Token("<entry_server>", "Join destination"), + Token("<entry_pretty_name>", "Display name"), + Token("<entry_motd>", "MOTD"), + ), + ), + ) + }, + note = STATES, + ), + ) + + val topics: List<String> = CATEGORIES.map { it.key } + + fun render(topic: String?): String? { + val category = topic?.let { key -> CATEGORIES.firstOrNull { it.key == key.lowercase(Locale.ROOT) } } + ?: CATEGORIES.first().takeIf { topic == null } + ?: return null + val sections = category.sections() + val width = sections.flatMap { it.tokens }.maxOf { ChatPixels.width(it.token) } + + return buildList { + add("${Msg.infoPrefix()}Placeholders ${tabs(category)}") + add(" ${Msg.MUTED}${category.usedIn}") + sections.forEach { section -> + add("") + add(" $KEY${section.title}") + section.tokens.forEach { add(" ${tokenRow(it, width)}") } + } + add("") + category.note?.let { add(" ${Msg.SUBTLE}$it") } + add(" ${Msg.SUBTLE}Click a placeholder to copy it.") + }.joinToString("\n") + } + + private fun tabs(active: Category): String = CATEGORIES.joinToString(" ${Msg.SUBTLE}· ") { category -> + if (category == active) { + "${Msg.ACCENT}<u>${category.title}</u>" + } else { + val command = "/$COMMAND_LABEL placeholders ${category.key}" + val tip = "${Msg.MUTED}${category.usedIn}" + "<click:run_command:'${arg(command)}'><hover:show_text:'${arg(tip)}'>" + + "${Msg.MUTED}${category.title}</hover></click>" + } + } + + private fun tokenRow(token: Token, width: Int): String { + val padding = " ".repeat(((width - ChatPixels.width(token.token)).coerceAtLeast(0) + 3) / 4) + val tip = "$TEXT${esc(token.token)}\n${Msg.MUTED}${esc(token.description)}\n\n${Msg.SUBTLE}Click to copy" + + return "<click:copy_to_clipboard:'${arg(token.token)}'><hover:show_text:'${arg(tip)}'>" + + "$TEXT${esc(token.token)}$padding ${Msg.MUTED}${esc(token.description)}</hover></click>" + } + + private fun arg(text: String) = text.replace("\\", "\\\\").replace("'", "\\'") + + private fun esc(text: String) = Msg.miniMessage.escapeTags(text) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcActionCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcActionCommand.kt new file mode 100644 index 0000000..c40fb79 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcActionCommand.kt @@ -0,0 +1,275 @@ +package app.simplecloud.npc.common.command.edit + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendWarning +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.config.ActionFields +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission + +class NpcActionCommand<C : NpcCommandSender>(pluginContext: NpcPluginContext) : AbstractNpcCommand<C>(pluginContext) { + + @Command("$COMMAND_SYNTAX edit <id> action") + @Permission(COMMAND_PERMISSION) + fun info(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val visibleActions = config.actions.filter { it.configuredTypes().isNotEmpty() }.ifEmpty { + sender.sendWarning("NPC {} has no actions.", id) + return + } + + val lines = buildList { + add("${Msg.infoPrefix()}Actions for ${Msg.ACCENT}$id") + visibleActions.forEach { + val interaction = it.interactionType.name.lowercase() + val types = it.configuredTypes().joinToString() + add(" ${Msg.SUBTLE}- ${Msg.ACCENT}$interaction ${Msg.SUBTLE}(${it.joinState}) -> <#e2e8f0>$types") + } + } + + sender.sendMessage(Msg.miniMessage.deserialize(lines.joinToString("\n"))) + } + + @Command("$COMMAND_SYNTAX edit <id> action join-target <interaction> <joinState> <enabled>") + @Permission(COMMAND_PERMISSION) + fun joinTarget( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interactionName: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("enabled") enabled: Boolean, + ) = update(sender, id, interactionName, joinState, ActionFields.JOIN_TARGET) { it.joinTarget = enabled } + + @Command("$COMMAND_SYNTAX edit <id> action open-inventory <interaction> <joinState> <inventoryId>") + @Permission(COMMAND_PERMISSION) + fun openInventory( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("inventoryId", suggestions = "inventoryIds") inventoryId: String, + ) { + findInventory(sender, inventoryId) ?: return + + update(sender, id, interaction, joinState, ActionFields.OPEN_INVENTORY) { it.openInventory = inventoryId } + } + + @Command("$COMMAND_SYNTAX edit <id> action play-sound <interaction> <joinState> <sound>") + @Permission(COMMAND_PERMISSION) + fun playSound( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("sound", parserName = "greedyString") sound: String, + ) { + if (!InputChecks.isValidSound(sound)) { + sender.sendError("Sound {} is invalid.", sound) + return + } + + update(sender, id, interaction, joinState, ActionFields.PLAY_SOUND) { it.playSound = sound } + } + + @Command("$COMMAND_SYNTAX edit <id> action execute-command <interaction> <joinState> <command>") + @Permission(COMMAND_PERMISSION) + fun executeCommand( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("command", parserName = "greedyString") command: String, + ) = update(sender, id, interaction, joinState, ActionFields.EXECUTE_COMMAND) { + it.executeCommand = command.removePrefix("/") + } + + @Command("$COMMAND_SYNTAX edit <id> action send-message <interaction> <joinState> <message>") + @Permission(COMMAND_PERMISSION) + fun sendMessage( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("message", parserName = "greedyString") message: String, + ) = update(sender, id, interaction, joinState, ActionFields.SEND_MESSAGE) { it.sendMessage = message } + + @Command("$COMMAND_SYNTAX edit <id> action action-bar <interaction> <joinState> <text>") + @Permission(COMMAND_PERMISSION) + fun actionBar( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("text", parserName = "greedyString") text: String, + ) = update(sender, id, interaction, joinState, ActionFields.ACTION_BAR) { it.actionBar = text } + + @Command("$COMMAND_SYNTAX edit <id> action teleport-here <interaction> <joinState>") + @Permission(COMMAND_PERMISSION) + fun teleportHere( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + ) { + val player = requirePlayer(sender) ?: return + val location = player.location() + + update(sender, id, interaction, joinState, ActionFields.TELEPORT) { it.teleport = location } + } + + @Command("$COMMAND_SYNTAX edit <id> action title <interaction> <joinState> <title>") + @Permission(COMMAND_PERMISSION) + fun title( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("title", parserName = "greedyString") title: String, + ) = update(sender, id, interaction, joinState, "title") { + it.sendTitle = (it.sendTitle ?: NpcConfig.TitleConfiguration()).copy(title = title) + } + + @Command("$COMMAND_SYNTAX edit <id> action subtitle <interaction> <joinState> <subtitle>") + @Permission(COMMAND_PERMISSION) + fun subtitle( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("subtitle", parserName = "greedyString") subtitle: String, + ) = update(sender, id, interaction, joinState, "subtitle") { + it.sendTitle = (it.sendTitle ?: NpcConfig.TitleConfiguration()).copy(subtitle = subtitle) + } + + @Command("$COMMAND_SYNTAX edit <id> action title-duration <interaction> <joinState> <fadeIn> <stay> <fadeOut>") + @Permission(COMMAND_PERMISSION) + fun titleDuration( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("fadeIn") fadeIn: Int, + @Argument("stay") stay: Int, + @Argument("fadeOut") fadeOut: Int, + ) { + val fadeRange = NpcConfig.TitleConfiguration.FADE_RANGE + val stayRange = NpcConfig.TitleConfiguration.STAY_RANGE + if (fadeIn !in fadeRange || fadeOut !in fadeRange || stay !in stayRange) { + sender.sendError( + "Fade ticks must be {}-{}, stay must be {}-{}.", + fadeRange.first, + fadeRange.last, + stayRange.first, + stayRange.last, + ) + return + } + + update(sender, id, interaction, joinState, "title-duration") { + val current = it.sendTitle ?: NpcConfig.TitleConfiguration() + it.sendTitle = current.copy(fadeIn = fadeIn, stay = stay, fadeOut = fadeOut) + } + } + + @Command("$COMMAND_SYNTAX edit <id> action send-to-server <interaction> <joinState> <server>") + @Permission(COMMAND_PERMISSION) + fun sendToServer( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("server", suggestions = "servers") server: String, + ) = update(sender, id, interaction, joinState, ActionFields.SEND_TO_SERVER) { it.sendToServer = server } + + @Command("$COMMAND_SYNTAX edit <id> action transfer-to-server <interaction> <joinState> <address>") + @Permission(COMMAND_PERMISSION) + fun transferToServer( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interaction: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("address", parserName = "greedyString") address: String, + ) { + if (!InputChecks.isValidHostPort(address)) { + sender.sendError("Address {} is invalid.", address) + return + } + + update(sender, id, interaction, joinState, ActionFields.TRANSFER_TO_SERVER) { it.transferToServer = address } + } + + @Command("$COMMAND_SYNTAX edit <id> action clear <interaction> <joinState> <actionType>") + @Permission(COMMAND_PERMISSION) + fun clear( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interactionName: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("actionType", suggestions = "actionTypes") actionType: String, + ) { + val config = findNpc(sender, id) ?: return + val interaction = findPlayerInteraction(sender, interactionName) ?: return + val action = config.findAction(interaction, joinState) ?: run { + sender.sendWarning("No action is configured for that interaction and join state.") + return + } + if (!action.clearField(actionType)) { + sender.sendError("Unknown action type {}.", actionType) + return + } + config.dropActionIfEmpty(interaction, joinState) + + saveConfig(sender, config, "Cleared {} for NPC {}.", actionType, id, refreshHologram = false) + } + + @Command("$COMMAND_SYNTAX edit <id> action clear <interaction> <joinState>") + @Permission(COMMAND_PERMISSION) + fun clearInteraction( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("interaction", suggestions = "playerInteractions") interactionName: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + ) { + val config = findNpc(sender, id) ?: return + val interaction = findPlayerInteraction(sender, interactionName) ?: return + val removed = config.actions.removeIf { + it.interactionType == interaction && it.joinState.equals(joinState, ignoreCase = true) + } + if (!removed) { + sender.sendWarning("No action is configured for that interaction and join state.") + return + } + + saveConfig(sender, config, "Cleared actions for NPC {}.", id, refreshHologram = false) + } + + private fun update( + sender: C, + id: String, + interactionName: String, + joinState: String, + actionName: String, + update: (NpcConfig.ActionConfiguration) -> Unit, + ) { + val config = findNpc(sender, id) ?: return + val interaction = findPlayerInteraction(sender, interactionName) ?: return + update(config.actionOrCreate(interaction, joinState)) + saveConfig( + sender, + config, + "Set {} for NPC {} on {}.", + actionName, + id, + interaction.name.lowercase(), + refreshHologram = false, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcHologramCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcHologramCommand.kt new file mode 100644 index 0000000..eb36ddf --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcHologramCommand.kt @@ -0,0 +1,117 @@ +package app.simplecloud.npc.common.command.edit + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission + +class NpcHologramCommand<C : NpcCommandSender>(pluginContext: NpcPluginContext) : AbstractNpcCommand<C>(pluginContext) { + + @Command("$COMMAND_SYNTAX edit <id> hologram") + @Permission(COMMAND_PERMISSION) + fun info(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val states = config.hologram.layouts.joinToString { "${it.joinState}:${it.lines.size}" }.ifBlank { "none" } + val status = if (config.hologram.enabled) "enabled" else "disabled" + sender.sendInfo( + "Hologram {}: {}, height {}, layouts {}", + id, + status, + config.hologram.startHeight, + states, + ) + } + + @Command("$COMMAND_SYNTAX edit <id> hologram enable") + @Permission(COMMAND_PERMISSION) + fun enable(sender: C, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, true) + + @Command("$COMMAND_SYNTAX edit <id> hologram disable") + @Permission(COMMAND_PERMISSION) + fun disable(sender: C, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, false) + + @Command("$COMMAND_SYNTAX edit <id> hologram height <height>") + @Permission(COMMAND_PERMISSION) + fun height( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("height") height: Double, + ) { + val config = findNpc(sender, id) ?: return + val range = NpcConfig.HologramConfigurationRoot.START_HEIGHT_RANGE + if (height !in range) { + sender.sendError("Hologram height must be between {} and {}.", range.start, range.endInclusive) + return + } + config.hologram.startHeight = height + saveConfig(sender, config, "Set hologram height for NPC {} to {}.", id, height) + } + + @Command("$COMMAND_SYNTAX edit <id> hologram line add <joinState> <content>") + @Permission(COMMAND_PERMISSION) + fun addLine( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("content", parserName = "greedyString") content: String, + ) { + val config = findNpc(sender, id) ?: return + val layout = config.hologram.layoutOrCreate(joinState) + layout.lines += HologramLine(content) + + saveConfig(sender, config, "Added hologram line {} for NPC {}.", layout.lines.size, id) + } + + @Command("$COMMAND_SYNTAX edit <id> hologram line set <joinState> <line> <content>") + @Permission(COMMAND_PERMISSION) + fun setLine( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("line") line: Int, + @Argument("content", parserName = "greedyString") content: String, + ) { + val config = findNpc(sender, id) ?: return + val layout = findLine(sender, config, joinState, line) ?: return + + layout.lines[line - 1] = layout.lines[line - 1].copy(text = content) + saveConfig(sender, config, "Set hologram line {} for NPC {}.", line, id) + } + + @Command("$COMMAND_SYNTAX edit <id> hologram line remove <joinState> <line>") + @Permission(COMMAND_PERMISSION) + fun removeLine( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("joinState", suggestions = "joinStates") joinState: String, + @Argument("line") line: Int, + ) { + val config = findNpc(sender, id) ?: return + val layout = findLine(sender, config, joinState, line) ?: return + + layout.lines.removeAt(line - 1) + saveConfig(sender, config, "Removed hologram line {} from NPC {}.", line, id) + } + + private fun findLine(sender: C, config: NpcConfig, joinState: String, line: Int): NpcConfig.HologramLayout? = + config.hologram.findLayout(joinState)?.takeIf { line in 1..it.lines.size } ?: run { + sender.sendError("Hologram line {} does not exist.", line) + null + } + + private fun setEnabled(sender: C, id: String, enabled: Boolean) { + val config = findNpc(sender, id) ?: return + config.hologram.enabled = enabled + val state = if (enabled) "enabled" else "disabled" + + saveConfig(sender, config, "Hologram for NPC {} was {}.", id, state) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcPushbackCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcPushbackCommand.kt new file mode 100644 index 0000000..4b67c70 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcPushbackCommand.kt @@ -0,0 +1,127 @@ +package app.simplecloud.npc.common.command.edit + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission + +class NpcPushbackCommand<C : NpcCommandSender>(pluginContext: NpcPluginContext) : AbstractNpcCommand<C>(pluginContext) { + + @Command("$COMMAND_SYNTAX edit <id> pushback") + @Permission(COMMAND_PERMISSION) + fun info(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val pushback = config.pushback + val status = if (pushback.enabled) "enabled" else "disabled" + + sender.sendInfo( + "Pushback {}: {}, radius {}, strength {}, vertical {}", + id, + status, + pushback.radius, + pushback.strength, + pushback.vertical, + ) + } + + @Command("$COMMAND_SYNTAX edit <id> pushback enable") + @Permission(COMMAND_PERMISSION) + fun enable(sender: C, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, true) + + @Command("$COMMAND_SYNTAX edit <id> pushback disable") + @Permission(COMMAND_PERMISSION) + fun disable(sender: C, @Argument("id", suggestions = "npcIds") id: String) = setEnabled(sender, id, false) + + @Command("$COMMAND_SYNTAX edit <id> pushback radius <value>") + @Permission(COMMAND_PERMISSION) + fun radius(sender: C, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) = + updateNumber( + sender, + id, + "radius", + value, + NpcConfig.PushbackConfiguration.RADIUS_RANGE, + ) { it.pushback.radius = value } + + @Command("$COMMAND_SYNTAX edit <id> pushback strength <value>") + @Permission(COMMAND_PERMISSION) + fun strength(sender: C, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) = + updateNumber( + sender, + id, + "strength", + value, + NpcConfig.PushbackConfiguration.STRENGTH_RANGE, + ) { it.pushback.strength = value } + + @Command("$COMMAND_SYNTAX edit <id> pushback vertical <value>") + @Permission(COMMAND_PERMISSION) + fun vertical(sender: C, @Argument("id", suggestions = "npcIds") id: String, @Argument("value") value: Double) = + updateNumber( + sender, + id, + "vertical boost", + value, + NpcConfig.PushbackConfiguration.VERTICAL_RANGE, + ) { it.pushback.vertical = value } + + @Command("$COMMAND_SYNTAX edit <id> pushback sound <sound>") + @Permission(COMMAND_PERMISSION) + fun sound( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("sound", parserName = "greedyString") sound: String, + ) { + val config = findNpc(sender, id) ?: return + if (!InputChecks.isValidSound(sound)) { + sender.sendError("Sound {} is invalid.", sound) + return + } + + config.pushback.sound = sound + saveConfig(sender, config, "Set pushback sound for NPC {} to {}.", id, sound, refreshHologram = false) + } + + @Command("$COMMAND_SYNTAX edit <id> pushback sound clear") + @Permission(COMMAND_PERMISSION) + fun clearSound(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + config.pushback.sound = null + + saveConfig(sender, config, "Cleared the pushback sound for NPC {}.", id, refreshHologram = false) + } + + private fun setEnabled(sender: C, id: String, enabled: Boolean) { + val config = findNpc(sender, id) ?: return + config.pushback.enabled = enabled + val state = if (enabled) "enabled" else "disabled" + + saveConfig(sender, config, "Pushback for NPC {} was {}.", id, state, refreshHologram = false) + } + + private fun updateNumber( + sender: C, + id: String, + property: String, + value: Double, + range: ClosedFloatingPointRange<Double>, + update: (NpcConfig) -> Unit, + ) { + val config = findNpc(sender, id) ?: return + if (value !in range) { + sender.sendError("Pushback {} must be between {} and {}.", property, range.start, range.endInclusive) + return + } + + update(config) + saveConfig(sender, config, "Set pushback {} for NPC {} to {}.", property, id, value, refreshHologram = false) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcTargetCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcTargetCommand.kt new file mode 100644 index 0000000..7bd9b32 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/edit/NpcTargetCommand.kt @@ -0,0 +1,68 @@ +package app.simplecloud.npc.common.command.edit + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.command.CommandMessages +import app.simplecloud.npc.common.manager.NpcOperationResult +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.common.text.sendWarning +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission + +class NpcTargetCommand<C : NpcCommandSender>(pluginContext: NpcPluginContext) : AbstractNpcCommand<C>(pluginContext) { + + @Command("$COMMAND_SYNTAX edit <id> target list") + @Permission(COMMAND_PERMISSION) + fun list(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + + sender.sendInfo("Targets for {}: {}", config.id, config.targetServers.joinToString()) + } + + @Command("$COMMAND_SYNTAX edit <id> target add <target>") + @Permission(COMMAND_PERMISSION) + fun add( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("target", suggestions = "targets") targetName: String, + ) { + val config = findNpc(sender, id) ?: return + BackgroundTasks.launch { + when (val result = pluginContext.npcManager.addTarget(config.id, targetName)) { + is NpcOperationResult.Success -> + sender.sendSuccess("Added target {} to NPC {}.", result.config.targetServers.last(), id) + + is NpcOperationResult.Failure -> CommandMessages.sendResult(sender, result) + } + } + } + + @Command("$COMMAND_SYNTAX edit <id> target remove <target>") + @Permission(COMMAND_PERMISSION) + fun remove( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("target", suggestions = "configuredTargets") targetName: String, + ) { + val config = findNpc(sender, id) ?: return + val target = config.targetServers.firstOrNull { it.equals(targetName, ignoreCase = true) } ?: run { + sender.sendWarning("NPC {} does not use target {}.", id, targetName) + return + } + + if (config.targetServers.size == 1) { + sender.sendError("NPC {} must keep at least one target.", id) + return + } + + config.targetServers.remove(target) + saveConfig(sender, config, "Removed target {} from NPC {}.", target, id) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/InventoryCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/InventoryCommand.kt new file mode 100644 index 0000000..e9aba1a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/InventoryCommand.kt @@ -0,0 +1,163 @@ +package app.simplecloud.npc.common.command.global + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_LABEL +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.command.INVENTORY_OPEN_OTHERS_PERMISSION +import app.simplecloud.npc.common.command.INVENTORY_OPEN_PERMISSION +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.manager.InventoryFailure +import app.simplecloud.npc.common.manager.InventoryOperationResult +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.common.text.sendWarning +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcCommandSender +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission + +class InventoryCommand<C : NpcCommandSender>(pluginContext: NpcPluginContext) : AbstractNpcCommand<C>(pluginContext) { + + @Command("$COMMAND_SYNTAX inventory create") + @Permission(COMMAND_PERMISSION) + fun createInteractive(sender: C) { + val player = sender.asPlayer() ?: run { + sender.sendInfo( + "The create wizard is in-game only, use /{} inventory create <id> <rows> from the console.", + COMMAND_LABEL, + ) + return + } + + pluginContext.editors.openInventoryCreate(player) + } + + @Command("$COMMAND_SYNTAX inventory create <id> <rows>") + @Permission(COMMAND_PERMISSION) + fun create(sender: C, @Argument("id") id: String, @Argument("rows") rows: Int) { + val config = when (val result = pluginContext.inventoryManager.create(id, rows)) { + is InventoryOperationResult.Success -> result.config + is InventoryOperationResult.Failure -> { + sendFailure(sender, result) + return + } + } + + sender.sendSuccess("Created inventory {}.", id) + sender.asPlayer()?.let { player -> pluginContext.editors.openInventory(player, config) } + } + + @Command("$COMMAND_SYNTAX inventory edit <id>") + @Permission(COMMAND_PERMISSION) + fun edit(sender: C, @Argument("id", suggestions = "inventoryIds") id: String) { + val config = pluginContext.inventoryRepository.find(id) ?: run { + sender.sendError( + "Inventory {} was not found, create it with /{} inventory create {} <rows>.", + id, + COMMAND_LABEL, + id, + ) + return + } + + val player = sender.asPlayer() ?: run { + info(sender, config.id) + sender.sendInfo("The editor is in-game only, run this as a player.") + return + } + + pluginContext.editors.openInventory(player, config) + } + + @Command("$COMMAND_SYNTAX inventory delete <id>") + @Permission(COMMAND_PERMISSION) + fun delete(sender: C, @Argument("id", suggestions = "inventoryIds") id: String) { + when (val result = pluginContext.inventoryManager.delete(id)) { + is InventoryOperationResult.Success -> sender.sendSuccess("Deleted inventory {}.", id) + is InventoryOperationResult.Failure -> sendFailure(sender, result) + } + } + + @Command("$COMMAND_SYNTAX inventory info <id>") + @Permission(COMMAND_PERMISSION) + fun info(sender: C, @Argument("id", suggestions = "inventoryIds") id: String) { + val config = findInventory(sender, id) ?: return + val liveGroups = config.liveGroups() + + sender.sendInfo( + "Inventory {}: {} rows, {} static items, {} live group(s).", + config.id, + config.rows, + config.items.count { it.liveGroup == null }, + liveGroups.size, + ) + + liveGroups.forEach { group -> + sender.sendInfo( + "Live group {}: source {}, {} slots{}.", + group.id, + group.sourceType ?: "(no source set)", + group.contentSlots, + if (group.paginated) ", paginated" else "", + ) + } + } + + @Command("$COMMAND_SYNTAX inventory open <id> [player]") + @Permission(INVENTORY_OPEN_PERMISSION) + fun open( + sender: C, + @Argument("id", suggestions = "inventoryIds") id: String, + @Argument("player", suggestions = "onlinePlayers") playerName: String?, + ) { + val config = findInventory(sender, id) ?: return + val target = if (playerName == null) { + requirePlayer(sender) ?: return + } else { + if (!sender.hasPermission(INVENTORY_OPEN_OTHERS_PERMISSION)) { + sender.sendError("You may only open menus for yourself.") + return + } + pluginContext.playerDirectory.findOnlinePlayer(playerName) ?: run { + sender.sendError("Player {} is not online.", playerName) + return + } + } + + val permission = config.openPermission + if (playerName != null && !permission.isNullOrBlank() && !target.hasPermission(permission)) { + sender.sendError("{} lacks {} to open {}.", target.name, permission, config.id) + return + } + + pluginContext.inventoryViews.open(target, config.id, InventoryOpenContext(emptyList())) + if (playerName != null) sender.sendSuccess("Opened {} for {}.", config.id, target.name) + } + + @Command("$COMMAND_SYNTAX inventory list") + @Permission(COMMAND_PERMISSION) + fun list(sender: C) { + val inventories = pluginContext.inventoryRepository.ids().ifEmpty { + sender.sendWarning("No inventories were found.") + return + } + + sender.sendInfo("Configured inventories ({}): {}", inventories.size, inventories.joinToString()) + } + + private fun sendFailure(sender: C, result: InventoryOperationResult.Failure) = when (result.failure) { + InventoryFailure.INVALID_ID -> sender.sendError("Invalid id {}. {}.", result.detail, ConfigIds.DESCRIPTION) + InventoryFailure.ALREADY_EXISTS -> sender.sendError("Inventory {} already exists.", result.detail) + InventoryFailure.INVALID_ROWS -> sender.sendError( + "Rows must be between {} and {}.", + InventoryConfiguration.MIN_ROWS, + InventoryConfiguration.MAX_ROWS, + ) + InventoryFailure.NOT_FOUND -> sender.sendError("Inventory {} was not found.", result.detail) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/NpcCommand.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/NpcCommand.kt new file mode 100644 index 0000000..2214bb8 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/command/global/NpcCommand.kt @@ -0,0 +1,294 @@ +package app.simplecloud.npc.common.command.global + +import app.simplecloud.npc.common.command.AbstractNpcCommand +import app.simplecloud.npc.common.command.COMMAND_LABEL +import app.simplecloud.npc.common.command.COMMAND_PERMISSION +import app.simplecloud.npc.common.command.COMMAND_SYNTAX +import app.simplecloud.npc.common.command.CommandMessages +import app.simplecloud.npc.common.command.NpcInfoPage +import app.simplecloud.npc.common.command.NpcSuccessMessage +import app.simplecloud.npc.common.command.PlaceholderPage +import app.simplecloud.npc.common.manager.NpcOperationResult +import app.simplecloud.npc.common.manager.NpcRayTrace +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.text.sendWarning +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcCommandSender +import app.simplecloud.npc.core.platform.NpcPlayer +import org.incendo.cloud.annotations.Argument +import org.incendo.cloud.annotations.Command +import org.incendo.cloud.annotations.Permission +import java.util.logging.Level + +class NpcCommand<C : NpcCommandSender>( + pluginContext: NpcPluginContext, +) : AbstractNpcCommand<C>(pluginContext) { + + @Command(COMMAND_SYNTAX) + @Permission(COMMAND_PERMISSION) + fun root(sender: C) = CommandMessages.sendHelp(sender) + + @Command("$COMMAND_SYNTAX help") + @Permission(COMMAND_PERMISSION) + fun help(sender: C) = CommandMessages.sendHelp(sender) + + @Command("$COMMAND_SYNTAX help <topic>") + @Permission(COMMAND_PERMISSION) + fun helpTopic( + sender: C, + @Argument("topic", suggestions = "helpTopics") topic: String, + ) = CommandMessages.sendHelp(sender, topic) + + @Command("$COMMAND_SYNTAX placeholders [topic]") + @Permission(COMMAND_PERMISSION) + fun placeholders( + sender: C, + @Argument("topic", suggestions = "placeholderTopics") topic: String?, + ) { + PlaceholderPage.render(topic)?.let { sender.sendMessage(Msg.miniMessage.deserialize(it)) } + ?: sender.sendError("Unknown category. Try: {}", PlaceholderPage.topics.joinToString()) + } + + @Command("$COMMAND_SYNTAX edit") + @Permission(COMMAND_PERMISSION) + fun editLookedAt(sender: C) { + val (player, config) = lookedAt(sender, "edit") ?: return + + pluginContext.editors.openNpc(player, config) + } + + @Command("$COMMAND_SYNTAX edit <id>") + @Permission(COMMAND_PERMISSION) + fun edit(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val player = sender.asPlayer() ?: run { + sender.sendInfo("Edit {}: target, hologram, action, pushback, teleport, skin", config.id) + return + } + + pluginContext.editors.openNpc(player, config) + } + + @Command("$COMMAND_SYNTAX create <id> <target>") + @Permission(COMMAND_PERMISSION) + fun create( + sender: C, + @Argument("id") id: String, + @Argument("target", suggestions = "targets") target: String, + ) { + val player = requirePlayer(sender) ?: return + BackgroundTasks.launch { + val result = pluginContext.npcManager.create(id, target, player) + CommandMessages.sendResult(sender, result, NpcSuccessMessage.CREATED) + + if (result is NpcOperationResult.Success) { + pluginContext.runSync { pluginContext.editors.openNpc(player, result.config) } + } + } + } + + @Command("$COMMAND_SYNTAX link <id> <provider> <reference> <target>") + @Permission(COMMAND_PERMISSION) + fun link( + sender: C, + @Argument("id") id: String, + @Argument("provider", suggestions = "linkProviders") provider: String, + @Argument("reference", suggestions = "linkReferences") reference: String, + @Argument("target", suggestions = "targets") target: String, + ) { + BackgroundTasks.launch { + val result = pluginContext.npcManager.link(id, target, provider, reference) + CommandMessages.sendResult(sender, result, NpcSuccessMessage.LINKED) + } + } + + @Command("$COMMAND_SYNTAX duplicate <id> <newId>") + @Permission(COMMAND_PERMISSION) + fun duplicate( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("newId") newId: String, + ) { + val config = findNpc(sender, id) ?: return + val player = requirePlayer(sender) ?: return + + CommandMessages.sendResult( + sender, + pluginContext.npcManager.duplicate(config.id, newId, player.location()), + NpcSuccessMessage.DUPLICATED, + ) + } + + @Command("$COMMAND_SYNTAX unlink <id>") + @Permission(COMMAND_PERMISSION) + fun unlink(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + + CommandMessages.sendResult(sender, pluginContext.npcManager.unlink(config.id), NpcSuccessMessage.UNLINKED) + } + + @Command("$COMMAND_SYNTAX delete") + @Permission(COMMAND_PERMISSION) + fun deleteLookedAt(sender: C) { + val (_, config) = lookedAt(sender, "delete") ?: return + + CommandMessages.sendResult(sender, pluginContext.npcManager.delete(config.id), NpcSuccessMessage.DELETED) + } + + @Command("$COMMAND_SYNTAX delete <id>") + @Permission(COMMAND_PERMISSION) + fun delete(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + + CommandMessages.sendResult(sender, pluginContext.npcManager.delete(config.id), NpcSuccessMessage.DELETED) + } + + @Command("$COMMAND_SYNTAX edit <id> teleport") + @Permission(COMMAND_PERMISSION) + fun teleport(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val player = requirePlayer(sender) ?: return + + CommandMessages.sendResult(sender, pluginContext.npcManager.teleport(config.id, player.location())) + } + + @Command("$COMMAND_SYNTAX tp <id>") + @Permission(COMMAND_PERMISSION) + fun teleportTo(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + val player = requirePlayer(sender) ?: return + if (config.entity.needsRelocation) { + sender.sendError("NPC {} has not been placed yet.", config.id) + return + } + val location = config.entity.location + if (!player.teleport(location.inFrontLookingBack(TELEPORT_DISTANCE))) { + sender.sendError("The world {} of NPC {} is not loaded.", location.world, config.id) + } + } + + @Command("$COMMAND_SYNTAX edit <id> skin <player>") + @Permission(COMMAND_PERMISSION) + fun skin( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("player") input: String, + ) { + val config = findNpc(sender, id) ?: return + pluginContext.playerDirectory.findOnlinePlayer(input)?.let { online -> + val skin = pluginContext.renderer.captureSkin(online) + CommandMessages.sendResult(sender, pluginContext.npcManager.updateSkin(config.id, skin)) + return + } + + sender.sendInfo("Looking up the skin for {}...", input) + BackgroundTasks.launch { + val skin = pluginContext.npcManager.lookupSkin(input) ?: run { + sender.sendError("Could not find a skin for {}.", input) + return@launch + } + CommandMessages.sendResult(sender, pluginContext.npcManager.updateSkin(config.id, skin)) + } + } + + @Command("$COMMAND_SYNTAX edit <id> skin data <data>") + @Permission(COMMAND_PERMISSION) + fun skinByData( + sender: C, + @Argument("id", suggestions = "npcIds") id: String, + @Argument("data", parserName = "greedyString") data: String, + ) { + val config = findNpc(sender, id) ?: return + val parts = data.trim().split(Regex("\\s+")) + val texture = parts.getOrNull(0).orEmpty() + val signature = parts.getOrNull(1).orEmpty() + if (parts.size != 2 || !InputChecks.isValidBase64(texture) || !InputChecks.isValidBase64(signature)) { + sender.sendError("The provided skin data is invalid.") + return + } + val skin = NpcConfig.SkinConfiguration(texture, signature) + CommandMessages.sendResult(sender, pluginContext.npcManager.updateSkin(config.id, skin)) + } + + @Command("$COMMAND_SYNTAX list") + @Permission(COMMAND_PERMISSION) + fun list(sender: C) { + val configs = pluginContext.npcRepository.findAll().ifEmpty { + sender.sendWarning("No NPCs were found.") + return + } + val lines = buildList { + add("${Msg.infoPrefix()}Configured NPCs ${Msg.SUBTLE}(${configs.size})") + configs.sortedBy { it.id }.forEach { + add(" ${Msg.SUBTLE}- ${Msg.ACCENT}${it.id} ${Msg.SUBTLE}(${it.targetServers.joinToString()})") + } + } + sender.sendMessage(Msg.miniMessage.deserialize(lines.joinToString("\n"))) + } + + @Command("$COMMAND_SYNTAX info") + @Permission(COMMAND_PERMISSION) + fun infoLookedAt(sender: C) { + val (_, config) = lookedAt(sender, "info") ?: return + + sendInfo(sender, config) + } + + @Command("$COMMAND_SYNTAX info <id>") + @Permission(COMMAND_PERMISSION) + fun info(sender: C, @Argument("id", suggestions = "npcIds") id: String) { + val config = findNpc(sender, id) ?: return + + sendInfo(sender, config) + } + + private fun sendInfo(sender: C, config: NpcConfig) { + val snapshot = CloudListCache.peek() + val status = pluginContext.npcManager.statusOf(config, snapshot) + sender.sendMessage(Msg.miniMessage.deserialize(NpcInfoPage.render(config, status, snapshot))) + } + + @Command("$COMMAND_SYNTAX reload") + @Permission(COMMAND_PERMISSION) + fun reload(sender: C) { + val result = runCatching { + pluginContext.npcManager.reload() + pluginContext.inventoryRepository.reload() + pluginContext.inventoryViews.requestRefresh() + }.onFailure { NpcLog.logger.log(Level.WARNING, "Reload failed", it) } + if (!CommandMessages.reload()) { + sender.sendWarning("messages.yml could not be read, the previous messages stay in use. See the console.") + } + val (path, fallback) = if (result.isSuccess) { + "command.reload.success" to Msg.success("SimpleCloud NPCs was reloaded.") + } else { + "command.reload.failed" to Msg.error("SimpleCloud NPCs could not be reloaded.") + } + CommandMessages.send(sender, path, fallback) + } + + private fun lookedAt(sender: C, subcommand: String): Pair<NpcPlayer, NpcConfig>? { + val player = sender.asPlayer() ?: run { + sender.sendError("Specify an id from the console: /{} {} <id>.", COMMAND_LABEL, subcommand) + return null + } + + val config = NpcRayTrace.findLookedAt(pluginContext.npcRepository, player) ?: run { + player.sendError("You're not looking at an NPC, get closer or use /{} {} <id>.", COMMAND_LABEL, subcommand) + return null + } + + return player to config + } + + private companion object { + const val TELEPORT_DISTANCE = 2.0 + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ChatInputPrompts.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ChatInputPrompts.kt new file mode 100644 index 0000000..bf73a7c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ChatInputPrompts.kt @@ -0,0 +1,181 @@ +package app.simplecloud.npc.common.editor + +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig.SoundOptions +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +sealed interface PromptResult { + data object Accepted : PromptResult + data class Rejected(val reason: Component) : PromptResult + + companion object { + fun rejectInvalidMiniMessage(text: String): Rejected? = + if (runCatching { Msg.miniMessage.deserialize(text) }.isSuccess) null + else Rejected(Component.text("That is not valid MiniMessage.")) + } +} + +enum class PromptFormat { PLAIN, MINI_MESSAGE } + +class PromptPlaceholder(val token: String, val description: String) + +class PromptExtra( + val label: String, + val hint: String, + val submit: (String) -> PromptResult, + val next: () -> Unit, +) + +data class Prompt( + val title: String = "Input", + val instruction: String = "", + val instructionArgs: List<Any?> = emptyList(), + val cancelKeyword: String = "cancel", + val current: String? = null, + val format: PromptFormat = PromptFormat.PLAIN, + val placeholders: List<PromptPlaceholder> = emptyList(), + val context: String? = null, + val onClear: (() -> Unit)? = null, + val extra: PromptExtra? = null, + val onCancel: () -> Unit = {}, + val onSubmit: (String) -> PromptResult, +) + +class ChatInputPrompts { + + private class Pending(val player: NpcPlayer, val prompt: Prompt) { + @Volatile + var staged: String? = null + } + + private val pending = ConcurrentHashMap<UUID, Pending>() + + fun start(player: NpcPlayer, prompt: Prompt) { + pending.remove(player.uniqueId)?.let { previous -> runCatching(previous.prompt.onCancel) } + val entry = Pending(player, prompt) + pending[player.uniqueId] = entry + + player.sendMessage(PromptCard.open(prompt, controls(entry))) + player.sendActionBar(PromptCard.hud(prompt, staged = false)) + player.playSound(OPEN_SOUND, OPEN_TONE) + } + + fun isPending(uuid: UUID): Boolean = pending.containsKey(uuid) + + fun cancel(uuid: UUID) { + pending.remove(uuid)?.let { entry -> + entry.player.sendActionBar(Component.empty()) + runCatching(entry.prompt.onCancel) + } + } + + fun refreshHud() { + pending.values.forEach { entry -> + entry.player.sendActionBar(PromptCard.hud(entry.prompt, entry.staged != null)) + } + } + + fun submit(uuid: UUID, text: String) { + val entry = pending[uuid] ?: return + val prompt = entry.prompt + val typed = text.trim() + + if (typed.equals(prompt.cancelKeyword, ignoreCase = true)) { + cancel(uuid) + return + } + if (prompt.format == PromptFormat.MINI_MESSAGE) { + PromptResult.rejectInvalidMiniMessage(typed)?.let { rejected -> + reject(entry, rejected.reason) + return + } + entry.staged = typed + entry.player.sendMessage(PromptCard.preview(prompt, typed, controls(entry))) + entry.player.sendActionBar(PromptCard.hud(prompt, staged = true)) + entry.player.playSound(PREVIEW_SOUND, PREVIEW_TONE) + return + } + + apply(entry, typed) + } + + private fun apply( + entry: Pending, + text: String, + submit: (String) -> PromptResult = entry.prompt.onSubmit, + then: () -> Unit = {}, + ) { + val uuid = entry.player.uniqueId + if (pending[uuid] !== entry) return + + val result = try { + submit(text) + } catch (exception: Exception) { + pending.remove(uuid, entry) + logger.log(Level.WARNING, "Chat prompt handler failed", exception) + entry.player.sendMessage(PromptCard.failed()) + entry.player.sendActionBar(Component.empty()) + runCatching(entry.prompt.onCancel) + return + } + + when (result) { + is PromptResult.Accepted -> { + pending.remove(uuid, entry) + entry.player.sendActionBar(Component.empty()) + entry.player.playSound(SUCCESS_SOUND, SUCCESS_TONE) + then() + } + + is PromptResult.Rejected -> reject(entry, result.reason) + } + } + + private fun reject(entry: Pending, reason: Component) { + entry.staged = null + entry.player.sendMessage(PromptCard.rejected(entry.prompt, reason, controls(entry))) + entry.player.playSound(ERROR_SOUND, ERROR_TONE) + } + + private fun controls(entry: Pending): PromptCard.Controls { + val uuid = entry.player.uniqueId + fun active(): Boolean = pending[uuid] === entry + + return PromptCard.Controls( + cancel = { if (active()) cancel(uuid) }, + clear = entry.prompt.onClear?.let { onClear -> + { + if (active()) { + pending.remove(uuid, entry) + entry.player.sendActionBar(Component.empty()) + entry.player.playSound(SUCCESS_SOUND, SUCCESS_TONE) + onClear() + } + } + }, + apply = { text -> if (active() && entry.staged == text) apply(entry, text) }, + extra = entry.prompt.extra?.let { extra -> + { text: String -> if (active() && entry.staged == text) apply(entry, text, extra.submit, extra.next) } + }, + ) + } + + private companion object { + private val logger = NpcLog.logger + + private const val OPEN_SOUND = "block.note_block.pling" + private const val PREVIEW_SOUND = "block.note_block.hat" + private const val SUCCESS_SOUND = "entity.experience_orb.pickup" + private const val ERROR_SOUND = "block.note_block.bass" + + private val OPEN_TONE = SoundOptions(volume = 0.6, pitch = 1.6) + private val PREVIEW_TONE = SoundOptions(volume = 0.6, pitch = 1.2) + private val SUCCESS_TONE = SoundOptions(volume = 0.5, pitch = 1.4) + private val ERROR_TONE = SoundOptions(volume = 0.7, pitch = 0.8) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorPorts.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorPorts.kt new file mode 100644 index 0000000..02ab2b7 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorPorts.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.common.editor + +import app.simplecloud.npc.common.editor.core.TextPrompts +import app.simplecloud.npc.common.inventory.view.InventoryViewController +import app.simplecloud.npc.common.manager.NpcManager +import app.simplecloud.npc.common.platform.Editors +import app.simplecloud.npc.common.platform.NpcPlayerDirectory +import app.simplecloud.npc.common.platform.PlatformCatalogs +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.render.ProviderCapabilities +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.render.HologramRenderer +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.repository.NpcRepository + +open class EditorPorts( + private val pluginContextProvider: () -> NpcPluginContext, + val textPrompts: TextPrompts, + val runSync: (() -> Unit) -> Unit, + val runLater: (delayTicks: Long, () -> Unit) -> Unit, +) { + val npcRepository: NpcRepository get() = pluginContextProvider().npcRepository + val inventoryRepository: InventoryRepository get() = pluginContextProvider().inventoryRepository + val renderer: NpcRenderer get() = pluginContextProvider().renderer + val hologramRenderer: HologramRenderer get() = pluginContextProvider().hologramRenderer + val npcManager: NpcManager get() = pluginContextProvider().npcManager + val playerDirectory: NpcPlayerDirectory get() = pluginContextProvider().playerDirectory + val inventoryViews: InventoryViewController get() = pluginContextProvider().inventoryViews + val catalogs: PlatformCatalogs get() = pluginContextProvider().catalogs + val providers: ProviderCapabilities get() = pluginContextProvider().providers + val editors: Editors get() = pluginContextProvider().editors +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorSet.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorSet.kt new file mode 100644 index 0000000..26a62bb --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/EditorSet.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.common.editor + +import app.simplecloud.npc.common.editor.inventory.InventoryEditor +import app.simplecloud.npc.common.editor.npc.NpcEditor +import app.simplecloud.npc.common.platform.Editors +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +class EditorSet( + private val npcEditor: NpcEditor, + private val inventoryEditor: InventoryEditor, +) : Editors { + override fun openNpc(player: NpcPlayer, config: NpcConfig) = npcEditor.open(player, config) + override fun openInventory(player: NpcPlayer, config: InventoryConfiguration) = inventoryEditor.open(player, config) + override fun openInventoryCreate(player: NpcPlayer) = inventoryEditor.openCreate(player) + override fun refreshOpenEditors(players: List<NpcPlayer>) { + npcEditor.refreshOpenScreens(players) + inventoryEditor.refresh(players) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptCard.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptCard.kt new file mode 100644 index 0000000..c126f63 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptCard.kt @@ -0,0 +1,242 @@ +package app.simplecloud.npc.common.editor + +import app.simplecloud.npc.common.text.Msg +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.JoinConfiguration +import net.kyori.adventure.text.event.ClickCallback +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.format.TextColor +import net.kyori.adventure.text.format.TextDecoration +import java.time.Duration + +object PromptCard { + + class Controls( + val cancel: () -> Unit, + val clear: (() -> Unit)?, + val apply: (String) -> Unit, + val extra: ((String) -> Unit)?, + ) + + private const val INDENT = " " + private const val RULE_WIDTH = 64 + private const val FORMAT_DOCS = "https://docs.advntr.dev/minimessage/format.html" + + private val BRAND = color("#0ea5e9") + private val ACCENT = color("#f8fafc") + private val MUTED = color("#94a3b8") + private val SUBTLE = color("#475569") + private val RULE = color("#334155") + private val SUCCESS = color("#a3e635") + private val WARNING = color("#f59e0b") + private val ERROR = color("#dc2626") + + private val CALLBACK_OPTIONS = ClickCallback.Options.builder() + .uses(1) + .lifetime(Duration.ofMinutes(15)) + .build() + + private val FORMAT_EXAMPLES = listOf( + "<red>Red text", + "<#0ea5e9>Any hex color", + "<gradient:#0ea5e9:#a3e635>A gradient</gradient>", + "<b>Bold</b> <i>italic</i> <u>underlined</u>", + "<rainbow>Rainbow</rainbow>", + ) + + fun open(prompt: Prompt, controls: Controls): Component = card( + listOfNotNull( + header(prompt, null), + instruction(prompt), + current(prompt), + placeholders(prompt), + Component.empty(), + buttons( + listOfNotNull( + prompt.current?.let { suggestButton("✎ Edit current", it) }, + controls.clear?.let { clear -> + button("⌫ Clear", WARNING, text("Removes the value.", MUTED), callback(clear)) + }, + formatButton().takeIf { prompt.format == PromptFormat.MINI_MESSAGE }, + cancelButton(prompt, controls), + ), + ), + ), + ) + + fun preview(prompt: Prompt, typed: String, controls: Controls): Component = card( + listOf( + header(prompt, "Preview"), + Msg.miniMessage.deserialize(typed), + Component.empty(), + buttons( + listOfNotNull( + button( + "✔ Apply", + SUCCESS, + text("Saves it exactly like this.", MUTED), + callback { controls.apply(typed) }, + ), + prompt.extra?.let { extra -> + controls.extra?.let { run -> + button("⟳ ${extra.label}", BRAND, text(extra.hint, MUTED), callback { run(typed) }) + } + }, + suggestButton("✎ Edit", typed), + cancelButton(prompt, controls), + ), + ), + ), + ) + + fun rejected(prompt: Prompt, reason: Component, controls: Controls): Component = Component.text() + .append(text(" ✖ ", ERROR)) + .append(reason.colorIfAbsent(ERROR)) + .append(text(" Try again, or ", MUTED)) + .append(cancelButton(prompt, controls)) + .build() + + fun failed(): Component = Component.text() + .append(text(" ✖ ", ERROR)) + .append(text("Something went wrong, the input was cancelled.", ERROR)) + .build() + + fun hud(prompt: Prompt, staged: Boolean): Component = if (staged) { + Component.text() + .append(text("✔ ", SUCCESS)) + .append(text("Preview ready", ACCENT)) + .append(text(" · click ", SUBTLE)) + .append(text("Apply", SUCCESS)) + .append(text(" or type again", SUBTLE)) + .build() + } else { + Component.text() + .append(text("⚡ ", BRAND)) + .append(text(prompt.title, ACCENT)) + .append(text(" · type in chat · ", SUBTLE)) + .append(text(prompt.cancelKeyword, MUTED)) + .append(text(" to stop", SUBTLE)) + .build() + } + + private fun card(lines: List<Component>): Component { + val body = lines.mapIndexed { index, line -> Component.text(if (index == 0) " " else INDENT).append(line) } + + return Component.join(JoinConfiguration.newlines(), listOf(Component.empty(), rule()) + body + rule()) + } + + private fun rule(): Component = Component.text(" ".repeat(RULE_WIDTH), RULE, TextDecoration.STRIKETHROUGH) + + private fun header(prompt: Prompt, suffix: String?): Component { + val builder = Component.text() + .append(text("⚡ ", BRAND)) + .append(text(prompt.title, ACCENT)) + suffix?.let { builder.append(text(" · ", SUBTLE)).append(text(it, ACCENT)) } + prompt.context?.let { builder.append(text(" · ", SUBTLE)).append(text(it, SUBTLE)) } + + return builder.build() + } + + private fun instruction(prompt: Prompt): Component { + val highlighted = Msg.highlight( + Msg.miniMessage.escapeTags(prompt.instruction), + prompt.instructionArgs.toTypedArray(), + Msg.MUTED, + ) + + return Msg.miniMessage.deserialize(Msg.MUTED + highlighted) + } + + private fun current(prompt: Prompt): Component? { + val value = prompt.current?.takeIf { it.isNotBlank() } ?: return null + val shown = if (prompt.format == PromptFormat.MINI_MESSAGE) { + runCatching { Msg.miniMessage.deserialize(value) }.getOrElse { text(value, ACCENT) } + } else { + text(value, ACCENT) + } + val hover = Component.join( + JoinConfiguration.newlines(), + text(value, MUTED), + Component.empty(), + text("Click to put it into your chat bar", SUBTLE), + ) + + return Component.text() + .append(text("Current ", SUBTLE)) + .append(shown.hoverEvent(HoverEvent.showText(hover)).clickEvent(ClickEvent.suggestCommand(value))) + .build() + } + + private fun placeholders(prompt: Prompt): Component? { + if (prompt.placeholders.isEmpty()) return null + val chips = prompt.placeholders.map { placeholder -> + val hover = Component.join( + JoinConfiguration.newlines(), + text(placeholder.token, ACCENT), + text(placeholder.description, MUTED), + Component.empty(), + text("Click to copy, then paste it into your text", SUBTLE), + ) + Component.text() + .append(text(placeholder.token, BRAND)) + .hoverEvent(HoverEvent.showText(hover)) + .clickEvent(ClickEvent.copyToClipboard(placeholder.token)) + .build() + } + + return Component.text() + .append(text("Insert ", SUBTLE)) + .append(Component.join(JoinConfiguration.separator(text(" ", SUBTLE)), chips)) + .build() + } + + private fun buttons(buttons: List<Component>): Component = + Component.join(JoinConfiguration.separator(Component.text(" ")), buttons) + + private fun cancelButton(prompt: Prompt, controls: Controls): Component = button( + "✖ Cancel", + ERROR, + text("Back to the menu. Typing '${prompt.cancelKeyword}' does the same.", MUTED), + callback(controls.cancel), + ) + + private fun suggestButton(label: String, value: String): Component = button( + label, + BRAND, + text("Puts the text into your chat bar to change it.", MUTED), + ClickEvent.suggestCommand(value), + ) + + private fun formatButton(): Component { + val examples = FORMAT_EXAMPLES.map { example -> + Component.text() + .append(text(example, SUBTLE)) + .append(text(" → ", SUBTLE)) + .append(Msg.miniMessage.deserialize(example)) + .build() + } + val hover = Component.join( + JoinConfiguration.newlines(), + listOf(text("MiniMessage formatting", ACCENT), Component.empty()) + examples + + listOf(Component.empty(), text("Click to open the full reference", SUBTLE)), + ) + + return button("? Format", MUTED, hover, ClickEvent.openUrl(FORMAT_DOCS)) + } + + private fun button(label: String, color: TextColor, hover: Component, click: ClickEvent): Component = + Component.text() + .append(text("[", SUBTLE)) + .append(text(label, color)) + .append(text("]", SUBTLE)) + .hoverEvent(HoverEvent.showText(hover)) + .clickEvent(click) + .build() + + private fun callback(action: () -> Unit): ClickEvent = ClickEvent.callback({ action() }, CALLBACK_OPTIONS) + + private fun text(content: String, color: TextColor): Component = Component.text(content, color) + + private fun color(hex: String): TextColor = TextColor.fromHexString(hex)!! +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptTokens.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptTokens.kt new file mode 100644 index 0000000..7486486 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/PromptTokens.kt @@ -0,0 +1,37 @@ +package app.simplecloud.npc.common.editor + +object PromptTokens { + + val PLAYER = listOf( + PromptPlaceholder("<playername>", "The name of the player who clicked."), + PromptPlaceholder("<playeruuid>", "Their UUID."), + ) + + val TARGET = listOf( + PromptPlaceholder("<target_name>", "Name of the NPC's target group or server."), + PromptPlaceholder("<target_online_players>", "Players online on the target."), + PromptPlaceholder("<target_max_players>", "Player slots of the target."), + PromptPlaceholder("<target_type>", "Server type of the target."), + PromptPlaceholder("<target_property:key>", "Any cloud property; replace key with its name."), + ) + + val PAGES = listOf( + PromptPlaceholder("<page>", "Current page of the first live group."), + PromptPlaceholder("<max_page>", "Number of pages."), + ) + + val ENTRY = listOf( + PromptPlaceholder("<entry_name>", "Server or group name."), + PromptPlaceholder("<entry_group_name>", "Its group."), + PromptPlaceholder("<entry_numerical_id>", "Number, e.g. 2."), + PromptPlaceholder("<entry_online_players>", "Players online."), + PromptPlaceholder("<entry_max_players>", "Player slots."), + PromptPlaceholder("<entry_state>", "State, e.g. AVAILABLE."), + PromptPlaceholder("<entry_motd>", "Message of the day."), + PromptPlaceholder("<entry_server>", "What joining connects to."), + ) + + val MENU = PLAYER + PAGES + TARGET + + val HOLOGRAM = TARGET + PLAYER +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasInput.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasInput.kt new file mode 100644 index 0000000..bbf2e55 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasInput.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.common.editor.canvas + +sealed interface CanvasInput { + data class Click(val slot: Int, val right: Boolean, val shift: Boolean) : CanvasInput + data class DoubleClick(val slot: Int) : CanvasInput + data class Key(val slot: Int, val key: Int) : CanvasInput + data class Drop(val slot: Int, val wholeStack: Boolean) : CanvasInput + data class Drag(val slots: List<Int>, val kind: DragKind) : CanvasInput + data class Outside(val right: Boolean) : CanvasInput + data class Clone(val slot: Int) : CanvasInput + + companion object { + const val OFFHAND_KEY = 40 + } +} + +enum class DragKind { SPLIT, ONE_EACH, CLONE } diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasSurface.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasSurface.kt new file mode 100644 index 0000000..092019a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/canvas/CanvasSurface.kt @@ -0,0 +1,26 @@ +package app.simplecloud.npc.common.editor.canvas + +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +data class CanvasView( + val title: String, + val size: Int, + val items: Map<Int, NpcItem>, + val toolbox: Map<Int, NpcItem> = emptyMap(), + val carried: NpcItem? = null, + val showInventory: Boolean = false, +) { + companion object { + const val TOOLBOX_SIZE = 36 + } +} + +interface CanvasSurface { + fun open(player: NpcPlayer, view: CanvasView, onInput: (CanvasInput) -> Unit, onClose: () -> Unit) + fun detach(player: NpcPlayer) + fun inventoryItem(player: NpcPlayer, index: Int): InventoryItemConfiguration? + fun equipmentItem(player: NpcPlayer, index: Int): NpcConfig.EquipmentItem? +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ActionFieldsPane.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ActionFieldsPane.kt new file mode 100644 index 0000000..560935a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ActionFieldsPane.kt @@ -0,0 +1,392 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptPlaceholder +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.editor.ui.after +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.config.NpcAnimation +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.NpcConfig.ActionConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +object ActionFieldsPane { + const val SIZE = 45 + const val HEADER_SLOT = 4 + const val DELETE_SLOT = 44 + + private const val COOLDOWN_SLOT = 40 + private val BLOCK_SLOTS = (10..16) + (19..25) + (28..34) + + const val ADD_SIZE = 45 + private const val ADD_COLUMNS = 8 + + private const val SILENT = "silent" + private val ANIMATIONS = NpcAnimation.entries + + private val COOLDOWN = Stepper( + label = "Cooldown", + material = "CLOCK", + range = 0.0..60.0, + step = 0.5, + default = NpcConfig.DEFAULT_COOLDOWN_MILLIS / 1000.0, + decimals = 1, + unit = "s", + hints = listOf("<hnt>Clicks inside it are ignored."), + ) + + enum class Scope { NPC, MENU } + + class Port( + val scope: Scope, + val player: NpcPlayer, + val action: ActionConfiguration, + val edit: ((ActionConfiguration) -> Unit) -> Unit, + val toggleJoinTarget: () -> Unit, + val prompt: (Prompt) -> Unit, + val rerender: () -> Unit, + val pickMenu: () -> Unit, + val pickServer: () -> Unit, + val pickSound: () -> Unit, + val openTitle: () -> Unit, + val openAdd: () -> Unit, + val closeAdd: () -> Unit, + ) + + private enum class Group(val label: String, val material: String) { + GO_TO("Go To", "LIGHT_BLUE_STAINED_GLASS_PANE"), + MESSAGE("Message", "YELLOW_STAINED_GLASS_PANE"), + EFFECT("Effect", "LIME_STAINED_GLASS_PANE"), + ADVANCED("Advanced", "ORANGE_STAINED_GLASS_PANE"), + } + + private class Block( + val group: Group, + val material: String, + val label: String, + val describe: String, + val scope: Scope? = null, + val available: (ActionConfiguration) -> Boolean = { true }, + val summary: (ActionConfiguration) -> String?, + val editHint: String? = "Edit", + val edit: ((Port) -> Unit)?, + val add: (Port) -> Unit = { port -> edit?.invoke(port) }, + val clear: (ActionConfiguration) -> Unit, + ) + + private val BLOCKS: List<Block> = listOf( + Block( + Group.GO_TO, "NETHER_STAR", "Join Target", "Sends the player to the best target server.", + summary = { a -> "<on>On".takeIf { a.joinTarget } }, + editHint = null, + edit = null, + add = { port -> port.toggleJoinTarget() }, + clear = { it.joinTarget = false }, + ), + Block( + Group.GO_TO, "CHEST", "Open Menu", "Opens one of your menus.", + summary = { a -> a.openInventory?.let { "<val>${NpcFormat.plain(it)}" } }, + edit = { it.pickMenu() }, + clear = { it.openInventory = null }, + ), + Block( + Group.GO_TO, "COMPASS", "Send To Server", "Sends the player to one server.", + summary = { a -> a.sendToServer?.let { "<val>${NpcFormat.plain(it)}" } }, + edit = { it.pickServer() }, + clear = { it.sendToServer = null }, + ), + Block( + Group.GO_TO, "RECOVERY_COMPASS", "Teleport", "Teleports the player to where you stand.", + summary = { a -> a.teleport?.let { "<val>${NpcFormat.position(it)}" } }, + editHint = "Move here", + edit = { port -> + val at = port.player.location() + port.edit { it.teleport = at } + }, + clear = { it.teleport = null }, + ), + Block( + Group.GO_TO, "ENDER_EYE", "Transfer", "Moves the player to another host.", + summary = { a -> a.transferToServer?.let { "<val>${NpcFormat.plain(it)}" } }, + edit = { port -> + chat( + port, + "Transfer To Host", + "Type the host to transfer to in chat, as host:port.", + port.action.transferToServer, + format = PromptFormat.PLAIN, + placeholders = emptyList(), + clear = { it.transferToServer = null }, + validate = { + if (InputChecks.isValidHostPort(it)) null + else PromptResult.Rejected(Component.text("Expected host:port.")) + }, + ) { a, text -> a.transferToServer = text } + }, + clear = { it.transferToServer = null }, + ), + Block( + Group.GO_TO, "OAK_DOOR", "Close Menu", "Closes the menu after the click.", Scope.MENU, + summary = { a -> "<on>On".takeIf { a.closeMenu } }, + editHint = null, + edit = null, + add = { port -> port.edit { it.closeMenu = true } }, + clear = { it.closeMenu = false }, + ), + Block( + Group.GO_TO, "ARROW", "Previous Menu", "Goes back to the menu before.", Scope.MENU, + summary = { a -> "<on>On".takeIf { a.previousMenu } }, + editHint = null, + edit = null, + add = { port -> port.edit { it.previousMenu = true } }, + clear = { it.previousMenu = false }, + ), + Block( + Group.MESSAGE, "WRITABLE_BOOK", "Chat Message", "Sends the player a chat message.", + summary = { a -> + NpcFormat.messageLines(a.sendMessage).takeIf { it.isNotEmpty() }?.let { lines -> + "<val>${Ui.quote(lines.first())}" + if (lines.size > 1) " <hnt>+${lines.size - 1}" else "" + } + }, + edit = { port -> + chat( + port, + "Chat Message", + "Type the chat message, {} starts a new line.", + port.action.sendMessage?.let { NpcFormat.messageLines(it).joinToString("<newline>") }, + args = listOf("<newline>"), + clear = { it.sendMessage = null }, + ) { a, text -> a.sendMessage = text } + }, + clear = { it.sendMessage = null }, + ), + Block( + Group.MESSAGE, "PAINTING", "Title", "Shows a big title on screen.", + summary = { a -> a.sendTitle?.let { "<val>${Ui.quote(it.title.ifBlank { it.subtitle })}" } }, + edit = { it.openTitle() }, + clear = { it.sendTitle = null }, + ), + Block( + Group.MESSAGE, "NAME_TAG", "Action Bar", "Shows text above the hotbar.", + summary = { a -> a.actionBar?.let { "<val>${Ui.quote(it)}" } }, + edit = { port -> + chat( + port, + "Action Bar", + "Type the action bar text in chat.", + port.action.actionBar, + clear = { it.actionBar = null }, + ) { a, text -> a.actionBar = text } + }, + clear = { it.actionBar = null }, + ), + Block( + Group.MESSAGE, "PAPER", "Speech Bubble", "The NPC says something above its head.", Scope.NPC, + summary = { a -> a.speech?.let { "<val>${Ui.quote(it)}" } }, + edit = { port -> + chat( + port, + "Speech Bubble", + "Type what the NPC says above its head.", + port.action.speech, + clear = { it.speech = null }, + ) { a, text -> a.speech = text } + }, + clear = { it.speech = null }, + ), + Block( + Group.EFFECT, "NOTE_BLOCK", "Sound", "Plays a sound to the player.", + summary = { a -> a.playSound?.let { "<val>${NpcFormat.plain(it)}" } }, + edit = { it.pickSound() }, + clear = { it.playSound = null }, + ), + Block( + Group.EFFECT, "FEATHER", "Animation", "The NPC moves; only this player sees it.", Scope.NPC, + summary = { a -> a.npcAnimation?.let { "<val>${animationLabel(it)}" } }, + editHint = "Next", + edit = { port -> + val next = port.action.npcAnimation?.let(ANIMATIONS::after) ?: ANIMATIONS.first() + port.edit { it.npcAnimation = next } + }, + clear = { it.npcAnimation = null }, + ), + Block( + Group.EFFECT, "COMMAND_BLOCK", "Command", "The player runs a command.", + summary = { a -> a.executeCommand?.let { "<val>/${NpcFormat.plain(it)}" } }, + edit = { port -> + chat( + port, + "Run Command", + "Type the command the player runs, without the leading /.", + port.action.executeCommand, + format = PromptFormat.PLAIN, + clear = { it.executeCommand = null }, + validate = { if (it.isBlank()) PromptResult.Rejected(Component.text("Type a command.")) else null }, + ) { a, text -> a.executeCommand = text.removePrefix("/") } + }, + clear = { it.executeCommand = null }, + ), + Block( + Group.ADVANCED, "IRON_DOOR", "Permission", "Only players with it may use the click.", + summary = { a -> a.permission?.takeIf { it.isNotBlank() }?.let { "<val>$it" } }, + edit = { port -> + chat( + port, + "Permission", + "Type the permission a player needs.", + port.action.permission, + format = PromptFormat.PLAIN, + placeholders = emptyList(), + clear = { + it.permission = null + it.denyMessage = null + }, + validate = { + if (it.isBlank() || it.any(Char::isWhitespace)) { + PromptResult.Rejected(Component.text("No spaces in a permission.")) + } else { + null + } + }, + ) { a, text -> a.permission = text } + }, + clear = { + it.permission = null + it.denyMessage = null + }, + ), + Block( + Group.ADVANCED, "OAK_SIGN", "Deny Message", "What players without the permission see.", + available = { a -> !a.permission.isNullOrBlank() }, + summary = { a -> + a.denyMessage?.let { if (it.isBlank()) "<hnt>nothing (silent)" else "<val>${Ui.quote(it)}" } + }, + edit = { port -> + chat( + port, + "Deny Message", + "Type what players without it see, or {} to show nothing.", + port.action.denyMessage?.ifBlank { SILENT }, + args = listOf(SILENT), + clear = { it.denyMessage = null }, + ) { a, text -> a.denyMessage = if (text.equals(SILENT, true)) "" else text } + }, + clear = { it.denyMessage = null }, + ), + ) + + private fun blocks(scope: Scope): List<Block> = BLOCKS.filter { it.scope == null || it.scope == scope } + + private fun isSet(block: Block, action: ActionConfiguration): Boolean = block.summary(action) != null + + fun fill(pane: Pane, port: Port) { + val action = port.action + val set = blocks(port.scope).filter { isSet(it, action) } + + set.zip(BLOCK_SLOTS).forEach { (block, slot) -> + val lore = listOfNotNull( + block.summary(action), + listOfNotNull( + block.editHint?.let { "<key>Left <hnt>$it" }, + "<key>Right <err>Remove", + ).joinToString(" <hnt>· "), + ) + pane.on(slot, Ui.item(block.material, "<ttl>${block.label}", lore, glowing = true)) { click -> + when (click) { + MenuClick.LEFT -> block.edit?.invoke(port) + MenuClick.RIGHT -> port.edit(block.clear) + else -> Unit + } + } + } + + val free = blocks(port.scope).count { !isSet(it, action) && it.available(action) } + BLOCK_SLOTS.getOrNull(set.size)?.takeIf { free > 0 }?.let { slot -> + val hint = if (set.isEmpty()) "<hnt>Nothing happens on this click yet." else null + pane.left(slot, Ui.addHead("Add", listOfNotNull(hint, "<key>Left <hnt>Pick what happens"))) { + port.openAdd() + } + } + + pane[COOLDOWN_SLOT] = COOLDOWN.element(action.cooldown / 1000.0) { seconds -> + port.edit { it.cooldown = (seconds * 1000).toLong() } + } + } + + fun fillAdd(pane: Pane, port: Port) { + val action = port.action + val all = blocks(port.scope) + + Group.entries.forEachIndexed { row, group -> + val first = row * 9 + pane[first] = Ui.item(group.material, "<ttl><b>${group.label}") + + all.filter { it.group == group }.take(ADD_COLUMNS).forEachIndexed { column, block -> + val slot = first + 1 + column + when { + isSet(block, action) -> pane[slot] = Ui.disabled(block.label, listOf("<hnt>Already added")) + !block.available(action) -> + pane[slot] = Ui.disabled(block.label, listOf("<hnt>Set a permission first")) + + else -> pane.left( + slot, + Ui.item( + block.material, + "<ttl>${block.label}", + listOf("<bd>${block.describe}", "<key>Left <hnt>Add"), + ), + ) { + port.closeAdd() + block.add(port) + } + } + } + } + } + + private fun chat( + port: Port, + title: String, + instruction: String, + current: String?, + args: List<Any?> = emptyList(), + format: PromptFormat = PromptFormat.MINI_MESSAGE, + placeholders: List<PromptPlaceholder> = PromptTokens.PLAYER, + clear: ((ActionConfiguration) -> Unit)? = null, + validate: (String) -> PromptResult.Rejected? = PromptResult::rejectInvalidMiniMessage, + apply: (ActionConfiguration, String) -> Unit, + ) { + port.prompt( + Prompt( + title = title, + instruction = instruction, + instructionArgs = args, + current = current, + format = format, + placeholders = placeholders, + onClear = clear?.takeIf { current != null }?.let { { port.edit(it) } }, + onCancel = port.rerender, + onSubmit = { input -> + val text = input.trim() + validate(text)?.let { return@Prompt it } + port.edit { apply(it, text) } + PromptResult.Accepted + }, + ), + ) + } + + private fun animationLabel(animation: NpcAnimation): String = when (animation) { + NpcAnimation.SWING_MAIN_HAND -> "Swing arm" + NpcAnimation.SWING_OFF_HAND -> "Swing off hand" + NpcAnimation.CROUCH -> "Crouch" + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ConfirmGate.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ConfirmGate.kt new file mode 100644 index 0000000..0861509 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ConfirmGate.kt @@ -0,0 +1,129 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.platform.NpcPlayer + +class ConfirmSpec<S : Any>( + val title: String, + val screen: S, + val verb: String, + val noun: String, + val head: NpcItem, + val fire: () -> Unit, +) + +object ConfirmGate { + private const val SIZE = 27 + + private const val HEAD_SLOT = 4 + private const val KEEP_SLOT = 11 + private const val FIRE_SLOT = 15 + + private val RED_SLOTS = (0..8) + (18..26) + private val GRAY_SLOTS = listOf(9, 10, 12, 13, 14, 16, 17) + + fun <S : Any> build(host: EditorHost<S>, player: NpcPlayer, spec: ConfirmSpec<S>): EditorMenu { + val key = spec.screen.toString() + val pane = Pane(SIZE) + + pane.on(HEAD_SLOT, spec.head) { disarm(host, player, spec) } + + pane.on( + KEEP_SLOT, + Ui.item("GREEN_CONCRETE", "<on><b>Keep It", listOf("<key>Left <hnt>Go back")), + ) { click -> + if (click == MenuClick.LEFT) host.back(player) else disarm(host, player, spec) + } + + pane.armed( + FIRE_SLOT, + host, + player, + spec.screen, + key, + idle = Ui.item( + "RED_CONCRETE", + "<err><b>${spec.verb}", + listOf("<key>Left <hnt>Arm the ${spec.noun}, then click again"), + ), + verb = spec.verb, + ) { spec.fire() } + + val cancel: (MenuClick) -> Unit = { disarm(host, player, spec) } + pane.fill(RED_SLOTS, Pane.RED_PANE, cancel) + pane.fill(GRAY_SLOTS, onClick = cancel) + pane.otherwise { _, _ -> disarm(host, player, spec) } + + return pane.menu(spec.title) + } + + private fun <S : Any> disarm(host: EditorHost<S>, player: NpcPlayer, spec: ConfirmSpec<S>) { + val session = host.session(player) + if (session.armedKey == null) return + session.disarm() + host.render(player, spec.screen) + } +} + +object Arming { + const val WINDOW_MILLIS = 5_000L + private const val TICKS_PER_SECOND = 20L + + fun remainingSeconds(session: EditorSession<*>): Long = + ((session.armedDeadlineMillis - System.currentTimeMillis() + 999) / 1000).coerceAtLeast(0) + + fun <S : Any> arm(host: EditorHost<S>, player: NpcPlayer, screen: S, key: String) { + val serial = host.session(player).arm(key, WINDOW_MILLIS) + host.render(player, screen) + countdown(host, player, screen, key, serial) + } + + private fun <S : Any> countdown(host: EditorHost<S>, player: NpcPlayer, screen: S, key: String, serial: Int) { + host.runLater(TICKS_PER_SECOND) { + val session = host.sessions.peek(player.uniqueId) ?: return@runLater + if (session.armedSerial != serial || session.armedKey != key) return@runLater + if (!host.isShowing(player) { it == screen }) return@runLater + + val expired = System.currentTimeMillis() >= session.armedDeadlineMillis + if (expired) session.disarm() + host.render(player, screen) + if (!expired) countdown(host, player, screen, key, serial) + } + } +} + +fun <S : Any> Pane.armed( + slot: Int, + host: EditorHost<S>, + player: NpcPlayer, + screen: S, + key: String, + idle: NpcItem, + verb: String, + fire: () -> Unit, +) { + val session = host.session(player) + val item = if (session.isArmed(key)) { + Ui.item( + "TNT", + "<err><b>Click again", + listOf("<warn>${Arming.remainingSeconds(session)}s left", "<key>Left <err>$verb"), + glowing = true, + ) + } else { + idle + } + + left(slot, item) { + if (session.isArmed(key)) { + session.disarm() + fire() + } else { + Arming.arm(host, player, screen, key) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorHost.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorHost.kt new file mode 100644 index 0000000..2b2f6f8 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorHost.kt @@ -0,0 +1,31 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.core.platform.NpcPlayer + +typealias ScreenAction<S> = (NpcPlayer, S) -> Unit + +interface EditorHost<S : Any> { + val sessions: EditorSessions<out EditorSession<S>> + val chatInputPrompts: ChatInputPrompts + val textPrompts: TextPrompts + val runLater: (delayTicks: Long, () -> Unit) -> Unit + + fun render(player: NpcPlayer, screen: S) + fun navigate(player: NpcPlayer, screen: S) + fun back(player: NpcPlayer, steps: Int = 1) + fun close(player: NpcPlayer) + + fun session(player: NpcPlayer): EditorSession<S> = sessions.of(player.uniqueId) + + fun isShowing(player: NpcPlayer, screen: (S) -> Boolean): Boolean = + sessions.peek(player.uniqueId)?.stack?.lastOrNull()?.let(screen) == true + + fun promptContext(screen: S): String? = null + + fun chatPrompt(player: NpcPlayer, prompt: Prompt) { + val top = sessions.peek(player.uniqueId)?.stack?.lastOrNull() + startChatPrompt(sessions, chatInputPrompts, player, prompt.copy(context = top?.let(::promptContext))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorSession.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorSession.kt new file mode 100644 index 0000000..7049f5a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/EditorSession.kt @@ -0,0 +1,72 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +open class EditorSession<S : Any> { + + val stack = ArrayDeque<S>() + + var menu: EditorMenu? = null + var armedKey: String? = null + private set + var armedDeadlineMillis = 0L + private set + var armedSerial = 0 + private set + var pickerResult: PickerResult? = null + + val pages = mutableMapOf<String, Int>() + + @Volatile + private var promptOpenedAt = 0L + + fun markPromptOpen() { + promptOpenedAt = System.currentTimeMillis() + } + + fun promptClosed() { + promptOpenedAt = 0L + } + + fun consumeFreshPromptOpen(): Boolean { + val openedAt = promptOpenedAt + val fresh = openedAt != 0L && System.currentTimeMillis() - openedAt <= PROMPT_OPEN_WINDOW_MILLIS + if (fresh) promptOpenedAt = 0L + + return fresh + } + + fun arm(key: String, windowMillis: Long): Int { + armedKey = key + armedDeadlineMillis = System.currentTimeMillis() + windowMillis + + return ++armedSerial + } + + fun disarm() { + armedKey = null + armedDeadlineMillis = 0L + } + + fun isArmed(key: String): Boolean = + armedKey == key && System.currentTimeMillis() < armedDeadlineMillis + + private companion object { + private const val PROMPT_OPEN_WINDOW_MILLIS = 1_000L + } +} + +open class EditorSessions<T : EditorSession<*>>(private val create: () -> T) { + + private val sessions = ConcurrentHashMap<UUID, T>() + + fun of(playerId: UUID): T = sessions.getOrPut(playerId, create) + + fun peek(playerId: UUID): T? = sessions[playerId] + + fun forgetPlayer(playerId: UUID) { + sessions.remove(playerId) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LinesPane.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LinesPane.kt new file mode 100644 index 0000000..3b6ee8f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LinesPane.kt @@ -0,0 +1,105 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Palette +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem + +class LineList<T>( + val lines: List<T>, + val textOf: (T) -> String, + val withText: (T, String) -> T, + val create: (String) -> T, + val edit: ((MutableList<T>) -> Boolean) -> Unit, + val prompt: (index: Int?, current: String?, onText: (String) -> Unit, onDelete: (() -> Unit)?) -> Unit, + val hints: (T) -> List<String> = { emptyList() }, + val secondary: LineAction? = null, + val opensSecondary: (T) -> Boolean = { false }, +) + +class LineAction(val label: String, val onClick: (index: Int) -> Unit) + +object LinesPane { + const val SIZE = 36 + + fun <T> place(pane: Pane, list: LineList<T>, slots: List<Int>, addLabel: String = "Add Line") { + val lines = list.lines + val max = slots.size + + lines.take(max).forEachIndexed { index, line -> + val text = list.textOf(line) + val secondary = list.secondary?.takeIf { list.opensSecondary(line) } + val rightLabel = list.secondary?.label ?: "Duplicate" + val item = lineItem(text, index, lines.size, list.hints(line), rightLabel, secondary) + pane.on(slots[index], item) { click -> + when (click) { + MenuClick.LEFT -> secondary?.onClick(index) ?: editText(list, index, text) + MenuClick.RIGHT -> list.secondary?.let { it.onClick(index) } ?: duplicate(list, index, max) + MenuClick.SHIFT_LEFT -> move(list, index, -1) + MenuClick.SHIFT_RIGHT -> move(list, index, +1) + else -> Unit + } + } + } + + if (lines.size < max) { + pane.left(slots[lines.size], Ui.addHead(addLabel, listOf("<key>Left <hnt>Type the text in chat"))) { + list.prompt(null, null, { typed -> + list.edit { fresh -> + if (fresh.size >= max) return@edit false + fresh += list.create(typed) + true + } + }, null) + } + } + } + + private fun <T> editText(list: LineList<T>, index: Int, text: String) { + list.prompt(index, text, { typed -> + list.edit { fresh -> + if (index !in fresh.indices) return@edit false + fresh[index] = list.withText(fresh[index], typed) + true + } + }) { + list.edit { fresh -> + if (index !in fresh.indices) return@edit false + fresh.removeAt(index) + true + } + } + } + + private fun <T> duplicate(list: LineList<T>, index: Int, max: Int) = list.edit { fresh -> + if (fresh.size >= max || index !in fresh.indices) return@edit false + fresh.add(index + 1, fresh[index]) + true + } + + private fun <T> move(list: LineList<T>, index: Int, direction: Int) = list.edit { fresh -> + val target = index + direction + if (index !in fresh.indices || target !in fresh.indices) return@edit false + fresh.add(target, fresh.removeAt(index)) + true + } + + private fun lineItem( + text: String, + index: Int, + count: Int, + hints: List<String>, + rightLabel: String, + leftOpens: LineAction?, + ): NpcItem { + val clicks = leftOpens?.let { "<key>Left <hnt>${it.label} · <key>Shift <hnt>Move" } + ?: "<key>Left <hnt>Edit · <key>Right <hnt>$rightLabel · <key>Shift <hnt>Move" + + return NpcItem( + "PAPER", + text.ifBlank { Palette.expand("<hnt>(blank line)") }, + (listOf("<hnt>Line ${index + 1} of $count") + hints + clicks).map(Palette::expand), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LiveServers.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LiveServers.kt new file mode 100644 index 0000000..c5d88c1 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/LiveServers.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.common.editor.core + +data class KnownTarget(val name: String, val group: Boolean) + +enum class ServerStatus { ONLINE, STARTING, STOPPING, UNKNOWN, OFFLINE } + +data class LiveServer(val name: String, val status: ServerStatus, val playerCount: Int) { + val online: Boolean get() = status == ServerStatus.ONLINE +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/Panes.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/Panes.kt new file mode 100644 index 0000000..0a633ae --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/Panes.kt @@ -0,0 +1,33 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.Paginator +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.platform.NpcPlayer + +fun Pane.back(host: EditorHost<*>, player: NpcPlayer) = + left(navRow.first, Ui.back()) { host.back(player) } + +fun Pane.toggle( + slot: Int, + label: String, + enabled: Boolean, + extraLore: List<String> = emptyList(), + material: String? = null, + onClick: () -> Unit, +) = left(slot, Ui.toggle(label, enabled, extraLore, material), onClick) + +fun pageKey(screen: Any): String = screen.toString() + +fun Pane.pager(pages: Paginator<*>, lore: List<String>, rerender: () -> Unit) { + val center = navRow.first + 4 + left(center - 1, Ui.previousPage(pages.page)) { + pages.turn(-1) + rerender() + } + this[center] = Ui.pageIndicator(pages.page, pages.pageCount, lore) + left(center + 1, Ui.nextPage(pages.page, pages.pageCount)) { + pages.turn(1) + rerender() + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerOptions.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerOptions.kt new file mode 100644 index 0000000..ef26468 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerOptions.kt @@ -0,0 +1,112 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.inventory.InventoryConfiguration + +object PickerOptions { + + val SOUND_TYPE_HINT = listOf( + "<bd>Sound keys are a lot to page through.", + "", + "<key>Left <hnt>Type it in chat", + "<hnt>A fragment filters the list above;", + "<hnt>an exact key is picked straight away.", + ) + + val MATERIAL_TYPE_HINT = listOf( + "<bd>Search every material.", + "", + "<key>Left <hnt>Type it in chat", + "<hnt>A fragment filters the list;", + "<hnt>an exact name is picked straight away.", + ) + + private const val MAX_AMBIGUOUS_MATCHES = 3 + + fun match(candidates: List<String>, typed: String, notFound: String): PickerResult? { + if (candidates.any { it.equals(typed, true) }) return null + val partial = candidates.filter { it.contains(typed, ignoreCase = true) } + + return if (partial.isEmpty()) { + PickerResult.Rejected("Not found", typed, notFound) + } else { + PickerResult.Ambiguous(typed, partial.take(MAX_AMBIGUOUS_MATCHES)) + } + } + + fun search(catalog: List<String>, typed: String, noun: String): PickerResult? { + if (catalog.any { it.equals(typed, true) }) return null + val matching = catalog.count { it.contains(typed, ignoreCase = true) } + + return if (matching == 0) { + PickerResult.Rejected("Not found", typed, "No $noun contains that.") + } else { + PickerResult.Filtered(typed, matching) + } + } + + fun canonical(catalog: List<String>, value: String): String = + catalog.firstOrNull { it.equals(value, true) } ?: value + + fun statusText(status: ServerStatus): String = when (status) { + ServerStatus.ONLINE -> "<on>online" + ServerStatus.OFFLINE -> "<off>offline" + else -> "<warn>${status.name.lowercase()}" + } + + fun sounds(catalog: List<String>, filter: String?, current: String?): List<PickerOption> = catalog + .filter { filter == null || it.contains(filter, ignoreCase = true) } + .map { key -> + val selected = key.equals(current, true) + PickerOption( + key, + Ui.option("NOTE_BLOCK", key, listOf("<key>Right <hnt>Preview", ""), selected), + available = !selected, + ) + } + + fun servers(servers: List<LiveServer>, current: String?): List<PickerOption> = servers.map { server -> + val typeLine = "<bd>Type <val>server" + val selected = server.name.equals(current, true) + when { + server.status == ServerStatus.OFFLINE -> PickerOption( + server.name, + Ui.disabled(server.name, listOf(typeLine, "", "<warn>Offline")), + available = false, + ) + + else -> PickerOption( + server.name, + Ui.option( + "MAP", + Msg.miniMessage.escapeTags(server.name), + listOf( + typeLine, + "<bd>Status ${statusText(server.status)}", + "<bd>Players <val>${server.playerCount}", + "", + ), + selected, + ), + available = !selected, + ) + } + } + + fun menus(menus: List<InventoryConfiguration>, current: String?): List<PickerOption> = menus + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.id }) + .map { menu -> + val selected = menu.id.equals(current, true) + PickerOption( + menu.id, + Ui.option( + "CHEST", + Msg.miniMessage.escapeTags(menu.id), + listOf("<bd>Rows <val>${menu.rows}", ""), + selected, + ), + available = !selected, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerResult.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerResult.kt new file mode 100644 index 0000000..a81d44a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerResult.kt @@ -0,0 +1,8 @@ +package app.simplecloud.npc.common.editor.core + +sealed interface PickerResult { + data class Added(val label: String, val detail: String) : PickerResult + data class Rejected(val headline: String, val typed: String, val reason: String) : PickerResult + data class Ambiguous(val typed: String, val matches: List<String>) : PickerResult + data class Filtered(val typed: String, val count: Int) : PickerResult +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerTemplate.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerTemplate.kt new file mode 100644 index 0000000..be456c3 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/PickerTemplate.kt @@ -0,0 +1,145 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Paginator +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.platform.NpcPlayer + +class PickerOption(val value: String, val item: NpcItem, val available: Boolean = true) + +class PickerSpec<S : Any>( + val title: String, + val screen: S, + val options: List<PickerOption>, + val onPick: (String) -> Unit, + val typeHint: List<String>? = DEFAULT_TYPE_HINT, + val typingUnavailable: String = "Pick one from the list above.", + val onTyped: (String) -> PickerResult? = { onPick(it); null }, + val onRightClick: (PickerOption) -> Unit = {}, + val extras: (Pane) -> Unit = {}, + val filteredNoun: String = "options", +) { + companion object { + val DEFAULT_TYPE_HINT = listOf( + "<bd>Not in the list above?", + "", + "<key>Left <hnt>Type it in chat", + ) + } +} + +object PickerTemplate { + const val SIZE = 54 + + val GRID_SLOTS = 0..26 + const val TYPE_SLOT = 31 + const val RESULT_SLOT = 40 + + private const val RESULT_TTL_TICKS = 60L + + fun <S : Any> build(host: EditorHost<S>, player: NpcPlayer, spec: PickerSpec<S>): EditorMenu { + val session = host.session(player) + val pages = Paginator(session.pages, pageKey(spec.screen), spec.options, GRID_SLOTS.count()) + val pane = Pane(SIZE) + + fun rerender() = host.render(player, spec.screen) + + pages.visible.forEachIndexed { index, option -> + pane.on(GRID_SLOTS.first + index, option.item) { click -> + when (click) { + MenuClick.RIGHT -> spec.onRightClick(option) + MenuClick.LEFT -> if (option.available) spec.onPick(option.value) + else -> Unit + } + } + } + + val typeHint = spec.typeHint + if (typeHint == null) { + pane[TYPE_SLOT] = Ui.disabled("Type A Name", listOf("<hnt>${spec.typingUnavailable}")) + } else { + pane.left(TYPE_SLOT, Ui.item("ANVIL", "<ttl>Type A Name", typeHint)) { promptTyped(host, player, spec) } + } + + spec.extras(pane) + + session.pickerResult?.let { result -> + pane.on(RESULT_SLOT, resultItem(result, spec.filteredNoun), MenuClick.DROP) { + session.pickerResult = null + rerender() + } + } + + pane.back(host, player) + pane.pager(pages, listOf("<bd>${spec.options.size} options", "<hnt>${pages.perPage} fit per page."), ::rerender) + pane.fillNavRow() + + return pane.menu(spec.title) + } + + fun <S : Any> showResult(host: EditorHost<S>, player: NpcPlayer, screen: S, result: PickerResult) { + val session = host.sessions.peek(player.uniqueId) ?: return + session.pickerResult = result + host.render(player, screen) + + host.runLater(RESULT_TTL_TICKS) { + val current = host.sessions.peek(player.uniqueId) ?: return@runLater + if (current.pickerResult !== result) return@runLater + current.pickerResult = null + if (host.isShowing(player) { it == screen }) host.render(player, screen) + } + } + + private fun <S : Any> promptTyped(host: EditorHost<S>, player: NpcPlayer, spec: PickerSpec<S>) { + host.textPrompts.open( + player, + title = "Search", + instruction = "Type a name in chat; part of one filters the list.", + current = null, + validate = { text -> "Type a name.".takeIf { text.isBlank() } }, + ) { text -> + val result = spec.onTyped(text) ?: return@open + val session = host.session(player) + session.pickerResult = result + if (result is PickerResult.Filtered) session.pages[pageKey(spec.screen)] = 0 + host.render(player, spec.screen) + } + } + + private fun resultItem(result: PickerResult, filteredNoun: String): NpcItem = when (result) { + is PickerResult.Added -> Ui.item("LIME_DYE", "<on>Added: <val>${result.label}", listOf("<hnt>${result.detail}")) + + is PickerResult.Rejected -> Ui.item( + "RED_DYE", + "<err>${result.headline}: <val>${Ui.quote(result.typed)}", + listOf("<hnt>${result.reason}", "<key>Q <hnt>Dismiss"), + ) + + is PickerResult.Filtered -> Ui.item( + "ORANGE_DYE", + "<warn>Filtered: <val>${Ui.quote(result.typed)}", + listOf( + "<bd><val>${result.count} <bd>matching $filteredNoun", + "", + "<hnt>Pick one above, or type more.", + "", + "<key>Q <hnt>Clear filter", + ), + ) + + is PickerResult.Ambiguous -> Ui.item( + "ORANGE_DYE", + "<warn>Ambiguous: <val>${Ui.quote(result.typed)}", + listOf( + "<bd>Matches " + result.matches.joinToString("<bd>, ") { "<val>$it" }, + "", + "<hnt>Pick one above, or type the full name.", + "", + "<key>Q <hnt>Dismiss", + ), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ScreenStackEditor.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ScreenStackEditor.kt new file mode 100644 index 0000000..112c335 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/ScreenStackEditor.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.platform.EditorMenuOpener +import app.simplecloud.npc.core.platform.NpcPlayer + +abstract class ScreenStackEditor<S : Any, T : EditorSession<S>>( + protected val opener: EditorMenuOpener, + protected val chatInputPrompts: ChatInputPrompts, + val sessions: EditorSessions<T>, +) { + + protected abstract fun build(player: NpcPlayer, screen: S): EditorMenu? + protected abstract fun exists(screen: S): Boolean + + protected open fun leavingScreen(session: T, popped: Boolean) { + session.disarm() + session.pickerResult = null + } + + protected fun start(player: NpcPlayer, root: S) = opener.runSync { + sessions.of(player.uniqueId).stack.clear() + chatInputPrompts.cancel(player.uniqueId) + navigate(player, root) + } + + fun navigate(player: NpcPlayer, screen: S) { + val session = sessions.peek(player.uniqueId) ?: return + leavingScreen(session, popped = false) + session.stack.addLast(screen) + show(player, screen) + } + + fun render(player: NpcPlayer, screen: S) { + val session = sessions.peek(player.uniqueId) ?: return + if (session.stack.lastOrNull() != screen) return + show(player, screen) + } + + fun replace(player: NpcPlayer, screen: S) { + val session = sessions.peek(player.uniqueId) ?: return + leavingScreen(session, popped = true) + session.stack.removeLastOrNull() + session.stack.addLast(screen) + show(player, screen) + } + + fun reset(player: NpcPlayer, screens: List<S>) { + val session = sessions.peek(player.uniqueId) ?: return + val top = screens.lastOrNull() ?: return close(player) + leavingScreen(session, popped = true) + session.stack.clear() + session.stack.addAll(screens) + show(player, top) + } + + fun back(player: NpcPlayer, steps: Int = 1) { + val session = sessions.peek(player.uniqueId) ?: return + leavingScreen(session, popped = true) + repeat(steps) { session.stack.removeLastOrNull() } + val parent = session.stack.lastOrNull() ?: return close(player) + show(player, parent) + } + + open fun close(player: NpcPlayer) { + sessions.forgetPlayer(player.uniqueId) + chatInputPrompts.cancel(player.uniqueId) + + player.closeInventory() + } + + fun refreshOpenScreens(players: List<NpcPlayer>) { + val editing = players.filter { sessions.peek(it.uniqueId) != null } + if (editing.isEmpty()) return + + opener.runSync { + editing.forEach { player -> + val session = sessions.peek(player.uniqueId) ?: return@forEach + val screen = session.stack.lastOrNull() ?: return@forEach + val menu = session.menu ?: return@forEach + if (!opener.isShowing(player, menu)) return@forEach + if (chatInputPrompts.isPending(player.uniqueId)) return@forEach + + render(player, screen) + } + } + } + + fun handleEscape(player: NpcPlayer) { + val session = sessions.peek(player.uniqueId) ?: return + if (session.consumeFreshPromptOpen()) return + if (chatInputPrompts.isPending(player.uniqueId)) return + + back(player) + } + + protected open fun show(player: NpcPlayer, screen: S) { + opener.runSync { + val session = sessions.peek(player.uniqueId) ?: return@runSync + val menu = build(player, screen) ?: return@runSync close(player) + + session.menu = menu + opener.open( + player, + menu, + onClick = { slot, click -> handleClick(player, screen, slot, click) }, + onClose = { handleEscape(player) }, + ) + } + } + + private fun handleClick(player: NpcPlayer, screen: S, slot: Int, click: MenuClick) { + if (click == MenuClick.DOUBLE_CLICK || click == MenuClick.OTHER) return + + if (!exists(screen)) return close(player) + + sessions.peek(player.uniqueId)?.menu?.click(slot, click) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/SoundTuning.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/SoundTuning.kt new file mode 100644 index 0000000..7a7459b --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/SoundTuning.kt @@ -0,0 +1,67 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig.SoundOptions +import app.simplecloud.npc.core.platform.NpcPlayer + +object SoundTuning { + private const val VOLUME_SLOT = 29 + private const val PITCH_SLOT = 33 + private const val PREVIEW_SLOT = 42 + + val DEFAULTS = SoundOptions() + + private val VOLUME = Stepper( + label = "Volume", + range = SoundOptions.VOLUME_RANGE, + step = 0.1, + default = DEFAULTS.volume, + decimals = 1, + hints = listOf("<hnt>Above 1.0 it only carries further."), + ) + private val PITCH = Stepper( + label = "Pitch", + range = SoundOptions.PITCH_RANGE, + step = 0.05, + default = DEFAULTS.pitch, + decimals = 2, + hints = listOf("<hnt>Lower is deeper and slower."), + ) + + fun place( + pane: Pane, + player: NpcPlayer, + sound: String?, + options: SoundOptions, + requireSound: Boolean = true, + tune: ((SoundOptions) -> SoundOptions) -> Unit, + ) { + if (requireSound && sound == null) { + val hint = listOf("<hnt>Pick a sound first.") + pane[VOLUME_SLOT] = Ui.disabled(VOLUME.label, hint) + pane[PITCH_SLOT] = Ui.disabled(PITCH.label, hint) + } else { + pane[VOLUME_SLOT] = VOLUME.element(options.volume) { value -> + tune { it.copy(volume = value) } + } + pane[PITCH_SLOT] = PITCH.element(options.pitch) { value -> + tune { it.copy(pitch = value) } + } + } + pane.left( + PREVIEW_SLOT, + Ui.item( + "BELL", + "<ttl>Preview", + listOf( + sound?.let { "<bd>Sound <val>$it" } ?: "<bd>Sound <off>none picked yet", + NpcFormat.soundSettings(options), + "<key>Left <hnt>Play it to me", + ), + ), + ) { sound?.let { player.playSound(it, options) } } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TextPrompts.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TextPrompts.kt new file mode 100644 index 0000000..9413fca --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TextPrompts.kt @@ -0,0 +1,99 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptExtra +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptPlaceholder +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +class TextPrompts(private val start: (NpcPlayer, Prompt) -> Unit) { + + fun open( + player: NpcPlayer, + title: String, + instruction: String, + current: String?, + format: PromptFormat = PromptFormat.PLAIN, + placeholders: List<PromptPlaceholder> = emptyList(), + onClear: (() -> Unit)? = null, + validate: (String) -> String? = { null }, + onResult: (String) -> Unit, + ) = start( + player, + Prompt( + title = title, + instruction = instruction, + current = current?.ifBlank { null }, + format = format, + placeholders = placeholders, + onClear = onClear, + onSubmit = { text -> + validate(text)?.let { return@Prompt PromptResult.Rejected(Component.text(it)) } + onResult(text) + PromptResult.Accepted + }, + ), + ) + + companion object { + fun <S : Any> chat( + sessions: EditorSessions<out EditorSession<S>>, + chatInputPrompts: ChatInputPrompts, + render: ScreenAction<S>, + context: (S) -> String?, + ) = TextPrompts { player, prompt -> + val top = sessions.peek(player.uniqueId)?.stack?.lastOrNull() + startChatPrompt( + sessions, + chatInputPrompts, + player, + prompt.copy( + context = top?.let(context), + onCancel = { sessions.peek(player.uniqueId)?.stack?.lastOrNull()?.let { render(player, it) } }, + ), + ) + } + } +} + +fun startChatPrompt( + sessions: EditorSessions<out EditorSession<*>>, + chatInputPrompts: ChatInputPrompts, + player: NpcPlayer, + prompt: Prompt, +) { + chatInputPrompts.cancel(player.uniqueId) + sessions.peek(player.uniqueId)?.markPromptOpen() + player.closeInventory() + chatInputPrompts.start( + player, + prompt.copy( + onCancel = { + sessions.peek(player.uniqueId)?.promptClosed() + prompt.onCancel() + }, + onClear = prompt.onClear?.let { clear -> + { + sessions.peek(player.uniqueId)?.promptClosed() + clear() + } + }, + onSubmit = { text -> prompt.onSubmit(text).also { closeOnAccept(sessions, player, it) } }, + extra = prompt.extra?.let { extra -> + PromptExtra( + extra.label, + extra.hint, + submit = { text -> extra.submit(text).also { closeOnAccept(sessions, player, it) } }, + next = extra.next, + ) + }, + ), + ) +} + +private fun closeOnAccept(sessions: EditorSessions<out EditorSession<*>>, player: NpcPlayer, result: PromptResult) { + if (result is PromptResult.Accepted) sessions.peek(player.uniqueId)?.promptClosed() +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TitlePane.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TitlePane.kt new file mode 100644 index 0000000..d1abef5 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/core/TitlePane.kt @@ -0,0 +1,123 @@ +package app.simplecloud.npc.common.editor.core + +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.config.NpcConfig.TitleConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object TitlePane { + const val SIZE = 27 + + private val fadeRange = TitleConfiguration.FADE_RANGE.toDoubleRange() + private val stayRange = TitleConfiguration.STAY_RANGE.toDoubleRange() + + private const val TITLE_SLOT = 2 + private const val SUBTITLE_SLOT = 4 + private const val PACING_SLOT = 6 + private const val FADE_IN_SLOT = 10 + private const val STAY_SLOT = 12 + private const val FADE_OUT_SLOT = 14 + private const val REMOVE_SLOT = 16 + + private val DEFAULTS = TitleConfiguration() + + private fun IntRange.toDoubleRange(): ClosedFloatingPointRange<Double> = first.toDouble()..last.toDouble() + + private fun ticksLine(ticks: Double) = + "<val>${NpcFormat.seconds(ticks.toInt(), 2)} <hnt>· ${ticks.toInt()} ticks" + + private val FADE_IN = ticksStepper("Fade In", fadeRange, 1.0, DEFAULTS.fadeIn) + private val STAY = ticksStepper("Stay", stayRange, 5.0, DEFAULTS.stay) + private val FADE_OUT = ticksStepper("Fade Out", fadeRange, 1.0, DEFAULTS.fadeOut) + + private fun ticksStepper(label: String, range: ClosedFloatingPointRange<Double>, step: Double, default: Int) = + Stepper(label, range, step, default.toDouble(), 0, bigMultiplier = 5, valueLine = ::ticksLine) + + class Port( + val player: NpcPlayer, + val title: TitleConfiguration?, + val textPrompts: TextPrompts, + val edit: ((TitleConfiguration) -> TitleConfiguration) -> Unit, + val removeAndBack: () -> Unit, + ) + + fun normalized(title: TitleConfiguration): TitleConfiguration? = + title.takeIf { it.title.isNotBlank() || it.subtitle.isNotBlank() } + + fun defaults(): TitleConfiguration = DEFAULTS + + fun fill(pane: Pane, port: Port) { + val title = port.title ?: DEFAULTS + + fun ask(label: String, value: String, set: (TitleConfiguration, String) -> TitleConfiguration) { + port.textPrompts.open( + port.player, + title = label, + instruction = "Type the ${label.lowercase()} in chat.", + current = value, + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.PLAYER, + onClear = { port.edit { set(it, "") } }.takeIf { value.isNotBlank() }, + ) { text -> + port.edit { set(it, text) } + } + } + + pane.left(TITLE_SLOT, textField("Title Text", title.title)) { + ask("Title Text", title.title) { current, text -> current.copy(title = text) } + } + pane.left(SUBTITLE_SLOT, textField("Subtitle Text", title.subtitle)) { + ask("Subtitle Text", title.subtitle) { current, text -> current.copy(subtitle = text) } + } + + val total = title.fadeIn + title.stay + title.fadeOut + pane[PACING_SLOT] = Ui.item( + "CLOCK", + "<ttl>Pacing", + listOf( + NpcFormat.pacing(title), + "<bd>On screen for <val>${NpcFormat.seconds(total)}", + ), + ) + + if (port.title == null) { + val hint = listOf("<hnt>Set a title or subtitle first.") + listOf(FADE_IN_SLOT to FADE_IN, STAY_SLOT to STAY, FADE_OUT_SLOT to FADE_OUT).forEach { (slot, stepper) -> + pane[slot] = Ui.disabled(stepper.label, hint) + } + } else { + pane[FADE_IN_SLOT] = FADE_IN.element(title.fadeIn.toDouble()) { value -> + port.edit { it.copy(fadeIn = value.toInt()) } + } + pane[STAY_SLOT] = STAY.element(title.stay.toDouble()) { value -> + port.edit { it.copy(stay = value.toInt()) } + } + pane[FADE_OUT_SLOT] = FADE_OUT.element(title.fadeOut.toDouble()) { value -> + port.edit { it.copy(fadeOut = value.toInt()) } + } + } + + pane.left( + REMOVE_SLOT, + Ui.item( + "RED_CONCRETE", + "<err>Remove Title", + listOf("<hnt>Other parts of the action stay.", "<key>Left <hnt>Remove"), + ), + ) { port.removeAndBack() } + } + + private fun textField(label: String, value: String): NpcItem = Ui.item( + "ANVIL", + "<ttl>$label", + listOf( + if (value.isBlank()) "<off>Not set" else "<val>${Ui.quote(value)}", + "<key>Left <hnt>Edit in chat", + ), + ) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CanvasOps.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CanvasOps.kt new file mode 100644 index 0000000..3714c48 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CanvasOps.kt @@ -0,0 +1,106 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.inventory.InventoryItems +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration + +object CanvasOps { + + private const val WIDTH = 9 + + fun itemAt(config: InventoryConfiguration, slot: Int): InventoryItemConfiguration? = + InventoryItems.itemAt(config, slot) + + fun put(config: InventoryConfiguration, slot: Int, item: InventoryItemConfiguration): InventoryConfiguration? { + if (slot !in 0 until config.size()) return null + val cleared = config.copy(items = InventoryItems.removeItem(config, slot)) + + return cleared.copy(items = (cleared.items + asCopyFor(cleared, item, slot)).toMutableList()) + } + + fun remove(config: InventoryConfiguration, slots: Collection<Int>): InventoryConfiguration? { + val present = slots.filter { itemAt(config, it) != null }.ifEmpty { return null } + return present.fold(config) { current, slot -> current.copy(items = InventoryItems.removeItem(current, slot)) } + } + + fun move(config: InventoryConfiguration, from: Int, to: Int): InventoryConfiguration? { + if (from == to || to !in 0 until config.size()) return null + val moving = itemAt(config, from) ?: return null + val other = itemAt(config, to) + + val items = config.items.mapNotNull { + when (it.slot) { + from -> other?.copy(slot = from) + to -> null + else -> it + } + } + + return config.copy(items = (items + moving.copy(slot = to)).toMutableList()) + } + + fun stamp( + config: InventoryConfiguration, + slots: Collection<Int>, + item: InventoryItemConfiguration, + ): InventoryConfiguration? { + val targets = slots.filter { it in 0 until config.size() }.distinct().ifEmpty { return null } + return targets.fold(config) { current, slot -> put(current, slot, item) ?: current } + } + + fun addRow(config: InventoryConfiguration): InventoryConfiguration? = + if (config.rows >= InventoryConfiguration.MAX_ROWS) null else config.copy(rows = config.rows + 1) + + fun removeRow(config: InventoryConfiguration): InventoryConfiguration? { + if (config.rows <= InventoryConfiguration.MIN_ROWS) return null + val newSize = (config.rows - 1) * WIDTH + if (config.items.any { it.slot >= newSize }) return null + + return config.copy(rows = config.rows - 1) + } + + fun shift( + config: InventoryConfiguration, + rows: Int, + columns: Int, + slots: Set<Int>? = null, + ): InventoryConfiguration? { + if (rows == 0 && columns == 0) return null + val (moving, kept) = config.items.partition { slots == null || it.slot in slots } + if (moving.isEmpty()) return null + val staying = kept.mapTo(mutableSetOf()) { it.slot } + + val moved = moving.map { item -> + val row = item.slot / WIDTH + rows + val column = item.slot % WIDTH + columns + if (row !in 0 until config.rows || column !in 0 until WIDTH) return null + val target = row * WIDTH + column + if (target in staying) return null + item.copy(slot = target) + } + + return config.copy(items = (config.items.filter { it.slot in staying } + moved).toMutableList()) + } + + fun similar(config: InventoryConfiguration, slot: Int): Set<Int> { + val reference = itemAt(config, slot) ?: return emptySet() + return config.items.filter { sameLook(it, reference) }.map { it.slot }.toSet() + } + + fun sameLook(a: InventoryItemConfiguration, b: InventoryItemConfiguration): Boolean = + a.material == b.material && a.name == b.name && a.lore == b.lore && a.amount == b.amount && + a.glowing == b.glowing && a.customModelData == b.customModelData && a.liveGroup == b.liveGroup && + a.pageControl == b.pageControl + + private fun asCopyFor( + config: InventoryConfiguration, + item: InventoryItemConfiguration, + slot: Int, + ): InventoryItemConfiguration { + val copy = item.deepCopy().copy(slot = slot) + val group = copy.liveGroup ?: return copy + val groupHasSource = config.items.any { it.liveGroup == group && (it.live != null || it.whenEmpty != null) } + + return if (groupHasSource) copy.copy(live = null, whenEmpty = null) else copy + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CommonMaterials.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CommonMaterials.kt new file mode 100644 index 0000000..841dd6f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/CommonMaterials.kt @@ -0,0 +1,54 @@ +package app.simplecloud.npc.common.editor.inventory + +object CommonMaterials { + + private val COLORS = listOf( + "WHITE", "LIGHT_GRAY", "GRAY", "BLACK", "BROWN", "RED", "ORANGE", "YELLOW", + "LIME", "GREEN", "CYAN", "LIGHT_BLUE", "BLUE", "PURPLE", "MAGENTA", "PINK", + ) + + val DYES: List<String> = COLORS.map { "${it}_DYE" } + val PANES: List<String> = COLORS.map { "${it}_STAINED_GLASS_PANE" } + val HEADS: List<String> = listOf("PLAYER_HEAD") + + val REDSTONE: List<String> = listOf( + "REDSTONE", "REDSTONE_TORCH", "REDSTONE_BLOCK", "COMPARATOR", "REPEATER", "LEVER", + "TRIPWIRE_HOOK", "BARRIER", "STRUCTURE_VOID", "TNT", "GUNPOWDER", "OBSERVER", "PISTON", + ) + + val TOOLS: List<String> = listOf( + "BOW", "CROSSBOW", "TRIDENT", "SHIELD", "ELYTRA", "ARROW", "SPECTRAL_ARROW", + "DIAMOND_SWORD", "NETHERITE_SWORD", "IRON_SWORD", "GOLDEN_SWORD", "WOODEN_SWORD", + "DIAMOND_PICKAXE", "IRON_PICKAXE", "DIAMOND_AXE", "IRON_AXE", "FISHING_ROD", "FLINT_AND_STEEL", + ) + + val FOOD: List<String> = listOf( + "GOLDEN_APPLE", "ENCHANTED_GOLDEN_APPLE", "APPLE", "BREAD", "COOKED_BEEF", "CAKE", + "POTION", "SPLASH_POTION", + ) + + val BLOCKS: List<String> = listOf( + "COMPASS", "CLOCK", "NAME_TAG", "PAPER", "MAP", "BOOK", "WRITABLE_BOOK", "ENCHANTED_BOOK", + "NETHER_STAR", "ENDER_PEARL", "ENDER_EYE", "EMERALD", "DIAMOND", "GOLD_INGOT", "IRON_INGOT", + "NETHERITE_INGOT", "AMETHYST_SHARD", "EXPERIENCE_BOTTLE", "TOTEM_OF_UNDYING", "BEACON", + "CHEST", "ENDER_CHEST", "BARREL", "ITEM_FRAME", "OAK_SIGN", "BELL", "LANTERN", "SOUL_LANTERN", + "TORCH", "DRAGON_BREATH", "BLAZE_POWDER", "GHAST_TEAR", "SLIME_BALL", "MAGMA_CREAM", + "FIREWORK_ROCKET", "FIREWORK_STAR", "SNOWBALL", "BUCKET", "WATER_BUCKET", "LAVA_BUCKET", + "GRASS_BLOCK", "STONE", "COBBLESTONE", "OAK_PLANKS", "GLOWSTONE", "SEA_LANTERN", "OBSIDIAN", + "CRYING_OBSIDIAN", "BEDROCK", "END_STONE", "NETHERRACK", "SPONGE", "BOOKSHELF", + "CRAFTING_TABLE", "FURNACE", "ANVIL", "GRINDSTONE", "LECTERN", "GOLD_BLOCK", "IRON_BLOCK", + "DIAMOND_BLOCK", "EMERALD_BLOCK", "NETHERITE_BLOCK", + ) + listOf("WOOL", "CONCRETE", "TERRACOTTA").flatMap { suffix -> COLORS.map { "${it}_$suffix" } } + + val CATEGORIES: List<Pair<String, List<String>>> = listOf( + "Blocks" to BLOCKS, + "Tools" to TOOLS, + "Food" to FOOD, + "Dyes" to DYES, + "Panes" to PANES, + "Heads" to HEADS, + "Redstone" to REDSTONE, + ) + + val COMMON: List<String> = (BLOCKS + TOOLS + FOOD + DYES + PANES + HEADS + REDSTONE).distinct() +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/EditHistory.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/EditHistory.kt new file mode 100644 index 0000000..d455828 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/EditHistory.kt @@ -0,0 +1,34 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.core.inventory.InventoryConfiguration + +class EditHistory(private val limit: Int = DEFAULT_LIMIT) { + + private val undo = ArrayDeque<InventoryConfiguration>() + private val redo = ArrayDeque<InventoryConfiguration>() + + val canUndo: Boolean get() = undo.isNotEmpty() + val canRedo: Boolean get() = redo.isNotEmpty() + val undoCount: Int get() = undo.size + val redoCount: Int get() = redo.size + + fun record(before: InventoryConfiguration) { + push(undo, before.deepCopy()) + redo.clear() + } + + fun undo(current: InventoryConfiguration): InventoryConfiguration? = + undo.removeLastOrNull()?.also { push(redo, current.deepCopy()) } + + fun redo(current: InventoryConfiguration): InventoryConfiguration? = + redo.removeLastOrNull()?.also { push(undo, current.deepCopy()) } + + private fun push(stack: ArrayDeque<InventoryConfiguration>, config: InventoryConfiguration) { + stack.addLast(config) + while (stack.size > limit) stack.removeFirst() + } + + companion object { + const val DEFAULT_LIMIT = 50 + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditor.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditor.kt new file mode 100644 index 0000000..73cfe2d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditor.kt @@ -0,0 +1,117 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.canvas.CanvasSurface +import app.simplecloud.npc.common.editor.core.ScreenStackEditor +import app.simplecloud.npc.common.editor.inventory.canvas.CanvasController +import app.simplecloud.npc.common.editor.inventory.screens.InventoryConfirms +import app.simplecloud.npc.common.editor.inventory.screens.InventoryHubMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.InventoryPickers +import app.simplecloud.npc.common.editor.inventory.screens.ItemActionMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.ItemTitleMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.LiveGroupMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.LoreMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.StateLooksMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.StudioMenuBuilder +import app.simplecloud.npc.common.editor.inventory.screens.TemplatesMenuBuilder +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.platform.EditorMenuOpener +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +class InventoryEditor( + opener: EditorMenuOpener, + chatInputPrompts: ChatInputPrompts, + private val canvasSurface: CanvasSurface, + pluginContextProvider: () -> NpcPluginContext, +) : ScreenStackEditor<InventoryEditorScreen, InventoryEditorSession>( + opener, + chatInputPrompts, + InventoryEditorSessions(), +) { + + val context = InventoryEditorContext( + pluginContextProvider, + chatInputPrompts, + sessions, + ::render, + ::navigate, + ::replace, + ::reset, + ::back, + ::close, + opener::runSync, + opener::runLater, + ) + + private val canvas = CanvasController(context, canvasSurface, ::handleEscape) + + fun open(player: NpcPlayer, config: InventoryConfiguration) = start(player, InventoryEditorScreen.Hub(config.id)) + + fun openCreate(player: NpcPlayer) = start(player, InventoryEditorScreen.Templates) + + override fun exists(screen: InventoryEditorScreen): Boolean = + screen !is InventoryEditorScreen.Bound || context.inventoryRepository.find(screen.inventoryId) != null + + override fun leavingScreen(session: InventoryEditorSession, popped: Boolean) { + super.leavingScreen(session, popped) + if (session.stack.lastOrNull() !is InventoryEditorScreen.Canvas) return + if (popped) session.leaveCanvas() else session.carried = null + } + + override fun show(player: NpcPlayer, screen: InventoryEditorScreen) { + if (screen is InventoryEditorScreen.Canvas) { + opener.runSync { + val session = sessions.peek(player.uniqueId) ?: return@runSync + if (context.inventoryRepository.find(screen.inventoryId) == null) { + context.reportMissing(player, screen.inventoryId) + close(player) + return@runSync + } + session.menu = null + opener.detach(player) + canvas.show(player, screen) + } + return + } + + canvasSurface.detach(player) + super.show(player, screen) + } + + override fun close(player: NpcPlayer) { + canvasSurface.detach(player) + super.close(player) + } + + fun refresh(players: List<NpcPlayer>) { + refreshOpenScreens(players) + opener.runSync { players.forEach(canvas::refresh) } + } + + override fun build(player: NpcPlayer, screen: InventoryEditorScreen): EditorMenu? { + if (screen == InventoryEditorScreen.Templates) return TemplatesMenuBuilder.build(context, player) + val bound = screen as InventoryEditorScreen.Bound + val config = context.inventoryRepository.find(bound.inventoryId) ?: run { + context.reportMissing(player, bound.inventoryId) + return null + } + + return when (bound) { + is InventoryEditorScreen.Hub -> InventoryHubMenuBuilder.build(context, player, config) + is InventoryEditorScreen.Studio -> StudioMenuBuilder.build(context, player, config, bound) + is InventoryEditorScreen.Lore -> LoreMenuBuilder.build(context, player, config, bound) + is InventoryEditorScreen.ItemAction -> ItemActionMenuBuilder.build(context, player, config, bound) + is InventoryEditorScreen.ItemActionBlocks -> + ItemActionMenuBuilder.buildBlocks(context, player, config, bound) + + is InventoryEditorScreen.ItemTitle -> ItemTitleMenuBuilder.build(context, player, config, bound) + is InventoryEditorScreen.LiveGroup -> LiveGroupMenuBuilder.build(context, player, config, bound.group) + is InventoryEditorScreen.StateLooks -> StateLooksMenuBuilder.build(context, player, config, bound.group) + is InventoryEditorScreen.Picker -> InventoryPickers.build(context, player, config, bound.purpose) + is InventoryEditorScreen.Confirm -> InventoryConfirms.build(context, player, config, bound.purpose) + is InventoryEditorScreen.Canvas -> error("The canvas is not a menu") + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorContext.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorContext.kt new file mode 100644 index 0000000..02a8183 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorContext.kt @@ -0,0 +1,131 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.EditorPorts +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.EditorHost +import app.simplecloud.npc.common.editor.core.EditorSessions +import app.simplecloud.npc.common.editor.core.ScreenAction +import app.simplecloud.npc.common.editor.core.TextPrompts +import app.simplecloud.npc.common.editor.npc.CloudNpcEditorLiveData +import app.simplecloud.npc.common.editor.npc.NpcEditorLiveData +import app.simplecloud.npc.common.manager.InventoryManager +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +class InventoryEditorContext( + private val pluginContextProvider: () -> NpcPluginContext, + override val chatInputPrompts: ChatInputPrompts, + override val sessions: EditorSessions<InventoryEditorSession>, + render: ScreenAction<InventoryEditorScreen>, + navigate: ScreenAction<InventoryEditorScreen>, + replace: ScreenAction<InventoryEditorScreen>, + private val resetTo: (NpcPlayer, List<InventoryEditorScreen>) -> Unit, + private val backSteps: (NpcPlayer, Int) -> Unit, + close: (NpcPlayer) -> Unit, + runSync: (() -> Unit) -> Unit, + runLater: (Long, () -> Unit) -> Unit, +) : EditorPorts( + pluginContextProvider, + TextPrompts.chat(sessions, chatInputPrompts, render, ::contextOf), + runSync, + runLater, +), EditorHost<InventoryEditorScreen> { + + private val renderScreen = render + private val navigateTo = navigate + private val replaceWith = replace + private val closeEditor = close + + val inventoryManager: InventoryManager get() = pluginContextProvider().inventoryManager + val liveData: NpcEditorLiveData by lazy { CloudNpcEditorLiveData(pluginContextProvider) } + + override fun render(player: NpcPlayer, screen: InventoryEditorScreen) = renderScreen(player, screen) + override fun promptContext(screen: InventoryEditorScreen): String? = contextOf(screen) + override fun navigate(player: NpcPlayer, screen: InventoryEditorScreen) = navigateTo(player, screen) + override fun back(player: NpcPlayer, steps: Int) = backSteps(player, steps) + override fun close(player: NpcPlayer) = closeEditor(player) + fun replace(player: NpcPlayer, screen: InventoryEditorScreen) = replaceWith(player, screen) + fun reset(player: NpcPlayer, screens: List<InventoryEditorScreen>) = resetTo(player, screens) + + override fun session(player: NpcPlayer): InventoryEditorSession = sessions.of(player.uniqueId) + + fun materialCatalog(): List<String> = catalogs.materials() + fun dataSourceTypes(): List<String> = inventoryViews.dataSourceTypes() + + fun change( + player: NpcPlayer, + inventoryId: String, + mutate: (InventoryConfiguration) -> InventoryConfiguration?, + ): Boolean { + val fresh = find(player, inventoryId) ?: return false + val updated = mutate(fresh.deepCopy()) ?: return false + if (updated == fresh) return false + + session(player).history(inventoryId).record(fresh) + save(updated) + + return true + } + + fun undo(player: NpcPlayer, inventoryId: String): Boolean = travel(player, inventoryId, EditHistory::undo) + + fun redo(player: NpcPlayer, inventoryId: String): Boolean = travel(player, inventoryId, EditHistory::redo) + + fun promptTitle(player: NpcPlayer, inventoryId: String, screen: InventoryEditorScreen) { + chatPrompt( + player, + Prompt( + title = "Menu Title", + instruction = "Type the menu title in chat.", + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.MENU, + current = inventoryRepository.find(inventoryId)?.title, + onCancel = { render(player, screen) }, + onSubmit = { input -> + val title = input.trim() + PromptResult.rejectInvalidMiniMessage(title)?.let { return@Prompt it } + change(player, inventoryId) { it.copy(title = title) } + render(player, screen) + PromptResult.Accepted + }, + ), + ) + } + + fun reportMissing(player: NpcPlayer, inventoryId: String) { + player.sendError("Inventory {} no longer exists.", inventoryId) + } + + private fun travel( + player: NpcPlayer, + inventoryId: String, + step: (EditHistory, InventoryConfiguration) -> InventoryConfiguration?, + ): Boolean { + val fresh = find(player, inventoryId) ?: return false + val target = step(session(player).history(inventoryId), fresh) ?: return false + save(target) + + return true + } + + private fun find(player: NpcPlayer, inventoryId: String): InventoryConfiguration? = + inventoryRepository.find(inventoryId) ?: run { + reportMissing(player, inventoryId) + close(player) + null + } + + private fun save(config: InventoryConfiguration) { + inventoryRepository.save(config) + inventoryViews.requestRefresh() + } +} + +private fun contextOf(screen: InventoryEditorScreen): String? = + (screen as? InventoryEditorScreen.Bound)?.let { "Menu ${it.inventoryId}" } diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorScreen.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorScreen.kt new file mode 100644 index 0000000..b3f6777 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorScreen.kt @@ -0,0 +1,69 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.core.interaction.PlayerInteraction + +enum class StudioTab { LOOK, CLICK, LIVE } + +sealed interface InventoryPickerPurpose { + data object NewItem : InventoryPickerPurpose + + data class Material(val slots: List<Int>) : InventoryPickerPurpose + + data object NewLiveGroup : InventoryPickerPurpose + + data class LiveSourceType(val group: String) : InventoryPickerPurpose + data class LiveSourceGroup(val group: String) : InventoryPickerPurpose + data class LookMaterial(val group: String, val state: String) : InventoryPickerPurpose + data object UsedBy : InventoryPickerPurpose + data class OpenInventory(val slots: List<Int>, val interaction: PlayerInteraction) : InventoryPickerPurpose + data class SendToServer(val slots: List<Int>, val interaction: PlayerInteraction) : InventoryPickerPurpose + data class Sound(val slots: List<Int>, val interaction: PlayerInteraction) : InventoryPickerPurpose +} + +sealed interface InventoryConfirmPurpose { + data object DeleteInventory : InventoryConfirmPurpose + data class DeleteLiveGroup(val group: String) : InventoryConfirmPurpose +} + +sealed interface InventoryEditorScreen { + + data object Templates : InventoryEditorScreen + + sealed interface Bound : InventoryEditorScreen { + val inventoryId: String + } + + data class Hub(override val inventoryId: String) : Bound + data class Canvas(override val inventoryId: String) : Bound + + data class Studio( + override val inventoryId: String, + val slots: List<Int>, + val tab: StudioTab = StudioTab.LOOK, + ) : Bound + + data class Lore(override val inventoryId: String, val slots: List<Int>) : Bound + + data class ItemAction( + override val inventoryId: String, + val slots: List<Int>, + val interaction: PlayerInteraction, + ) : Bound + + data class ItemActionBlocks( + override val inventoryId: String, + val slots: List<Int>, + val interaction: PlayerInteraction, + ) : Bound + + data class ItemTitle( + override val inventoryId: String, + val slots: List<Int>, + val interaction: PlayerInteraction, + ) : Bound + + data class LiveGroup(override val inventoryId: String, val group: String) : Bound + data class StateLooks(override val inventoryId: String, val group: String) : Bound + data class Picker(override val inventoryId: String, val purpose: InventoryPickerPurpose) : Bound + data class Confirm(override val inventoryId: String, val purpose: InventoryConfirmPurpose) : Bound +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorSession.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorSession.kt new file mode 100644 index 0000000..8025eca --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/InventoryEditorSession.kt @@ -0,0 +1,92 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.editor.core.EditorSession +import app.simplecloud.npc.common.editor.core.EditorSessions +import app.simplecloud.npc.common.editor.inventory.canvas.CanvasRender +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration + +enum class CanvasMode { DESIGN, LIVE } + +data class Carried(val item: InventoryItemConfiguration, val from: Int?) + +class InventoryEditorSession : EditorSession<InventoryEditorScreen>() { + + private val histories = mutableMapOf<String, EditHistory>() + + fun history(inventoryId: String): EditHistory = histories.getOrPut(inventoryId.lowercase()) { EditHistory() } + + var carried: Carried? = null + var lastPlaced: InventoryItemConfiguration? = null + var mode = CanvasMode.DESIGN + + var thisSlotOnly = false + + var activeGroup: String? = null + + var handMode = false + + var live: CanvasRender.LiveState? = null + var liveSerial = 0 + val livePages = mutableMapOf<String, Int>() + + var lastCanvas: InventoryConfiguration? = null + + val selection = linkedSetOf<Int>() + var selectionOf: String? = null + private set + + fun selectionFor(inventoryId: String): Set<Int> = + if (selectionOf.equals(inventoryId, true)) selection else emptySet() + + fun select(inventoryId: String, slots: Collection<Int>, add: Boolean = true) { + if (!selectionOf.equals(inventoryId, true)) { + selection.clear() + selectionOf = inventoryId + } + if (add) selection += slots else selection -= slots.toSet() + } + + fun clearSelection() { + selection.clear() + selectionOf = null + } + + private val recent = ArrayDeque<String>() + val recentMaterials: List<String> get() = recent.toList() + + fun usedMaterial(material: String) { + recent.remove(material) + recent.addFirst(material) + while (recent.size > RECENT_MATERIALS) recent.removeLast() + } + + var materialCategory: String = RECENT_CATEGORY + + val keyBindings = mutableMapOf<Int, InventoryItemConfiguration>() + + fun renameGroup(from: String, to: String) { + fun moved(item: InventoryItemConfiguration) = if (item.liveGroup == from) item.copy(liveGroup = to) else item + if (activeGroup == from) activeGroup = to + lastPlaced = lastPlaced?.let(::moved) + carried = carried?.let { it.copy(item = moved(it.item)) } + keyBindings.replaceAll { _, item -> moved(item) } + } + + fun leaveCanvas() { + carried = null + clearSelection() + mode = CanvasMode.DESIGN + handMode = false + live = null + livePages.clear() + } + + companion object { + const val RECENT_MATERIALS = 7 + const val RECENT_CATEGORY = "Recent" + const val ALL_CATEGORY = "All" + } +} + +class InventoryEditorSessions : EditorSessions<InventoryEditorSession>(::InventoryEditorSession) diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/LiveGroups.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/LiveGroups.kt new file mode 100644 index 0000000..f04d4dc --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/LiveGroups.kt @@ -0,0 +1,163 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.inventory.source.GroupServersSource +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.SourceConfiguration + +object LiveGroups { + + class SourceInfo( + val icon: String, + val label: String, + val lines: List<String>, + val slotMaterial: String, + val slotName: String, + val slotLore: List<String>, + ) + + val COLORS = listOf( + "LIME" to "#a3e635", + "LIGHT_BLUE" to "#38bdf8", + "ORANGE" to "#fb923c", + "MAGENTA" to "#e879f9", + "YELLOW" to "#facc15", + "RED" to "#f87171", + "CYAN" to "#22d3ee", + ) + + private val JOIN_LORE = listOf( + "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>", + "", + "<#475569>Click to join", + ) + + fun names(config: InventoryConfiguration): List<String> = + config.items.sortedBy { it.slot }.mapNotNull { it.liveGroup }.distinct() + + fun members(config: InventoryConfiguration, group: String): List<InventoryItemConfiguration> = + config.items.filter { it.liveGroup == group }.sortedBy { it.slot } + + fun contentSlots(config: InventoryConfiguration, group: String): List<Int> = + members(config, group).filter { it.pageControl == null }.map { it.slot } + + fun colorOf(config: InventoryConfiguration, group: String): Pair<String, String> { + val index = names(config).indexOf(group).coerceAtLeast(0) + return COLORS[index % COLORS.size] + } + + fun source(config: InventoryConfiguration, group: String): SourceConfiguration? = + members(config, group).firstNotNullOfOrNull { it.live } + + fun whenEmpty(config: InventoryConfiguration, group: String): InventoryItemConfiguration? = + members(config, group).firstNotNullOfOrNull { it.whenEmpty } + + fun setSource( + config: InventoryConfiguration, + group: String, + transform: (SourceConfiguration) -> SourceConfiguration, + ): InventoryConfiguration? { + val current = source(config, group) ?: SourceConfiguration() + return withShared(config, group, live = transform(current), whenEmpty = whenEmpty(config, group)) + } + + fun setLook( + config: InventoryConfiguration, + group: String, + state: String, + transform: (InventoryConfiguration.StateLook) -> InventoryConfiguration.StateLook?, + ): InventoryConfiguration? = setSource(config, group) { source -> + val looks = source.looks.filterKeys { !it.equals(state, true) }.toMutableMap() + transform(source.lookFor(state) ?: InventoryConfiguration.StateLook()) + ?.takeUnless { it.isEmpty() } + ?.let { looks[state] = it } + source.copy(looks = looks) + } + + fun setWhenEmpty( + config: InventoryConfiguration, + group: String, + look: InventoryItemConfiguration?, + ): InventoryConfiguration? = + withShared(config, group, live = source(config, group), whenEmpty = look?.copy(slot = -1)) + + fun rename(config: InventoryConfiguration, group: String, newName: String): InventoryConfiguration? { + if (newName.isBlank() || newName in names(config)) return null + val items = config.items.map { if (it.liveGroup == group) it.copy(liveGroup = newName) else it } + + return config.copy(items = items.toMutableList()) + } + + fun delete(config: InventoryConfiguration, group: String): InventoryConfiguration? { + if (members(config, group).isEmpty()) return null + return config.copy(items = config.items.filterNot { it.liveGroup == group }.toMutableList()) + } + + fun lacksPageControls(config: InventoryConfiguration, group: String): Boolean = + members(config, group).none { it.pageControl != null } + + fun freeName(config: InventoryConfiguration, type: String): String { + val base = when (type) { + GroupServersSource.TYPE, "persistent-servers" -> "servers" + "groups" -> "groups" + else -> "entries" + } + val taken = names(config).toSet() + + return generateSequence(1) { it + 1 }.map { if (it == 1) base else "$base-$it" }.first { it !in taken } + } + + fun newSlot(group: String, type: String): InventoryItemConfiguration { + val info = describe(type) + return InventoryItemConfiguration( + material = info.slotMaterial, + name = info.slotName, + lore = info.slotLore.toMutableList(), + liveGroup = group, + live = SourceConfiguration(type = type), + ) + } + + fun describe(type: String): SourceInfo = when (type) { + GroupServersSource.TYPE -> SourceInfo( + "GRASS_BLOCK", "Servers Of A Group", + listOf("Every running server of one group,", "e.g. Lobby-1, Lobby-2, ..."), + "GRASS_BLOCK", "<#a3e635><entry_group_name>-<entry_numerical_id>", JOIN_LORE, + ) + + "persistent-servers" -> SourceInfo( + "ENDER_CHEST", "Persistent Servers", listOf("Every persistent server."), + "ENDER_CHEST", "<#a3e635><entry_name>", JOIN_LORE, + ) + + "groups" -> SourceInfo( + "CHEST", "Groups", listOf("One entry per server group."), + "CHEST", "<#a3e635><entry_name>", JOIN_LORE, + ) + + "static" -> SourceInfo( + "PAPER", "Fixed List", listOf("Entries written by hand in the YAML."), + "PAPER", "<#e2e8f0>Entry", emptyList(), + ) + + else -> SourceInfo("COMPARATOR", type, listOf("Provided by an addon."), "PAPER", "<#e2e8f0>Entry", emptyList()) + } + + private fun withShared( + config: InventoryConfiguration, + group: String, + live: SourceConfiguration?, + whenEmpty: InventoryItemConfiguration?, + ): InventoryConfiguration? { + val holder = members(config, group).firstOrNull { it.pageControl == null } ?: return null + val items = config.items.map { item -> + when { + item.liveGroup != group -> item + item.slot == holder.slot -> item.copy(live = live, whenEmpty = whenEmpty) + else -> item.copy(live = null, whenEmpty = null) + } + } + + return config.copy(items = items.toMutableList()) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/MenuTemplates.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/MenuTemplates.kt new file mode 100644 index 0000000..0349ba6 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/MenuTemplates.kt @@ -0,0 +1,180 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.common.inventory.source.GroupServersSource +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.EntryStates +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.PageControl +import app.simplecloud.npc.core.inventory.InventoryConfiguration.SourceConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.StateLook + +enum class MenuTemplate(val label: String, val icon: String, val description: List<String>) { + BLANK("Blank", "WHITE_STAINED_GLASS_PANE", listOf("Three empty rows.")), + SERVER_SELECTOR( + "Server Selector", + "COMPASS", + listOf("Every running server of the NPC's", "target group, with page arrows."), + ), + GROUP_OVERVIEW("Group Overview", "CHEST", listOf("One slot per group,", "with its player count.")), + PERSISTENT_SERVERS("Persistent Servers", "ENDER_CHEST", listOf("One slot per persistent server.")), + NAVIGATION("Navigation", "OAK_SIGN", listOf("Three fixed buttons that send", "players to a server each.")), + ; + + fun build(id: String): InventoryConfiguration = when (this) { + BLANK -> InventoryConfiguration(id = id, rows = 3) + SERVER_SELECTOR -> serverSelector(id) + GROUP_OVERVIEW -> liveGrid( + id, "<#0ea5e9><bold>Groups", "groups", "groups", "CHEST", + "<#a3e635><entry_name>", + JOIN_LORE, + ) + + PERSISTENT_SERVERS -> liveGrid( + id, "<#0ea5e9><bold>Servers", "servers", "persistent-servers", "ENDER_CHEST", + "<#a3e635><entry_name>", + JOIN_LORE, + ) + + NAVIGATION -> navigation(id) + } + + private companion object { + const val PANE = "GRAY_STAINED_GLASS_PANE" + + val JOIN_LORE = listOf( + "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>", + "", + "<#475569>Click to join", + ) + + fun border(rows: Int): List<InventoryItemConfiguration> { + val size = rows * 9 + return (0 until size) + .filter { it < 9 || it >= size - 9 || it % 9 == 0 || it % 9 == 8 } + .map { InventoryItemConfiguration(slot = it, material = PANE, name = " ", hideTooltip = true) } + } + + fun withBorder(rows: Int, items: List<InventoryItemConfiguration>): MutableList<InventoryItemConfiguration> { + val taken = items.map { it.slot }.toSet() + return (border(rows).filterNot { it.slot in taken } + items).toMutableList() + } + + fun serverSelector(id: String): InventoryConfiguration { + val content = (10..16) + (19..25) + val live = content.mapIndexed { index, slot -> + InventoryItemConfiguration( + slot = slot, + material = "GRASS_BLOCK", + name = "<#a3e635><entry_group_name>-<entry_numerical_id>", + lore = mutableListOf( + "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>", + "<#94a3b8>State: <#f8fafc><entry_state>", + "", + "<#475569>Click to join this server", + ), + liveGroup = "servers", + live = if (index == 0) { + SourceConfiguration( + type = GroupServersSource.TYPE, + group = GroupServersSource.TARGET_GROUP, + states = mutableListOf("AVAILABLE", "INGAME"), + looks = mutableMapOf( + EntryStates.FULL to StateLook( + material = "RED_CONCRETE", + name = "<#dc2626><entry_group_name>-<entry_numerical_id>", + lore = mutableListOf( + "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>", + "", + "<#dc2626>This server is full", + ), + ), + "INGAME" to StateLook( + material = "ORANGE_CONCRETE", + name = "<#f59e0b><entry_group_name>-<entry_numerical_id>", + lore = mutableListOf( + "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>", + "", + "<#f59e0b>A round is running", + ), + ), + ), + ) + } else null, + whenEmpty = if (index == 0) { + InventoryItemConfiguration(material = PANE, name = "<#475569>Spinning up...") + } else null, + ) + } + val controls = listOf( + InventoryItemConfiguration( + slot = 39, material = "ARROW", name = "<#e2e8f0>Previous page", + liveGroup = "servers", pageControl = PageControl.PREVIOUS, + ), + InventoryItemConfiguration( + slot = 41, material = "ARROW", name = "<#e2e8f0>Next page", + liveGroup = "servers", pageControl = PageControl.NEXT, + ), + InventoryItemConfiguration( + slot = 4, material = "NETHER_STAR", name = "<#f8fafc>Join a Server", glowing = true, + lore = mutableListOf("<#94a3b8>Joins the emptiest server."), + actions = mutableListOf(NpcConfig.ActionConfiguration(joinTarget = true)), + ), + ) + + return InventoryConfiguration( + id = id, + title = "<#0ea5e9><bold>Servers", + rows = 5, + items = withBorder(5, live + controls), + ) + } + + fun liveGrid( + id: String, + title: String, + group: String, + sourceType: String, + material: String, + name: String, + lore: List<String>, + ): InventoryConfiguration { + val live = (10..16).mapIndexed { index, slot -> + InventoryItemConfiguration( + slot = slot, + material = material, + name = name, + lore = lore.toMutableList(), + liveGroup = group, + live = SourceConfiguration(type = sourceType).takeIf { index == 0 }, + ) + } + + return InventoryConfiguration(id = id, title = title, rows = 3, items = withBorder(3, live)) + } + + fun navigation(id: String): InventoryConfiguration { + fun button(slot: Int, material: String, label: String, server: String) = InventoryItemConfiguration( + slot = slot, + material = material, + name = "<#a3e635>$label", + lore = mutableListOf("<#475569>Click to go to $server"), + actions = mutableListOf(NpcConfig.ActionConfiguration(sendToServer = server)), + ) + + return InventoryConfiguration( + id = id, + title = "<#0ea5e9><bold>Navigation", + rows = 3, + items = withBorder( + 3, + listOf( + button(11, "GRASS_BLOCK", "Lobby", "Lobby"), + button(13, "DIAMOND_SWORD", "Survival", "Survival"), + button(15, "GOLDEN_APPLE", "Minigames", "Minigames"), + ), + ), + ) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/StudioTargets.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/StudioTargets.kt new file mode 100644 index 0000000..ddc865d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/StudioTargets.kt @@ -0,0 +1,40 @@ +package app.simplecloud.npc.common.editor.inventory + +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration + +object StudioTargets { + + fun of(config: InventoryConfiguration, slots: List<Int>, thisSlotOnly: Boolean): Set<Int> { + val chosen = slots.filter { slot -> config.items.any { it.slot == slot } }.toSet() + if (thisSlotOnly) return chosen + val groups = config.items + .filter { it.slot in chosen && it.pageControl == null } + .mapNotNull { it.liveGroup } + .toSet() + + return chosen + config.items.filter { it.liveGroup in groups && it.pageControl == null }.map { it.slot } + } + + fun items(config: InventoryConfiguration, targets: Set<Int>): List<InventoryItemConfiguration> = + config.items.filter { it.slot in targets }.sortedBy { it.slot } + + fun edit( + config: InventoryConfiguration, + targets: Set<Int>, + transform: (InventoryItemConfiguration) -> InventoryItemConfiguration, + ): InventoryConfiguration { + val items = config.items.map { if (it.slot in targets) transform(it) else it } + return config.copy(items = items.toMutableList()) + } + + fun <T> shared(items: List<InventoryItemConfiguration>, field: (InventoryItemConfiguration) -> T): Shared<T> { + val values = items.map(field).distinct() + return if (values.size == 1) Shared.Same(values.single()) else Shared.Mixed + } + + sealed interface Shared<out T> { + data class Same<T>(val value: T) : Shared<T> + data object Mixed : Shared<Nothing> + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasController.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasController.kt new file mode 100644 index 0000000..b5f22f9 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasController.kt @@ -0,0 +1,459 @@ +package app.simplecloud.npc.common.editor.inventory.canvas + +import app.simplecloud.npc.common.editor.canvas.CanvasInput +import app.simplecloud.npc.common.editor.canvas.CanvasSurface +import app.simplecloud.npc.common.editor.inventory.CanvasMode +import app.simplecloud.npc.common.editor.inventory.CanvasOps +import app.simplecloud.npc.common.editor.inventory.Carried +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryEditorSession +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.inventory.screens.InventoryHubMenuBuilder +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.PageControl +import app.simplecloud.npc.core.platform.NpcPlayer +import kotlinx.coroutines.CancellationException + +class CanvasController( + private val context: InventoryEditorContext, + private val surface: CanvasSurface, + private val onEscape: (NpcPlayer) -> Unit, +) { + + fun show(player: NpcPlayer, screen: InventoryEditorScreen.Canvas) { + val config = context.inventoryRepository.find(screen.inventoryId) ?: return + val session = context.session(player) + if (session.mode == CanvasMode.LIVE && session.live == null) requestLive(player, screen) + + session.lastCanvas = config + surface.open( + player, + CanvasRender.view(config, session), + onInput = { input -> handle(player, screen, input) }, + onClose = { onEscape(player) }, + ) + } + + fun refresh(player: NpcPlayer) { + val top = context.sessions.peek(player.uniqueId)?.stack?.lastOrNull() + val screen = top as? InventoryEditorScreen.Canvas ?: return + if (context.chatInputPrompts.isPending(player.uniqueId)) return + val session = context.session(player) + + when { + session.mode == CanvasMode.LIVE -> requestLive(player, screen) + context.inventoryRepository.find(screen.inventoryId) != session.lastCanvas -> context.render(player, screen) + } + } + + private fun handle(player: NpcPlayer, screen: InventoryEditorScreen.Canvas, input: CanvasInput) { + val config = context.inventoryRepository.find(screen.inventoryId) ?: return context.close(player) + val session = context.session(player) + val size = config.size() + + when { + session.handMode -> handMode(player, session, size, input) + input is CanvasInput.Click && input.slot >= size -> + toolbox(player, screen, config, session, input.slot - size, input) + + session.mode == CanvasMode.LIVE -> livePaging(player, screen, config, session, input) + else -> canvas(player, screen, config, session, input) + } + + if (context.isShowing(player) { it == screen }) context.render(player, screen) + } + + private fun canvas( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + input: CanvasInput, + ) { + val size = config.size() + when (input) { + is CanvasInput.Click -> when { + input.shift && !input.right -> { + val add = input.slot !in session.selectionFor(config.id) + session.select(config.id, listOf(input.slot), add = add) + } + + input.shift -> { + session.clearSelection() + if (CanvasOps.itemAt(config, input.slot) != null) { + session.select(config.id, CanvasOps.similar(config, input.slot)) + } + } + + !input.right -> leftClick(player, screen, config, session, input.slot) + else -> rightClick(player, screen, config, session, input.slot) + } + + is CanvasInput.Drag -> session.carried?.let { carried -> + val targets = input.slots.filter { it < size && it != carried.from } + if (change(player, config) { CanvasOps.stamp(it, targets, carried.item) }) placed(session, carried.item) + } + + is CanvasInput.Key -> if (input.slot < size) key(player, screen, config, session, input.slot, input.key) + is CanvasInput.Drop -> when { + input.slot >= size -> Unit + session.carried != null -> throwAway(player, config, session) + else -> erase(player, config, session, input.slot) + } + + is CanvasInput.Outside -> throwAway(player, config, session) + is CanvasInput.DoubleClick, is CanvasInput.Clone -> Unit + } + } + + private fun leftClick( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + slot: Int, + ) { + val carried = session.carried + val existing = CanvasOps.itemAt(config, slot) + when { + carried != null -> drop(player, config, session, carried, slot) + existing != null -> session.carried = Carried(existing, from = slot) + else -> { + val picker = InventoryEditorScreen.Picker(screen.inventoryId, InventoryPickerPurpose.NewItem) + context.navigate(player, picker) + } + } + } + + private fun rightClick( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + slot: Int, + ) { + val carried = session.carried + when { + carried != null -> { + val stamped = slot != carried.from && change(player, config) { CanvasOps.put(it, slot, carried.item) } + if (stamped) placed(session, carried.item) + } + + CanvasOps.itemAt(config, slot) != null -> openStudio(player, screen, session, config, slot) + else -> session.lastPlaced?.let { last -> change(player, config) { CanvasOps.put(it, slot, last) } } + } + } + + private fun drop( + player: NpcPlayer, + config: InventoryConfiguration, + session: InventoryEditorSession, + carried: Carried, + slot: Int, + ) { + val from = carried.from?.takeIf { stillAt(config, it, carried.item) } + when { + from == slot -> session.carried = null + from != null -> { + change(player, config) { CanvasOps.move(it, from, slot) } + session.carried = null + session.selection.remove(from) + } + + else -> { + val replaced = CanvasOps.itemAt(config, slot) + if (change(player, config) { CanvasOps.put(it, slot, carried.item) }) { + session.carried = replaced?.let { Carried(it, from = null) } + placed(session, carried.item) + } + } + } + } + + private fun placed(session: InventoryEditorSession, item: InventoryItemConfiguration) { + session.lastPlaced = item + item.liveGroup?.let { session.activeGroup = it } + } + + private fun key( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + slot: Int, + key: Int, + ) { + val existing = CanvasOps.itemAt(config, slot) + when (key) { + CanvasInput.OFFHAND_KEY, Toolbox.KEY_COPY -> when { + key == CanvasInput.OFFHAND_KEY && session.carried != null -> session.carried = null + existing != null -> session.carried = Carried(existing, from = null) + } + + Toolbox.KEY_STAMP -> session.lastPlaced?.let { last -> + change(player, config) { CanvasOps.stamp(it, targets(config, session, slot), last) } + } + + Toolbox.KEY_ERASE -> erase(player, config, session, slot) + Toolbox.KEY_LIVE -> { + val group = session.activeGroup?.let { template(config, it) } + if (group == null) player.sendInfo("Pick a live group first: left click its swatch in the toolbox.") + else change(player, config) { CanvasOps.stamp(it, targets(config, session, slot), group) } + } + + Toolbox.KEY_DECOR -> change(player, config) { CanvasOps.stamp(it, targets(config, session, slot), DECOR) } + Toolbox.KEY_STUDIO -> if (existing != null) openStudio(player, screen, session, config, slot) + in Toolbox.KEY_BINDINGS -> { + val carried = session.carried + val bound = session.keyBindings[key] + when { + carried != null -> { + session.keyBindings[key] = carried.item.copy(slot = -1) + player.sendInfo("Bound it to key {}.", key + 1) + } + + bound != null -> change(player, config) { + CanvasOps.stamp(it, targets(config, session, slot), bound) + } + } + } + } + } + + private fun erase(player: NpcPlayer, config: InventoryConfiguration, session: InventoryEditorSession, slot: Int) { + val targets = targets(config, session, slot) + if (change(player, config) { CanvasOps.remove(it, targets) }) session.selection.removeAll(targets.toSet()) + } + + private fun throwAway(player: NpcPlayer, config: InventoryConfiguration, session: InventoryEditorSession) { + val carried = session.carried ?: return + val from = carried.from + if (from != null && stillAt(config, from, carried.item)) { + change(player, config) { CanvasOps.remove(it, listOf(from)) } + session.selection.remove(from) + } + session.carried = null + } + + private fun toolbox( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + index: Int, + click: CanvasInput.Click, + ) { + when (index) { + Toolbox.HUB -> context.back(player) + + Toolbox.UNDO, Toolbox.REDO -> { + val moved = when (index) { + Toolbox.UNDO -> context.undo(player, config.id) + else -> context.redo(player, config.id) + } + if (moved && session.carried?.from != null) session.carried = null + if (session.mode == CanvasMode.LIVE) requestLive(player, screen) + } + + Toolbox.MODE -> { + session.carried = null + session.mode = if (session.mode == CanvasMode.LIVE) CanvasMode.DESIGN else CanvasMode.LIVE + session.live = null + if (session.mode == CanvasMode.LIVE) requestLive(player, screen) + } + + Toolbox.TITLE -> context.promptTitle(player, screen.inventoryId, screen) + Toolbox.ROWS -> rows(player, config, session, click) + Toolbox.FILL -> { + val empty = (0 until config.size()).filter { CanvasOps.itemAt(config, it) == null } + change(player, config) { CanvasOps.stamp(it, empty, session.carried?.item ?: DECOR) } + } + + Toolbox.BORDER -> { + val size = config.size() + val edge = (0 until size).filter { slot -> + val onEdge = slot < 9 || slot >= size - 9 || slot % 9 == 0 || slot % 9 == 8 + onEdge && CanvasOps.itemAt(config, slot) == null + } + change(player, config) { CanvasOps.stamp(it, edge, session.carried?.item ?: DECOR) } + } + + in Toolbox.RECENT -> { + session.recentMaterials.getOrNull(index - Toolbox.RECENT.first)?.let { material -> + session.carried = Carried(InventoryItemConfiguration(material = material), from = null) + } + } + + Toolbox.PICK -> { + val picker = InventoryEditorScreen.Picker(config.id, InventoryPickerPurpose.NewItem) + context.navigate(player, picker) + } + + Toolbox.HAND -> { + session.carried = null + session.handMode = true + } + + in Toolbox.SWATCHES -> LiveGroups.names(config).getOrNull(index - Toolbox.SWATCHES.first)?.let { group -> + session.activeGroup = group + if (click.right) { + context.navigate(player, InventoryEditorScreen.LiveGroup(config.id, group)) + } else { + template(config, group)?.let { session.carried = Carried(it, from = null) } + } + } + + Toolbox.NEW_GROUP -> { + val picker = InventoryEditorScreen.Picker(config.id, InventoryPickerPurpose.NewLiveGroup) + context.navigate(player, picker) + } + + Toolbox.PREVIOUS_ARROW, Toolbox.NEXT_ARROW -> { + val group = session.activeGroup?.takeIf { it in LiveGroups.names(config) } + if (group == null) { + player.sendInfo("Pick a live group first: left click its swatch in the toolbox.") + } else { + val previous = index == Toolbox.PREVIOUS_ARROW + session.carried = Carried( + InventoryItemConfiguration( + material = "ARROW", + name = if (previous) "<#e2e8f0>Previous page" else "<#e2e8f0>Next page", + liveGroup = group, + pageControl = if (previous) PageControl.PREVIOUS else PageControl.NEXT, + ), + from = null, + ) + } + } + } + } + + private fun rows( + player: NpcPlayer, + config: InventoryConfiguration, + session: InventoryEditorSession, + click: CanvasInput.Click, + ) { + val selection = session.selectionFor(config.id).toSet() + when { + click.shift -> { + val delta = if (click.right) -1 else 1 + val moved = change(player, config) { + CanvasOps.shift(it, rows = delta, columns = 0, slots = selection.ifEmpty { null }) + } + when { + !moved -> player.sendInfo("That would push items off the menu.") + selection.isNotEmpty() -> { + session.clearSelection() + session.select(config.id, selection.map { it + delta * 9 }) + } + } + } + + click.right -> if (!change(player, config) { CanvasOps.removeRow(it) }) { + player.sendInfo("Clear the bottom row first; one row is the minimum.") + } + + else -> if (!change(player, config) { CanvasOps.addRow(it) }) player.sendInfo("Six rows is the maximum.") + } + } + + private fun handMode(player: NpcPlayer, session: InventoryEditorSession, size: Int, input: CanvasInput) { + session.handMode = false + if (input !is CanvasInput.Click || input.slot < size) return + + val look = surface.inventoryItem(player, input.slot - size) ?: return + session.carried = Carried(look.copy(amount = 1), from = null) + session.usedMaterial(look.material) + } + + private fun livePaging( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + config: InventoryConfiguration, + session: InventoryEditorSession, + input: CanvasInput, + ) { + if (input !is CanvasInput.Click) return + val control = CanvasOps.itemAt(config, input.slot)?.takeIf { it.pageControl != null } + ?: return player.sendInfo("This is the live preview; switch the toolbox's eye back to Design to edit.") + val group = control.liveGroup ?: return + val page = session.livePages[group] ?: 0 + val turned = if (control.pageControl == PageControl.PREVIOUS) page - 1 else page + 1 + session.livePages[group] = turned.coerceAtLeast(0) + requestLive(player, screen) + } + + private fun requestLive(player: NpcPlayer, screen: InventoryEditorScreen.Canvas) { + val session = context.session(player) + val serial = ++session.liveSerial + session.live = session.live?.let { CanvasRender.LiveState(it.slots, loading = true, cloudDown = it.cloudDown) } + ?: CanvasRender.LiveState(emptyMap(), loading = true, cloudDown = false) + + val pages = session.livePages.toMap() + BackgroundTasks.launch { + val config = context.inventoryRepository.find(screen.inventoryId) ?: return@launch + val targets = InventoryHubMenuBuilder.usedBy(context, config.id).firstOrNull()?.targetServers.orEmpty() + val result = try { + Result.success(context.inventoryViews.preview(config, player, InventoryOpenContext(targets), pages)) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Result.failure(exception) + } + + context.runSync { + if (session.liveSerial != serial || session.mode != CanvasMode.LIVE) return@runSync + val preview = result.getOrNull() + session.live = CanvasRender.LiveState( + preview?.slots.orEmpty(), + loading = false, + cloudDown = preview == null, + ) + preview?.pages?.let { session.livePages.putAll(it) } + val showing = context.isShowing(player) { it == screen } + if (showing && !context.chatInputPrompts.isPending(player.uniqueId)) context.render(player, screen) + } + } + } + + private fun openStudio( + player: NpcPlayer, + screen: InventoryEditorScreen.Canvas, + session: InventoryEditorSession, + config: InventoryConfiguration, + slot: Int, + ) { + val selection = session.selectionFor(config.id) + val slots = if (slot in selection) listOf(slot) + selection.filter { it != slot } else listOf(slot) + context.navigate(player, InventoryEditorScreen.Studio(screen.inventoryId, slots)) + } + + private fun targets(config: InventoryConfiguration, session: InventoryEditorSession, slot: Int): List<Int> { + val selection = session.selectionFor(config.id) + return if (slot in selection) selection.toList() else listOf(slot) + } + + private fun template(config: InventoryConfiguration, group: String): InventoryItemConfiguration? = + LiveGroups.members(config, group).firstOrNull { it.pageControl == null } + ?.deepCopy()?.copy(slot = -1, live = null, whenEmpty = null) + + private fun stillAt(config: InventoryConfiguration, slot: Int, item: InventoryItemConfiguration): Boolean = + CanvasOps.itemAt(config, slot)?.let { CanvasOps.sameLook(it, item) } == true + + private fun change( + player: NpcPlayer, + config: InventoryConfiguration, + mutate: (InventoryConfiguration) -> InventoryConfiguration?, + ): Boolean = context.change(player, config.id, mutate) + + private companion object { + val DECOR = InventoryItemConfiguration(material = Toolbox.DECOR_MATERIAL, name = " ", hideTooltip = true) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasRender.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasRender.kt new file mode 100644 index 0000000..3acf228 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/CanvasRender.kt @@ -0,0 +1,294 @@ +package app.simplecloud.npc.common.editor.inventory.canvas + +import app.simplecloud.npc.common.editor.canvas.CanvasView +import app.simplecloud.npc.common.editor.inventory.CanvasMode +import app.simplecloud.npc.common.editor.inventory.InventoryEditorSession +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.ui.Palette +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.view.ItemIconResolver +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.PageControl + +object CanvasRender { + + private const val MAX_CARRIED = 99 + + class LiveState(val slots: Map<Int, NpcItem>, val loading: Boolean, val cloudDown: Boolean) + + fun view(config: InventoryConfiguration, session: InventoryEditorSession): CanvasView { + val live = session.live + val items = if (session.mode == CanvasMode.LIVE && live != null) live.slots else designItems(config, session) + + return CanvasView( + title = config.title, + size = config.size(), + items = items, + toolbox = toolbox(config, session, live), + carried = session.carried?.let { carriedIcon(config, it.item) }, + showInventory = session.handMode, + ) + } + + fun designItems(config: InventoryConfiguration, session: InventoryEditorSession): Map<Int, NpcItem> { + val selection = session.selectionFor(config.id) + val lifted = session.carried?.from + val positions = LiveGroups.names(config).associateWith { LiveGroups.contentSlots(config, it) } + + return config.items.filter { it.slot in 0 until config.size() }.associate { item -> + if (item.slot == lifted) return@associate item.slot to liftedMarker() + + val base = ItemIconResolver.icon(item) + val icon = item.liveGroup?.let { group -> + val (_, hex) = LiveGroups.colorOf(config, group) + val label = base.name.ifBlank { Ui.pretty(item.material) } + when (item.pageControl) { + PageControl.PREVIOUS -> base.copy(name = "<$hex>◀ <white>$label") + PageControl.NEXT -> base.copy(name = "<$hex>▶ <white>$label") + else -> base.copy( + name = "<$hex>● <white>$label", + amount = ((positions[group]?.indexOf(item.slot) ?: 0) + 1).coerceIn(1, 64), + ) + } + } ?: base + + val selected = item.slot in selection + item.slot to icon.copy( + glowing = icon.glowing || selected, + hideTooltip = false, + lore = icon.lore + footer(item, selected), + ) + } + } + + private fun footer(item: InventoryItemConfiguration, selected: Boolean): List<String> = listOfNotNull( + "", + item.liveGroup?.let { group -> + if (item.pageControl != null) { + "<hnt>Page ${item.pageControl} of <val>$group" + } else { + "<hnt>Live slot of <val>$group" + } + }, + "<info>● Selected".takeIf { selected }, + "<hnt>Slot ${item.slot} · <key>Left <hnt>lift · <key>Right <hnt>Studio · <key>Q <hnt>delete", + ).map(Palette::expand) + + private fun liftedMarker(): NpcItem = Ui.item( + "LIGHT_GRAY_STAINED_GLASS_PANE", + "<hnt>Lifted from here", + listOf("<hnt>Drop it anywhere, or press <key>F <hnt>to put it back."), + ) + + private fun carriedIcon(config: InventoryConfiguration, item: InventoryItemConfiguration): NpcItem { + val free = (0 until config.size()).count { slot -> config.items.none { it.slot == slot } } + val icon = ItemIconResolver.icon(item) + + return icon.copy( + name = icon.name.ifBlank { Ui.pretty(item.material) }, + amount = free.coerceIn(1, MAX_CARRIED), + maxStackSize = MAX_CARRIED, + hideTooltip = false, + ) + } + + fun toolbox(config: InventoryConfiguration, session: InventoryEditorSession, live: LiveState?): Map<Int, NpcItem> { + val history = session.history(config.id) + val selection = session.selectionFor(config.id) + val tools = mutableMapOf<Int, NpcItem>() + + tools[Toolbox.HUB] = Ui.back().copy(lore = listOf(Palette.expand("<hnt>To the menu's hub. Esc does the same."))) + tools[Toolbox.UNDO] = Ui.item( + if (history.canUndo) "CLOCK" else "GRAY_DYE", + "<ttl>Undo", + listOf("<bd><val>${history.undoCount} <bd>steps", "", "<key>Left <hnt>Undo the last change"), + ) + tools[Toolbox.REDO] = Ui.item( + if (history.canRedo) "CLOCK" else "GRAY_DYE", + "<ttl>Redo", + listOf("<bd><val>${history.redoCount} <bd>steps", "", "<key>Left <hnt>Redo"), + ) + tools[Toolbox.MODE] = if (session.mode == CanvasMode.LIVE) { + Ui.item( + "SPYGLASS", + "<ttl>Live Preview <on>ON", + listOfNotNull( + "<bd>What players see right now.", + when { + live == null || live.loading -> "<hnt>Loading the cloud data..." + live.cloudDown -> "<warn>The cloud is unreachable; live slots stay empty." + else -> null + }, + "<hnt>Page arrows turn pages here.", + "", + "<key>Left <hnt>Back to Design", + ), + glowing = true, + ) + } else { + Ui.item( + "ENDER_EYE", + "<ttl>Design", + listOf("<bd>Templates as you edit them.", "", "<key>Left <hnt>Show the live preview"), + ) + } + tools[Toolbox.TITLE] = Ui.item( + "NAME_TAG", + "<ttl>Title", + listOf("<bd>Now: " + config.title, "", "<key>Left <hnt>Type a new one in chat"), + ) + tools[Toolbox.ROWS] = Ui.item( + "LADDER", + "<ttl>Rows <val>${config.rows}", + listOf( + "<key>Left <hnt>Add a row <key>Right <hnt>Remove the bottom row", + "<key>Shift+Left <hnt>Move ${if (selection.isEmpty()) "everything" else "the selection"} down", + "<key>Shift+Right <hnt>Move it up", + ), + ) + tools[Toolbox.FILL] = Ui.item( + Toolbox.DECOR_MATERIAL, + "<ttl>Fill Empty Slots", + listOf("<bd>With what you carry, or a gray pane.", "", "<key>Left <hnt>Fill"), + ) + tools[Toolbox.BORDER] = Ui.item( + "BLACK_STAINED_GLASS_PANE", + "<ttl>Border", + listOf( + "<bd>Frames the empty edge slots with what", + "<bd>you carry, or a gray pane.", + "", + "<key>Left <hnt>Draw", + ), + ) + tools[Toolbox.HELP] = Ui.item("BOOK", "<ttl>Controls", HELP) + + session.recentMaterials.zip(Toolbox.RECENT.toList()).forEach { (material, index) -> + tools[index] = Ui.item( + material, + "<ttl>${Ui.pretty(material)}", + listOf("<hnt>Recently used", "", "<key>Left <hnt>Pick up a new one"), + ) + } + tools[Toolbox.PICK] = + Ui.item("CHEST", "<ttl>Pick A Material...", listOf("<bd>Search every material.", "", "<key>Left <hnt>Open")) + tools[Toolbox.HAND] = Ui.item( + "LEATHER", + "<ttl>From Your Inventory", + listOf( + "<bd>Shows your own items here; click one", + "<bd>to copy its look onto the cursor.", + "<hnt>Styled with an anvil or another plugin? Works.", + "", + "<key>Left <hnt>Show my items", + ), + ) + + LiveGroups.names(config).zip(Toolbox.SWATCHES.toList()).forEach { (group, index) -> + tools[index] = swatch(config, session, group) + } + tools[Toolbox.NEW_GROUP] = Ui.addHead( + "New Live Group", + listOf("<bd>Servers, groups, ... that fill", "<bd>slots on their own.", "", "<key>Left <hnt>Pick a source"), + ) + val active = session.activeGroup?.takeIf { it in LiveGroups.names(config) } + tools[Toolbox.PREVIOUS_ARROW] = arrow("Previous Page", active) + tools[Toolbox.NEXT_ARROW] = arrow("Next Page", active) + + tools[Toolbox.HOTBAR + Toolbox.KEY_STAMP] = key( + 1, + "PAPER", + "Stamp", + session.lastPlaced?.let { "<bd>Places <val>${Ui.pretty(it.material)}" } + ?: "<hnt>Places the last item you dropped.", + ) + tools[Toolbox.HOTBAR + Toolbox.KEY_ERASE] = + key(2, "BARRIER", "Erase", "<bd>Deletes the item (or the selection).") + tools[Toolbox.HOTBAR + Toolbox.KEY_COPY] = key(3, "SHEARS", "Copy", "<bd>Puts a copy on your cursor.") + tools[Toolbox.HOTBAR + Toolbox.KEY_LIVE] = key( + 4, + "LIME_STAINED_GLASS_PANE", + "Live", + active?.let { "<bd>Adds a slot to <val>$it" } ?: "<hnt>Pick a live group first.", + ) + tools[Toolbox.HOTBAR + Toolbox.KEY_DECOR] = + key(5, Toolbox.DECOR_MATERIAL, "Decor", "<bd>Places a gray pane without tooltip.") + tools[Toolbox.HOTBAR + Toolbox.KEY_STUDIO] = key(6, "SMITHING_TABLE", "Studio", "<bd>Opens the Item Studio.") + Toolbox.KEY_BINDINGS.forEach { keyIndex -> + val bound = session.keyBindings[keyIndex] + tools[Toolbox.HOTBAR + keyIndex] = if (bound == null) { + key( + keyIndex + 1, + "LIGHT_GRAY_STAINED_GLASS_PANE", + "Free Key", + "<hnt>Carry an item and press ${keyIndex + 1} to bind it.", + ) + } else { + val icon = ItemIconResolver.icon(bound) + val label = icon.name.ifBlank { Ui.pretty(bound.material) } + icon.copy( + name = Palette.expand("<key>${keyIndex + 1} <ttl>") + label, + lore = listOf( + "<bd>Hover a slot and press ${keyIndex + 1}", + "<bd>to place it.", + ).map(Palette::expand), + hideTooltip = false, + ) + } + } + + return tools + } + + private fun swatch(config: InventoryConfiguration, session: InventoryEditorSession, group: String): NpcItem { + val (color, _) = LiveGroups.colorOf(config, group) + val source = LiveGroups.source(config, group) + val slots = LiveGroups.contentSlots(config, group).size + val active = session.activeGroup == group + val sourceLabel = source?.let { LiveGroups.describe(it.type).label } ?: "<err>none" + val arrowsMissing = LiveGroups.lacksPageControls(config, group) + + return Ui.item( + "${color}_STAINED_GLASS_PANE", + "<ttl>$group", + listOfNotNull( + "<bd>Source <val>$sourceLabel", + "<bd><val>$slots <bd>slots", + "<warn>No page arrows: extra entries are hidden.".takeIf { arrowsMissing }, + "<on>Active: arrows and key 4 use it".takeIf { active }, + "", + "<key>Left <hnt>Pick up a slot to paint", + "<key>Right <hnt>Group settings", + ), + glowing = active, + amount = slots.coerceIn(1, 64), + ) + } + + private fun arrow(label: String, group: String?): NpcItem = Ui.item( + "ARROW", + "<ttl>$label", + if (group == null) listOf("<hnt>Pick a live group first.") else listOf( + "<bd>Turns the pages of <val>$group", + "", + "<key>Left <hnt>Pick up", + ), + ) + + private fun key(number: Int, material: String, label: String, line: String): NpcItem = + Ui.item(material, "<key>$number <ttl>$label", listOf(line, "", "<hnt>Hover a slot above and press $number.")) + + private val HELP = listOf( + "<key>Left <hnt>lift an item, click again to drop it", + "<key>Right <hnt>while carrying stamps a copy", + "<key>Drag <hnt>while carrying paints every slot", + "<key>Right <hnt>on an item opens the Item Studio", + "<key>Shift+Left <hnt>select <key>Shift+Right <hnt>select alike", + "<key>Q <hnt>delete <key>F <hnt>copy / put back", + "<key>1-6 <hnt>hotbar tools <key>7-9 <hnt>your own items", + "<hnt>Click outside the window to throw away.", + "<hnt>Every change saves at once; Undo is above.", + ) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/Toolbox.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/Toolbox.kt new file mode 100644 index 0000000..b6c1912 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/canvas/Toolbox.kt @@ -0,0 +1,35 @@ +package app.simplecloud.npc.common.editor.inventory.canvas + +object Toolbox { + const val HUB = 0 + const val UNDO = 1 + const val REDO = 2 + const val MODE = 3 + const val TITLE = 4 + const val ROWS = 5 + const val FILL = 6 + const val BORDER = 7 + const val HELP = 8 + + val RECENT = 9..15 + const val PICK = 16 + const val HAND = 17 + + val SWATCHES = 18..23 + const val NEW_GROUP = 24 + const val PREVIOUS_ARROW = 25 + const val NEXT_ARROW = 26 + + const val HOTBAR = 27 + + const val KEY_STAMP = 0 + const val KEY_ERASE = 1 + const val KEY_COPY = 2 + const val KEY_LIVE = 3 + const val KEY_DECOR = 4 + const val KEY_STUDIO = 5 + + val KEY_BINDINGS = 6..8 + + const val DECOR_MATERIAL = "GRAY_STAINED_GLASS_PANE" +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryConfirms.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryConfirms.kt new file mode 100644 index 0000000..e1381b8 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryConfirms.kt @@ -0,0 +1,89 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.core.ConfirmGate +import app.simplecloud.npc.common.editor.core.ConfirmSpec +import app.simplecloud.npc.common.editor.inventory.InventoryConfirmPurpose +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object InventoryConfirms { + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + purpose: InventoryConfirmPurpose, + ): EditorMenu { + val screen = InventoryEditorScreen.Confirm(config.id, purpose) + val spec = when (purpose) { + InventoryConfirmPurpose.DeleteInventory -> { + val users = InventoryHubMenuBuilder.usedBy(context, config.id).size + + InventoryHubMenuBuilder.menusOpening(context, config.id).size + val usersLine = if (users == 0) { + "<hnt>Nothing opens it." + } else { + "<warn>$users NPC(s) or menus open it and will open nothing." + } + ConfirmSpec<InventoryEditorScreen>( + title = Ui.title("Delete Menu?", config.id), + screen = screen, + verb = "Delete", + noun = "deletion", + head = Ui.item( + "CHEST", + "<err><b>Delete ${config.id}?", + listOf( + "<bd><val>${config.items.size} <bd>items · " + + "<val>${config.liveGroups().size} <bd>live groups", + usersLine, + "", + "<err>The file goes. There is no undo.", + ), + glowing = true, + ), + fire = { + context.inventoryManager.delete(config.id) + player.sendSuccess("Deleted menu {}.", config.id) + context.close(player) + }, + ) + } + + is InventoryConfirmPurpose.DeleteLiveGroup -> { + val slots = LiveGroups.members(config, purpose.group).size + ConfirmSpec<InventoryEditorScreen>( + title = Ui.title("Delete Live Group?", purpose.group), + screen = screen, + verb = "Delete", + noun = "deletion", + head = Ui.item( + "PAPER", + "<err><b>Delete live group ${purpose.group}?", + listOf( + "<bd><val>$slots <bd>slots, page arrows included.", + "", + "<hnt>Undo on the canvas brings it back.", + ), + glowing = true, + ), + fire = { + context.change(player, config.id) { LiveGroups.delete(it, purpose.group) } + val stack = context.session(player).stack.toList() + val anchor = stack.indexOfLast { + it is InventoryEditorScreen.Canvas || it is InventoryEditorScreen.Hub + } + context.back(player, if (anchor == -1) 1 else stack.lastIndex - anchor) + }, + ) + } + } + + return ConfirmGate.build(context, player, spec) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryHubMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryHubMenuBuilder.kt new file mode 100644 index 0000000..a421c54 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryHubMenuBuilder.kt @@ -0,0 +1,252 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.inventory.InventoryConfirmPurpose +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +object InventoryHubMenuBuilder { + private const val SIZE = 36 + + private const val SNAPSHOT_SLOT = 4 + private const val CANVAS_SLOT = 11 + private const val TITLE_SLOT = 13 + private const val RENAME_SLOT = 15 + private const val USED_BY_SLOT = 20 + private const val DUPLICATE_SLOT = 22 + private const val DELETE_SLOT = 24 + private const val UNDO_SLOT = 27 + private const val CLOSE_SLOT = 31 + + private val HEADER_FILLER_SLOTS = (0..3) + (5..8) + private const val DEFAULT_TITLE = "<#0ea5e9>Menu" + private const val USED_BY_LISTED = 6 + + fun usedBy(context: InventoryEditorContext, inventoryId: String): List<NpcConfig> = + context.npcRepository.findAll() + .filter { npc -> npc.actions.any { it.openInventory.equals(inventoryId, true) } } + .sortedBy { it.id.lowercase() } + + fun menusOpening(context: InventoryEditorContext, inventoryId: String): List<InventoryConfiguration> = + context.inventoryRepository.findAll() + .filter { menu -> !menu.id.equals(inventoryId, true) && opensMenu(menu, inventoryId) } + + private fun opensMenu(menu: InventoryConfiguration, inventoryId: String): Boolean = + actionsOf(menu).any { it.openInventory.equals(inventoryId, true) } + + private fun actionsOf(menu: InventoryConfiguration): List<NpcConfig.ActionConfiguration> = + menu.items.flatMap { item -> item.actions + item.live?.entries.orEmpty().flatMap { it.actions } } + + fun build(context: InventoryEditorContext, player: NpcPlayer, config: InventoryConfiguration): EditorMenu { + val screen = InventoryEditorScreen.Hub(config.id) + val users = usedBy(context, config.id) + val menus = menusOpening(context, config.id) + val history = context.session(player).history(config.id) + val pane = Pane(SIZE) + + pane[SNAPSHOT_SLOT] = Ui.item( + "CHEST", + "<ttl>Menu <val>${config.id}", + listOf( + "<bd>Title " + config.title, + "<bd>Rows <val>${config.rows} <hnt>· ${config.size()} slots", + "<bd><val>${config.items.count { it.liveGroup == null }} <bd>items · " + + "<val>${config.liveGroups().size} <bd>live groups", + "<bd>Opened by <val>${users.size} <bd>NPC${if (users.size == 1) "" else "s"} · " + + "<val>${menus.size} <bd>menu${if (menus.size == 1) "" else "s"}", + ), + ) + + pane.left( + CANVAS_SLOT, + Ui.item( + "CRAFTING_TABLE", + "<ttl>Edit On The Canvas", + listOf( + "<bd>Menu editor", + "", + "<key>Left <info>Open", + ), + ), + ) { context.navigate(player, InventoryEditorScreen.Canvas(config.id)) } + + pane.on( + TITLE_SLOT, + Ui.item( + "NAME_TAG", + "<ttl>Title", + listOf( + "<bd>Now: " + config.title, + "<hnt>MiniMessage, e.g. <#0ea5e9><bold>Servers", + "", + "<key>Left <hnt>Type a new one in chat", + "<key>Q <hnt>Reset to the default", + ), + ), + ) { click -> + when (click) { + MenuClick.LEFT -> context.promptTitle(player, config.id, InventoryEditorScreen.Hub(config.id)) + MenuClick.DROP -> if (context.change(player, config.id) { it.copy(title = DEFAULT_TITLE) }) { + context.render(player, screen) + } + + else -> Unit + } + } + + pane.left( + RENAME_SLOT, + Ui.item( + "ANVIL", + "<ttl>Rename", + listOf( + "<bd>Id <val>${config.id}", + "", + "<key>Left <hnt>Type a new id in chat", + ), + ), + ) { promptRename(context, player, config) } + + pane.left( + USED_BY_SLOT, + Ui.item( + "ARMOR_STAND", + "<ttl>Used By", + if (users.isEmpty()) { + listOf("<off>No NPC opens this menu yet.") + } else { + users.take(USED_BY_LISTED).map { "<bd>· <val>${it.id}" } + + listOfNotNull( + (users.size - USED_BY_LISTED).takeIf { it > 0 }?.let { "<hnt>and $it more" }, + "", + "<key>Left <info>Jump to one of them", + ) + }, + ), + ) { + if (users.isNotEmpty()) { + context.navigate(player, InventoryEditorScreen.Picker(config.id, InventoryPickerPurpose.UsedBy)) + } + } + + pane.left( + DUPLICATE_SLOT, + Ui.item( + "MAP", + "<ttl>Duplicate", + listOf("<bd>A copy with a new id, opened here.", "", "<key>Left <hnt>Duplicate"), + ), + ) { duplicate(context, player, config) } + + pane.left( + DELETE_SLOT, + Ui.item( + "TNT", + "<err>Delete This Menu", + listOf( + if (users.isEmpty()) "<hnt>No NPC opens it." else "<warn>${users.size} NPC(s) still open it.", + "", + "<key>Left <hnt>Asks first", + ), + ), + ) { + val confirm = InventoryEditorScreen.Confirm(config.id, InventoryConfirmPurpose.DeleteInventory) + context.navigate(player, confirm) + } + + pane.on( + UNDO_SLOT, + Ui.item( + if (history.canUndo || history.canRedo) "CLOCK" else "GRAY_DYE", + "<ttl>Undo / Redo", + listOf( + "<bd><val>${history.undoCount} <bd>steps back · <val>${history.redoCount} <bd>forward", + "", + "<key>Left <hnt>Undo <key>Right <hnt>Redo", + ), + ), + ) { click -> + val moved = when (click) { + MenuClick.LEFT -> context.undo(player, config.id) + MenuClick.RIGHT -> context.redo(player, config.id) + else -> false + } + if (moved) context.render(player, screen) + } + + pane.left(CLOSE_SLOT, Ui.close()) { context.close(player) } + pane.fill(HEADER_FILLER_SLOTS) + pane.fillNavRow() + + return pane.menu(Ui.title("⚡ Menu Editor", config.id)) + } + + private fun promptRename(context: InventoryEditorContext, player: NpcPlayer, config: InventoryConfiguration) { + context.chatPrompt( + player, + Prompt( + title = "Rename Menu", + instruction = "Type the new id in chat.", + current = config.id, + onCancel = { context.render(player, InventoryEditorScreen.Hub(config.id)) }, + onSubmit = { input -> + val newId = input.trim() + when { + !ConfigIds.isValid(newId) -> PromptResult.Rejected(Component.text(ConfigIds.DESCRIPTION)) + newId.equals(config.id, true) -> + PromptResult.Rejected(Component.text("That is the current id.")) + + context.inventoryRepository.find(newId) != null -> + PromptResult.Rejected(Component.text("A menu called $newId already exists.")) + + else -> { + rename(context, config.id, newId) + player.sendSuccess("Renamed menu {} to {}.", config.id, newId) + context.reset(player, listOf(InventoryEditorScreen.Hub(newId))) + PromptResult.Accepted + } + } + }, + ), + ) + } + + private fun rename(context: InventoryEditorContext, oldId: String, newId: String) { + val current = context.inventoryRepository.find(oldId) ?: return + val others = menusOpening(context, oldId) + context.inventoryRepository.save(relinked(current.copy(id = newId), oldId, newId)) + context.inventoryRepository.delete(current.id) + others.forEach { context.inventoryRepository.save(relinked(it, oldId, newId)) } + + usedBy(context, oldId).forEach { npc -> + npc.actions.filter { it.openInventory.equals(oldId, true) }.forEach { it.openInventory = newId } + context.npcRepository.save(npc) + } + } + + private fun relinked(menu: InventoryConfiguration, oldId: String, newId: String): InventoryConfiguration = + menu.apply { + actionsOf(this).filter { it.openInventory.equals(oldId, true) }.forEach { it.openInventory = newId } + } + + private fun duplicate(context: InventoryEditorContext, player: NpcPlayer, config: InventoryConfiguration) { + val id = generateSequence(1) { it + 1 } + .map { if (it == 1) "${config.id}-copy" else "${config.id}-copy-$it" } + .first { context.inventoryRepository.find(it) == null } + context.inventoryRepository.save(config.deepCopy().copy(id = id)) + player.sendSuccess("Duplicated menu {} as {}.", config.id, id) + context.reset(player, listOf(InventoryEditorScreen.Hub(id))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryPickers.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryPickers.kt new file mode 100644 index 0000000..c8b985f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/InventoryPickers.kt @@ -0,0 +1,387 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerOptions +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.core.PickerSpec +import app.simplecloud.npc.common.editor.core.PickerTemplate +import app.simplecloud.npc.common.editor.core.SoundTuning +import app.simplecloud.npc.common.editor.core.pageKey +import app.simplecloud.npc.common.editor.inventory.Carried +import app.simplecloud.npc.common.editor.inventory.CommonMaterials +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryEditorSession +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.inventory.StudioTargets +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.ItemClickActions +import app.simplecloud.npc.common.inventory.source.GroupServersSource +import app.simplecloud.npc.core.config.NpcConfig.ActionConfiguration +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object InventoryPickers { + + private val CATEGORY_SLOTS = listOf(27, 28, 29, 30, 32, 33, 34, 35, 36) + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + purpose: InventoryPickerPurpose, + ): EditorMenu { + val screen = InventoryEditorScreen.Picker(config.id, purpose) + val spec = when (purpose) { + InventoryPickerPurpose.NewItem, + is InventoryPickerPurpose.Material, + is InventoryPickerPurpose.LookMaterial, + -> materials(context, player, config, screen, purpose) + + InventoryPickerPurpose.NewLiveGroup -> sourceTypes(context, player, config, screen, null) + is InventoryPickerPurpose.LiveSourceType -> sourceTypes(context, player, config, screen, purpose.group) + is InventoryPickerPurpose.LiveSourceGroup -> sourceGroups(context, player, config, screen, purpose.group) + InventoryPickerPurpose.UsedBy -> usedBy(context, player, config, screen) + is InventoryPickerPurpose.OpenInventory -> openInventory(context, player, config, screen, purpose) + is InventoryPickerPurpose.SendToServer -> sendToServer(context, player, config, screen, purpose) + is InventoryPickerPurpose.Sound -> sounds(context, player, config, screen, purpose) + } + + return PickerTemplate.build(context, player, spec) + } + + private fun materials( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + purpose: InventoryPickerPurpose, + ): PickerSpec<InventoryEditorScreen> { + val session = context.session(player) + val catalog = context.materialCatalog() + val known = catalog.toSet() + val filter = (session.pickerResult as? PickerResult.Filtered)?.typed + val current = when (purpose) { + is InventoryPickerPurpose.Material -> purpose.slots.firstOrNull()?.let { slot -> + config.items.firstOrNull { it.slot == slot }?.material + } + + is InventoryPickerPurpose.LookMaterial -> + LiveGroups.source(config, purpose.group)?.lookFor(purpose.state)?.material + + else -> null + } + + val listed = when { + filter != null -> catalog.filter { it.contains(filter, ignoreCase = true) } + session.materialCategory == InventoryEditorSession.RECENT_CATEGORY -> + session.recentMaterials.ifEmpty { CommonMaterials.COMMON.take(RECENT_FALLBACK) } + + session.materialCategory == InventoryEditorSession.ALL_CATEGORY -> catalog.sorted() + else -> CommonMaterials.CATEGORIES.firstOrNull { it.first == session.materialCategory }?.second.orEmpty() + }.filter { it in known } + + fun pick(material: String) { + val canonical = catalog.firstOrNull { it.equals(material, true) } ?: return + session.usedMaterial(canonical) + session.pickerResult = null + when (purpose) { + is InventoryPickerPurpose.Material -> context.change(player, config.id) { fresh -> + StudioTargets.edit(fresh, purpose.slots.toSet()) { it.copy(material = canonical) } + } + + is InventoryPickerPurpose.LookMaterial -> context.change(player, config.id) { fresh -> + LiveGroups.setLook(fresh, purpose.group, purpose.state) { it.copy(material = canonical) } + } + + else -> session.carried = Carried(InventoryItemConfiguration(material = canonical), from = null) + } + context.back(player) + } + + val section = if (purpose is InventoryPickerPurpose.NewItem) "New Item" else "Material" + + return PickerSpec( + title = "$section · ${config.id}", + screen = screen, + options = listed.map { material -> + val selected = material == current + PickerOption( + material, + Ui.option(material, Ui.pretty(material), listOf("<hnt>$material", ""), selected = selected), + available = !selected, + ) + }, + onPick = ::pick, + typeHint = PickerOptions.MATERIAL_TYPE_HINT, + onTyped = { typed -> + PickerOptions.search(catalog, typed, "material") ?: run { + pick(typed) + null + } + }, + extras = { pane -> categoryChips(context, player, screen, pane) }, + filteredNoun = "materials", + ) + } + + private fun categoryChips( + context: InventoryEditorContext, + player: NpcPlayer, + screen: InventoryEditorScreen.Picker, + pane: Pane, + ) { + val session = context.session(player) + val filtered = session.pickerResult is PickerResult.Filtered + val names = listOf(InventoryEditorSession.RECENT_CATEGORY) + CommonMaterials.CATEGORIES.map { it.first } + + InventoryEditorSession.ALL_CATEGORY + + names.zip(CATEGORY_SLOTS).forEach { (name, slot) -> + val selected = !filtered && session.materialCategory == name + pane.left(slot, Ui.option("NAME_TAG", name, emptyList(), selected)) { + session.materialCategory = name + session.pickerResult = null + session.pages[pageKey(screen)] = 0 + context.render(player, screen) + } + } + } + + private fun sourceTypes( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + group: String?, + ): PickerSpec<InventoryEditorScreen> { + val current = group?.let { LiveGroups.source(config, it)?.type } + return PickerSpec( + title = if (group == null) "New Live Group · ${config.id}" else "Source · $group", + screen = screen, + options = context.dataSourceTypes().map { type -> + val info = LiveGroups.describe(type) + PickerOption( + type, + Ui.option(info.icon, info.label, info.lines.map { "<bd>$it" } + "", selected = type == current), + available = type != current, + ) + }, + typeHint = null, + typingUnavailable = "Sources come from the plugin and its addons.", + onPick = { type -> + if (group == null) { + val name = LiveGroups.freeName(config, type) + val session = context.session(player) + session.carried = Carried(LiveGroups.newSlot(name, type), from = null) + session.activeGroup = name + } else { + context.change(player, config.id) { + LiveGroups.setSource(it, group) { source -> source.copy(type = type) } + } + } + context.back(player) + }, + ) + } + + private fun sourceGroups( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + group: String, + ): PickerSpec<InventoryEditorScreen> { + val current = LiveGroups.source(config, group)?.group ?: GroupServersSource.TARGET_GROUP + val targetOption = PickerOption( + GroupServersSource.TARGET_GROUP, + Ui.option( + "ARMOR_STAND", + "The NPC's Target", + listOf("<bd>Whatever group the NPC that", "<bd>opened the menu targets.", ""), + selected = current == GroupServersSource.TARGET_GROUP, + ), + available = current != GroupServersSource.TARGET_GROUP, + ) + val groups = context.liveData.knownTargets().filter { it.group }.map { target -> + PickerOption( + target.name, + Ui.option( + "PAPER", + target.name, + listOf("<bd>Live <val>${context.liveData.summaryOf(target.name).liveServers} <bd>servers", ""), + selected = target.name.equals(current, true), + ), + available = !target.name.equals(current, true), + ) + } + + return PickerSpec( + title = "Group · $group", + screen = screen, + options = listOf(targetOption) + groups, + onPick = { value -> + context.change(player, config.id) { + LiveGroups.setSource(it, group) { source -> source.copy(group = value) } + } + context.back(player) + }, + ) + } + + private fun usedBy( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + ): PickerSpec<InventoryEditorScreen> = PickerSpec( + title = "Used By · ${config.id}", + screen = screen, + options = InventoryHubMenuBuilder.usedBy(context, config.id).map { npc -> + PickerOption( + npc.id, + Ui.item("ARMOR_STAND", "<ttl>${npc.id}", listOf("", "<key>Left <info>Open its editor")), + ) + }, + typeHint = null, + typingUnavailable = "Only NPCs that open this menu are listed.", + onPick = { id -> + val npc = context.npcRepository.find(id) ?: return@PickerSpec + context.close(player) + context.editors.openNpc(player, npc) + }, + ) + + private fun openInventory( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + purpose: InventoryPickerPurpose.OpenInventory, + ): PickerSpec<InventoryEditorScreen> = PickerSpec( + title = "Open Menu · ${config.id}", + screen = screen, + options = PickerOptions.menus( + context.inventoryRepository.findAll(), + currentAction(config, purpose.slots, purpose.interaction)?.openInventory, + ), + typeHint = null, + typingUnavailable = "Create the menu first, then pick it here.", + onPick = { id -> + editClicks(context, player, config, purpose.slots, purpose.interaction) { it.openInventory = id } + context.back(player) + }, + ) + + private fun sendToServer( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + purpose: InventoryPickerPurpose.SendToServer, + ): PickerSpec<InventoryEditorScreen> { + val names = context.liveData.servers().map { it.name } + + fun apply(server: String) { + val canonical = PickerOptions.canonical(names, server) + editClicks(context, player, config, purpose.slots, purpose.interaction) { it.sendToServer = canonical } + context.back(player) + } + + return PickerSpec( + title = "Send To Server · ${config.id}", + screen = screen, + options = PickerOptions.servers( + context.liveData.servers(), + currentAction(config, purpose.slots, purpose.interaction)?.sendToServer, + ), + onPick = ::apply, + onTyped = { typed -> + PickerOptions.match(names, typed, "No server by that name.").also { result -> + if (result == null) apply(typed) + } + }, + ) + } + + private fun sounds( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Picker, + purpose: InventoryPickerPurpose.Sound, + ): PickerSpec<InventoryEditorScreen> { + val session = context.session(player) + val catalog = context.catalogs.sounds() + val filter = (session.pickerResult as? PickerResult.Filtered)?.typed + val action = currentAction(config, purpose.slots, purpose.interaction) + val options = action?.playSoundOptions ?: SoundTuning.DEFAULTS + + fun pick(sound: String) { + val canonical = PickerOptions.canonical(catalog, sound) + val filtered = session.pickerResult is PickerResult.Filtered + editClicks(context, player, config, purpose.slots, purpose.interaction) { it.playSound = canonical } + if (filtered) { + context.render(player, screen) + } else { + val result = PickerResult.Added(canonical, "Tune volume and pitch below, or go Back.") + PickerTemplate.showResult(context, player, screen, result) + } + } + + return PickerSpec( + title = "Pick Sound · ${config.id}", + screen = screen, + options = PickerOptions.sounds(catalog, filter, action?.playSound), + onPick = ::pick, + typeHint = PickerOptions.SOUND_TYPE_HINT, + onTyped = { typed -> + PickerOptions.search(catalog, typed, "sound key") ?: run { + pick(typed) + null + } + }, + onRightClick = { option -> player.playSound(option.value, options) }, + extras = { pane -> + SoundTuning.place(pane, player, action?.playSound, options) { updated -> + val changed = context.change(player, config.id) { fresh -> + val items = fresh.items.filter { it.slot in purpose.slots } + val actions = items.mapNotNull { ItemClickActions.of(it, purpose.interaction) } + if (actions.none { it.playSound != null }) return@change null + actions.forEach { it.playSoundOptions = updated(it.playSoundOptions) } + fresh + } + if (changed) context.render(player, screen) + } + }, + filteredNoun = "sounds", + ) + } + + private fun currentAction( + config: InventoryConfiguration, + slots: List<Int>, + interaction: PlayerInteraction, + ): ActionConfiguration? = + config.items.firstOrNull { it.slot in slots }?.let { ItemClickActions.of(it, interaction) } + + private fun editClicks( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + slots: List<Int>, + interaction: PlayerInteraction, + edit: (ActionConfiguration) -> Unit, + ) { + context.change(player, config.id) { fresh -> + fresh.items.filter { it.slot in slots }.forEach { edit(ItemClickActions.orCreate(it, interaction)) } + fresh + } + } + + private const val RECENT_FALLBACK = 27 +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemActionMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemActionMenuBuilder.kt new file mode 100644 index 0000000..5455d33 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemActionMenuBuilder.kt @@ -0,0 +1,120 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.core.ActionFieldsPane +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.ItemClickActions +import app.simplecloud.npc.core.config.NpcConfig.ActionConfiguration +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object ItemActionMenuBuilder { + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.ItemAction, + ): EditorMenu { + val slots = screen.slots + val pane = Pane(ActionFieldsPane.SIZE) + + pane[ActionFieldsPane.HEADER_SLOT] = Ui.item( + Clicks.material(screen.interaction), + "<ttl>${Clicks.label(screen.interaction)}", + listOfNotNull("<warn>Applies to ${slots.size} items".takeIf { slots.size > 1 }), + glowing = true, + ) + + ActionFieldsPane.fill(pane, port(context, player, config, slots, screen.interaction)) + + pane.left( + ActionFieldsPane.DELETE_SLOT, + Ui.item( + "RED_CONCRETE", + "<err>Clear This Click", + listOf("<hnt>Undo on the canvas brings it back.", "<key>Left <hnt>Clear"), + ), + ) { + context.change(player, config.id) { fresh -> + fresh.items + .filter { it.slot in slots } + .forEach { item -> item.actions.removeIf { it.interactionType == screen.interaction } } + fresh + } + context.back(player) + } + + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title(Clicks.label(screen.interaction), "item")) + } + + fun buildBlocks( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.ItemActionBlocks, + ): EditorMenu { + val pane = Pane(ActionFieldsPane.ADD_SIZE) + + ActionFieldsPane.fillAdd(pane, port(context, player, config, screen.slots, screen.interaction)) + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("Add to ${Clicks.short(screen.interaction)}", "item")) + } + + private fun port( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + slots: List<Int>, + interaction: PlayerInteraction, + ): ActionFieldsPane.Port { + val editor = InventoryEditorScreen.ItemAction(config.id, slots, interaction) + val first = config.items.firstOrNull { it.slot == slots.first() } + val action = first?.let { ItemClickActions.of(it, interaction) } + ?: ActionConfiguration(interactionType = interaction) + + fun edit(mutate: (ActionConfiguration) -> Unit) { + context.change(player, config.id) { fresh -> + fresh.items.filter { it.slot in slots }.forEach { item -> + mutate(ItemClickActions.orCreate(item, interaction)) + item.actions.removeIf { it.isEmpty() } + } + fresh + } + context.render(player, editor) + } + + fun pick(purpose: InventoryPickerPurpose) = + context.navigate(player, InventoryEditorScreen.Picker(config.id, purpose)) + + return ActionFieldsPane.Port( + scope = ActionFieldsPane.Scope.MENU, + player = player, + action = action, + edit = ::edit, + toggleJoinTarget = { edit { it.joinTarget = !action.joinTarget } }, + prompt = { context.chatPrompt(player, it) }, + rerender = { context.render(player, editor) }, + pickMenu = { pick(InventoryPickerPurpose.OpenInventory(slots, interaction)) }, + pickServer = { pick(InventoryPickerPurpose.SendToServer(slots, interaction)) }, + pickSound = { pick(InventoryPickerPurpose.Sound(slots, interaction)) }, + openTitle = { context.navigate(player, InventoryEditorScreen.ItemTitle(config.id, slots, interaction)) }, + openAdd = { + context.navigate(player, InventoryEditorScreen.ItemActionBlocks(config.id, slots, interaction)) + }, + closeAdd = { context.back(player) }, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemTitleMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemTitleMenuBuilder.kt new file mode 100644 index 0000000..94c0fe3 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/ItemTitleMenuBuilder.kt @@ -0,0 +1,60 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.core.TitlePane +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.ItemClickActions +import app.simplecloud.npc.core.config.NpcConfig.TitleConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object ItemTitleMenuBuilder { + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.ItemTitle, + ): EditorMenu { + val first = config.items.firstOrNull { it.slot == screen.slots.first() } + val pane = Pane(TitlePane.SIZE) + + fun change(transform: (TitleConfiguration?) -> TitleConfiguration?) { + context.change(player, config.id) { fresh -> + fresh.items.filter { it.slot in screen.slots }.forEach { item -> + val action = ItemClickActions.orCreate(item, screen.interaction) + action.sendTitle = transform(action.sendTitle) + item.actions.removeIf { it.isEmpty() } + } + fresh + } + } + + TitlePane.fill( + pane, + TitlePane.Port( + player = player, + title = first?.let { ItemClickActions.of(it, screen.interaction) }?.sendTitle, + textPrompts = context.textPrompts, + edit = { mutate -> + change { TitlePane.normalized(mutate(it ?: TitlePane.defaults())) } + context.render(player, screen) + }, + removeAndBack = { + change { null } + context.back(player) + }, + ), + ) + + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("Title", Clicks.label(screen.interaction))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LiveGroupMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LiveGroupMenuBuilder.kt new file mode 100644 index 0000000..f9002cd --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LiveGroupMenuBuilder.kt @@ -0,0 +1,240 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.core.toggle +import app.simplecloud.npc.common.editor.inventory.InventoryConfirmPurpose +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.editor.ui.after +import app.simplecloud.npc.common.inventory.source.GroupServersSource +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +object LiveGroupMenuBuilder { + private const val SIZE = 45 + + private const val HEADER_SLOT = 4 + private const val SOURCE_SLOT = 10 + private const val GROUP_SLOT = 12 + private const val SORT_SLOT = 14 + private const val EMPTY_SLOT = 16 + private val STATE_SLOTS = 19..25 + private const val RENAME_SLOT = 30 + private const val DELETE_SLOT = 32 + private const val LOOKS_SLOT = 34 + + private val SORTS = listOf("NUMERICAL_ID", "PLAYER_COUNT") + private val EMPTY_LOOK = InventoryItemConfiguration( + material = "GRAY_STAINED_GLASS_PANE", + name = "<#475569>Spinning up...", + ) + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + group: String, + ): EditorMenu { + val screen = InventoryEditorScreen.LiveGroup(config.id, group) + val source = LiveGroups.source(config, group) + val type = source?.type + val (color, _) = LiveGroups.colorOf(config, group) + val pane = Pane(SIZE) + + fun change(mutate: (InventoryConfiguration) -> InventoryConfiguration?) { + context.change(player, config.id, mutate) + context.render(player, screen) + } + + fun pick(purpose: InventoryPickerPurpose) = + context.navigate(player, InventoryEditorScreen.Picker(config.id, purpose)) + + if (LiveGroups.members(config, group).isEmpty()) { + pane.back(context, player) + pane.fillNavRow() + return pane.menu(Ui.title("Live Group", group)) + } + + pane[HEADER_SLOT] = Ui.item( + "${color}_STAINED_GLASS_PANE", + "<ttl>Live Group <val>$group", + listOfNotNull( + "<bd><val>${LiveGroups.contentSlots(config, group).size} <bd>slots", + if (LiveGroups.lacksPageControls(config, group)) { + "<warn>No page arrows: extra entries are hidden." + } else { + "<bd>Has page arrows" + }, + ), + ) + + val info = type?.let(LiveGroups::describe) + pane.left( + SOURCE_SLOT, + Ui.item( + info?.icon ?: "BARRIER", + "<ttl>Source", + listOf("<bd>Now: <val>${info?.label ?: "none"}", "", "<key>Left <hnt>Change"), + ), + ) { pick(InventoryPickerPurpose.LiveSourceType(group)) } + + if (type == GroupServersSource.TYPE) { + val target = source.group?.takeIf { it.isNotBlank() } ?: GroupServersSource.TARGET_GROUP + val targetLabel = if (target == GroupServersSource.TARGET_GROUP) "the opening NPC's target" else target + pane.left( + GROUP_SLOT, + Ui.item("PAPER", "<ttl>Which Group", listOf("<bd>Now: <val>$targetLabel", "", "<key>Left <hnt>Change")), + ) { pick(InventoryPickerPurpose.LiveSourceGroup(group)) } + + val sort = source.sort?.uppercase() ?: SORTS.first() + val sortLabel = if (sort == "PLAYER_COUNT") "most players first" else "by number" + pane.left( + SORT_SLOT, + Ui.item("HOPPER", "<ttl>Order", listOf("<bd>Now: <val>$sortLabel", "", "<key>Left <hnt>Switch")), + ) { + val next = SORTS.after(sort) + change { LiveGroups.setSource(it, group) { s -> s.copy(sort = next) } } + } + + val chosen = source.states.map { it.uppercase() }.toSet() + val noneHint = listOfNotNull("<hnt>None picked: everything but stopping.".takeIf { chosen.isEmpty() }) + val states = ServerState.entries.filterNot { it.name.startsWith("UNKNOWN") } + states.zip(STATE_SLOTS.toList()).forEach { (state, slot) -> + val on = state.name in chosen + pane.toggle( + slot, + state.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase), + on, + extraLore = noneHint, + ) { + change { + LiveGroups.setSource(it, group) { s -> + val states = s.states.map(String::uppercase).toMutableSet() + if (on) states -= state.name else states += state.name + s.copy(states = states.sorted().toMutableList()) + } + } + } + } + } + + val emptyLook = LiveGroups.whenEmpty(config, group) + pane.on( + EMPTY_SLOT, + Ui.item( + emptyLook?.material ?: "LIGHT_GRAY_STAINED_GLASS_PANE", + "<ttl>When A Slot Is Empty", + listOf( + emptyLook?.let { "<bd>Shows " + (it.name ?: it.material) } ?: "<off>Stays empty", + "", + "<key>Left <hnt>${if (emptyLook == null) "Show a placeholder" else "Leave empty slots empty"}", + "<key>Right <hnt>Type the placeholder's name", + ), + ), + ) { click -> + when (click) { + MenuClick.LEFT -> change { + LiveGroups.setWhenEmpty(it, group, if (emptyLook == null) EMPTY_LOOK else null) + } + MenuClick.RIGHT -> context.chatPrompt( + player, + Prompt( + title = "Empty Slot Name", + instruction = "Type what an empty slot is called in chat.", + current = emptyLook?.name, + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.MENU, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val name = input.trim() + PromptResult.rejectInvalidMiniMessage(name)?.let { return@Prompt it } + change { LiveGroups.setWhenEmpty(it, group, (emptyLook ?: EMPTY_LOOK).copy(name = name)) } + PromptResult.Accepted + }, + ), + ) + else -> Unit + } + } + + pane.left( + RENAME_SLOT, + Ui.item( + "ANVIL", + "<ttl>Rename", + listOf("<bd>Now: <val>$group", "", "<key>Left <hnt>Type a new name in chat"), + ), + ) { + context.chatPrompt( + player, + Prompt( + title = "Rename Live Group", + instruction = "Type the new name of the live group in chat.", + current = group, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val name = input.trim() + if (name.isBlank() || name in LiveGroups.names(config)) { + return@Prompt PromptResult.Rejected(Component.text("Pick a name no other group uses.")) + } + val renamed = context.change(player, config.id) { LiveGroups.rename(it, group, name) } + if (!renamed) { + return@Prompt PromptResult.Rejected(Component.text("Pick a name no other group uses.")) + } + context.session(player).renameGroup(group, name) + context.replace(player, InventoryEditorScreen.LiveGroup(config.id, name)) + PromptResult.Accepted + }, + ), + ) + } + + val looks = source?.looks?.size ?: 0 + if (StateLooksMenuBuilder.statesFor(type).isNotEmpty()) { + pane.left( + LOOKS_SLOT, + Ui.item( + "PAINTING", + "<ttl>State Looks", + listOf( + if (looks == 0) "<off>None set" else "<bd><val>$looks <bd>states styled", + "<hnt>Full, starting, offline… each its own look.", + "", + "<key>Left <hnt>Open", + ), + glowing = looks > 0, + ), + ) { context.navigate(player, InventoryEditorScreen.StateLooks(config.id, group)) } + } + + pane.left( + DELETE_SLOT, + Ui.item( + "TNT", + "<err>Delete Group", + listOf("<bd>Removes all its slots and arrows.", "", "<key>Left <hnt>Asks first"), + ), + ) { + val purpose = InventoryConfirmPurpose.DeleteLiveGroup(group) + context.navigate(player, InventoryEditorScreen.Confirm(config.id, purpose)) + } + + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("Live Group", group)) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LoreMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LoreMenuBuilder.kt new file mode 100644 index 0000000..d4c8ca0 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/LoreMenuBuilder.kt @@ -0,0 +1,92 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptPlaceholder +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.LineList +import app.simplecloud.npc.common.editor.core.LinesPane +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.StudioTargets +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +object LoreMenuBuilder { + private const val NOTE_SLOT = 31 + private val LINE_SLOTS = (0..26).toList() + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Lore, + ): EditorMenu { + val targets = StudioTargets.of(config, screen.slots, context.session(player).thisSlotOnly) + val first = config.items.firstOrNull { it.slot == screen.slots.first() } + val pane = Pane(LinesPane.SIZE) + + val lines = LineList( + lines = first?.lore.orEmpty(), + textOf = { it }, + withText = { _, text -> text }, + create = { it }, + edit = { mutate -> + context.change(player, config.id) { cfg -> + val updated = cfg.items.firstOrNull { it.slot == screen.slots.first() }?.lore.orEmpty() + .toMutableList() + if (!mutate(updated)) return@change null + StudioTargets.edit(cfg, targets) { it.copy(lore = updated.toMutableList()) } + } + context.render(player, screen) + }, + prompt = { _, current, onText, onDelete -> + val tokens = PromptTokens.MENU + PromptTokens.ENTRY.takeIf { first?.liveGroup != null }.orEmpty() + promptLine(context, player, screen, current, tokens, onText, onDelete) + }, + ) + LinesPane.place(pane, lines, LINE_SLOTS) + if (targets.size > 1) pane[NOTE_SLOT] = Ui.item("PAPER", "<warn>Applies to ${targets.size} items") + pane.back(context, player) + pane.fillNavRow() + + val itemName = first?.name?.let(Msg.miniMessage::stripTags)?.ifBlank { null } ?: "item" + + return pane.menu(Ui.title("Lore", itemName)) + } + + private fun promptLine( + context: InventoryEditorContext, + player: NpcPlayer, + screen: InventoryEditorScreen.Lore, + current: String?, + placeholders: List<PromptPlaceholder>, + apply: (String) -> Unit, + onDelete: (() -> Unit)?, + ) { + context.chatPrompt( + player, + Prompt( + title = "Lore Line", + instruction = "Type the lore line in chat.", + format = PromptFormat.MINI_MESSAGE, + placeholders = placeholders, + current = current, + onClear = onDelete, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val text = input.trim() + PromptResult.rejectInvalidMiniMessage(text)?.let { return@Prompt it } + apply(text) + PromptResult.Accepted + }, + ), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StateLooksMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StateLooksMenuBuilder.kt new file mode 100644 index 0000000..ce5b610 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StateLooksMenuBuilder.kt @@ -0,0 +1,150 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Palette +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.source.GroupServersSource +import app.simplecloud.npc.common.inventory.view.ItemIconResolver +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.EntryStates +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.StateLook +import app.simplecloud.npc.core.platform.NpcPlayer + +object StateLooksMenuBuilder { + private const val SIZE = 45 + + private const val HEADER_SLOT = 4 + private val STATE_SLOTS = listOf(19, 21, 23, 25) + private const val NO_STATES_SLOT = 22 + + fun statesFor(sourceType: String?): List<String> = when (sourceType) { + null, "static" -> emptyList() + GroupServersSource.TYPE -> EntryStates.SERVER_STATES + else -> EntryStates.TARGET_STATES + } + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + group: String, + ): EditorMenu { + val screen = InventoryEditorScreen.StateLooks(config.id, group) + val source = LiveGroups.source(config, group) + val template = LiveGroups.members(config, group).firstOrNull { it.pageControl == null } + ?: InventoryItemConfiguration() + val states = statesFor(source?.type) + val pane = Pane(SIZE) + + fun change(state: String, transform: (StateLook) -> StateLook?) { + context.change(player, config.id) { LiveGroups.setLook(it, group, state, transform) } + context.render(player, screen) + } + + fun prompt(title: String, instruction: String, current: String?, apply: (String) -> Unit) { + context.chatPrompt( + player, + Prompt( + title = title, + instruction = instruction, + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.MENU + PromptTokens.ENTRY, + current = current, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val text = input.trim() + PromptResult.rejectInvalidMiniMessage(text)?.let { return@Prompt it } + apply(text) + PromptResult.Accepted + }, + ), + ) + } + + pane[HEADER_SLOT] = Ui.item( + "PAINTING", + "<ttl>State Looks <hnt>· <val>$group", + listOf( + "<bd>How an entry looks while its server", + "<bd>is full, starting or offline.", + "<hnt>Anything not set keeps the slot's own look.", + ), + ) + + states.zip(STATE_SLOTS).forEach { (state, slot) -> + val look = source?.lookFor(state) + val preview = ItemIconResolver.icon(look?.let { ItemIconResolver.styled(template, it) } ?: template) + val glowing = look?.glowing ?: template.glowing + pane.on( + slot, + preview.copy( + name = Palette.expand("<ttl>${label(state)}"), + lore = listOfNotNull( + if (look == null) "<off>Uses the slot's own look" else "<on>Own look set", + look?.material?.let { "<bd>Material <val>$it" }, + look?.name?.let { "<bd>Name <val>${Ui.quote(it)}" }, + look?.lore?.let { "<bd>Lore <val>${it.size} <bd>lines" }, + look?.glowing?.let { "<bd>Glint <val>${if (it) "on" else "off"}" }, + "", + "<key>Left <hnt>Material", + "<key>Right <hnt>Name", + "<key>Shift+Left <hnt>Glint ${if (glowing) "off" else "on"}", + "<key>Shift+Right <hnt>Lore, lines split by |", + "<key>Q <hnt>Back to the slot's look".takeIf { look != null }, + ).map(Palette::expand), + glowing = glowing, + amount = 1, + ), + ) { click -> + when (click) { + MenuClick.LEFT -> context.navigate( + player, + InventoryEditorScreen.Picker(config.id, InventoryPickerPurpose.LookMaterial(group, state)), + ) + MenuClick.RIGHT -> prompt( + "Look Name", + "Type the name entries show while ${label(state).lowercase()}.", + look?.name ?: template.name, + ) { text -> change(state) { it.copy(name = text) } } + MenuClick.SHIFT_LEFT -> change(state) { it.copy(glowing = !glowing) } + MenuClick.SHIFT_RIGHT -> prompt( + "Look Lore", + "Type the lore in chat, split lines with |.", + (look?.lore ?: template.lore).joinToString(" | "), + ) { text -> + change(state) { it.copy(lore = text.split('|').map(String::trim).toMutableList()) } + } + MenuClick.DROP -> if (look != null) change(state) { null } + else -> Unit + } + } + } + + if (states.isEmpty()) { + pane[NO_STATES_SLOT] = Ui.disabled("No States", listOf("<hnt>Hand-written entries have no server state.")) + } + + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("State Looks", group)) + } + + fun label(state: String): String = when (state) { + EntryStates.FULL -> "Full" + EntryStates.OFFLINE -> "Offline" + else -> state.lowercase().replaceFirstChar(Char::uppercase) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StudioMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StudioMenuBuilder.kt new file mode 100644 index 0000000..9c8fb5c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/StudioMenuBuilder.kt @@ -0,0 +1,466 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptPlaceholder +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.core.toggle +import app.simplecloud.npc.common.editor.inventory.CanvasMode +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.InventoryPickerPurpose +import app.simplecloud.npc.common.editor.inventory.LiveGroups +import app.simplecloud.npc.common.editor.inventory.StudioTab +import app.simplecloud.npc.common.editor.inventory.StudioTargets +import app.simplecloud.npc.common.editor.inventory.StudioTargets.Shared +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Palette +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.inventory.ItemClickActions +import app.simplecloud.npc.common.inventory.view.HeadOwners +import app.simplecloud.npc.common.inventory.view.ItemIconResolver +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.utils.SkinInput +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.HeadConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +object StudioMenuBuilder { + private const val SIZE = 45 + + private const val LOOK_TAB = 1 + private const val CLICK_TAB = 2 + private const val LIVE_TAB = 3 + private const val PREVIEW_SLOT = 6 + private val HEADER_FILLER = listOf(0, 4, 5, 7, 8) + + private const val MATERIAL_SLOT = 19 + private const val NAME_SLOT = 20 + private const val LORE_SLOT = 21 + private const val AMOUNT_SLOT = 22 + private const val GLINT_SLOT = 23 + private const val TOOLTIP_SLOT = 24 + private const val MODEL_SLOT = 25 + private const val HEAD_SLOT = 31 + + private val CLICK_SLOTS = mapOf( + PlayerInteraction.LEFT_CLICK to 20, + PlayerInteraction.RIGHT_CLICK to 21, + PlayerInteraction.SHIFT_LEFT_CLICK to 23, + PlayerInteraction.SHIFT_RIGHT_CLICK to 24, + ) + private const val CLICK_INFO_SLOT = 31 + + private const val GROUP_SLOT = 20 + private const val SCOPE_SLOT = 22 + private const val PLACEHOLDERS_SLOT = 24 + + private const val COUNT_SLOT = 40 + private const val DELETE_SLOT = 44 + + private val AMOUNT = Stepper( + label = "Amount", + range = 1.0..64.0, + step = 1.0, + default = 1.0, + decimals = 0, + material = "SLIME_BALL", + bigMultiplier = 8, + ) + private val MODEL_DATA = Stepper( + label = "Custom Model Data", + range = 0.0..9_999_999.0, + step = 1.0, + default = 0.0, + decimals = 0, + material = "ITEM_FRAME", + hints = listOf("<hnt>0 means none; for resource packs."), + bigMultiplier = 100, + ) + + fun build( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Studio, + ): EditorMenu { + val session = context.session(player) + val targets = StudioTargets.of(config, screen.slots, session.thisSlotOnly) + val items = StudioTargets.items(config, targets) + val first = config.items.firstOrNull { it.slot == screen.slots.first() } ?: items.firstOrNull() + val pane = Pane(SIZE) + if (first == null) { + pane.back(context, player) + pane.fillNavRow() + return pane.menu("Item Studio · nothing here") + } + val isLive = items.any { it.liveGroup != null && it.pageControl == null } + val tab = if (screen.tab == StudioTab.LIVE && !isLive) StudioTab.LOOK else screen.tab + + fun edit(transform: (InventoryItemConfiguration) -> InventoryItemConfiguration) { + context.change(player, config.id) { StudioTargets.edit(it, targets, transform) } + context.render(player, screen) + } + + fun tabItem(slot: Int, which: StudioTab, material: String, label: String, enabled: Boolean = true) { + if (!enabled) { + pane[slot] = Ui.disabled(label, listOf("<hnt>Only for live slots.")) + return + } + pane.left(slot, Ui.option(material, label, emptyList(), selected = tab == which)) { + if (tab != which) context.replace(player, screen.copy(tab = which)) + } + } + + tabItem(LOOK_TAB, StudioTab.LOOK, "PAINTING", "Look") + tabItem(CLICK_TAB, StudioTab.CLICK, "LEVER", "Click") + tabItem(LIVE_TAB, StudioTab.LIVE, "LIME_STAINED_GLASS_PANE", "Live", enabled = isLive) + val livePreview = session.live?.slots?.get(first.slot)?.takeIf { session.mode == CanvasMode.LIVE } + pane[PREVIEW_SLOT] = livePreview + ?.let { it.copy(lore = it.lore + Palette.expand("<hnt>Live preview from the canvas")) } + ?: preview(first) + pane.fill(HEADER_FILLER) + + when (tab) { + StudioTab.LOOK -> look(context, player, config, screen, items, pane, ::edit) + StudioTab.CLICK -> click(context, player, config, items, targets, pane) + StudioTab.LIVE -> live(context, player, config, screen, items, pane) + } + + if (targets.size > 1) { + pane[COUNT_SLOT] = Ui.item( + "CHEST_MINECART", + "<ttl>Editing <val>${targets.size} <ttl>items", + listOf("<bd>Changes here apply to all of them.", "<hnt>Fields that differ show as mixed."), + amount = targets.size.coerceIn(1, 64), + ) + } + val deleted = if (targets.size > 1) "these ${targets.size} items" else "this item" + pane.left( + DELETE_SLOT, + Ui.item( + "TNT", + "<err>Delete", + listOf("<bd>Removes $deleted.", "<hnt>Undo on the canvas brings it back.", "", "<key>Left <hnt>Delete"), + ), + ) { + context.change(player, config.id) { cfg -> + cfg.copy(items = cfg.items.filterNot { it.slot in targets }.toMutableList()) + } + context.session(player).selection.removeAll(targets) + context.back(player) + } + pane.back(context, player) + pane.fillNavRow() + + val title = first.name?.let(Msg.miniMessage::stripTags)?.takeIf { it.isNotBlank() } + ?: Ui.pretty(first.material) + + return pane.menu(Ui.title("Item Studio", title)) + } + + private fun preview(item: InventoryItemConfiguration): NpcItem { + val icon = ItemIconResolver.icon(item) + return icon.copy(name = icon.name.ifBlank { Ui.pretty(item.material) }, hideTooltip = false) + } + + private fun look( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Studio, + items: List<InventoryItemConfiguration>, + pane: Pane, + edit: ((InventoryItemConfiguration) -> InventoryItemConfiguration) -> Unit, + ) { + val slots = items.map { it.slot } + val material = StudioTargets.shared(items) { it.material } + pane.left( + MATERIAL_SLOT, + Ui.item( + (material as? Shared.Same)?.value ?: "BUNDLE", + "<ttl>Material", + listOf(valueLine(material) { Ui.pretty(it) }, "", "<key>Left <hnt>Pick another"), + ), + ) { context.navigate(player, InventoryEditorScreen.Picker(config.id, InventoryPickerPurpose.Material(slots))) } + + val name = StudioTargets.shared(items) { it.name } + pane.on( + NAME_SLOT, + Ui.item( + "NAME_TAG", + "<ttl>Name", + listOf( + when (name) { + Shared.Mixed -> "<warn>mixed" + is Shared.Same -> name.value?.let { "<bd>Now: $it" } ?: "<off>Default item name" + }, + "<hnt>MiniMessage and <entry_*> placeholders work.", + "", + "<key>Left <hnt>Type it in chat <key>Q <hnt>Clear", + ), + ), + ) { click -> + when (click) { + MenuClick.LEFT -> prompt( + context, + player, + screen, + "Item Name", + "Type the item name in chat.", + (name as? Shared.Same)?.value, + placeholders = PromptTokens.MENU + + PromptTokens.ENTRY.takeIf { items.any { it.liveGroup != null } }.orEmpty(), + ) { text -> + context.change(player, config.id) { + StudioTargets.edit(it, slots.toSet()) { item -> item.copy(name = text) } + } + } + MenuClick.DROP -> edit { it.copy(name = null) } + else -> Unit + } + } + + val lore = StudioTargets.shared(items) { it.lore.toList() } + pane.left( + LORE_SLOT, + Ui.item( + "WRITABLE_BOOK", + "<ttl>Lore", + when (lore) { + Shared.Mixed -> listOf("<warn>mixed") + is Shared.Same -> lore.value + .ifEmpty { listOf("<off>No lore") } + .take(6) + .map { "<bd>" + it.ifBlank { " " } } + } + listOf("", "<key>Left <hnt>Edit the lines"), + ), + ) { context.navigate(player, InventoryEditorScreen.Lore(config.id, slots)) } + + val amount = (StudioTargets.shared(items) { it.amount } as? Shared.Same)?.value ?: 1 + pane[AMOUNT_SLOT] = AMOUNT.element(amount.toDouble()) { value -> + edit { it.copy(amount = value.toInt()) } + } + + val glint = (StudioTargets.shared(items) { it.glowing } as? Shared.Same)?.value + pane.toggle(GLINT_SLOT, "Enchant Glint", glint == true, material = "EXPERIENCE_BOTTLE") { + edit { it.copy(glowing = glint != true) } + } + + val hidden = (StudioTargets.shared(items) { it.hideTooltip } as? Shared.Same)?.value + pane.toggle( + TOOLTIP_SLOT, + "Hide Tooltip", + hidden == true, + extraLore = listOf("<hnt>For decoration; hovering shows nothing."), + material = "GLASS_PANE", + ) { edit { it.copy(hideTooltip = hidden != true) } } + + val model = (StudioTargets.shared(items) { it.customModelData } as? Shared.Same)?.value ?: 0 + pane[MODEL_SLOT] = MODEL_DATA.element(model.toDouble()) { value -> + edit { it.copy(customModelData = value.toInt().takeIf { data -> data > 0 }) } + } + + val head = StudioTargets.shared(items) { it.head } + val isHead = items.all { it.material == "PLAYER_HEAD" } + if (!isHead) { + pane[HEAD_SLOT] = Ui.disabled( + "Head Texture", + listOf("<hnt>For player heads; set the material to Player Head."), + ) + } else { + pane.on( + HEAD_SLOT, + Ui.item( + "PLAYER_HEAD", + "<ttl>Head Texture", + listOf( + when (head) { + Shared.Mixed -> "<warn>mixed" + is Shared.Same -> headLine(head.value) + }, + "<hnt>A texture value (e.g. from minecraft-heads.com),", + "<hnt>a username or UUID, or whoever opens the menu.", + "", + "<key>Left <hnt>Type it in chat <key>Right <hnt>Viewer's head", + "<key>Q <hnt>Clear", + ), + ), + ) { click -> + when (click) { + MenuClick.LEFT -> prompt( + context, + player, + screen, + "Player Head", + "Paste a texture value, or type a username or UUID.", + (head as? Shared.Same)?.value?.owner, + format = PromptFormat.PLAIN, + validate = { + if (headOf(it) != null) null + else PromptResult.Rejected(Component.text("That is no texture, username or UUID.")) + }, + ) { text -> + val parsed = headOf(text) ?: return@prompt + parsed.owner?.let(HeadOwners::skin) + context.change(player, config.id) { + StudioTargets.edit(it, slots.toSet()) { item -> item.copy(head = parsed) } + } + } + MenuClick.RIGHT -> { + val viewer = (head as? Shared.Same)?.value?.showsViewer == true + edit { it.copy(head = if (viewer) null else HeadConfiguration(viewer = true)) } + } + MenuClick.DROP -> edit { it.copy(head = null) } + else -> Unit + } + } + } + } + + private fun headLine(head: HeadConfiguration?): String = when { + head == null -> "<off>Plain head" + head.showsViewer -> "<on>The viewer's own head" + head.owner != null -> "<bd>Head of <val>${head.owner}" + else -> "<bd>Custom texture set" + } + + private fun headOf(input: String): HeadConfiguration? = when (val parsed = SkinInput.parse(input)) { + is SkinInput.Uuid -> HeadConfiguration(owner = parsed.uuid.toString()) + is SkinInput.Username -> HeadConfiguration(owner = parsed.name) + is SkinInput.Texture -> HeadConfiguration(texture = parsed.value, signature = parsed.signature) + null -> null + } + + private fun click( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + items: List<InventoryItemConfiguration>, + targets: Set<Int>, + pane: Pane, + ) { + val slots = targets.sorted() + val first = items.first() + CLICK_SLOTS.forEach { (interaction, slot) -> + val action = ItemClickActions.of(first, interaction) + val configured = action?.configuredTypes().orEmpty() + pane.left( + slot, + Ui.item( + Clicks.material(interaction), + "<ttl>${Clicks.label(interaction)}", + configured.map { "<bd>· <val>$it" }.ifEmpty { listOf("<off>Nothing") } + + listOf("", "<key>Left <hnt>Edit"), + glowing = configured.isNotEmpty(), + ), + ) { context.navigate(player, InventoryEditorScreen.ItemAction(config.id, slots, interaction)) } + } + + val legacy = first.actions.isNotEmpty() && + first.actions.all { it.interactionType == PlayerInteraction.RIGHT_CLICK } + val liveSource = first.liveGroup?.let { LiveGroups.source(config, it) } + pane[CLICK_INFO_SLOT] = Ui.item( + "BOOK", + "<ttl>How Clicks Work", + listOfNotNull( + "<bd>Each click type runs its own actions;", + "<bd>shift clicks fall back to the plain one.", + "<warn>Only right click is set, so it runs on any click.".takeIf { legacy }, + "<hnt>Live slot with no actions: the source's default runs.".takeIf { liveSource != null }, + ), + ) + } + + private fun live( + context: InventoryEditorContext, + player: NpcPlayer, + config: InventoryConfiguration, + screen: InventoryEditorScreen.Studio, + items: List<InventoryItemConfiguration>, + pane: Pane, + ) { + val session = context.session(player) + val group = items.firstNotNullOfOrNull { it.liveGroup } ?: return + val (color, _) = LiveGroups.colorOf(config, group) + val sourceLabel = LiveGroups.source(config, group)?.let { LiveGroups.describe(it.type).label } ?: "none" + pane.left( + GROUP_SLOT, + Ui.item( + "${color}_STAINED_GLASS_PANE", + "<ttl>Group <val>$group", + listOf( + "<bd>Source <val>$sourceLabel", + "<bd><val>${LiveGroups.contentSlots(config, group).size} <bd>slots", + "", + "<key>Left <hnt>Group settings", + ), + ), + ) { context.navigate(player, InventoryEditorScreen.LiveGroup(config.id, group)) } + + pane.toggle( + SCOPE_SLOT, + "This Slot Only", + session.thisSlotOnly, + extraLore = listOf("<hnt>Off: Look edits restyle every slot", "<hnt>of the group at once."), + material = "TARGET", + ) { + session.thisSlotOnly = !session.thisSlotOnly + context.render(player, screen) + } + + pane[PLACEHOLDERS_SLOT] = Ui.item( + "OAK_SIGN", + "<ttl>Placeholders", + PromptTokens.ENTRY.map { "<val>${it.token} <hnt>${it.description}" }, + ) + } + + private fun valueLine(shared: Shared<String>, format: (String) -> String): String = when (shared) { + Shared.Mixed -> "<warn>mixed" + is Shared.Same -> "<bd>Now: <val>${format(shared.value)}" + } + + private fun prompt( + context: InventoryEditorContext, + player: NpcPlayer, + screen: InventoryEditorScreen.Studio, + title: String, + instruction: String, + current: String?, + format: PromptFormat = PromptFormat.MINI_MESSAGE, + placeholders: List<PromptPlaceholder> = emptyList(), + validate: (String) -> PromptResult.Rejected? = PromptResult::rejectInvalidMiniMessage, + apply: (String) -> Unit, + ) { + context.chatPrompt( + player, + Prompt( + title = title, + instruction = instruction, + current = current, + format = format, + placeholders = placeholders, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val text = input.trim() + validate(text)?.let { return@Prompt it } + apply(text) + context.render(player, screen) + PromptResult.Accepted + }, + ), + ) + } + +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/TemplatesMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/TemplatesMenuBuilder.kt new file mode 100644 index 0000000..636be4b --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/inventory/screens/TemplatesMenuBuilder.kt @@ -0,0 +1,54 @@ +package app.simplecloud.npc.common.editor.inventory.screens + +import app.simplecloud.npc.common.editor.inventory.InventoryEditorContext +import app.simplecloud.npc.common.editor.inventory.InventoryEditorScreen +import app.simplecloud.npc.common.editor.inventory.MenuTemplate +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendSuccess +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.platform.NpcPlayer + +object TemplatesMenuBuilder { + private const val SIZE = 27 + private val TEMPLATE_SLOTS = 11..15 + + fun build(context: InventoryEditorContext, player: NpcPlayer): EditorMenu { + val pane = Pane(SIZE) + + MenuTemplate.entries.zip(TEMPLATE_SLOTS).forEach { (template, slot) -> + pane.left( + slot, + Ui.item( + template.icon, + "<ttl>${template.label}", + template.description.map { "<bd>$it" } + listOf("", "<key>Left <hnt>Create and open the canvas"), + ), + ) { create(context, player, template) } + } + + pane.left(pane.navRow.first + 4, Ui.close()) { context.close(player) } + pane.fill(0..8) + pane.fillNavRow() + + return pane.menu("New Menu · pick a start") + } + + private fun create(context: InventoryEditorContext, player: NpcPlayer, template: MenuTemplate) { + val id = freeId(context, template) + if (!ConfigIds.isValid(id)) return player.sendError("Could not find a free id for a new menu.") + + context.inventoryRepository.save(template.build(id)) + player.sendSuccess("Created menu {} from the {} template.", id, template.label) + context.reset(player, listOf(InventoryEditorScreen.Hub(id), InventoryEditorScreen.Canvas(id))) + } + + private fun freeId(context: InventoryEditorContext, template: MenuTemplate): String { + val base = template.name.lowercase().replace('_', '-') + return generateSequence(1) { it + 1 } + .map { if (it == 1) base else "$base-$it" } + .first { context.inventoryRepository.find(it) == null } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/EditorMenu.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/EditorMenu.kt new file mode 100644 index 0000000..fe1872a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/EditorMenu.kt @@ -0,0 +1,41 @@ +package app.simplecloud.npc.common.editor.menu + +import app.simplecloud.npc.common.item.NpcItem + +enum class MenuClick { + LEFT, + SHIFT_LEFT, + RIGHT, + SHIFT_RIGHT, + DROP, + MIDDLE, + OFFHAND, + DOUBLE_CLICK, + OTHER; + + fun step(small: Double, big: Double): Double = when (this) { + LEFT -> small + SHIFT_LEFT -> big + RIGHT -> -small + SHIFT_RIGHT -> -big + else -> 0.0 + } +} + +class Element( + val item: NpcItem, + val onClick: (MenuClick) -> Unit = {}, +) + +class EditorMenu( + val title: String, + val size: Int, + val elements: Map<Int, Element>, + val fallback: ((slot: Int, click: MenuClick) -> Unit)? = null, +) { + val items: Map<Int, NpcItem> by lazy { elements.mapValues { it.value.item } } + + fun click(slot: Int, click: MenuClick) { + elements[slot]?.onClick?.invoke(click) ?: fallback?.invoke(slot, click) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Paginator.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Paginator.kt new file mode 100644 index 0000000..b7338e2 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Paginator.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.common.editor.menu + +class Paginator<T>( + private val pages: MutableMap<String, Int>, + private val key: String, + private val all: List<T>, + val perPage: Int, +) { + val pageCount: Int = maxOf(1, (all.size + perPage - 1) / perPage) + val page: Int = (pages[key] ?: 0).coerceIn(0, pageCount - 1) + + init { + pages[key] = page + } + + val visible: List<T> = all.drop(page * perPage).take(perPage) + + fun turn(delta: Int) { + pages[key] = (page + delta).coerceIn(0, pageCount - 1) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Pane.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Pane.kt new file mode 100644 index 0000000..c90c43e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/menu/Pane.kt @@ -0,0 +1,49 @@ +package app.simplecloud.npc.common.editor.menu + +import app.simplecloud.npc.common.item.NpcItem + +class Pane(val size: Int) { + + private val elements = mutableMapOf<Int, Element>() + private var fallback: ((slot: Int, click: MenuClick) -> Unit)? = null + + val navRow: IntRange get() = (size - 9) until size + + operator fun set(slot: Int, item: NpcItem) { + elements[slot] = Element(item) + } + + operator fun set(slot: Int, element: Element) { + elements[slot] = element + } + + fun on(slot: Int, item: NpcItem, onClick: (MenuClick) -> Unit) { + elements[slot] = Element(item, onClick) + } + + fun on(slot: Int, item: NpcItem, click: MenuClick, action: () -> Unit) { + elements[slot] = Element(item) { if (it == click) action() } + } + + fun left(slot: Int, item: NpcItem, action: () -> Unit) = on(slot, item, MenuClick.LEFT, action) + + fun otherwise(handler: (slot: Int, click: MenuClick) -> Unit) { + fallback = handler + } + + fun fill(slots: Iterable<Int>, material: String = GRAY_PANE, onClick: ((MenuClick) -> Unit)? = null) { + val pane = Element(filler(material), onClick ?: {}) + slots.forEach { slot -> elements.putIfAbsent(slot, pane) } + } + + fun fillNavRow() = fill(navRow) + + fun menu(title: String): EditorMenu = EditorMenu(title, size, elements.toMap(), fallback) + + companion object { + const val GRAY_PANE = "GRAY_STAINED_GLASS_PANE" + const val RED_PANE = "RED_STAINED_GLASS_PANE" + + private fun filler(material: String): NpcItem = NpcItem(material, " ", hideTooltip = true) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/CloudNpcEditorLiveData.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/CloudNpcEditorLiveData.kt new file mode 100644 index 0000000..0ac002a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/CloudNpcEditorLiveData.kt @@ -0,0 +1,90 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.common.editor.core.KnownTarget +import app.simplecloud.npc.common.editor.core.LiveServer +import app.simplecloud.npc.common.editor.core.ServerStatus +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.CloudSnapshot +import app.simplecloud.npc.core.cloud.CloudSnapshots +import app.simplecloud.npc.core.cloud.JoinStateResolver +import app.simplecloud.npc.core.cloud.connectName +import app.simplecloud.npc.core.cloud.groupName +import app.simplecloud.npc.core.cloud.isProxy +import app.simplecloud.npc.core.config.NpcConfig +import kotlin.math.sqrt + +class CloudNpcEditorLiveData( + private val pluginContextProvider: () -> NpcPluginContext, + private val snapshots: CloudSnapshots = CloudListCache, +) : NpcEditorLiveData { + + private fun peek(): CloudSnapshot = snapshots.peek() + + override fun cloudKnown(): Boolean = peek().known + + private fun CloudSnapshot.serversOf(target: String) = servers.filter { server -> + server.groupName().equals(target, true) || server.persistentServer?.name.equals(target, true) + } + + override fun summaryOf(target: String): TargetSummary { + val snapshot = peek() + val servers = snapshot.serversOf(target) + + return TargetSummary( + isGroup = snapshot.groups.any { it.name.equals(target, true) }, + liveServers = servers.size, + players = servers.sumOf { it.playerCount ?: 0 }, + known = snapshot.known, + ) + } + + override fun playersInRange(config: NpcConfig): Int { + val npc = config.entity.location + val range = config.entity.viewDistance + + return pluginContextProvider().playerDirectory.onlinePlayers().count { player -> + val at = player.location() + at.world == npc.world && distance(at.x - npc.x, at.y - npc.y, at.z - npc.z) <= range + } + } + + private fun distance(dx: Double, dy: Double, dz: Double): Double = sqrt(dx * dx + dy * dy + dz * dz) + + override fun joinState(target: String): String? = + peek().takeIf { it.known }?.let { JoinStateResolver.resolve(target, it) } + + override fun knownTargets(): List<KnownTarget> { + val snapshot = peek() + val groups = snapshot.groups.filterNot { it.isProxy() }.map { KnownTarget(it.name, group = true) } + val persistent = + snapshot.persistentServers.filterNot { it.isProxy() }.map { KnownTarget(it.name, group = false) } + + return (groups + persistent).distinctBy { it.name.lowercase() } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + + override fun servers(): List<LiveServer> { + val snapshot = peek() + if (!snapshot.known) return emptyList() + + val running = snapshot.servers.mapNotNull { server -> + val name = server.connectName() ?: server.persistentServer?.name ?: return@mapNotNull null + val status = when (server.state) { + ServerState.AVAILABLE, ServerState.INGAME -> ServerStatus.ONLINE + ServerState.PREPARING, ServerState.STARTING -> ServerStatus.STARTING + ServerState.STOPPING, ServerState.CLEANUP -> ServerStatus.STOPPING + else -> ServerStatus.UNKNOWN + } + + LiveServer(name, status, playerCount = server.playerCount ?: 0) + } + val runningNames = running.mapTo(HashSet()) { it.name.lowercase() } + val down = snapshot.persistentServers + .filterNot { it.isProxy() || it.name.lowercase() in runningNames } + .map { LiveServer(it.name, ServerStatus.OFFLINE, playerCount = 0) } + + return (running + down).sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditor.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditor.kt new file mode 100644 index 0000000..e5013bc --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditor.kt @@ -0,0 +1,114 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.canvas.CanvasSurface +import app.simplecloud.npc.common.editor.core.ScreenStackEditor +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.action.ActionEditorMenuBuilder +import app.simplecloud.npc.common.editor.npc.action.TitleEditorMenuBuilder +import app.simplecloud.npc.common.editor.npc.appearance.EquipmentCanvas +import app.simplecloud.npc.common.editor.npc.appearance.GlowMenuBuilder +import app.simplecloud.npc.common.editor.npc.appearance.SkinMenuBuilder +import app.simplecloud.npc.common.editor.npc.confirm.ConfirmGateMenuBuilder +import app.simplecloud.npc.common.editor.npc.hologram.HologramFramesMenuBuilder +import app.simplecloud.npc.common.editor.npc.picker.PickerMenuBuilder +import app.simplecloud.npc.common.editor.npc.tabs.ActionsTabBuilder +import app.simplecloud.npc.common.editor.npc.tabs.BehaviorTabBuilder +import app.simplecloud.npc.common.editor.npc.tabs.HologramTabBuilder +import app.simplecloud.npc.common.editor.npc.tabs.LooksTabBuilder +import app.simplecloud.npc.common.editor.npc.tabs.NpcTabBuilder +import app.simplecloud.npc.common.editor.npc.tabs.TargetsTabBuilder +import app.simplecloud.npc.common.platform.EditorMenuOpener +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +class NpcEditor( + opener: EditorMenuOpener, + chatInputPrompts: ChatInputPrompts, + private val canvasSurface: CanvasSurface, + liveData: NpcEditorLiveData? = null, + pluginContextProvider: () -> NpcPluginContext, +) : ScreenStackEditor<NpcEditorScreen, NpcEditorSession>(opener, chatInputPrompts, NpcEditorSessions()) { + + private val context = NpcEditorContext( + pluginContextProvider, + chatInputPrompts, + sessions, + ::render, + ::navigate, + ::replace, + ::back, + ::close, + opener::runSync, + opener::runLater, + liveData ?: CloudNpcEditorLiveData(pluginContextProvider), + ) + + private val equipmentCanvas = EquipmentCanvas(context, canvasSurface, ::handleEscape) + + fun open(player: NpcPlayer, config: NpcConfig) = start(player, NpcEditorScreen.Tab(config.id, NpcTab.NPC)) + + override fun leavingScreen(session: NpcEditorSession, popped: Boolean) { + super.leavingScreen(session, popped) + session.carriedEquipment = null + } + + override fun show(player: NpcPlayer, screen: NpcEditorScreen) { + if (screen is NpcEditorScreen.Equipment) { + opener.runSync { + val session = sessions.peek(player.uniqueId) ?: return@runSync + session.menu = null + opener.detach(player) + equipmentCanvas.show(player, screen) + } + return + } + + canvasSurface.detach(player) + super.show(player, screen) + } + + override fun close(player: NpcPlayer) { + canvasSurface.detach(player) + super.close(player) + } + + override fun exists(screen: NpcEditorScreen): Boolean = context.npcRepository.find(screen.npcId) != null + + override fun build(player: NpcPlayer, screen: NpcEditorScreen): EditorMenu? { + val config = context.npcRepository.find(screen.npcId) ?: run { + context.reportMissing(player, screen.npcId) + return null + } + + return build(player, config, screen) + } + + private fun build(player: NpcPlayer, config: NpcConfig, screen: NpcEditorScreen): EditorMenu = when (screen) { + is NpcEditorScreen.Tab -> when (screen.tab) { + NpcTab.NPC -> NpcTabBuilder.build(context, player, config) + NpcTab.LOOKS -> LooksTabBuilder.build(context, player, config) + NpcTab.BEHAVIOR -> BehaviorTabBuilder.build(context, player, config) + NpcTab.HOLOGRAM -> HologramTabBuilder.build(context, player, config) + NpcTab.ACTIONS -> ActionsTabBuilder.build(context, player, config) + NpcTab.TARGETS -> TargetsTabBuilder.build(context, player, config) + } + + is NpcEditorScreen.SkinPicker -> SkinMenuBuilder.build(context, player, config) + is NpcEditorScreen.Glow -> GlowMenuBuilder.build(context, player, config) + is NpcEditorScreen.Equipment -> error("The equipment editor is not a menu") + is NpcEditorScreen.HologramFrames -> HologramFramesMenuBuilder.build(context, player, config, screen) + is NpcEditorScreen.ActionEditor -> + ActionEditorMenuBuilder.build(context, player, config, screen.interaction, screen.joinState) + + is NpcEditorScreen.ActionBlocks -> + ActionEditorMenuBuilder.buildBlocks(context, player, config, screen.interaction, screen.joinState) + + is NpcEditorScreen.TitleEditor -> + TitleEditorMenuBuilder.build(context, player, config, screen.interaction, screen.joinState) + + is NpcEditorScreen.Picker -> PickerMenuBuilder.build(context, player, config, screen.purpose) + is NpcEditorScreen.Confirm -> ConfirmGateMenuBuilder.build(context, player, config, screen.purpose) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorContext.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorContext.kt new file mode 100644 index 0000000..72bde2f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorContext.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.EditorPorts +import app.simplecloud.npc.common.editor.core.EditorHost +import app.simplecloud.npc.common.editor.core.EditorSessions +import app.simplecloud.npc.common.editor.core.ScreenAction +import app.simplecloud.npc.common.editor.core.TextPrompts +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +enum class Refresh { + NONE, + ENTITY, + HOLOGRAM, + ALL, +} + +class NpcEditorContext( + pluginContextProvider: () -> NpcPluginContext, + override val chatInputPrompts: ChatInputPrompts, + override val sessions: EditorSessions<NpcEditorSession>, + render: ScreenAction<NpcEditorScreen>, + navigate: ScreenAction<NpcEditorScreen>, + private val replaceTop: ScreenAction<NpcEditorScreen> = { _, _ -> }, + private val backSteps: (NpcPlayer, Int) -> Unit, + close: (NpcPlayer) -> Unit, + runSync: (() -> Unit) -> Unit, + runLater: (Long, () -> Unit) -> Unit, + val liveData: NpcEditorLiveData, +) : EditorPorts( + pluginContextProvider, + TextPrompts.chat(sessions, chatInputPrompts, render, ::contextOf), + runSync, + runLater, +), EditorHost<NpcEditorScreen> { + private val renderScreen = render + private val navigateTo = navigate + private val closeEditor = close + + override fun render(player: NpcPlayer, screen: NpcEditorScreen) = renderScreen(player, screen) + override fun promptContext(screen: NpcEditorScreen): String? = contextOf(screen) + override fun navigate(player: NpcPlayer, screen: NpcEditorScreen) = navigateTo(player, screen) + fun replace(player: NpcPlayer, screen: NpcEditorScreen) = replaceTop(player, screen) + + override fun back(player: NpcPlayer, steps: Int) = backSteps(player, steps) + override fun close(player: NpcPlayer) = closeEditor(player) + + override fun session(player: NpcPlayer): NpcEditorSession = sessions.of(player.uniqueId) + + fun commit( + player: NpcPlayer, + npcId: String, + then: NpcEditorScreen, + refresh: Refresh = Refresh.NONE, + mutate: (NpcConfig) -> NpcConfig?, + ) { + if (save(player, npcId, refresh, mutate) != Saved.MISSING) render(player, then) + } + + fun commitAndBack( + player: NpcPlayer, + npcId: String, + steps: Int = 1, + refresh: Refresh = Refresh.NONE, + mutate: (NpcConfig) -> NpcConfig?, + ) { + if (save(player, npcId, refresh, mutate) != Saved.MISSING) back(player, steps) + } + + fun change( + player: NpcPlayer, + npcId: String, + refresh: Refresh = Refresh.NONE, + mutate: (NpcConfig) -> NpcConfig?, + ): Boolean = save(player, npcId, refresh, mutate) == Saved.CHANGED + + fun editEntity( + player: NpcPlayer, + npcId: String, + then: NpcEditorScreen, + mutate: (NpcConfig.NpcEntityConfiguration) -> NpcConfig.NpcEntityConfiguration, + ) = commit(player, npcId, then, Refresh.ENTITY) { fresh -> fresh.copy(entity = mutate(fresh.entity)) } + + fun reportMissing(player: NpcPlayer, npcId: String) { + player.sendError("NPC {} no longer exists.", npcId) + } + + private enum class Saved { MISSING, UNCHANGED, CHANGED } + + private fun save( + player: NpcPlayer, + npcId: String, + refresh: Refresh, + mutate: (NpcConfig) -> NpcConfig?, + ): Saved { + val fresh = npcRepository.find(npcId) ?: run { + reportMissing(player, npcId) + close(player) + return Saved.MISSING + } + val updated = mutate(fresh) ?: return Saved.UNCHANGED + + npcRepository.save(updated) + when (refresh) { + Refresh.ENTITY -> renderer.refresh(updated) + Refresh.HOLOGRAM -> hologramRenderer.createOrUpdate(updated) + Refresh.ALL -> { + renderer.refresh(updated) + hologramRenderer.createOrUpdate(updated) + } + Refresh.NONE -> Unit + } + + return Saved.CHANGED + } +} + +private fun contextOf(screen: NpcEditorScreen): String = "NPC ${screen.npcId}" diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorLiveData.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorLiveData.kt new file mode 100644 index 0000000..d7cc4e6 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorLiveData.kt @@ -0,0 +1,34 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.common.editor.core.KnownTarget +import app.simplecloud.npc.common.editor.core.LiveServer +import app.simplecloud.npc.core.config.NpcConfig + +class HologramLiveState(val joinState: String?, val shown: String?) { + val fallbackInUse: Boolean get() = shown != null && joinState != null && shown != joinState +} + +class TargetSummary( + val isGroup: Boolean, + val liveServers: Int, + val players: Int, + val known: Boolean = true, +) + +interface NpcEditorLiveData { + fun cloudKnown(): Boolean = true + fun summaryOf(target: String): TargetSummary + fun playersInRange(config: NpcConfig): Int + fun joinState(target: String): String? + fun knownTargets(): List<KnownTarget> + fun servers(): List<LiveServer> + + fun hologramState(config: NpcConfig): HologramLiveState { + val joinState = config.targetServers.firstOrNull()?.let { joinState(it) } + val shown = joinState + ?.takeIf { config.hologram.enabled } + ?.let { config.hologram.layoutShownFor(it)?.joinState?.lowercase() } + + return HologramLiveState(joinState, shown) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorScreen.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorScreen.kt new file mode 100644 index 0000000..a3dbd24 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorScreen.kt @@ -0,0 +1,51 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.core.interaction.PlayerInteraction + +sealed interface PickerPurpose { + data object AddTarget : PickerPurpose + + data class AddJoinState(val forHologram: Boolean) : PickerPurpose + data class PickMenu(val interaction: PlayerInteraction, val joinState: String) : PickerPurpose + data class PickServer(val interaction: PlayerInteraction, val joinState: String) : PickerPurpose + data class PickSound(val field: SoundField) : PickerPurpose +} + +sealed interface SoundField { + data object Push : SoundField + + data class Action(val interaction: PlayerInteraction, val joinState: String) : SoundField +} + +sealed interface ConfirmPurpose { + data class RemoveJoinState(val joinState: String) : ConfirmPurpose + data class RemoveLayout(val joinState: String) : ConfirmPurpose +} + +enum class NpcTab(val label: String, val material: String, val description: String) { + NPC("NPC", "PLAYER_HEAD", "Summon, copy or delete it"), + LOOKS("Looks", "ARMOR_STAND", "Skin, name, glow, pose"), + BEHAVIOR("Behavior", "PISTON", "Looking and pushing"), + HOLOGRAM("Hologram", "GLOW_ITEM_FRAME", "Lines above its head"), + ACTIONS("Actions", "LEVER", "What a click does"), + TARGETS("Targets", "COMPASS", "Where it sends players"), +} + +sealed class NpcEditorScreen(val npcId: String) { + data class Tab(private val id: String, val tab: NpcTab) : NpcEditorScreen(id) + data class SkinPicker(private val id: String) : NpcEditorScreen(id) + data class Glow(private val id: String) : NpcEditorScreen(id) + data class Equipment(private val id: String) : NpcEditorScreen(id) + data class HologramFrames(private val id: String, val joinState: String, val line: Int) : NpcEditorScreen(id) + data class ActionEditor(private val id: String, val interaction: PlayerInteraction, val joinState: String) : + NpcEditorScreen(id) + + data class ActionBlocks(private val id: String, val interaction: PlayerInteraction, val joinState: String) : + NpcEditorScreen(id) + + data class TitleEditor(private val id: String, val interaction: PlayerInteraction, val joinState: String) : + NpcEditorScreen(id) + + data class Picker(private val id: String, val purpose: PickerPurpose) : NpcEditorScreen(id) + data class Confirm(private val id: String, val purpose: ConfirmPurpose) : NpcEditorScreen(id) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorSession.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorSession.kt new file mode 100644 index 0000000..3dafba6 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcEditorSession.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.common.editor.core.EditorSession +import app.simplecloud.npc.common.editor.core.EditorSessions +import app.simplecloud.npc.core.config.NpcConfig + +class NpcEditorSession : EditorSession<NpcEditorScreen>() { + var hologramState: String? = null + var actionState: String? = null + var carriedEquipment: NpcConfig.EquipmentItem? = null +} + +class NpcEditorSessions : EditorSessions<NpcEditorSession>(::NpcEditorSession) diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcFormat.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcFormat.kt new file mode 100644 index 0000000..6e00204 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/NpcFormat.kt @@ -0,0 +1,105 @@ +package app.simplecloud.npc.common.editor.npc + +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.location.NpcLocation +import java.util.Locale + +object NpcFormat { + + val CLICK_ROWS = listOf( + PlayerInteraction.LEFT_CLICK, + PlayerInteraction.SHIFT_LEFT_CLICK, + PlayerInteraction.RIGHT_CLICK, + PlayerInteraction.SHIFT_RIGHT_CLICK, + ) + + fun displayName(config: NpcConfig): String = + config.entity.customName?.takeIf { it.isNotBlank() }?.let(Msg.miniMessage::stripTags) ?: config.id + + fun plain(text: String): String = Msg.miniMessage.escapeTags(text) + + fun joinState(joinState: String): String = joinState.lowercase() + + fun isDefaultJoinState(joinState: String): Boolean = joinState.equals(NpcConfig.DEFAULT_JOIN_STATE, true) + + fun actionJoinStates(config: NpcConfig): List<String> = + (listOf(NpcConfig.DEFAULT_JOIN_STATE) + config.joinStates + config.actions.map { it.joinState }) + .map { it.lowercase() } + .distinct() + + fun hologramJoinStates(config: NpcConfig): List<String> = + (listOf(NpcConfig.DEFAULT_JOIN_STATE) + config.hologram.layouts.map { it.joinState }) + .map { it.lowercase() } + .distinct() + + fun cellName(interaction: PlayerInteraction, joinState: String): String = + "${Clicks.short(interaction)} · ${joinState(joinState)}" + + fun seconds(ticks: Int, decimals: Int = 1): String = String.format(Locale.ROOT, "%.${decimals}fs", ticks / 20.0) + + fun pacing(title: NpcConfig.TitleConfiguration): String = + "<bd><val>${seconds(title.fadeIn)} <bd>in · " + + "<val>${seconds(title.stay)} <bd>hold · " + + "<val>${seconds(title.fadeOut)} <bd>out" + + fun messageLines(message: String?): List<String> = message?.split('\n')?.filter { it.isNotBlank() }.orEmpty() + + fun fieldLines(action: NpcConfig.ActionConfiguration): List<String> = buildList { + action.openInventory?.let { add("<bd>Menu <val>${plain(it)}") } + action.sendToServer?.let { add("<bd>Server <val>${plain(it)}") } + action.teleport?.let { add("<bd>Teleport <val>${position(it)}") } + action.transferToServer?.let { add("<bd>Transfer <val>${plain(it)}") } + action.sendMessage?.let { add("<bd>Chat <val>${messageLines(it).size} lines") } + action.sendTitle?.let { add("<bd>Title <val>${Ui.quote(it.title.ifBlank { it.subtitle })}") } + action.playSound?.let { add("<bd>Sound <val>${plain(it)}") } + action.executeCommand?.let { add("<bd>Command <val>/${plain(it)}") } + action.actionBar?.let { add("<bd>Action bar <val>${Ui.quote(it)}") } + action.speech?.let { add("<bd>Says <val>${Ui.quote(it)}") } + action.npcAnimation?.let { add("<bd>Animation <val>${it.name.lowercase().replace('_', ' ')}") } + } + + fun soundSettings(options: NpcConfig.SoundOptions): String = + "<hnt>${String.format(Locale.ROOT, "%.1f", options.volume)} vol · " + + "${String.format(Locale.ROOT, "%.2f", options.pitch)} pitch" + + fun position(location: NpcLocation, separator: String = " "): String = + "${location.world}$separator${location.x.toInt()} ${location.y.toInt()} ${location.z.toInt()}" + + fun skinLabel(skin: NpcConfig.SkinConfiguration): String = + skin.sourcePlayer ?: if (skin.texture != null) "custom" else "default" + + fun glowColor(entity: NpcConfig.NpcEntityConfiguration): String = entity.glowColor ?: DEFAULT_GLOW_COLOR + + class GlowColor(val teamName: String, val label: String, val icon: String) + + val GLOW_COLORS = listOf( + GlowColor("white", "White", "WHITE_WOOL"), + GlowColor("gray", "Gray", "LIGHT_GRAY_WOOL"), + GlowColor("dark_gray", "Dark Gray", "GRAY_WOOL"), + GlowColor("black", "Black", "BLACK_WOOL"), + GlowColor("red", "Red", "RED_WOOL"), + GlowColor("dark_red", "Dark Red", "NETHER_WART_BLOCK"), + GlowColor("gold", "Gold", "ORANGE_WOOL"), + GlowColor("yellow", "Yellow", "YELLOW_WOOL"), + GlowColor("green", "Green", "LIME_WOOL"), + GlowColor("dark_green", "Dark Green", "GREEN_WOOL"), + GlowColor("aqua", "Aqua", "CYAN_WOOL"), + GlowColor("dark_aqua", "Dark Aqua", "WARPED_WART_BLOCK"), + GlowColor("blue", "Blue", "LIGHT_BLUE_WOOL"), + GlowColor("dark_blue", "Dark Blue", "BLUE_WOOL"), + GlowColor("light_purple", "Light Purple", "MAGENTA_WOOL"), + GlowColor("dark_purple", "Purple", "PURPLE_WOOL"), + ) + + fun glowLabel(teamName: String): String = + GLOW_COLORS.firstOrNull { it.teamName.equals(teamName, true) }?.label ?: teamName + + fun glowIcon(teamName: String): String = + (GLOW_COLORS.firstOrNull { it.teamName.equals(teamName, true) } ?: GLOW_COLORS.first()).icon + + private const val DEFAULT_GLOW_COLOR = "white" +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/ActionEditorMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/ActionEditorMenuBuilder.kt new file mode 100644 index 0000000..c2978a7 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/ActionEditorMenuBuilder.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.common.editor.npc.action + +import app.simplecloud.npc.common.editor.core.ActionFieldsPane +import app.simplecloud.npc.common.editor.core.armed +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.SoundField +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer + +object ActionEditorMenuBuilder { + + fun build( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + interaction: PlayerInteraction, + joinState: String, + ): EditorMenu { + val screen = NpcEditorScreen.ActionEditor(config.id, interaction, joinState) + val pane = Pane(ActionFieldsPane.SIZE) + + pane[ActionFieldsPane.HEADER_SLOT] = Ui.item( + Clicks.material(interaction), + "<ttl>${Clicks.label(interaction)}", + listOf("<hnt>Join state <val>${NpcFormat.joinState(joinState)}"), + glowing = true, + ) + + ActionFieldsPane.fill(pane, port(context, player, config, interaction, joinState, screen)) + + pane.back(context, player) + if (config.findAction(interaction, joinState) != null) { + pane.armed( + ActionFieldsPane.DELETE_SLOT, + context, + player, + screen, + "delete-action", + idle = Ui.item("RED_CONCRETE", "<err>Delete Action", listOf("<key>Left <hnt>Remove everything")), + verb = "Delete this action", + ) { + context.commitAndBack(player, config.id) { fresh -> + fresh.actions.removeIf { it.interactionType == interaction && it.joinState.equals(joinState, true) } + fresh + } + } + } + pane.fillNavRow() + + return pane.menu(Ui.title(NpcFormat.cellName(interaction, joinState), NpcFormat.displayName(config))) + } + + fun buildBlocks( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + interaction: PlayerInteraction, + joinState: String, + ): EditorMenu { + val editor = NpcEditorScreen.ActionEditor(config.id, interaction, joinState) + val pane = Pane(ActionFieldsPane.ADD_SIZE) + + ActionFieldsPane.fillAdd(pane, port(context, player, config, interaction, joinState, editor)) + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("Add to ${Clicks.short(interaction)}", NpcFormat.displayName(config))) + } + + private fun port( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + interaction: PlayerInteraction, + joinState: String, + screen: NpcEditorScreen.ActionEditor, + ): ActionFieldsPane.Port { + fun navigate(purpose: PickerPurpose) = context.navigate(player, NpcEditorScreen.Picker(config.id, purpose)) + + return ActionFieldsPane.Port( + scope = ActionFieldsPane.Scope.NPC, + player = player, + action = config.findAction(interaction, joinState) ?: NpcConfig.ActionConfiguration(interaction, joinState), + edit = { mutate -> + context.commit(player, config.id, screen) { fresh -> + fresh.apply { + mutate(actionOrCreate(interaction, joinState)) + dropActionIfEmpty(interaction, joinState) + } + } + }, + toggleJoinTarget = { + context.commit(player, config.id, screen) { fresh -> + val target = fresh.actionOrCreate(interaction, joinState) + val enable = !target.joinTarget + if (enable) fresh.actions.forEach { it.joinTarget = false } + target.joinTarget = enable + fresh.dropActionIfEmpty(interaction, joinState) + fresh + } + }, + prompt = { context.chatPrompt(player, it) }, + rerender = { context.render(player, screen) }, + pickMenu = { navigate(PickerPurpose.PickMenu(interaction, joinState)) }, + pickServer = { navigate(PickerPurpose.PickServer(interaction, joinState)) }, + pickSound = { navigate(PickerPurpose.PickSound(SoundField.Action(interaction, joinState))) }, + openTitle = { context.navigate(player, NpcEditorScreen.TitleEditor(config.id, interaction, joinState)) }, + openAdd = { context.navigate(player, NpcEditorScreen.ActionBlocks(config.id, interaction, joinState)) }, + closeAdd = { context.back(player) }, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/TitleEditorMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/TitleEditorMenuBuilder.kt new file mode 100644 index 0000000..0fbe8ff --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/action/TitleEditorMenuBuilder.kt @@ -0,0 +1,57 @@ +package app.simplecloud.npc.common.editor.npc.action + +import app.simplecloud.npc.common.editor.core.TitlePane +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer + +object TitleEditorMenuBuilder { + + fun build( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + interaction: PlayerInteraction, + joinState: String, + ): EditorMenu { + val screen = NpcEditorScreen.TitleEditor(config.id, interaction, joinState) + val pane = Pane(TitlePane.SIZE) + + TitlePane.fill( + pane, + TitlePane.Port( + player = player, + title = config.findAction(interaction, joinState)?.sendTitle, + textPrompts = context.textPrompts, + edit = { mutate -> + context.commit(player, config.id, screen) { fresh -> + val action = fresh.actionOrCreate(interaction, joinState) + action.sendTitle = TitlePane.normalized(mutate(action.sendTitle ?: TitlePane.defaults())) + fresh.dropActionIfEmpty(interaction, joinState) + fresh + } + }, + removeAndBack = { + context.commitAndBack(player, config.id) { fresh -> + fresh.apply { + actionOrCreate(interaction, joinState).sendTitle = null + dropActionIfEmpty(interaction, joinState) + } + } + }, + ), + ) + + pane.back(context, player) + pane.fillNavRow() + + return pane.menu(Ui.title("Title", NpcFormat.displayName(config))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/EquipmentCanvas.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/EquipmentCanvas.kt new file mode 100644 index 0000000..9ded1c3 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/EquipmentCanvas.kt @@ -0,0 +1,287 @@ +package app.simplecloud.npc.common.editor.npc.appearance + +import app.simplecloud.npc.common.editor.canvas.CanvasInput +import app.simplecloud.npc.common.editor.canvas.CanvasSurface +import app.simplecloud.npc.common.editor.canvas.CanvasView +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcEditorSession +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Palette +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.config.EquipmentSlot +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.NpcConfig.EquipmentConfiguration +import app.simplecloud.npc.core.config.NpcConfig.EquipmentItem +import app.simplecloud.npc.core.platform.NpcPlayer + +class EquipmentCanvas( + private val context: NpcEditorContext, + private val surface: CanvasSurface, + private val onEscape: (NpcPlayer) -> Unit, +) { + + fun show(player: NpcPlayer, screen: NpcEditorScreen.Equipment) { + val config = context.npcRepository.find(screen.npcId) ?: run { + context.reportMissing(player, screen.npcId) + context.close(player) + return + } + val session = context.session(player) + + surface.open( + player, + view(config, session), + onInput = { input -> handle(player, screen, input) }, + onClose = { onEscape(player) }, + ) + } + + private fun handle(player: NpcPlayer, screen: NpcEditorScreen.Equipment, input: CanvasInput) { + val session = context.session(player) + val config = context.npcRepository.find(screen.npcId) ?: return + + when (input) { + is CanvasInput.Click -> when { + input.slot >= SIZE -> fromInventory(player, screen, session, input.slot - SIZE, input.shift) + else -> SLOT_AT[input.slot]?.let { slot -> clickSlot(player, screen, session, config, slot, input) } + ?: button(player, screen, session, config, input.slot) + } + + is CanvasInput.Key -> SLOT_AT[input.slot]?.let { slot -> + val index = if (input.key == CanvasInput.OFFHAND_KEY) input.key else HOTBAR_START + input.key + surface.equipmentItem(player, index)?.let { put(player, screen, slot, it) } + } + + is CanvasInput.Drag -> session.carriedEquipment?.let { carried -> + val slots = input.slots.mapNotNull(SLOT_AT::get) + if (slots.isEmpty()) return + session.carriedEquipment = null + edit(player, screen) { equipment -> slots.fold(equipment) { acc, slot -> acc.with(slot, carried) } } + } + + is CanvasInput.Drop -> SLOT_AT[input.slot]?.let { slot -> put(player, screen, slot, null) } + + is CanvasInput.Outside -> if (session.carriedEquipment != null) { + session.carriedEquipment = null + context.render(player, screen) + } + + is CanvasInput.DoubleClick, is CanvasInput.Clone -> Unit + } + } + + private fun fromInventory( + player: NpcPlayer, + screen: NpcEditorScreen.Equipment, + session: NpcEditorSession, + index: Int, + shift: Boolean, + ) { + val look = surface.equipmentItem(player, index) ?: run { + if (session.carriedEquipment != null) { + session.carriedEquipment = null + context.render(player, screen) + } + return + } + + if (shift) { + put(player, screen, EquipmentSlot.suitedFor(look.material), look) + } else { + session.carriedEquipment = look + context.render(player, screen) + } + } + + private fun clickSlot( + player: NpcPlayer, + screen: NpcEditorScreen.Equipment, + session: NpcEditorSession, + config: NpcConfig, + slot: EquipmentSlot, + click: CanvasInput.Click, + ) { + val worn = config.entity.equipment.of(slot) + val carried = session.carriedEquipment + + when { + click.shift -> put(player, screen, slot, null) + carried != null -> { + session.carriedEquipment = worn + put(player, screen, slot, carried) + } + + click.right -> worn?.let { put(player, screen, slot, it.copy(glowing = !it.glowing)) } + worn != null -> { + session.carriedEquipment = worn + put(player, screen, slot, null) + } + } + } + + private fun button( + player: NpcPlayer, + screen: NpcEditorScreen.Equipment, + session: NpcEditorSession, + config: NpcConfig, + slot: Int, + ) { + when (slot) { + BACK_SLOT -> context.back(player) + COPY_SLOT -> { + val worn = player.wornArmor() + edit(player, screen) { equipment -> + ARMOR.fold(equipment) { acc, armorSlot -> acc.with(armorSlot, worn.of(armorSlot)) } + } + } + + CLEAR_SLOT -> if (config.entity.equipment.filled().isNotEmpty() || session.carriedEquipment != null) { + session.carriedEquipment = null + edit(player, screen) { EquipmentConfiguration() } + } + } + } + + private fun put(player: NpcPlayer, screen: NpcEditorScreen.Equipment, slot: EquipmentSlot, item: EquipmentItem?) = + edit(player, screen) { it.with(slot, item) } + + private fun edit( + player: NpcPlayer, + screen: NpcEditorScreen.Equipment, + mutate: (EquipmentConfiguration) -> EquipmentConfiguration, + ) { + context.editEntity(player, screen.npcId, screen) { it.copy(equipment = mutate(it.equipment)) } + } + + private fun view(config: NpcConfig, session: NpcEditorSession): CanvasView { + val equipment = config.entity.equipment + val items = mutableMapOf<Int, NpcItem>() + + (0 until SIZE).forEach { items[it] = BACKGROUND } + NAV_ROW.forEach { items[it] = NAV_FILLER } + + items[HEADER_SLOT] = Ui.item( + "ARMOR_STAND", + "<ttl>Equipment", + listOf( + "<bd><val>${equipment.filled().size} <bd>of <val>${EquipmentSlot.entries.size} <bd>slots worn", + "", + "<hnt>Pick up any item from your inventory", + "<hnt>below and drop it on the NPC.", + "<key>Shift+Left <hnt>on your item puts it where it fits.", + ), + ) + SLOTS.forEach { (slot, menuSlot) -> items[menuSlot] = slotIcon(slot, equipment.of(slot)) } + + items[BACK_SLOT] = Ui.back() + items[COPY_SLOT] = Ui.item( + "LEATHER_CHESTPLATE", + "<ttl>Copy My Armor", + listOf("<bd>Dresses the NPC in the armor", "<bd>you are wearing right now.", "", "<key>Left <hnt>Copy"), + ) + items[CLEAR_SLOT] = if (equipment.filled().isEmpty()) { + Ui.disabled("Take Everything Off", listOf("<hnt>The NPC wears nothing.")) + } else { + Ui.item("BUCKET", "<err>Take Everything Off", listOf("", "<key>Left <hnt>Clear all slots")) + } + items[HELP_SLOT] = Ui.item("BOOK", "<ttl>Controls", HELP) + + return CanvasView( + title = Ui.title("Equipment", NpcFormat.displayName(config)), + size = SIZE, + items = items, + carried = session.carriedEquipment?.let { icon(it, "<ttl>${Ui.pretty(it.material)}", emptyList()) }, + showInventory = true, + ) + } + + private fun slotIcon(slot: EquipmentSlot, worn: EquipmentItem?): NpcItem { + val label = label(slot) + worn ?: return Ui.item( + "LIGHT_GRAY_STAINED_GLASS_PANE", + "<ttl>$label", + listOf( + "<off>Empty", + "", + "<hnt>Drop an item from your inventory here", + ), + ) + + val details = listOfNotNull( + "<bd>Wears <val>${Ui.pretty(worn.material)}", + worn.color?.let { "<bd>Color <val>$it" }, + worn.trim?.let { "<bd>Trim <val>${pretty(it.pattern)} <bd>in <val>${pretty(it.material)}" }, + worn.customModelData?.let { "<bd>Model data <val>$it" }, + ) + + return icon( + worn, + "<ttl>$label", + details + listOf( + "", + "<key>Left <hnt>Pick it up <key>Right <hnt>Glint ${if (worn.glowing) "off" else "on"}", + "<key>Shift+Left <hnt>or <key>Q <hnt>Take it off", + ), + ) + } + + private fun icon(item: EquipmentItem, name: String, lore: List<String>): NpcItem = NpcItem( + material = item.material, + name = Palette.expand(name), + lore = lore.map(Palette::expand), + glowing = item.glowing, + skinTexture = item.headTexture, + customModelData = item.customModelData, + armorColor = item.color, + armorTrim = item.trim, + ) + + private fun pretty(key: String): String = Ui.pretty(key.substringAfter(':').uppercase()) + + private fun label(slot: EquipmentSlot): String = when (slot) { + EquipmentSlot.MAIN_HAND -> "Main Hand" + EquipmentSlot.OFF_HAND -> "Off Hand" + EquipmentSlot.HELMET -> "Helmet" + EquipmentSlot.CHESTPLATE -> "Chestplate" + EquipmentSlot.LEGGINGS -> "Leggings" + EquipmentSlot.BOOTS -> "Boots" + } + + private companion object { + const val SIZE = 54 + const val HOTBAR_START = 27 + const val HEADER_SLOT = 4 + const val BACK_SLOT = 45 + const val COPY_SLOT = 48 + const val CLEAR_SLOT = 50 + const val HELP_SLOT = 53 + val NAV_ROW = 45 until SIZE + + val SLOTS = mapOf( + EquipmentSlot.HELMET to 13, + EquipmentSlot.MAIN_HAND to 21, + EquipmentSlot.CHESTPLATE to 22, + EquipmentSlot.OFF_HAND to 23, + EquipmentSlot.LEGGINGS to 31, + EquipmentSlot.BOOTS to 40, + ) + val SLOT_AT = SLOTS.entries.associate { (slot, menuSlot) -> menuSlot to slot } + val ARMOR = listOf(EquipmentSlot.HELMET, EquipmentSlot.CHESTPLATE, EquipmentSlot.LEGGINGS, EquipmentSlot.BOOTS) + + val BACKGROUND = NpcItem("BLACK_STAINED_GLASS_PANE", " ", hideTooltip = true) + val NAV_FILLER = NpcItem(Pane.GRAY_PANE, " ", hideTooltip = true) + + val HELP = listOf( + "<key>Left <hnt>on your item picks up a copy", + "<key>Left <hnt>on a slot drops it there", + "<key>Shift+Left <hnt>on your item puts it where it fits", + "<key>1-9 <hnt>/ <key>F <hnt>on a slot takes that hotbar / off-hand item", + "<key>Right <hnt>on a slot toggles the glint", + "<key>Q <hnt>takes it off", + "<hnt>Your own items never move.", + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/GlowMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/GlowMenuBuilder.kt new file mode 100644 index 0000000..c45d841 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/GlowMenuBuilder.kt @@ -0,0 +1,38 @@ +package app.simplecloud.npc.common.editor.npc.appearance + +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object GlowMenuBuilder { + private const val SIZE = 27 + + private val COLOR_SLOTS = (0..7) + (9..16) + private val FILLER_SLOTS = listOf(8, 17) + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val current = NpcFormat.glowColor(config.entity) + val pane = Pane(SIZE) + + NpcFormat.GLOW_COLORS.zip(COLOR_SLOTS).forEach { (color, slot) -> + val selected = color.teamName.equals(current, true) + pane.left(slot, Ui.option(color.icon, color.label, listOf(""), selected)) { + context.commitAndBack(player, config.id, refresh = Refresh.ENTITY) { fresh -> + fresh.copy(entity = fresh.entity.copy(glowing = true, glowColor = color.teamName)) + } + } + } + + pane.back(context, player) + pane.fill(FILLER_SLOTS) + pane.fillNavRow() + + return pane.menu(Ui.title("Glow Color", NpcFormat.displayName(config))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/SkinMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/SkinMenuBuilder.kt new file mode 100644 index 0000000..7792a5e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/appearance/SkinMenuBuilder.kt @@ -0,0 +1,152 @@ +package app.simplecloud.npc.common.editor.npc.appearance + +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.core.pageKey +import app.simplecloud.npc.common.editor.core.pager +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Paginator +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.manager.NpcOperationResult +import app.simplecloud.npc.common.text.sendError +import app.simplecloud.npc.common.text.sendInfo +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.common.utils.SkinInput +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object SkinMenuBuilder { + private const val SIZE = 54 + + private val GRID_SLOTS = 0..35 + private val SEPARATOR_ROW = 36..44 + private const val RESET_SLOT = 51 + private const val INPUT_SLOT = 53 + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val session = context.session(player) + val online = + context.playerDirectory.onlinePlayers().sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + val current = config.entity.skin.sourcePlayer + val screen = NpcEditorScreen.SkinPicker(config.id) + val pages = Paginator(session.pages, pageKey(screen), online, GRID_SLOTS.count()) + val pane = Pane(SIZE) + + pages.visible.forEachIndexed { index, onlinePlayer -> + val selected = onlinePlayer.name.equals(current, true) + pane.left( + GRID_SLOTS.first + index, + Ui.head( + if (selected) "<on>${onlinePlayer.name}" else "<ttl>${onlinePlayer.name}", + listOf(if (selected) "<on>In use" else "<key>Left <hnt>Use this skin"), + owner = onlinePlayer.uniqueId, + glowing = selected, + ), + ) { applySkin(context, player, config, context.renderer.captureSkin(onlinePlayer)) } + } + pane.fill(SEPARATOR_ROW) + + pane.back(context, player) + pane.pager(pages, listOf("<bd>${online.size} players online", "<hnt>${pages.perPage} skins fit per page.")) { + context.render(player, screen) + } + + pane.left( + RESET_SLOT, + Ui.item( + "BARRIER", + "<err>Reset Skin", + listOf("<bd>Now <val>${NpcFormat.skinLabel(config.entity.skin)}", "<key>Left <hnt>Back to default"), + ), + ) { applySkin(context, player, config, NpcConfig.SkinConfiguration()) } + pane.left( + INPUT_SLOT, + Ui.item("WRITABLE_BOOK", "<ttl>Username Or UUID", listOf("<key>Left <hnt>Type it in chat")), + ) { promptSkin(context, player, config) } + pane.fillNavRow() + + return pane.menu(Ui.title("Skin", NpcFormat.displayName(config))) + } + + private fun promptSkin(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig) { + context.textPrompts.open( + player, + title = "Skin", + instruction = "Type the username or UUID of the player whose skin to use.", + current = config.entity.skin.sourcePlayer, + validate = { input -> + when (SkinInput.parse(input)) { + null -> "That is no username or UUID." + is SkinInput.Texture -> "Set texture skins in the NPC's config file." + else -> null + } + }, + ) { text -> + when (val parsed = SkinInput.parse(text)) { + is SkinInput.Uuid -> lookUp(context, player, config, text) { + context.renderer.captureSkinByUuid(parsed.uuid) + } + is SkinInput.Username -> lookUp(context, player, config, text) { + context.renderer.captureSkinByUsername(parsed.name)?.copy(sourcePlayer = parsed.name) + } + + is SkinInput.Texture, null -> context.render(player, NpcEditorScreen.SkinPicker(config.id)) + } + } + } + + private fun lookUp( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + label: String, + fetch: suspend () -> NpcConfig.SkinConfiguration?, + ) { + player.sendInfo("Looking up the skin for {}...", label) + + BackgroundTasks.launch { + val skin = fetch() + context.runSync { + if (skin == null) { + player.sendError("Could not find a skin for {}.", label) + context.render(player, NpcEditorScreen.SkinPicker(config.id)) + } else { + applySkin(context, player, config, skin) + } + } + } + } + + private fun applySkin( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + skin: NpcConfig.SkinConfiguration, + ) { + val stillHere = context.isShowing(player) { it is NpcEditorScreen.SkinPicker && it.npcId == config.id } + + if (config.entity.provider !in context.providers.fixedSkinProviders) { + player.sendError( + "NPC {} uses provider '{}', which mirrors each viewer's own skin automatically, " + + "so a fixed skin can't be set for it.", + config.id, + config.entity.provider, + ) + + if (stillHere) context.back(player) + + return + } + + when (context.npcManager.updateSkin(config.id, skin)) { + is NpcOperationResult.Success -> if (stillHere) context.back(player) + is NpcOperationResult.Failure -> { + context.reportMissing(player, config.id) + if (stillHere) context.close(player) + } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/confirm/ConfirmGateMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/confirm/ConfirmGateMenuBuilder.kt new file mode 100644 index 0000000..6699131 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/confirm/ConfirmGateMenuBuilder.kt @@ -0,0 +1,110 @@ +package app.simplecloud.npc.common.editor.npc.confirm + +import app.simplecloud.npc.common.editor.core.ConfirmGate +import app.simplecloud.npc.common.editor.core.ConfirmSpec +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.ConfirmPurpose +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object ConfirmGateMenuBuilder { + private class Wording(val title: String, val verb: String, val noun: String) + + private fun wording(purpose: ConfirmPurpose): Wording = when (purpose) { + is ConfirmPurpose.RemoveJoinState -> Wording( + "Remove ${NpcFormat.joinState(purpose.joinState)}?", + "Remove", + "removal", + ) + + is ConfirmPurpose.RemoveLayout -> Wording( + "Remove ${NpcFormat.joinState(purpose.joinState)} layout?", + "Remove", + "removal", + ) + } + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, purpose: ConfirmPurpose): EditorMenu { + val words = wording(purpose) + return ConfirmGate.build( + context, + player, + ConfirmSpec<NpcEditorScreen>( + title = Ui.title(words.title, NpcFormat.displayName(config)), + screen = NpcEditorScreen.Confirm(config.id, purpose), + verb = words.verb, + noun = words.noun, + head = head(config, purpose), + fire = { fire(context, player, config, purpose) }, + ), + ) + } + + private fun head(config: NpcConfig, purpose: ConfirmPurpose): NpcItem = when (purpose) { + is ConfirmPurpose.RemoveJoinState -> { + val layout = config.hologram.findLayout(purpose.joinState) + val actionCount = config.actions.count { + it.joinState.equals(purpose.joinState, true) && it.configuredTypes().isNotEmpty() + } + + Ui.item( + "PAPER", + "<err><b>Remove ${NpcFormat.joinState(purpose.joinState)}?", + listOf( + layout?.let { "<bd>Hologram layout <val>yes <bd>· <val>${it.lines.size} <bd>lines" } + ?: "<bd>Hologram layout <off>none", + "<bd><val>$actionCount <bd>configured actions", + "", + "<err>Both go. There is no undo.", + ), + glowing = true, + ) + } + + is ConfirmPurpose.RemoveLayout -> { + val lines = config.hologram.findLayout(purpose.joinState)?.lines?.size ?: 0 + Ui.item( + "PAPER", + "<err><b>Remove ${NpcFormat.joinState(purpose.joinState)} layout?", + listOf( + "<bd><val>$lines <bd>hologram lines", + "<hnt>Actions for ${NpcFormat.joinState(purpose.joinState)} are kept.", + "", + "<err>The lines go. There is no undo.", + ), + glowing = true, + ) + } + } + + private fun fire(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, purpose: ConfirmPurpose) { + when (purpose) { + is ConfirmPurpose.RemoveJoinState -> context.commitAndBack( + player, + config.id, + refresh = Refresh.HOLOGRAM, + ) { fresh -> + val state = purpose.joinState.lowercase() + fresh.hologram.layouts.removeIf { it.joinState.equals(state, true) } + fresh.actions.removeIf { it.joinState.equals(state, true) } + fresh.joinStates.removeIf { it.equals(state, true) } + fresh + } + + is ConfirmPurpose.RemoveLayout -> context.commitAndBack( + player, + config.id, + refresh = Refresh.HOLOGRAM, + ) { fresh -> + fresh.hologram.layouts.removeIf { it.joinState.equals(purpose.joinState, true) } + fresh + } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramFramesMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramFramesMenuBuilder.kt new file mode 100644 index 0000000..c7ba67c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramFramesMenuBuilder.kt @@ -0,0 +1,127 @@ +package app.simplecloud.npc.common.editor.npc.hologram + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.LineList +import app.simplecloud.npc.common.editor.core.LinesPane +import app.simplecloud.npc.common.editor.core.back +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.text.PlayerPlaceholders + +object HologramFramesMenuBuilder { + private const val SIZE = 27 + private const val INTERVAL_SLOT = 22 + private const val REFRESH_SLOT = 24 + private val FRAME_SLOTS = (0..17).toList() + + private val INTERVAL = Stepper( + label = "Interval", + range = HologramLine.FRAME_INTERVAL_RANGE, + step = 0.5, + default = HologramLine.DEFAULT_FRAME_INTERVAL, + decimals = 1, + unit = "seconds", + hints = listOf("<hnt>How long each text stays."), + ) + + private val REFRESH = Stepper( + label = "Placeholder Refresh", + range = HologramLine.REFRESH_RANGE, + step = 0.25, + default = HologramLine.DEFAULT_REFRESH, + decimals = 2, + unit = "seconds", + hints = listOf("<hnt>How often PlaceholderAPI values update."), + ) + + fun build( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + screen: NpcEditorScreen.HologramFrames, + ): EditorMenu { + val line = config.hologram.findLayout(screen.joinState)?.lines?.getOrNull(screen.line) + val pane = Pane(SIZE) + + fun editLine(mutate: (HologramLine) -> HologramLine?) = + context.commit(player, config.id, screen, Refresh.HOLOGRAM) { fresh -> + val lines = fresh.hologram.findLayout(screen.joinState)?.lines ?: return@commit null + val current = lines.getOrNull(screen.line) ?: return@commit null + lines[screen.line] = mutate(current) ?: return@commit null + fresh + } + + val frames = LineList( + lines = line?.texts.orEmpty(), + textOf = { it }, + withText = { _, text -> text }, + create = { it }, + edit = { mutate -> + editLine { current -> + val updated = current.texts.toMutableList() + if (mutate(updated)) current.withFrames(updated) else null + } + }, + prompt = { _, current, onText, onDelete -> + promptFrame(context, player, screen, current, onText, onDelete) + }, + ) + LinesPane.place(pane, frames, FRAME_SLOTS, "Add Text") + + val interval = line?.interval ?: HologramLine.DEFAULT_FRAME_INTERVAL + pane[INTERVAL_SLOT] = INTERVAL.element(interval) { value -> + editLine { it.copy(frameInterval = value) } + } + if (line != null && line.texts.any(PlayerPlaceholders::usesExternal)) { + pane[REFRESH_SLOT] = REFRESH.element(line.refreshSeconds) { value -> + editLine { it.copy(refresh = value) } + } + } + pane.back(context, player) + pane.fillNavRow() + + val title = "Rotating · ${NpcFormat.joinState(screen.joinState)} #${screen.line + 1}" + + return pane.menu(Ui.title(title, NpcFormat.displayName(config))) + } + + private fun promptFrame( + context: NpcEditorContext, + player: NpcPlayer, + screen: NpcEditorScreen.HologramFrames, + current: String?, + apply: (String) -> Unit, + onDelete: (() -> Unit)?, + ) { + context.chatPrompt( + player, + Prompt( + title = "Rotating Text", + instruction = "Type one of the texts this line rotates through.", + current = current, + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.HOLOGRAM, + onClear = onDelete, + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + val text = input.trim() + HologramLineEditor.tooLong(text)?.let { return@Prompt it } + apply(text) + PromptResult.Accepted + }, + ), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramLineEditor.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramLineEditor.kt new file mode 100644 index 0000000..7d94c7e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/hologram/HologramLineEditor.kt @@ -0,0 +1,214 @@ +package app.simplecloud.npc.common.editor.npc.hologram + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptExtra +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.PromptTokens +import app.simplecloud.npc.common.editor.core.LineAction +import app.simplecloud.npc.common.editor.core.LineList +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.text.PlayerPlaceholders +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.flattener.ComponentFlattener + +class LineTarget(val npcId: String, val joinState: String, val screen: NpcEditorScreen) + +object HologramLineEditor { + private const val MAX_VISIBLE_LENGTH = 64 + + private val LINE_GONE = Component.text("That line no longer exists.") + + private val PLACEHOLDER = Regex("<[a-z]+_[a-z_]+>|%[^%\\s]+%") + + fun lines( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + joinState: String, + screen: NpcEditorScreen, + ): LineList<HologramLine> { + val state = joinState.lowercase() + val target = LineTarget(config.id, state, screen) + + return LineList( + lines = config.hologram.findLayout(state)?.lines.orEmpty(), + textOf = HologramLine::shownText, + withText = { line, text -> + if (line.rotating) line.withFrames(listOf(text) + line.texts.drop(1)) else line.copy(text = text) + }, + create = ::HologramLine, + edit = { mutate -> + context.commit(player, config.id, screen, Refresh.HOLOGRAM) { fresh -> + fresh.takeIf { mutate(it.hologram.layoutOrCreate(state).lines) } + } + }, + prompt = { index, current, onText, onDelete -> + promptLine(context, player, target, index, current, onText, onDelete) + }, + hints = { line -> + val rotation = listOf( + "<hnt>Rotates <val>${line.texts.size} <hnt>texts every " + + "<val>${NpcFormat.seconds((line.interval * 20).toInt())}", + ).takeIf { line.rotating }.orEmpty() + val live = when { + line.texts.any(PlayerPlaceholders::isPersonal) -> listOf("<hnt>Shown per player") + line.texts.any(PLACEHOLDER::containsMatchIn) -> listOf("<hnt>Has placeholders") + else -> emptyList() + } + + rotation + live + }, + secondary = LineAction("Rotating texts") { index -> + context.navigate(player, NpcEditorScreen.HologramFrames(config.id, state, index)) + }, + opensSecondary = HologramLine::rotating, + ) + } + + private fun promptLine( + context: NpcEditorContext, + player: NpcPlayer, + screen: LineTarget, + index: Int?, + current: String?, + apply: (String) -> Unit, + onDelete: (() -> Unit)?, + ) { + var target = index + + context.chatPrompt( + player, + Prompt( + title = "Hologram Line", + instruction = "Type the hologram line in chat.", + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.HOLOGRAM, + current = current, + onClear = onDelete, + onCancel = { context.render(player, screen.screen) }, + onSubmit = { input -> + val text = input.trim() + tooLong(text)?.let { return@Prompt it } + apply(text) + PromptResult.Accepted + }, + extra = rotateExtra( + save = { text -> saveLine(context, player, screen, target, text)?.also { target = it } != null }, + next = { target?.let { promptNextText(context, player, screen, it) } }, + ), + ), + ) + } + + private fun promptNextText( + context: NpcEditorContext, + player: NpcPlayer, + screen: LineTarget, + index: Int, + ) { + val line = context.npcRepository.find(screen.npcId) + ?.hologram?.findLayout(screen.joinState)?.lines?.getOrNull(index) + ?: return context.render(player, screen.screen) + + context.chatPrompt( + player, + Prompt( + title = "Rotating Text", + instruction = "Type text {} of line {}; it takes turns with the others.", + instructionArgs = listOf(line.texts.size + 1, index + 1), + format = PromptFormat.MINI_MESSAGE, + placeholders = PromptTokens.HOLOGRAM, + onCancel = { context.render(player, screen.screen) }, + onSubmit = { input -> + val text = input.trim() + tooLong(text)?.let { return@Prompt it } + context.commit(player, screen.npcId, screen.screen, Refresh.HOLOGRAM) { fresh -> + appendText(fresh, screen, index, text) + } + PromptResult.Accepted + }, + extra = rotateExtra( + save = { text -> + context.change(player, screen.npcId, Refresh.HOLOGRAM) { appendText(it, screen, index, text) } + }, + next = { promptNextText(context, player, screen, index) }, + ), + ), + ) + } + + private fun rotateExtra(save: (String) -> Boolean, next: () -> Unit) = PromptExtra( + label = "Apply + add rotating text", + hint = "Saves it and asks right away for another text this line takes turns with.", + submit = { input -> + val text = input.trim() + tooLong(text) ?: if (save(text)) PromptResult.Accepted else PromptResult.Rejected(LINE_GONE) + }, + next = next, + ) + + private fun saveLine( + context: NpcEditorContext, + player: NpcPlayer, + screen: LineTarget, + index: Int?, + text: String, + ): Int? { + var saved: Int? = null + context.change(player, screen.npcId, Refresh.HOLOGRAM) { fresh -> + val lines = fresh.hologram.layoutOrCreate(screen.joinState).lines + when (index) { + null -> { + lines += HologramLine(text) + saved = lines.lastIndex + } + + in lines.indices -> { + val line = lines[index] + lines[index] = line.withFrames(listOf(text) + line.texts.drop(1)) + saved = index + } + + else -> return@change null + } + fresh + } + + return saved + } + + private fun appendText( + fresh: NpcConfig, + screen: LineTarget, + index: Int, + text: String, + ): NpcConfig? { + val lines = fresh.hologram.findLayout(screen.joinState)?.lines ?: return null + val line = lines.getOrNull(index) ?: return null + lines[index] = line.withFrames(line.texts + text) + + return fresh + } + + fun tooLong(text: String): PromptResult.Rejected? { + val visible = visibleLength(text) + if (visible <= MAX_VISIBLE_LENGTH) return null + + return PromptResult.Rejected( + Component.text("That line shows $visible characters, the maximum is $MAX_VISIBLE_LENGTH."), + ) + } + + private fun visibleLength(text: String): Int { + val component: Component = runCatching { Msg.miniMessage.deserialize(text) }.getOrElse { Component.text(text) } + return buildString { ComponentFlattener.basic().flatten(component) { append(it) } }.length + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddJoinStateSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddJoinStateSource.kt new file mode 100644 index 0000000..930afa4 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddJoinStateSource.kt @@ -0,0 +1,96 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.cloud.JoinStates +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +class AddJoinStateSource(override val purpose: PickerPurpose.AddJoinState) : PickerSource { + private val forHologram = purpose.forHologram + + override val title = if (forHologram) "Add Layout" else "Add Join State" + + private fun present(config: NpcConfig): List<String> = + if (forHologram) NpcFormat.hologramJoinStates(config) else NpcFormat.actionJoinStates(config) + + override fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> { + val present = present(config) + val atCap = !forHologram && present.size >= NpcConfig.MAX_JOIN_STATES + val liveTargets = config.targetServers + .mapNotNull { target -> context.liveData.joinState(target)?.let { it.lowercase() to target } } + .groupBy({ it.first }, { it.second }) + + return JoinStates.offered(config, liveTargets.keys).map { state -> + val label = NpcFormat.joinState(state) + val usedLine = "<bd>Used by " + listOfNotNull( + "<val>hologram".takeIf { config.hologram.findLayout(state) != null }, + "<val>actions".takeIf { state in NpcFormat.actionJoinStates(config) }, + ).ifEmpty { listOf("<off>nothing yet") }.joinToString("<bd>, ") + val liveLine = liveTargets[state]?.let { "<on>Live now <hnt>on ${it.joinToString(", ")}" } + val lore = listOfNotNull(usedLine, liveLine) + when { + state in present -> PickerOption( + label, + Ui.unavailable(label, lore, "Already present here."), + available = false, + ) + + atCap -> PickerOption(label, Ui.unavailable(label, lore, "No free slot left here."), available = false) + else -> PickerOption(state, Ui.item("PAPER", "<ttl>$label", lore + listOf("", "<key>Left <hnt>Add"))) + } + } + } + + override fun resolveTyped(context: NpcEditorContext, config: NpcConfig, typed: String): PickerResult? { + val state = typed.removePrefix("@").lowercase() + val present = present(config) + + return when { + !JOIN_STATE_PATTERN.matches(state) -> + PickerResult.Rejected("Invalid", typed, "Letters, digits, _ and - only, up to 24 characters.") + + state in present && forHologram -> + PickerResult.Rejected("Already present", typed, "That join state already has a layout.") + + state in present -> PickerResult.Rejected("Already present", typed, "That join state already has a column.") + + !forHologram && present.size >= NpcConfig.MAX_JOIN_STATES -> + PickerResult.Rejected( + "Cap reached", + typed, + "All ${NpcConfig.MAX_JOIN_STATES} join state columns are in use.", + ) + + else -> null + } + } + + override fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) { + val state = value.removePrefix("@").lowercase() + val session = context.session(player) + if (forHologram) session.hologramState = state else session.actionState = state + if (forHologram) { + context.commitAndBack(player, config.id, refresh = Refresh.HOLOGRAM) { fresh -> + fresh.apply { hologram.layoutOrCreate(state) } + } + } else { + context.commitAndBack(player, config.id) { fresh -> + if (fresh.joinStates.any { it.equals(state, true) }) return@commitAndBack null + if (present(fresh).size >= NpcConfig.MAX_JOIN_STATES) return@commitAndBack null + + fresh.joinStates += state + fresh + } + } + } + + private companion object { + private val JOIN_STATE_PATTERN = Regex("^[a-z0-9_-]{1,24}$") + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddTargetSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddTargetSource.kt new file mode 100644 index 0000000..8f68169 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/AddTargetSource.kt @@ -0,0 +1,106 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerOptions +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.core.ServerStatus +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object AddTargetSource : PickerSource { + override val purpose = PickerPurpose.AddTarget + override val title = "Add Target" + + override fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> { + val live = context.liveData + val servers = live.servers() + val atCap = config.targetServers.size >= NpcConfig.MAX_TARGETS + + return live.knownTargets().map { target -> + val typeLine = "<bd>Type <val>${if (target.group) "group" else "server"}" + val used = config.targetServers.any { it.equals(target.name, true) } + when { + used -> PickerOption( + target.name, + Ui.unavailable(target.name, listOf(typeLine), "Already targeted."), + available = false, + ) + + atCap -> PickerOption( + target.name, + Ui.unavailable(target.name, listOf(typeLine), "Target cap of ${NpcConfig.MAX_TARGETS} reached."), + available = false, + ) + + target.group -> PickerOption( + target.name, + Ui.item( + "PAPER", + "<ttl>${target.name}", + listOf( + typeLine, + "<bd>Live <val>${live.summaryOf(target.name).liveServers} <bd>servers", + "", + "<key>Left <hnt>Add", + ), + ), + ) + + else -> { + val status = servers.firstOrNull { it.name.equals(target.name, true) }?.status + ?: ServerStatus.OFFLINE + PickerOption( + target.name, + Ui.item( + "MAP", + "<ttl>${target.name}", + listOf( + typeLine, + "<bd>Status ${PickerOptions.statusText(status)}", + "", + "<key>Left <hnt>Add", + ), + ), + ) + } + } + } + } + + override fun resolveTyped(context: NpcEditorContext, config: NpcConfig, typed: String): PickerResult? = when { + config.targetServers.any { it.equals(typed, true) } -> + PickerResult.Rejected("Already targeted", typed, "This NPC already has that target.") + + config.targetServers.size >= NpcConfig.MAX_TARGETS -> + PickerResult.Rejected("Cap reached", typed, "All ${NpcConfig.MAX_TARGETS} target slots are in use.") + + else -> PickerOptions.match( + context.liveData.knownTargets().map { it.name }, + typed, + "No group or server by that name.", + ) + } + + override fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) { + val canonical = context.liveData.knownTargets().firstOrNull { it.name.equals(value, true) }?.name ?: value + var count = config.targetServers.size + val added = context.change(player, config.id, Refresh.HOLOGRAM) { fresh -> + if (fresh.targetServers.size >= NpcConfig.MAX_TARGETS) return@change null + if (fresh.targetServers.any { it.equals(canonical, true) }) return@change null + + fresh.targetServers += canonical + count = fresh.targetServers.size + fresh + } + val result = if (added) { + PickerResult.Added(canonical, "Now targeting $count of ${NpcConfig.MAX_TARGETS}.") + } else { + PickerResult.Rejected("Not added", canonical, "Already targeted, or all target slots are in use.") + } + PickerMenuBuilder.showResult(context, player, config, purpose, result) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickMenuSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickMenuSource.kt new file mode 100644 index 0000000..471f65d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickMenuSource.kt @@ -0,0 +1,26 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerOptions +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +class PickMenuSource(override val purpose: PickerPurpose.PickMenu) : PickerSource { + override val title = "Open Menu" + override val typeHint: List<String>? = null + override val typingUnavailable = "Menus are created in the Menu Builder." + + override fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> = + PickerOptions.menus( + context.inventoryRepository.findAll(), + config.findAction(purpose.interaction, purpose.joinState)?.openInventory, + ) + + override fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) { + context.commitAndBack(player, config.id) { fresh -> + fresh.apply { actionOrCreate(purpose.interaction, purpose.joinState).openInventory = value } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickServerSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickServerSource.kt new file mode 100644 index 0000000..6a8638d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickServerSource.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerOptions +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +class PickServerSource(override val purpose: PickerPurpose.PickServer) : PickerSource { + override val title = "Send To Server" + + override fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> = + PickerOptions.servers( + context.liveData.servers(), + config.findAction(purpose.interaction, purpose.joinState)?.sendToServer, + ) + + override fun resolveTyped(context: NpcEditorContext, config: NpcConfig, typed: String): PickerResult? = + PickerOptions.match(context.liveData.servers().map { it.name }, typed, "No server by that name.") + + override fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) { + val canonical = PickerOptions.canonical(context.liveData.servers().map { it.name }, value) + context.commitAndBack(player, config.id) { fresh -> + fresh.apply { actionOrCreate(purpose.interaction, purpose.joinState).sendToServer = canonical } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickSoundSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickSoundSource.kt new file mode 100644 index 0000000..ddda326 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickSoundSource.kt @@ -0,0 +1,88 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerOptions +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.core.SoundTuning +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.SoundField +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +class PickSoundSource(override val purpose: PickerPurpose.PickSound) : PickerSource { + private val field = purpose.field + + override val title = "Pick Sound" + override val filteredNoun = "sounds" + + override val typeHint: List<String> = PickerOptions.SOUND_TYPE_HINT + + private fun currentSound(config: NpcConfig): String? = when (field) { + SoundField.Push -> config.pushback.sound + is SoundField.Action -> config.findAction(field.interaction, field.joinState)?.playSound + } + + private fun soundOptions(config: NpcConfig): NpcConfig.SoundOptions = when (field) { + SoundField.Push -> config.pushback.soundOptions + is SoundField.Action -> config.findAction(field.interaction, field.joinState)?.playSoundOptions + ?: SoundTuning.DEFAULTS + } + + private fun setSound(config: NpcConfig, sound: String) = when (field) { + SoundField.Push -> config.pushback.sound = sound + is SoundField.Action -> config.actionOrCreate(field.interaction, field.joinState).playSound = sound + } + + private fun setSoundOptions(config: NpcConfig, options: NpcConfig.SoundOptions) = when (field) { + SoundField.Push -> config.pushback.soundOptions = options + is SoundField.Action -> config.actionOrCreate(field.interaction, field.joinState).playSoundOptions = options + } + + override fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> { + val current = currentSound(config) + val filter = (context.session(player).pickerResult as? PickerResult.Filtered)?.typed + + return PickerOptions.sounds(context.catalogs.sounds(), filter, current) + } + + override fun resolveTyped(context: NpcEditorContext, config: NpcConfig, typed: String): PickerResult? = + PickerOptions.search(context.catalogs.sounds(), typed, "sound key") + + override fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) { + val canonical = PickerOptions.canonical(context.catalogs.sounds(), value) + val saved = context.change(player, config.id) { fresh -> fresh.also { setSound(it, canonical) } } + if (!saved) return + + if (context.session(player).pickerResult is PickerResult.Filtered) { + context.render(player, NpcEditorScreen.Picker(config.id, purpose)) + } else { + val result = PickerResult.Added(canonical, "Tune volume and pitch below, or go Back.") + PickerMenuBuilder.showResult(context, player, config, purpose, result) + } + } + + override fun onRightClick(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, option: PickerOption) { + player.playSound(option.value, soundOptions(config)) + } + + override fun extras(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, pane: Pane) { + val screen = NpcEditorScreen.Picker(config.id, purpose) + + SoundTuning.place( + pane, + player, + currentSound(config), + soundOptions(config), + requireSound = field is SoundField.Action, + ) { updated -> + context.commit(player, config.id, screen) { fresh -> + if (field is SoundField.Action && currentSound(fresh) == null) return@commit null + setSoundOptions(fresh, updated(soundOptions(fresh))) + fresh + } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerMenuBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerMenuBuilder.kt new file mode 100644 index 0000000..6d2d043 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerMenuBuilder.kt @@ -0,0 +1,48 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.core.PickerSpec +import app.simplecloud.npc.common.editor.core.PickerTemplate +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object PickerMenuBuilder { + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, purpose: PickerPurpose): EditorMenu { + val source = PickerSource.of(purpose) + val spec = PickerSpec<NpcEditorScreen>( + title = Ui.title(source.title, NpcFormat.displayName(config)), + screen = NpcEditorScreen.Picker(config.id, purpose), + options = source.options(context, player, config), + onPick = { value -> source.apply(context, player, config, value) }, + typeHint = source.typeHint, + typingUnavailable = source.typingUnavailable, + onTyped = { typed -> + context.npcRepository.find(config.id)?.let { fresh -> + source.resolveTyped(context, fresh, typed).also { result -> + if (result == null) source.apply(context, player, fresh, typed) + } + } + }, + onRightClick = { option -> source.onRightClick(context, player, config, option) }, + extras = { pane -> source.extras(context, player, config, pane) }, + filteredNoun = source.filteredNoun, + ) + + return PickerTemplate.build(context, player, spec) + } + + fun showResult( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + purpose: PickerPurpose, + result: PickerResult, + ) = PickerTemplate.showResult(context, player, NpcEditorScreen.Picker(config.id, purpose), result) +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerSource.kt new file mode 100644 index 0000000..f5ddf5e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/picker/PickerSource.kt @@ -0,0 +1,41 @@ +package app.simplecloud.npc.common.editor.npc.picker + +import app.simplecloud.npc.common.editor.core.PickerOption +import app.simplecloud.npc.common.editor.core.PickerResult +import app.simplecloud.npc.common.editor.core.PickerSpec +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +interface PickerSource { + val purpose: PickerPurpose + val title: String + + val typeHint: List<String>? get() = PickerSpec.DEFAULT_TYPE_HINT + + val filteredNoun: String get() = "options" + + val typingUnavailable: String get() = "Pick one from the list above." + + fun options(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): List<PickerOption> + + fun resolveTyped(context: NpcEditorContext, config: NpcConfig, typed: String): PickerResult? = null + + fun apply(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, value: String) + + fun onRightClick(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, option: PickerOption) = Unit + + fun extras(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, pane: Pane) = Unit + + companion object { + fun of(purpose: PickerPurpose): PickerSource = when (purpose) { + PickerPurpose.AddTarget -> AddTargetSource + is PickerPurpose.AddJoinState -> AddJoinStateSource(purpose) + is PickerPurpose.PickMenu -> PickMenuSource(purpose) + is PickerPurpose.PickServer -> PickServerSource(purpose) + is PickerPurpose.PickSound -> PickSoundSource(purpose) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/ActionsTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/ActionsTabBuilder.kt new file mode 100644 index 0000000..2b29590 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/ActionsTabBuilder.kt @@ -0,0 +1,94 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.ConfirmPurpose +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.ui.Clicks +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer + +object ActionsTabBuilder { + private const val ADD_STATE_SLOT = 26 + private const val SUMMARY_LINES = 3 + private val CARDS = listOf( + PlayerInteraction.LEFT_CLICK to 28, + PlayerInteraction.RIGHT_CLICK to 30, + PlayerInteraction.SHIFT_LEFT_CLICK to 32, + PlayerInteraction.SHIFT_RIGHT_CLICK to 34, + ) + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val screen = NpcEditorScreen.Tab(config.id, NpcTab.ACTIONS) + val session = context.session(player) + val states = NpcFormat.actionJoinStates(config) + val live = context.liveData.hologramState(config).joinState?.lowercase() + val selected = session.actionState?.takeIf { it in states } + ?: live?.takeIf { it in states } + ?: NpcConfig.DEFAULT_JOIN_STATE.lowercase() + val pane = TabFrame.pane(context, player, config, NpcTab.ACTIONS) + + StateChipRow.place( + pane, + context, + player, + screen, + "action-states:${config.id}", + StateChips( + states = states, + selected = selected, + live = live, + detail = { state -> "<val>${configured(config, state)} <hnt>of ${CARDS.size} clicks set" }, + select = { state -> + session.actionState = state + context.render(player, screen) + }, + remove = { state -> + context.navigate(player, NpcEditorScreen.Confirm(config.id, ConfirmPurpose.RemoveJoinState(state))) + }, + ), + ) + + CARDS.forEach { (interaction, slot) -> + val action = config.findAction(interaction, selected)?.takeIf { it.configuredTypes().isNotEmpty() } + val item = if (action == null) { + Ui.item( + "LIGHT_GRAY_CONCRETE", + "<ttl>${Clicks.label(interaction)}", + listOf("<off>Nothing happens", "<key>Left <hnt>Set up"), + ) + } else { + val summary = + listOfNotNull("<on>Join target".takeIf { action.joinTarget }) + NpcFormat.fieldLines(action) + Ui.item( + "LIME_CONCRETE", + "<on>${Clicks.label(interaction)}", + summary.take(SUMMARY_LINES) + listOf("<key>Left <hnt>Edit"), + ) + } + pane.left(slot, item) { + context.navigate(player, NpcEditorScreen.ActionEditor(config.id, interaction, selected)) + } + } + + if (states.size < NpcConfig.MAX_JOIN_STATES) { + val add = Ui.addHead("Add Join State", listOf("<hnt>Other clicks for another join state")) + pane.left(ADD_STATE_SLOT, add) { + val purpose = PickerPurpose.AddJoinState(forHologram = false) + context.navigate(player, NpcEditorScreen.Picker(config.id, purpose)) + } + } + + return TabFrame.menu(pane, config) + } + + private fun configured(config: NpcConfig, state: String): Int = + CARDS.count { (interaction, _) -> + config.findAction(interaction, state)?.configuredTypes()?.isNotEmpty() == true + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/BehaviorTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/BehaviorTabBuilder.kt new file mode 100644 index 0000000..811a805 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/BehaviorTabBuilder.kt @@ -0,0 +1,150 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.SoundField +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object BehaviorTabBuilder { + private const val LOOK_AT_SLOT = 19 + private const val LOOK_AT_RANGE_SLOT = 21 + private const val RENDER_RANGE_SLOT = 23 + private const val PUSH_SLOT = 28 + private const val PUSH_STRENGTH_SLOT = 30 + private const val PUSH_RADIUS_SLOT = 31 + private const val UPWARD_BOOST_SLOT = 32 + private const val PUSH_SOUND_SLOT = 33 + + private val ENTITY_DEFAULTS = NpcConfig.NpcEntityConfiguration() + private val PUSH_DEFAULTS = NpcConfig.PushbackConfiguration() + + private val LOOK_AT_RANGE = Stepper( + label = "Look Range", + range = NpcConfig.NpcEntityConfiguration.LOOK_AT_PLAYER_DISTANCE_RANGE, + step = 0.5, + default = ENTITY_DEFAULTS.lookAtPlayerDistance, + decimals = 1, + unit = "blocks", + ) + private val RENDER_RANGE = Stepper( + label = "Render Range", + material = "SPYGLASS", + range = NpcConfig.NpcEntityConfiguration.VIEW_DISTANCE_RANGE, + step = 4.0, + default = ENTITY_DEFAULTS.viewDistance, + decimals = 0, + unit = "blocks", + ) + private val PUSH_STRENGTH = Stepper( + label = "Strength", + range = NpcConfig.PushbackConfiguration.STRENGTH_RANGE, + step = 0.05, + default = PUSH_DEFAULTS.strength, + decimals = 2, + ) + private val PUSH_RADIUS = Stepper( + label = "Radius", + range = NpcConfig.PushbackConfiguration.RADIUS_RANGE, + step = 0.5, + default = PUSH_DEFAULTS.radius, + decimals = 1, + unit = "blocks", + ) + private val UPWARD_BOOST = Stepper( + label = "Upward Boost", + range = NpcConfig.PushbackConfiguration.VERTICAL_RANGE, + step = 0.05, + default = PUSH_DEFAULTS.vertical, + decimals = 2, + ) + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val entity = config.entity + val pushback = config.pushback + val screen = NpcEditorScreen.Tab(config.id, NpcTab.BEHAVIOR) + val pane = TabFrame.pane(context, player, config, NpcTab.BEHAVIOR) + + fun editEntity(mutate: (NpcConfig.NpcEntityConfiguration) -> NpcConfig.NpcEntityConfiguration) = + context.editEntity(player, config.id, screen, mutate) + + fun editPushback(mutate: (NpcConfig.PushbackConfiguration) -> Unit) = + context.commit(player, config.id, screen) { fresh -> fresh.also { mutate(it.pushback) } } + + pane.left(LOOK_AT_SLOT, Ui.toggle("Look At Player", entity.lookAtPlayer, material = "ENDER_EYE")) { + editEntity { it.copy(lookAtPlayer = !it.lookAtPlayer) } + } + if (entity.lookAtPlayer) { + pane[LOOK_AT_RANGE_SLOT] = LOOK_AT_RANGE.element(entity.lookAtPlayerDistance) { value -> + editEntity { it.copy(lookAtPlayerDistance = value) } + } + } + pane[RENDER_RANGE_SLOT] = RENDER_RANGE.element(entity.viewDistance) { value -> + editEntity { it.copy(viewDistance = value) } + } + + pane.on( + PUSH_SLOT, + Ui.toggle( + "Push", + pushback.enabled, + listOf("<key>Right <hnt>Try it on me").takeIf { pushback.enabled }.orEmpty(), + material = "PISTON", + ), + ) { click -> + when (click) { + MenuClick.LEFT -> editPushback { it.enabled = !it.enabled } + MenuClick.RIGHT -> if (pushback.enabled) { + player.push(entity.location, pushback.strength, pushback.vertical) + pushback.sound?.let { player.playSound(it, pushback.soundOptions) } + } + + else -> Unit + } + } + + if (pushback.enabled) { + pane[PUSH_STRENGTH_SLOT] = PUSH_STRENGTH.element(pushback.strength) { value -> + editPushback { it.strength = value } + } + pane[PUSH_RADIUS_SLOT] = PUSH_RADIUS.element(pushback.radius) { value -> + editPushback { it.radius = value } + } + pane[UPWARD_BOOST_SLOT] = UPWARD_BOOST.element(pushback.vertical) { value -> + editPushback { it.vertical = value } + } + + val sound = pushback.sound + pane.on( + PUSH_SOUND_SLOT, + Ui.item( + "NOTE_BLOCK", + "<ttl>Push Sound", + listOf( + sound?.let { "<val>$it" } ?: "<off>None", + if (sound == null) "<key>Left <hnt>Choose" else "<key>Left <hnt>Change · <key>Right <err>Off", + ), + glowing = sound != null, + ), + ) { click -> + when (click) { + MenuClick.LEFT -> context.navigate( + player, + NpcEditorScreen.Picker(config.id, PickerPurpose.PickSound(SoundField.Push)), + ) + + MenuClick.RIGHT -> if (sound != null) editPushback { it.sound = null } + else -> Unit + } + } + } + + return TabFrame.menu(pane, config) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/HologramTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/HologramTabBuilder.kt new file mode 100644 index 0000000..02b70b9 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/HologramTabBuilder.kt @@ -0,0 +1,95 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.core.LinesPane +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.ConfirmPurpose +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.npc.hologram.HologramLineEditor +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.Providers +import java.util.Locale + +object HologramTabBuilder { + private const val TOGGLE_SLOT = 18 + private const val HEIGHT_SLOT = 19 + private const val ADD_LAYOUT_SLOT = 26 + private val LINE_SLOTS = (27..44).toList() + + private val START_HEIGHT = Stepper( + label = "Height", + range = NpcConfig.HologramConfigurationRoot.START_HEIGHT_RANGE, + step = 0.05, + default = NpcConfig.HologramConfigurationRoot().startHeight, + decimals = 2, + valueLine = { "<val>${String.format(Locale.ROOT, "%+.2f", it)} <hnt>blocks" }, + ) + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val hologram = config.hologram + val screen = NpcEditorScreen.Tab(config.id, NpcTab.HOLOGRAM) + val session = context.session(player) + val layouts = NpcFormat.hologramJoinStates(config) + val live = context.liveData.hologramState(config).shown + val selected = session.hologramState?.takeIf { it in layouts } + ?: live?.takeIf { it in layouts } + ?: NpcConfig.DEFAULT_JOIN_STATE.lowercase() + val pane = TabFrame.pane(context, player, config, NpcTab.HOLOGRAM) + + fun edit(mutate: (NpcConfig.HologramConfigurationRoot) -> Unit) = + context.commit(player, config.id, screen, Refresh.HOLOGRAM) { fresh -> fresh.also { mutate(it.hologram) } } + + pane.left(TOGGLE_SLOT, Ui.toggle("Hologram", hologram.enabled, linkedHint(config), "GLOW_ITEM_FRAME")) { + edit { it.enabled = !it.enabled } + } + pane[HEIGHT_SLOT] = START_HEIGHT.element(hologram.startHeight) { value -> edit { it.startHeight = value } } + + StateChipRow.place( + pane, + context, + player, + screen, + "hologram-layouts:${config.id}", + StateChips( + states = layouts, + selected = selected, + live = live, + detail = { state -> "<val>${lineCount(config, state)} <hnt>lines" }, + select = { state -> + session.hologramState = state + context.render(player, screen) + }, + remove = { state -> + if (lineCount(config, state) == 0) { + edit { it.layouts.removeIf { layout -> layout.joinState.equals(state, true) } } + } else { + context.navigate(player, NpcEditorScreen.Confirm(config.id, ConfirmPurpose.RemoveLayout(state))) + } + }, + ), + ) + + LinesPane.place(pane, HologramLineEditor.lines(context, player, config, selected, screen), LINE_SLOTS) + + pane.left(ADD_LAYOUT_SLOT, Ui.addHead("Add Layout", listOf("<hnt>Other lines for another join state"))) { + context.navigate(player, NpcEditorScreen.Picker(config.id, PickerPurpose.AddJoinState(forHologram = true))) + } + + return TabFrame.menu(pane, config) + } + + private fun linkedHint(config: NpcConfig): List<String> { + if (!config.entity.providerLinked) return emptyList() + + return listOf("<warn>Turn off ${Providers.displayName(config.entity.provider)}'s own name or both show.") + } + + private fun lineCount(config: NpcConfig, state: String): Int = config.hologram.findLayout(state)?.lines?.size ?: 0 +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/LooksTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/LooksTabBuilder.kt new file mode 100644 index 0000000..f55b36c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/LooksTabBuilder.kt @@ -0,0 +1,144 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.PromptFormat +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Stepper +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.editor.ui.after +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.NpcPose +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.Providers + +object LooksTabBuilder { + private const val SKIN_SLOT = 19 + private const val NAME_SLOT = 21 + private const val GLOW_SLOT = 23 + private const val EQUIPMENT_SLOT = 25 + private const val POSE_SLOT = 28 + private const val SCALE_SLOT = 30 + + private val SCALE = Stepper( + label = "Size", + material = "SLIME_BALL", + range = NpcConfig.NpcEntityConfiguration.SCALE_RANGE, + step = 0.05, + default = 1.0, + decimals = 2, + unit = "x", + ) + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val entity = config.entity + val name = NpcFormat.displayName(config) + val screen = NpcEditorScreen.Tab(config.id, NpcTab.LOOKS) + val pane = TabFrame.pane(context, player, config, NpcTab.LOOKS) + + fun edit(mutate: (NpcConfig.NpcEntityConfiguration) -> NpcConfig.NpcEntityConfiguration) = + context.editEntity(player, config.id, screen, mutate) + + if (entity.provider in context.providers.fixedSkinProviders) { + pane.left( + SKIN_SLOT, + Ui.head( + "<ttl>Skin", + listOf("<val>${NpcFormat.skinLabel(entity.skin)}", "<key>Left <hnt>Choose"), + texture = entity.skin.texture, + signature = entity.skin.signature, + ), + ) { context.navigate(player, NpcEditorScreen.SkinPicker(config.id)) } + } else { + pane[SKIN_SLOT] = Ui.disabled("Skin", listOf("<hnt>${entity.provider} shows each viewer their own skin.")) + } + + pane.left(NAME_SLOT, Ui.item("NAME_TAG", "<ttl>Name", listOf("<val>$name", "<key>Left <hnt>Change"))) { + context.textPrompts.open( + player, + title = "Display Name", + instruction = "Type the name shown above the NPC in chat.", + current = entity.customName, + format = PromptFormat.MINI_MESSAGE, + onClear = { edit { it.copy(customName = null) } }.takeIf { entity.customName != null }, + ) { text -> + edit { it.copy(customName = text.ifBlank { null }) } + } + } + + val color = NpcFormat.glowColor(entity) + val glowItem = if (entity.glowing) { + Ui.item( + NpcFormat.glowIcon(color), + "<ttl>Glow", + listOf( + "<on>On <hnt>· <val>${NpcFormat.glowLabel(color)}", + "<key>Left <hnt>Turn off · <key>Right <hnt>Color", + ), + glowing = true, + ) + } else { + Ui.item("GRAY_DYE", "<ttl>Glow", listOf("<off>Off", "<key>Left <hnt>Turn on · <key>Right <hnt>Color")) + } + pane.on(GLOW_SLOT, glowItem) { click -> + when (click) { + MenuClick.LEFT -> edit { it.copy(glowing = !it.glowing) } + MenuClick.RIGHT -> context.navigate(player, NpcEditorScreen.Glow(config.id)) + else -> Unit + } + } + + if (entity.provider in Providers.OWN && !entity.providerLinked) { + val worn = entity.equipment.filled().size + pane.left( + EQUIPMENT_SLOT, + Ui.item( + "ARMOR_STAND", + "<ttl>Equipment", + listOf( + if (worn == 0) "<off>Nothing worn" else "<val>$worn <hnt>items worn", + "<key>Left <hnt>Dress it", + ), + glowing = worn > 0, + ), + ) { context.navigate(player, NpcEditorScreen.Equipment(config.id)) } + + pane.left( + POSE_SLOT, + Ui.item( + "LEATHER_BOOTS", + "<ttl>Pose", + listOf("<val>${poseLabel(entity.pose)}", "<key>Left <hnt>Next pose"), + ), + ) { + context.commit(player, config.id, screen, Refresh.ALL) { fresh -> + fresh.copy(entity = fresh.entity.copy(pose = NpcPose.entries.after(fresh.entity.pose))) + } + } + + pane[SCALE_SLOT] = SCALE.element(entity.scale) { value -> + context.commit(player, config.id, screen, Refresh.ALL) { fresh -> + fresh.copy(entity = fresh.entity.copy(scale = value)) + } + } + } else { + val reason = listOf("<hnt>Only NPCs this plugin draws itself.") + pane[EQUIPMENT_SLOT] = Ui.disabled("Equipment", reason) + pane[POSE_SLOT] = Ui.disabled("Pose", reason) + pane[SCALE_SLOT] = Ui.disabled("Size", reason) + } + + return TabFrame.menu(pane, config) + } + + private fun poseLabel(pose: NpcPose): String = when (pose) { + NpcPose.STANDING -> "Standing" + NpcPose.SNEAKING -> "Sneaking" + NpcPose.SWIMMING -> "Swimming" + NpcPose.SLEEPING -> "Lying down" + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/NpcTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/NpcTabBuilder.kt new file mode 100644 index 0000000..19ca15c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/NpcTabBuilder.kt @@ -0,0 +1,125 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.Prompt +import app.simplecloud.npc.common.editor.PromptResult +import app.simplecloud.npc.common.editor.core.armed +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.manager.NpcFailure +import app.simplecloud.npc.common.manager.NpcOperationResult +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +object NpcTabBuilder { + private const val SUMMON_SLOT = 20 + private const val DUPLICATE_SLOT = 22 + private const val DELETE_SLOT = 24 + private const val TARGETS_SLOT = 29 + private const val LIVE_SLOT = 31 + private const val NEARBY_SLOT = 33 + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val screen = NpcEditorScreen.Tab(config.id, NpcTab.NPC) + val live = context.liveData + val pane = TabFrame.pane(context, player, config, NpcTab.NPC) + + pane.left( + SUMMON_SLOT, + Ui.item( + "ENDER_PEARL", + "<ttl>Summon To Me", + listOf("<bd>Now at <val>${NpcFormat.position(config.entity.location)}", "<key>Left <hnt>Move it here"), + ), + ) { + context.npcManager.teleport(config.id, player.location()) + context.render(player, screen) + } + + pane.left( + DUPLICATE_SLOT, + Ui.item( + "SPAWNER", + "<ttl>Duplicate", + listOf("<bd>A copy appears where you stand.", "<key>Left <hnt>Name the copy"), + ), + ) { promptDuplicate(context, player, config, screen) } + + pane.armed( + DELETE_SLOT, + context, + player, + screen, + "delete-npc", + idle = Ui.item("BARRIER", "<err>Delete NPC", listOf("<err>There is no undo.", "<key>Left <hnt>Delete")), + verb = "Delete ${NpcFormat.displayName(config)}", + ) { + context.npcManager.delete(config.id) + context.close(player) + } + + val targets = config.targetServers + pane[TARGETS_SLOT] = Ui.item( + "COMPASS", + "<ttl>Targets", + listOfNotNull( + targets.firstOrNull()?.let { "<val>$it" } ?: "<off>None", + (targets.size - 1).takeIf { it > 0 }?.let { "<hnt>+$it more" }, + ), + ) + + val joinState = live.hologramState(config).joinState + pane[LIVE_SLOT] = Ui.item( + "REDSTONE_TORCH", + "<ttl>Join State", + listOf(joinState?.let { "<val>${NpcFormat.joinState(it)}" } ?: "<off>unknown", "<hnt>of the first target"), + ) + + pane[NEARBY_SLOT] = Ui.item( + "PLAYER_HEAD", + "<ttl>Nearby", + listOf("<val>${live.playersInRange(config)} <hnt>players in range"), + ) + + return TabFrame.menu(pane, config) + } + + private fun promptDuplicate( + context: NpcEditorContext, + player: NpcPlayer, + config: NpcConfig, + screen: NpcEditorScreen, + ) { + context.chatPrompt( + player, + Prompt( + title = "Duplicate NPC", + instruction = "Type the id of the copy in chat.", + onCancel = { context.render(player, screen) }, + onSubmit = { input -> + when (val result = context.npcManager.duplicate(config.id, input.trim(), player.location())) { + is NpcOperationResult.Success -> { + context.replace(player, NpcEditorScreen.Tab(result.config.id, NpcTab.NPC)) + PromptResult.Accepted + } + + is NpcOperationResult.Failure -> PromptResult.Rejected( + Component.text( + when (result.failure) { + NpcFailure.ALREADY_EXISTS -> "That id is taken." + NpcFailure.INVALID_ID -> ConfigIds.DESCRIPTION + else -> "The copy could not be created." + }, + ), + ) + } + }, + ), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/StateChips.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/StateChips.kt new file mode 100644 index 0000000..7d4f318 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/StateChips.kt @@ -0,0 +1,77 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.menu.Paginator +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.platform.NpcPlayer + +class StateChips( + val states: List<String>, + val selected: String, + val live: String?, + val detail: (String) -> String?, + val select: (String) -> Unit, + val remove: (String) -> Unit, +) + +object StateChipRow { + private const val PREVIOUS_SLOT = 20 + private val CHIP_SLOTS = 21..24 + private const val NEXT_SLOT = 25 + private const val SELECTED_MATERIAL = "LIGHT_BLUE_CONCRETE" + private const val OTHER_MATERIAL = "WHITE_CONCRETE" + + fun place( + pane: Pane, + context: NpcEditorContext, + player: NpcPlayer, + screen: NpcEditorScreen, + key: String, + chips: StateChips, + ) { + if (chips.states.size <= 1) return + val pages = Paginator(context.session(player).pages, key, chips.states, CHIP_SLOTS.count()) + + pages.visible.forEachIndexed { index, state -> + val selected = state == chips.selected + val removable = !NpcFormat.isDefaultJoinState(state) + val label = NpcFormat.joinState(state) + val hints = listOfNotNull( + "<key>Left <hnt>Show".takeIf { !selected }, + "<key>Right <err>Remove".takeIf { removable }, + ).joinToString(" <hnt>· ").ifEmpty { null } + val lore = listOfNotNull( + chips.detail(state), + "<on>Live now".takeIf { state == chips.live }, + "<hnt>Shown below".takeIf { selected }, + hints, + ) + + val live = if (state == chips.live) "<on>● " else "" + val name = if (selected) "$live<ttl><b>$label" else "$live<bd>$label" + val material = if (selected) SELECTED_MATERIAL else OTHER_MATERIAL + pane.on(CHIP_SLOTS.first + index, Ui.item(material, name, lore, glowing = selected)) { click -> + when (click) { + MenuClick.LEFT -> if (!selected) chips.select(state) + MenuClick.RIGHT -> if (removable) chips.remove(state) + else -> Unit + } + } + } + + if (pages.pageCount > 1) { + pane.left(PREVIOUS_SLOT, Ui.previousPage(pages.page)) { + pages.turn(-1) + context.render(player, screen) + } + pane.left(NEXT_SLOT, Ui.nextPage(pages.page, pages.pageCount)) { + pages.turn(1) + context.render(player, screen) + } + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TabFrame.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TabFrame.kt new file mode 100644 index 0000000..e5e1c11 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TabFrame.kt @@ -0,0 +1,55 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.Pane +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcFormat +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object TabFrame { + const val SIZE = 54 + + private const val EXIT_SLOT = 8 + private const val UNDERLINE_FIRST = 9 + private val HEADER = 0..17 + private val FOOTER = 45..53 + + private const val ACTIVE_LINE = "LIGHT_BLUE_STAINED_GLASS_PANE" + + fun pane(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig, active: NpcTab): Pane { + val pane = Pane(SIZE) + + NpcTab.entries.forEachIndexed { slot, tab -> + val selected = tab == active + val name = if (selected) "<on><b>${tab.label}" else "<ttl>${tab.label}" + val lore = listOf("<hnt>${tab.description}") + if (selected) emptyList() else listOf("<key>Left <hnt>Open") + val item = if (tab == NpcTab.NPC) { + Ui.head(name, lore, config.entity.skin.texture, config.entity.skin.signature, glowing = selected) + } else { + Ui.item(tab.material, name, lore, glowing = selected) + } + + if (selected) { + pane[slot] = item + } else { + pane.left(slot, item) { context.replace(player, NpcEditorScreen.Tab(config.id, tab)) } + } + } + + pane.fill(listOf(UNDERLINE_FIRST + active.ordinal), ACTIVE_LINE) + pane.left(EXIT_SLOT, Ui.close()) { context.close(player) } + + return pane + } + + fun menu(pane: Pane, config: NpcConfig): EditorMenu { + pane.fill(HEADER) + pane.fill(FOOTER) + + return pane.menu(Ui.title("⚡ NPC Editor", NpcFormat.displayName(config))) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TargetsTabBuilder.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TargetsTabBuilder.kt new file mode 100644 index 0000000..bf6d706 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/npc/tabs/TargetsTabBuilder.kt @@ -0,0 +1,77 @@ +package app.simplecloud.npc.common.editor.npc.tabs + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.editor.npc.NpcEditorContext +import app.simplecloud.npc.common.editor.npc.NpcEditorScreen +import app.simplecloud.npc.common.editor.npc.NpcTab +import app.simplecloud.npc.common.editor.npc.PickerPurpose +import app.simplecloud.npc.common.editor.npc.Refresh +import app.simplecloud.npc.common.editor.ui.Ui +import app.simplecloud.npc.common.editor.ui.after +import app.simplecloud.npc.core.config.JoinStrategy +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer + +object TargetsTabBuilder { + private val TARGET_SLOTS = (19..25) + (28..34) + private const val STRATEGY_SLOT = 40 + + fun build(context: NpcEditorContext, player: NpcPlayer, config: NpcConfig): EditorMenu { + val live = context.liveData + val screen = NpcEditorScreen.Tab(config.id, NpcTab.TARGETS) + val pane = TabFrame.pane(context, player, config, NpcTab.TARGETS) + val targets = config.targetServers + val removable = targets.size > 1 + + targets.zip(TARGET_SLOTS).forEach { (target, slot) -> + val summary = live.summaryOf(target) + val state = when { + !summary.known -> "<hnt>Waiting for the cloud…" + summary.liveServers == 0 -> "<warn>Offline" + else -> "<val>${summary.liveServers} <hnt>servers · <val>${summary.players} <hnt>players" + } + val lore = listOf( + "<hnt>${if (summary.isGroup) "Group" else "Server"}", + state, + if (removable) "<key>Right <err>Remove" else "<hnt>The last target stays.", + ) + pane.on(slot, Ui.item(if (summary.isGroup) "BOOKSHELF" else "BOOK", "<ttl>$target", lore)) { click -> + if (click != MenuClick.RIGHT || !removable) return@on + context.commit(player, config.id, screen, Refresh.HOLOGRAM) { fresh -> + if (fresh.targetServers.size <= 1) return@commit null + fresh.targetServers.remove(target) + fresh + } + } + } + + if (targets.size < NpcConfig.MAX_TARGETS) { + val add = Ui.addHead("Add Target", listOf("<key>Left <hnt>Pick a group or server")) + pane.left(TARGET_SLOTS[targets.size], add) { + context.navigate(player, NpcEditorScreen.Picker(config.id, PickerPurpose.AddTarget)) + } + } + + pane.left( + STRATEGY_SLOT, + Ui.item( + "HOPPER", + "<ttl>Join Strategy", + listOf("<val>${strategyLabel(config.joinStrategy)}", "<key>Left <hnt>Next"), + ), + ) { + context.commit(player, config.id, screen) { fresh -> + fresh.copy(joinStrategy = JoinStrategy.entries.after(fresh.joinStrategy)) + } + } + + return TabFrame.menu(pane, config) + } + + private fun strategyLabel(strategy: JoinStrategy): String = when (strategy) { + JoinStrategy.LEAST_PLAYERS -> "Emptiest server first" + JoinStrategy.MOST_PLAYERS -> "Fill servers up" + JoinStrategy.RANDOM -> "Random server" + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Clicks.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Clicks.kt new file mode 100644 index 0000000..6c5b5cf --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Clicks.kt @@ -0,0 +1,27 @@ +package app.simplecloud.npc.common.editor.ui + +import app.simplecloud.npc.core.interaction.PlayerInteraction + +object Clicks { + + fun short(interaction: PlayerInteraction): String = when (interaction) { + PlayerInteraction.LEFT_CLICK -> "Left-click" + PlayerInteraction.RIGHT_CLICK -> "Right-click" + PlayerInteraction.SHIFT_LEFT_CLICK -> "Shift+Left" + PlayerInteraction.SHIFT_RIGHT_CLICK -> "Shift+Right" + } + + fun label(interaction: PlayerInteraction): String = when (interaction) { + PlayerInteraction.LEFT_CLICK -> "Left-click" + PlayerInteraction.RIGHT_CLICK -> "Right-click" + PlayerInteraction.SHIFT_LEFT_CLICK -> "Shift + Left-click" + PlayerInteraction.SHIFT_RIGHT_CLICK -> "Shift + Right-click" + } + + fun material(interaction: PlayerInteraction): String = when (interaction) { + PlayerInteraction.LEFT_CLICK -> "STONE_BUTTON" + PlayerInteraction.SHIFT_LEFT_CLICK -> "POLISHED_BLACKSTONE_BUTTON" + PlayerInteraction.RIGHT_CLICK -> "OAK_BUTTON" + PlayerInteraction.SHIFT_RIGHT_CLICK -> "CRIMSON_BUTTON" + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Palette.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Palette.kt new file mode 100644 index 0000000..a789374 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Palette.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.common.editor.ui + +object Palette { + + private val TOKENS = listOf( + "<ttl>" to "<#f1f5f9>", + "<bd>" to "<#9fb0c4>", + "<hnt>" to "<#5b6b83>", + "<val>" to "<#ffd479>", + "<key>" to "<#b79cff>", + "<on>" to "<#8ee06a>", + "<off>" to "<#7d8794>", + "<warn>" to "<#ffb038>", + "<err>" to "<#ff6b5e>", + "<info>" to "<#52c8f5>", + ) + + fun expand(text: String): String { + if (text.isEmpty() || '<' !in text) return text + return TOKENS.fold(text) { expanded, (token, hex) -> expanded.replace(token, hex) } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Stepper.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Stepper.kt new file mode 100644 index 0000000..9562af7 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Stepper.kt @@ -0,0 +1,53 @@ +package app.simplecloud.npc.common.editor.ui + +import app.simplecloud.npc.common.editor.menu.Element +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.item.NpcItem +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt + +data class Stepper( + val label: String, + val range: ClosedFloatingPointRange<Double>, + val step: Double, + val default: Double, + val decimals: Int, + val material: String = "COMPARATOR", + val unit: String? = null, + val hints: List<String> = emptyList(), + val bigMultiplier: Int = 10, + val valueLine: ((Double) -> String)? = null, +) { + val min: Double get() = range.start + val max: Double get() = range.endInclusive + + fun fmt(value: Double): String = + if (decimals == 0) value.roundToInt().toString() else String.format(Locale.ROOT, "%.${decimals}f", value) + + fun round(value: Double): Double { + val factor = 10.0.pow(decimals) + return (value * factor).roundToInt() / factor + } + + fun item(value: Double): NpcItem { + val valueLine = valueLine?.invoke(value) ?: "<val>${fmt(value)}" + unit?.let { " <hnt>$it" }.orEmpty() + return Ui.item( + material, + "<ttl>$label", + listOf("$valueLine <hnt>(${fmt(min)} – ${fmt(max)})") + hints + listOf( + "<key>Left <hnt>+${fmt(step)} · <key>Right <hnt>-${fmt(step)} · <key>Shift <hnt>x$bigMultiplier", + ), + ) + } + + fun element(current: Double, commit: (Double) -> Unit): Element = + Element(item(current)) { click -> stepped(current, click)?.let(commit) } + + fun stepped(current: Double, click: MenuClick): Double? = when (click) { + MenuClick.LEFT, MenuClick.RIGHT, MenuClick.SHIFT_LEFT, MenuClick.SHIFT_RIGHT -> + round((current + click.step(step, step * bigMultiplier)).coerceIn(min, max)) + + else -> null + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Ui.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Ui.kt new file mode 100644 index 0000000..cffd47e --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/editor/ui/Ui.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.common.editor.ui + +import app.simplecloud.npc.common.item.NpcItem +import java.util.UUID + +object Ui { + private const val ADD_HEAD_TEXTURE = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjBiNTVmNzQ2ODFjNjgyODNhMWMxY2U1MWYxYzgzYjUyZTI5NzFjOTFlZTM0ZWZjYjU5OGRmMzk5MGE3ZTcifX19" + private const val BACK_HEAD_TEXTURE = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYWQ3M2NmNjZkMzFiODNjZDhiODY0NGMxNTk1OGMxYjczYzhkOTczMjNiODAxMTcwYzFkODg2NGJiNmE4NDZkIn19fQ==" + private const val PREVIOUS_HEAD_TEXTURE = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvY2RjOWU0ZGNmYTQyMjFhMWZhZGMxYjViMmIxMWQ4YmVlYjU3ODc5YWYxYzQyMzYyMTQyYmFlMWVkZDUifX19" + private const val NEXT_HEAD_TEXTURE = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvOTU2YTM2MTg0NTllNDNiMjg3YjIyYjdlMjM1ZWM2OTk1OTQ1NDZjNmZjZDZkYzg0YmZjYTRjZjMwYWI5MzExIn19fQ==" + + private const val TITLE_NAME_LENGTH = 18 + private const val DISABLED_MATERIAL = "LIGHT_GRAY_DYE" + + fun item( + material: String, + name: String, + lore: List<String> = emptyList(), + glowing: Boolean = false, + amount: Int = 1, + ): NpcItem = NpcItem( + material, + Palette.expand(name), + lore.map(Palette::expand), + amount = amount, + glowing = glowing, + ) + + fun head( + name: String, + lore: List<String> = emptyList(), + texture: String? = null, + signature: String? = null, + owner: UUID? = null, + glowing: Boolean = false, + ): NpcItem = NpcItem.playerHead( + name = Palette.expand(name), + lore = lore.map(Palette::expand), + owner = owner, + texture = texture, + signature = signature, + glowing = glowing, + ) + + fun option(material: String, name: String, lore: List<String>, selected: Boolean): NpcItem = item( + material, + if (selected) "<on>$name" else "<ttl>$name", + lore + if (selected) listOf("<on>Currently selected") else listOf("<key>Left <hnt>Select"), + glowing = selected, + ) + + fun disabled(name: String, lore: List<String>): NpcItem = item(DISABLED_MATERIAL, "<off>$name", lore) + + fun unavailable(name: String, lore: List<String>, reason: String): NpcItem = + disabled(name, lore + listOf("<err>$reason")) + + fun field(material: String, label: String, valueLine: String?, setVerb: String): NpcItem = item( + material, + "<ttl>$label", + listOfNotNull( + valueLine ?: "<off>Not set", + "", + setVerb, + "<key>Q <hnt>Clear".takeIf { valueLine != null }, + ), + glowing = valueLine != null, + ) + + fun toggle( + label: String, + enabled: Boolean, + extraLore: List<String> = emptyList(), + material: String? = null, + ): NpcItem = item( + material ?: if (enabled) "LIME_DYE" else "GRAY_DYE", + "<ttl>$label", + listOf(if (enabled) "<on>On" else "<off>Off") + extraLore + + listOf(if (enabled) "<key>Left <hnt>Turn off" else "<key>Left <hnt>Turn on"), + glowing = enabled, + ) + + fun addHead(label: String, lore: List<String> = emptyList()): NpcItem = + head("<ttl>$label", lore, texture = ADD_HEAD_TEXTURE) + + fun back(): NpcItem = head("<ttl>Back", texture = BACK_HEAD_TEXTURE) + + fun close(): NpcItem = item("OAK_DOOR", "<ttl>Exit") + + fun previousPage(page: Int): NpcItem = + if (page <= 0) { + head("<off>Previous Page", listOf("<hnt>You are on the first page."), texture = PREVIOUS_HEAD_TEXTURE) + } else { + head("<ttl>Previous Page", listOf("<key>Left <hnt>Page $page"), texture = PREVIOUS_HEAD_TEXTURE) + } + + fun nextPage(page: Int, pageCount: Int): NpcItem = + if (page >= pageCount - 1) { + head("<off>Next Page", listOf("<hnt>No further pages."), texture = NEXT_HEAD_TEXTURE) + } else { + head("<ttl>Next Page", listOf("<key>Left <hnt>Page ${page + 2}"), texture = NEXT_HEAD_TEXTURE) + } + + fun pageIndicator(page: Int, pageCount: Int, lore: List<String>): NpcItem = + item("PAPER", "<ttl>Page ${page + 1} <hnt>of <ttl>$pageCount", lore) + + fun title(section: String, npcName: String): String = "$section · ${clipName(npcName)}" + + private fun clipName(name: String): String = + if (name.length <= TITLE_NAME_LENGTH) name else name.take(TITLE_NAME_LENGTH) + "..." + + fun pretty(material: String): String = + material.lowercase().split('_').joinToString(" ") { it.replaceFirstChar(Char::uppercase) } + + fun quote(text: String): String = "\"$text\"" +} + +internal fun <T> List<T>.after(current: T): T = this[(indexOf(current) + 1) % size] diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/InventoryItems.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/InventoryItems.kt new file mode 100644 index 0000000..9fd0e03 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/InventoryItems.kt @@ -0,0 +1,48 @@ +package app.simplecloud.npc.common.inventory + +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration + +object InventoryItems { + + fun itemAt(config: InventoryConfiguration, slot: Int): InventoryItemConfiguration? = + config.items.firstOrNull { it.slot == slot } + + fun removeItem( + config: InventoryConfiguration, + slot: Int, + ): MutableList<InventoryItemConfiguration> { + val existing = itemAt(config, slot) ?: return config.items.toMutableList() + + return handOverSource(existing, config.items.filterNot { it.slot == slot }.toMutableList()) + } + + fun detachFromGroup( + config: InventoryConfiguration, + slot: Int, + ): MutableList<InventoryItemConfiguration> { + val existing = itemAt(config, slot) ?: return config.items.toMutableList() + val detached = existing.copy(liveGroup = null, live = null, whenEmpty = null, pageControl = null) + + return handOverSource(existing, config.items.map { if (it.slot == slot) detached else it }.toMutableList()) + } + + private fun handOverSource( + leaving: InventoryItemConfiguration, + items: MutableList<InventoryItemConfiguration>, + ): MutableList<InventoryItemConfiguration> { + if (leaving.liveGroup == null || (leaving.live == null && leaving.whenEmpty == null)) return items + + val nextIndex = items.indexOfFirst { + it.slot != leaving.slot && it.liveGroup == leaving.liveGroup && it.pageControl == null + } + if (nextIndex >= 0) { + items[nextIndex] = items[nextIndex].copy( + live = items[nextIndex].live ?: leaving.live, + whenEmpty = items[nextIndex].whenEmpty ?: leaving.whenEmpty, + ) + } + + return items + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/ItemClickActions.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/ItemClickActions.kt new file mode 100644 index 0000000..cfc8596 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/ItemClickActions.kt @@ -0,0 +1,27 @@ +package app.simplecloud.npc.common.inventory + +import app.simplecloud.npc.core.config.NpcConfig.ActionConfiguration +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration + +object ItemClickActions { + + fun of(item: InventoryItemConfiguration, click: PlayerInteraction): ActionConfiguration? = + item.actions.firstOrNull { it.interactionType == click } + + fun orCreate(item: InventoryItemConfiguration, click: PlayerInteraction): ActionConfiguration = + of(item, click) ?: ActionConfiguration(interactionType = click).also(item.actions::add) + + fun forClick(actions: List<ActionConfiguration>, click: PlayerInteraction): List<ActionConfiguration> = + actions.filter { it.interactionType == click } + .ifEmpty { actions.filter { it.interactionType == click.unshifted() } } + .ifEmpty { + if (actions.all { it.interactionType == PlayerInteraction.RIGHT_CLICK }) actions else emptyList() + } + + private fun PlayerInteraction.unshifted(): PlayerInteraction = when (this) { + PlayerInteraction.SHIFT_LEFT_CLICK -> PlayerInteraction.LEFT_CLICK + PlayerInteraction.SHIFT_RIGHT_CLICK -> PlayerInteraction.RIGHT_CLICK + else -> this + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupServersSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupServersSource.kt new file mode 100644 index 0000000..c095911 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupServersSource.kt @@ -0,0 +1,73 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.api.server.Server +import app.simplecloud.api.server.ServerState +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.cloud.TargetResolution +import app.simplecloud.npc.core.cloud.TargetResolver +import app.simplecloud.npc.core.cloud.TargetType +import app.simplecloud.npc.core.cloud.connectName +import app.simplecloud.npc.core.cloud.groupName +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.EntryStates + +object GroupServersSource : InventoryDataSource { + + const val TYPE = InventoryConfiguration.SourceConfiguration.DEFAULT_TYPE + const val TARGET_GROUP = "<target>" + override val type = TYPE + override val defaultEntryActions = listOf(NpcConfig.ActionConfiguration(sendToServer = "<entry_server>")) + + override suspend fun entries( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): List<InventoryEntry> { + val groupName = resolveGroupName(source, context) ?: return emptyList() + val states = source.states.mapNotNull { runCatching { ServerState.valueOf(it.uppercase()) }.getOrNull() } + + return CloudListCache.servers() + .asSequence() + .filter { it.groupName().equals(groupName, ignoreCase = true) } + .filter { server -> + if (states.isNotEmpty()) server.state in states + else server.state != ServerState.STOPPING && server.state != ServerState.CLEANUP + } + .sortedWith(comparatorFor(source.sort)) + .mapNotNull(::toEntry) + .toList() + } + + private suspend fun resolveGroupName( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): String? { + source.group?.takeIf { it.isNotBlank() && it != TARGET_GROUP }?.let { return it } + + val target = context.targetServers.firstOrNull() ?: return null + val resolution = TargetResolver.resolve(target) as? TargetResolution.Found ?: return null + + return resolution.target.name.takeIf { resolution.target.type == TargetType.GROUP } + } + + private fun comparatorFor(sort: String?): Comparator<Server> = when (sort?.uppercase()) { + "PLAYER_COUNT" -> compareByDescending<Server> { it.playerCount ?: 0 }.thenBy { it.numericalId } + else -> compareBy { it.numericalId } + } + + private fun toEntry(server: Server): InventoryEntry? { + val connectName = server.connectName() ?: return null + + return InventoryEntry( + key = "server:$connectName", + placeholders = ServerBridge.OfServer(server), + state = EntryStates.of(server), + substitutions = mapOf( + "<entry_server>" to connectName, + "<entry_group>" to server.groupName().orEmpty(), + "<entry_numerical_id>" to server.numericalId.toString(), + ), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupsSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupsSource.kt new file mode 100644 index 0000000..9784fd8 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/GroupsSource.kt @@ -0,0 +1,35 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.cloud.isProxy +import app.simplecloud.npc.core.cloud.serversOf +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.EntryStates + +object GroupsSource : InventoryDataSource { + + override val type = "groups" + override val defaultEntryActions = listOf(NpcConfig.ActionConfiguration(joinTarget = true)) + + override suspend fun entries( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): List<InventoryEntry> { + val servers = CloudListCache.servers() + return CloudListCache.groups() + .filterNot { it.isProxy() } + .sortedBy { it.name.lowercase() } + .map { group -> + val bridge = ServerBridge.OfGroup(group) + InventoryEntry( + key = "group:${group.name}", + placeholders = bridge, + state = EntryStates.of(serversOf(bridge, servers)), + substitutions = mapOf("<entry_group>" to group.name), + targetOverride = listOf(group.name), + ) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSource.kt new file mode 100644 index 0000000..7b01664 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSource.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration + +interface InventoryDataSource { + + val type: String + val defaultEntryActions: List<NpcConfig.ActionConfiguration> + + suspend fun entries( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): List<InventoryEntry> +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSources.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSources.kt new file mode 100644 index 0000000..378e2ee --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryDataSources.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.common.inventory.source + +import java.util.concurrent.ConcurrentHashMap + +class InventoryDataSources { + + private val sources = ConcurrentHashMap<String, InventoryDataSource>() + + fun register(source: InventoryDataSource) { + sources[source.type.lowercase()] = source + } + + fun find(type: String): InventoryDataSource? = sources[type.lowercase()] + + fun types(): List<String> = sources.keys.sorted() + + companion object { + fun builtIns(): InventoryDataSources = InventoryDataSources().apply { + register(GroupServersSource) + register(PersistentServersSource) + register(GroupsSource) + register(StaticEntriesSource) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryEntry.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryEntry.kt new file mode 100644 index 0000000..f993622 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryEntry.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration + +class InventoryEntry( + val key: String, + val placeholders: ServerBridge? = null, + val substitutions: Map<String, String> = emptyMap(), + val targetOverride: List<String> = emptyList(), + val overrideIcon: InventoryConfiguration.InventoryItemConfiguration? = null, + val overrideActions: List<NpcConfig.ActionConfiguration>? = null, + val state: String? = null, +) diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryOpenContext.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryOpenContext.kt new file mode 100644 index 0000000..cb2637b --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/InventoryOpenContext.kt @@ -0,0 +1,8 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.config.JoinStrategy + +data class InventoryOpenContext( + val targetServers: List<String> = emptyList(), + val joinStrategy: JoinStrategy = JoinStrategy.LEAST_PLAYERS, +) diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/PersistentServersSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/PersistentServersSource.kt new file mode 100644 index 0000000..34d9685 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/PersistentServersSource.kt @@ -0,0 +1,34 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.cloud.isProxy +import app.simplecloud.npc.core.cloud.serversOf +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.EntryStates + +object PersistentServersSource : InventoryDataSource { + + override val type = "persistent-servers" + override val defaultEntryActions = listOf(NpcConfig.ActionConfiguration(sendToServer = "<entry_server>")) + + override suspend fun entries( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): List<InventoryEntry> { + val servers = CloudListCache.servers() + return CloudListCache.persistentServers() + .filterNot { it.isProxy() } + .sortedBy { it.name.lowercase() } + .map { persistentServer -> + val bridge = ServerBridge.OfPersistentServer(persistentServer) + InventoryEntry( + key = "pserver:${persistentServer.name}", + placeholders = bridge, + substitutions = mapOf("<entry_server>" to persistentServer.name), + state = EntryStates.of(serversOf(bridge, servers)), + ) + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/StaticEntriesSource.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/StaticEntriesSource.kt new file mode 100644 index 0000000..8f716ea --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/source/StaticEntriesSource.kt @@ -0,0 +1,20 @@ +package app.simplecloud.npc.common.inventory.source + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration + +object StaticEntriesSource : InventoryDataSource { + override val type = "static" + override val defaultEntryActions = emptyList<NpcConfig.ActionConfiguration>() + + override suspend fun entries( + source: InventoryConfiguration.SourceConfiguration, + context: InventoryOpenContext, + ): List<InventoryEntry> = source.entries.mapIndexed { index, item -> + InventoryEntry( + key = "static:$index", + overrideIcon = item, + overrideActions = item.actions, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/EntryActions.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/EntryActions.kt new file mode 100644 index 0000000..c21ccac --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/EntryActions.kt @@ -0,0 +1,27 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.text.substitute +import app.simplecloud.npc.core.config.NpcConfig + +object EntryActions { + fun substituted( + action: NpcConfig.ActionConfiguration, + substitutions: Map<String, String>, + ): NpcConfig.ActionConfiguration { + if (substitutions.isEmpty()) return action + + fun apply(value: String): String = value.substitute(substitutions) + + return action.copy( + playSound = action.playSound?.let(::apply), + executeCommand = action.executeCommand?.let(::apply), + sendMessage = action.sendMessage?.let(::apply), + sendToServer = action.sendToServer?.let(::apply), + transferToServer = action.transferToServer?.let(::apply), + openInventory = action.openInventory?.let(::apply), + actionBar = action.actionBar?.let(::apply), + denyMessage = action.denyMessage?.let(::apply), + sendTitle = action.sendTitle?.let { it.copy(title = apply(it.title), subtitle = apply(it.subtitle)) }, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/HeadOwners.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/HeadOwners.kt new file mode 100644 index 0000000..f3c3cba --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/HeadOwners.kt @@ -0,0 +1,53 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.core.config.NpcConfig.SkinConfiguration +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +object HeadOwners { + + private class Entry(val skin: SkinConfiguration?, val fetchedAt: Long) + + private val cache = ConcurrentHashMap<String, Entry>() + private val fetching = ConcurrentHashMap.newKeySet<String>() + + @Volatile + var lookup: (suspend (String) -> SkinConfiguration?)? = null + + @Volatile + var onResolved: () -> Unit = {} + + fun skin(owner: String): SkinConfiguration? { + val key = owner.trim().lowercase() + if (key.isEmpty()) return null + val entry = cache[key] + if (entry == null || expired(entry)) fetch(key) + + return entry?.skin + } + + private fun expired(entry: Entry): Boolean { + val ttl = if (entry.skin == null) MISS_TTL else HIT_TTL + return System.currentTimeMillis() - entry.fetchedAt > ttl + } + + private fun fetch(key: String) { + val lookup = lookup ?: return + if (!fetching.add(key)) return + + BackgroundTasks.launch { + try { + val skin = runCatching { lookup(key) }.getOrNull() + cache[key] = Entry(skin, System.currentTimeMillis()) + if (skin != null) onResolved() + } finally { + fetching.remove(key) + } + } + } + + private val HIT_TTL = 6.hours.inWholeMilliseconds + private val MISS_TTL = 10.minutes.inWholeMilliseconds +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryLayout.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryLayout.kt new file mode 100644 index 0000000..a66edc3 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryLayout.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.item.NpcItem + +object InventoryLayout { + + fun maxPage(slotCount: Int, entryCount: Int): Int = + ((entryCount - 1).coerceAtLeast(0)) / slotCount.coerceAtLeast(1) + + fun <T> pageWindow(items: List<T>, slotCount: Int, page: Int): List<T> = + slotCount.coerceAtLeast(1).let { size -> items.drop(page * size).take(size) } + + fun diff(old: Map<Int, NpcItem>, new: Map<Int, NpcItem>): Map<Int, NpcItem?> = buildMap { + new.forEach { (slot, icon) -> if (old[slot] != icon) put(slot, icon) } + old.keys.forEach { slot -> if (slot !in new) put(slot, null) } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryOpener.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryOpener.kt new file mode 100644 index 0000000..07322b4 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryOpener.kt @@ -0,0 +1,10 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.core.platform.NpcPlayer + +fun interface InventoryOpener { + fun open(player: NpcPlayer, inventoryId: String, context: InventoryOpenContext) + + fun back(player: NpcPlayer) = Unit +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryViewController.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryViewController.kt new file mode 100644 index 0000000..39c3343 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/InventoryViewController.kt @@ -0,0 +1,373 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.action.InteractionExecutor +import app.simplecloud.npc.common.inventory.ItemClickActions +import app.simplecloud.npc.common.inventory.source.InventoryDataSources +import app.simplecloud.npc.common.inventory.source.InventoryEntry +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.platform.InteractionHooks +import app.simplecloud.npc.common.platform.InventoryViewRenderer +import app.simplecloud.npc.common.platform.RenderedInventory +import app.simplecloud.npc.common.text.PlayerMessages +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudOutageLog +import app.simplecloud.npc.core.cloud.CloudUnavailableException +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.cloud.ServerBridgeResolver +import app.simplecloud.npc.core.config.JoinStrategy +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.platform.NpcPlayer +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +class InventoryViewController( + private val repository: InventoryRepository, + private val renderer: InventoryViewRenderer, + private val sources: InventoryDataSources, + private val hooks: () -> InteractionHooks = { InteractionHooks.NONE }, + private val executorProvider: () -> InteractionExecutor, +) : InventoryOpener { + + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.log(Level.WARNING, "Unhandled inventory view failure", throwable) + }, + ) + private val views = ConcurrentHashMap<UUID, OpenView>() + private val refreshMutex = Mutex() + + override fun open(player: NpcPlayer, inventoryId: String, context: InventoryOpenContext) { + val history = currentView(player)?.let { it.history + (it.inventoryId to it.context) }.orEmpty() + open(player, inventoryId, context, history, playOpenSound = true) + } + + override fun back(player: NpcPlayer) { + val current = currentView(player) ?: return + val (inventoryId, context) = current.history.lastOrNull() ?: run { + player.closeInventory() + return + } + + open(player, inventoryId, context, current.history.dropLast(1), playOpenSound = false) + } + + private fun currentView(player: NpcPlayer): OpenView? = + views.values.firstOrNull { it.player.uniqueId == player.uniqueId } + + private fun open( + player: NpcPlayer, + inventoryId: String, + context: InventoryOpenContext, + history: List<Pair<String, InventoryOpenContext>>, + playOpenSound: Boolean, + ) { + scope.launch { + val config = repository.find(inventoryId) ?: run { + logger.warning("Cannot open inventory '$inventoryId' for ${player.name}: it does not exist.") + return@launch + } + val permission = config.openPermission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) { + PlayerMessages.denied(player, null) + return@launch + } + val view = OpenView( + viewId = UUID.randomUUID(), + player = player, + inventoryId = config.id, + context = context, + pages = ConcurrentHashMap(), + history = history, + ) + val slots = computeSlots(config, view) + val title = view.lastTitle + + view.lastSlots = slots + + val openSound = config.openSound?.takeIf { it.isNotBlank() && playOpenSound } + renderer.runSync { + val opened = renderer.openView(player, view.viewId, RenderedInventory(title, config.size(), slots)) + if (!opened) return@runSync + views.values.removeIf { it.player.uniqueId == player.uniqueId } + views[view.viewId] = view + openSound?.let { player.playSound(it) } + } + } + } + + fun handleClick( + player: NpcPlayer, + viewId: UUID, + slot: Int, + click: PlayerInteraction = PlayerInteraction.RIGHT_CLICK, + ) { + val view = views[viewId] ?: return + val config = repository.find(view.inventoryId) ?: run { + discardView(viewId) + renderer.closeView(viewId) + return + } + + val liveGroups = liveGroups(config) + if (!hooks().menuClick(config.id, slot, player, click)) return + val cooldownKey = "menu:${view.inventoryId}:$slot:$click" + + liveGroups.forEach { (groupId, members) -> + val control = members.firstOrNull { it.pageControl != null && it.slot == slot } ?: return@forEach + val page = view.pages[groupId] ?: 0 + val maxPage = InventoryLayout.maxPage(contentSlots(members).size, view.lastEntryCounts[groupId] ?: 0) + + when (control.pageControl) { + InventoryConfiguration.PageControl.PREVIOUS -> if (page > 0) { + view.pages[groupId] = page - 1 + scope.launch { refreshMutex.withLock { rerender(view) } } + } + + InventoryConfiguration.PageControl.NEXT -> if (page < maxPage) { + view.pages[groupId] = page + 1 + scope.launch { refreshMutex.withLock { rerender(view) } } + } + } + return + } + + val staticItem = config.items.firstOrNull { it.slot == slot && it.liveGroup == null && visibleTo(it, player) } + if (staticItem != null) { + val actions = ItemClickActions.forClick(staticItem.actions, click) + runActions(player, actions, view.context.targetServers, view.context.joinStrategy, cooldownKey) + return + } + + liveGroups.forEach { (groupId, members) -> + val contentSlots = contentSlots(members) + val slotIndex = contentSlots.indexOfFirst { it.slot == slot } + if (slotIndex == -1) return@forEach + + val entry = view.lastEntries[groupId]?.getOrNull(slotIndex) ?: return + val source = members.firstNotNullOfOrNull { it.live } + val item = contentSlots[slotIndex] + val templates = when { + entry.overrideActions != null -> entry.overrideActions + item.actions.isEmpty() -> source?.let { sources.find(it.type) }?.defaultEntryActions.orEmpty() + else -> ItemClickActions.forClick(item.actions, click) + } + val actions = templates.map { EntryActions.substituted(it, entry.substitutions) } + val targets = entry.targetOverride.ifEmpty { view.context.targetServers } + + runActions(player, actions, targets, view.context.joinStrategy, cooldownKey) + return + } + } + + class Preview(val slots: Map<Int, NpcItem>, val pages: Map<String, Int>, val entryCounts: Map<String, Int>) + + suspend fun preview( + config: InventoryConfiguration, + player: NpcPlayer, + context: InventoryOpenContext, + pages: Map<String, Int>, + ): Preview { + val view = OpenView(UUID.randomUUID(), player, config.id, context, ConcurrentHashMap(pages)) + val slots = computeSlots(config, view) + + return Preview(slots, view.pages.toMap(), view.lastEntryCounts.toMap()) + } + + fun viewClosed(viewId: UUID) = discardView(viewId) + + private fun discardView(viewId: UUID) { + views.remove(viewId) + } + + fun dataSourceTypes(): List<String> = sources.types() + + fun requestRefresh() { + scope.launch { refreshOpenViews() } + } + + suspend fun refreshOpenViews() = refreshMutex.withLock { + views.values.forEach { view -> + try { + rerender(view) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.warning( + "Could not refresh inventory view '${view.inventoryId}' for ${view.player.name}: " + + exception.message, + ) + } + } + } + + fun shutdown() { + scope.cancel() + views.keys.toList().forEach { viewId -> runCatching { renderer.closeView(viewId) } } + views.clear() + } + + private suspend fun rerender(view: OpenView) { + val config = repository.find(view.inventoryId) ?: run { + discardView(view.viewId) + renderer.closeView(view.viewId) + return + } + val previousTitle = view.lastTitle + val slots = computeSlots(config, view) + val changes = InventoryLayout.diff(view.lastSlots, slots) + val title = view.lastTitle.takeIf { it != previousTitle } + + view.lastSlots = slots + if (changes.isNotEmpty() || title != null) { + renderer.runSync { + title?.let { renderer.updateTitle(view.viewId, it) } + if (changes.isNotEmpty()) renderer.updateSlots(view.viewId, changes) + } + } + } + + private suspend fun computeSlots(config: InventoryConfiguration, view: OpenView): Map<Int, NpcItem> { + val slots = mutableMapOf<Int, NpcItem>() + val size = config.size() + val viewer = view.player.uniqueId + var pages: Pair<Int, Int>? = null + + liveGroups(config).forEach { (groupId, members) -> + val contentSlots = contentSlots(members) + val sourceConfig = members.firstNotNullOfOrNull { it.live } ?: run { + logger.warning( + "Inventory '${config.id}' live-group '$groupId' has no `live` source configured, skipping.", + ) + return@forEach + } + val source = sources.find(sourceConfig.type) ?: run { + logger.warning( + "Inventory '${config.id}' live-group '$groupId' uses unknown source type " + + "'${sourceConfig.type}', skipping.", + ) + return@forEach + } + + val entries = try { + source.entries(sourceConfig, view.context) + } catch (exception: CloudUnavailableException) { + CloudOutageLog.warnThrottled( + "Inventory '${config.id}' shows no live entries: ${exception.message}", + key = "inventory:${config.id}", + ) + emptyList() + } + val maxPage = InventoryLayout.maxPage(contentSlots.size, entries.size) + val requested = view.pages[groupId] ?: 0 + val page = requested.coerceIn(0, maxPage) + if (page != requested) view.pages[groupId] = page + if (pages == null) pages = page + 1 to maxPage + 1 + val window = InventoryLayout.pageWindow(entries, contentSlots.size, page) + view.lastEntries[groupId] = window + view.lastEntryCounts[groupId] = entries.size + + val whenEmpty = members.firstNotNullOfOrNull { it.whenEmpty } + contentSlots.forEachIndexed { index, item -> + val entry = window.getOrNull(index) + if (entry != null) { + slots[item.slot] = ItemIconResolver.liveIcon(item, entry, sourceConfig.lookFor(entry.state), viewer) + } else { + whenEmpty?.let { slots[item.slot] = ItemIconResolver.icon(it, viewer) } + } + } + + if (maxPage > 0) { + members.filter { it.pageControl == InventoryConfiguration.PageControl.PREVIOUS }.forEach { + if (page > 0) slots[it.slot] = ItemIconResolver.icon(it, viewer) + } + members.filter { it.pageControl == InventoryConfiguration.PageControl.NEXT }.forEach { + if (page < maxPage) slots[it.slot] = ItemIconResolver.icon(it, viewer) + } + } + } + + config.items + .filter { it.slot in 0 until size && it.liveGroup == null && visibleTo(it, view.player) } + .forEach { item -> slots.putIfAbsent(item.slot, ItemIconResolver.icon(item, viewer)) } + + val text = MenuText(view.player, targetOf(view), pages) + view.lastTitle = text.resolve(config.title) + + return slots.mapValues { (_, item) -> + item.copy(name = text.resolve(item.name), lore = item.lore.map { text.resolve(it) }) + } + } + + private suspend fun targetOf(view: OpenView): ServerBridge? { + val target = view.context.targetServers.firstOrNull() ?: return null + return try { + ServerBridgeResolver.resolve(target) + } catch (_: CloudUnavailableException) { + null + } + } + + private fun visibleTo(item: InventoryItemConfiguration, player: NpcPlayer): Boolean = + item.viewPermission.let { it.isNullOrBlank() || player.hasPermission(it) } + + private fun liveGroups(config: InventoryConfiguration): Map<String, List<InventoryItemConfiguration>> { + val size = config.size() + return config.items.filter { it.liveGroup != null && it.slot in 0 until size }.groupBy { it.liveGroup!! } + } + + private fun contentSlots(members: List<InventoryItemConfiguration>) = + members.filter { it.pageControl == null }.sortedBy { it.slot } + + private fun runActions( + player: NpcPlayer, + actions: List<NpcConfig.ActionConfiguration>, + targetServers: List<String>, + strategy: JoinStrategy, + cooldownKey: String, + ) { + if (actions.isEmpty()) return + val executor = executorProvider() + if (!executor.passesCooldown(player, cooldownKey, actions)) return + + scope.launch { + actions.forEach { executor.executeAction(targetServers, it, player, strategy) } + } + } + + private class OpenView( + val viewId: UUID, + val player: NpcPlayer, + val inventoryId: String, + val context: InventoryOpenContext, + val pages: ConcurrentHashMap<String, Int>, + val history: List<Pair<String, InventoryOpenContext>> = emptyList(), + ) { + @Volatile + var lastSlots: Map<Int, NpcItem> = emptyMap() + + @Volatile + var lastTitle: String = "" + + val lastEntries = ConcurrentHashMap<String, List<InventoryEntry>>() + val lastEntryCounts = ConcurrentHashMap<String, Int>() + } + + private companion object { + private val logger = NpcLog.logger + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/ItemIconResolver.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/ItemIconResolver.kt new file mode 100644 index 0000000..5703606 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/ItemIconResolver.kt @@ -0,0 +1,67 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.inventory.source.InventoryEntry +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.text.substitute +import app.simplecloud.npc.core.cloud.BridgePlaceholders +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import java.util.UUID + +object ItemIconResolver { + + fun icon(item: InventoryConfiguration.InventoryItemConfiguration, viewer: UUID? = null): NpcItem { + val head = item.head + val showsViewer = head?.showsViewer == true && viewer != null + val owned = head?.owner?.takeUnless { showsViewer }?.let(HeadOwners::skin) + val texture = owned?.texture ?: head?.texture?.takeIf { it.isNotBlank() && !showsViewer } + + return NpcItem( + material = item.material, + name = item.name.orEmpty(), + lore = item.lore.toList(), + amount = item.amount, + glowing = item.glowing, + customModelData = item.customModelData, + hideTooltip = item.hideTooltip, + skinTexture = texture, + skinSignature = if (owned != null) owned.signature else head?.signature?.takeIf { texture != null }, + playerHeadOwner = viewer?.takeIf { showsViewer }, + ) + } + + suspend fun liveIcon( + item: InventoryConfiguration.InventoryItemConfiguration, + entry: InventoryEntry, + look: InventoryConfiguration.StateLook? = null, + viewer: UUID? = null, + ): NpcItem { + entry.overrideIcon?.let { return icon(it, viewer) } + val styled = look?.let { styled(item, it) } ?: item + + return icon(styled, viewer).copy( + name = resolveText(styled.name.orEmpty(), entry), + lore = styled.lore.map { resolveText(it, entry) }, + ) + } + + fun styled( + item: InventoryConfiguration.InventoryItemConfiguration, + look: InventoryConfiguration.StateLook, + ): InventoryConfiguration.InventoryItemConfiguration = item.copy( + material = look.material ?: item.material, + name = look.name ?: item.name, + lore = look.lore?.toMutableList() ?: item.lore, + glowing = look.glowing ?: item.glowing, + head = if (look.material != null) null else item.head, + ) + + private suspend fun resolveText(text: String, entry: InventoryEntry): String { + if (text.isEmpty()) return text + val resolved = entry.placeholders + ?.let { Msg.miniMessage.serialize(BridgePlaceholders.append(it, text, "entry")) } + ?: text + + return resolved.substitute(entry.substitutions) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/MenuText.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/MenuText.kt new file mode 100644 index 0000000..7faa313 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/inventory/view/MenuText.kt @@ -0,0 +1,33 @@ +package app.simplecloud.npc.common.inventory.view + +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.text.PlayerPlaceholders +import app.simplecloud.npc.common.text.substitute +import app.simplecloud.npc.core.cloud.BridgePlaceholders +import app.simplecloud.npc.core.cloud.ServerBridge +import app.simplecloud.npc.core.platform.NpcPlayer +import kotlinx.coroutines.CancellationException + +class MenuText( + private val player: NpcPlayer, + private val target: ServerBridge?, + private val pages: Pair<Int, Int>?, +) { + suspend fun resolve(text: String): String { + if (text.isEmpty()) return text + val pageValues = mapOf( + "<page>" to (pages?.first ?: 1).toString(), + "<max_page>" to (pages?.second ?: 1).toString(), + ) + val resolved = PlayerPlaceholders.resolve(text, player).substitute(pageValues) + if (target == null || "<target_" !in resolved) return resolved + + return try { + Msg.miniMessage.serialize(BridgePlaceholders.append(target, resolved)) + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + resolved + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/item/NpcItem.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/item/NpcItem.kt new file mode 100644 index 0000000..2857333 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/item/NpcItem.kt @@ -0,0 +1,39 @@ +package app.simplecloud.npc.common.item + +import app.simplecloud.npc.core.config.NpcConfig +import java.util.UUID + +data class NpcItem( + val material: String, + val name: String, + val lore: List<String> = emptyList(), + val playerHeadOwner: UUID? = null, + val skinTexture: String? = null, + val skinSignature: String? = null, + val amount: Int = 1, + val glowing: Boolean = false, + val customModelData: Int? = null, + val hideTooltip: Boolean = false, + val maxStackSize: Int? = null, + val armorColor: String? = null, + val armorTrim: NpcConfig.ArmorTrim? = null, +) { + companion object { + fun playerHead( + name: String, + lore: List<String> = emptyList(), + owner: UUID? = null, + texture: String? = null, + signature: String? = null, + glowing: Boolean = false, + ): NpcItem = NpcItem( + material = "PLAYER_HEAD", + name = name, + lore = lore, + playerHeadOwner = owner, + skinTexture = texture, + skinSignature = signature, + glowing = glowing, + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/InventoryManager.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/InventoryManager.kt new file mode 100644 index 0000000..5bfdad1 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/InventoryManager.kt @@ -0,0 +1,43 @@ +package app.simplecloud.npc.common.manager + +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryRepository + +class InventoryManager( + private val repository: InventoryRepository, +) { + + fun create(id: String, rows: Int): InventoryOperationResult { + if (!ConfigIds.isValid(id)) return InventoryOperationResult.Failure(InventoryFailure.INVALID_ID, id) + if (repository.find(id) != null) return InventoryOperationResult.Failure(InventoryFailure.ALREADY_EXISTS, id) + if (rows !in InventoryConfiguration.ROWS) return InventoryOperationResult.Failure( + InventoryFailure.INVALID_ROWS, + rows.toString(), + ) + + val config = InventoryConfiguration(id = id, rows = rows) + repository.save(config) + + return InventoryOperationResult.Success(config) + } + + fun delete(id: String): InventoryOperationResult { + val config = repository.find(id) ?: return InventoryOperationResult.Failure(InventoryFailure.NOT_FOUND, id) + repository.delete(config.id) + + return InventoryOperationResult.Success(config) + } +} + +sealed interface InventoryOperationResult { + data class Success(val config: InventoryConfiguration) : InventoryOperationResult + data class Failure(val failure: InventoryFailure, val detail: String? = null) : InventoryOperationResult +} + +enum class InventoryFailure { + INVALID_ID, + ALREADY_EXISTS, + INVALID_ROWS, + NOT_FOUND, +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcManager.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcManager.kt new file mode 100644 index 0000000..a006e40 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcManager.kt @@ -0,0 +1,552 @@ +package app.simplecloud.npc.common.manager + +import app.simplecloud.npc.common.render.ProviderCapabilities +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudSnapshot +import app.simplecloud.npc.core.cloud.JoinStateResolver +import app.simplecloud.npc.core.cloud.ResolvedTarget +import app.simplecloud.npc.core.cloud.TargetResolution +import app.simplecloud.npc.core.cloud.TargetResolver +import app.simplecloud.npc.core.config.ConfigIds +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.HologramRenderer +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.Providers +import app.simplecloud.npc.core.repository.NpcRepository +import app.simplecloud.npc.core.repository.ReloadDiff +import kotlinx.coroutines.CancellationException +import java.util.logging.Level +import kotlin.math.abs + +class NpcManager( + private val repository: NpcRepository, + private val renderer: NpcRenderer, + private val hologramRenderer: HologramRenderer, + private val providers: ProviderCapabilities, +) { + fun save(config: NpcConfig, refreshHologram: Boolean = true) { + repository.save(config) + if (refreshHologram) hologramRenderer.createOrUpdate(config) + } + + fun reload(): ReloadDiff<NpcConfig> = repository.reload().also(::apply) + + fun apply(diff: ReloadDiff<NpcConfig>) { + diff.removed.forEach { config -> + perNpc(config.id, "remove") { + hologramRenderer.destroy(config) + renderer.despawn(config) + } + } + diff.added.forEach { config -> perNpc(config.id, "place") { spawnAndSave(config) } } + diff.changed.forEach { change -> + perNpc(change.current.id, "update") { + if (change.previous.entity.provider != change.current.entity.provider) { + hologramRenderer.destroy(change.previous) + renderer.despawn(change.previous) + spawnAndSave(change.current) + } else { + renderer.refresh(change.current) + hologramRenderer.createOrUpdate(change.current) + } + } + } + } + + fun reconcileOnBoot(): List<String> { + val reconciled = repository.findAll().map { original -> + perNpc(original.id, "place", fallback = original) { + var config = reconcileProvider(original) + if (config.entity.needsRelocation) { + if (config != original) repository.save(config) + return@perNpc config + } + + config = withCanonicalReference(renderer.spawn(config)) + renderer.locationOf(config) + ?.takeUnless { it.isSameSpot(config.entity.location) } + ?.let { live -> config = config.copy(entity = config.entity.copy(location = live)) } + + if (config != original) repository.save(config) + hologramRenderer.createOrUpdate(config) + config + } + } + + return reconciled.filter { it.entity.needsRelocation }.map { it.id } + } + + private fun perNpc(id: String, action: String, block: () -> Unit) = perNpc(id, action, Unit, block) + + private fun <T> perNpc(id: String, action: String, fallback: T, block: () -> T): T = try { + block() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + logger.log(Level.WARNING, "Could not $action NPC '$id'", exception) + fallback + } + + private fun NpcLocation.isSameSpot(other: NpcLocation): Boolean = + world.equals(other.world, ignoreCase = true) && + abs(x - other.x) <= LOCATION_DRIFT_TOLERANCE && + abs(y - other.y) <= LOCATION_DRIFT_TOLERANCE && + abs(z - other.z) <= LOCATION_DRIFT_TOLERANCE + + private fun spawnAndSave(config: NpcConfig) { + val spawned = withCanonicalReference(renderer.spawn(config)) + if (spawned != config) repository.save(spawned) + hologramRenderer.createOrUpdate(spawned) + } + + private fun withCanonicalReference(config: NpcConfig): NpcConfig { + if (!config.entity.providerLinked) return config + val canonical = renderer.canonicalReference(config) ?: return config + val stored = config.entity.providerReference + if (canonical == stored) return config + + logger.info( + "NPC '${config.id}': its ${config.entity.provider} reference '$stored' is ambiguous; " + + "storing '$canonical' instead.", + ) + + return config.copy(entity = config.entity.copy(providerReference = canonical)) + } + + fun statusOf(config: NpcConfig, snapshot: CloudSnapshot): NpcStatus = + statusOf(config, config.targetServers.map { TargetResolver.resolve(it, snapshot) }) + + private fun statusOf(config: NpcConfig, resolutions: List<TargetResolution>): NpcStatus = when { + config.entity.needsRelocation -> NpcStatus.NEEDS_RELOCATION + config.targetServers.isEmpty() -> NpcStatus.TARGET_MISSING + resolutions.any { it == TargetResolution.Ambiguous } -> NpcStatus.TARGET_AMBIGUOUS + resolutions.any { it == TargetResolution.NotFound } -> NpcStatus.TARGET_MISSING + else -> NpcStatus.COMPLETE + } + + suspend fun lookupSkin(input: String): NpcConfig.SkinConfiguration? { + val uuid = InputChecks.parseUuid(input) + return if (uuid != null) renderer.captureSkinByUuid(uuid) else renderer.captureSkinByUsername(input) + } + + private fun exists(id: String): Boolean = repository.find(id) != null + + private suspend fun resolveTarget(name: String): TargetLookup = + when (val resolution = TargetResolver.resolve(name)) { + is TargetResolution.Found -> TargetLookup.Found(resolution.target) + TargetResolution.Ambiguous -> TargetLookup.Failed( + NpcOperationResult.Failure( + NpcFailure.TARGET_AMBIGUOUS, + name, + ), + ) + + TargetResolution.NotFound -> TargetLookup.Failed( + NpcOperationResult.Failure( + NpcFailure.TARGET_NOT_FOUND, + name, + ), + ) + } + + suspend fun create(id: String, targetName: String, player: NpcPlayer): NpcOperationResult { + validateId(id)?.let { return it } + if (exists(id)) return NpcOperationResult.Failure(NpcFailure.ALREADY_EXISTS, id) + + val target = when (val lookup = resolveTarget(targetName)) { + is TargetLookup.Found -> lookup.target + is TargetLookup.Failed -> return lookup.failure + } + + val entity = NpcConfig.NpcEntityConfiguration( + location = player.location(), + provider = providers.defaultProvider, + skin = renderer.captureSkin(player), + ) + + return persistNew(createDefaultConfig(id, target.name, entity)).also { result -> + if (result is NpcOperationResult.Success) { + val provider = result.config.entity.provider + logger.info("Created NPC '$id' using provider '$provider' (target: ${target.name}).") + } + } + } + + fun duplicate(sourceId: String, newId: String, location: NpcLocation): NpcOperationResult { + validateId(newId)?.let { return it } + if (exists(newId)) return NpcOperationResult.Failure(NpcFailure.ALREADY_EXISTS, newId) + val source = repository.find(sourceId) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, sourceId) + + val entity = source.entity + val ownProvider = !entity.providerLinked && entity.provider.lowercase() in Providers.OWN + val copy = source.copy( + id = newId, + entity = entity.copy( + location = location, + provider = if (ownProvider) entity.provider else providers.defaultProvider, + providerReference = null, + providerLinked = false, + needsRelocation = false, + dormantProviderReferences = emptyMap(), + ), + ) + + return persistNew(copy).also { result -> + if (result is NpcOperationResult.Success) logger.info("Duplicated NPC '$sourceId' as '$newId'.") + } + } + + suspend fun link(id: String, targetName: String, provider: String, reference: String): NpcOperationResult { + validateId(id)?.let { return it } + if (exists(id)) return NpcOperationResult.Failure(NpcFailure.ALREADY_EXISTS, id) + + val target = when (val lookup = resolveTarget(targetName)) { + is TargetLookup.Found -> lookup.target + is TargetLookup.Failed -> return lookup.failure + } + + val providerKey = provider.lowercase() + if (providerKey !in providers.linkingProviders) { + return NpcOperationResult.Failure(NpcFailure.PROVIDER_UNAVAILABLE, provider) + } + if (repository.findByProvider(providerKey, reference) != null) { + return NpcOperationResult.Failure(NpcFailure.PROVIDER_NPC_ALREADY_LINKED, reference) + } + + val entity = NpcConfig.NpcEntityConfiguration( + provider = providerKey, + providerReference = reference, + providerLinked = true, + ) + val snapshot = renderer.snapshotOf(NpcConfig(id = id, entity = entity)) + ?: return NpcOperationResult.Failure(NpcFailure.PROVIDER_NPC_NOT_FOUND, reference) + + val config = createDefaultConfig(id, target.name, entity.copy(location = snapshot.location ?: NpcLocation())) + .also { it.hologram.enabled = false } + + return persistNew(config).also { result -> + if (result is NpcOperationResult.Success) { + logger.info("Linked NPC '$id' to $providerKey NPC '$reference' (target: ${target.name}).") + } + } + } + + private fun persistNew(config: NpcConfig): NpcOperationResult { + var spawned: NpcConfig? = null + return try { + spawned = renderer.spawn(config) + repository.save(spawned) + hologramRenderer.createOrUpdate(spawned) + + NpcOperationResult.Success(spawned) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + val placed = spawned ?: config + runCatching { hologramRenderer.destroy(placed) } + runCatching { renderer.despawn(placed) } + runCatching { repository.delete(config.id) } + NpcOperationResult.Failure(NpcFailure.CREATE_FAILED, exception.message) + } + } + + suspend fun addTarget(id: String, targetName: String): NpcOperationResult { + val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) + val target = when (val lookup = resolveTarget(targetName)) { + is TargetLookup.Found -> lookup.target + is TargetLookup.Failed -> return lookup.failure + } + if (config.targetServers.any { it.equals(target.name, ignoreCase = true) }) { + return NpcOperationResult.Failure(NpcFailure.TARGET_ALREADY_USED, target.name) + } + if (config.targetServers.size >= NpcConfig.MAX_TARGETS) { + return NpcOperationResult.Failure(NpcFailure.TARGET_LIMIT, NpcConfig.MAX_TARGETS.toString()) + } + + config.targetServers += target.name + save(config) + + return NpcOperationResult.Success(config) + } + + fun unlink(id: String): NpcOperationResult { + val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) + if (!config.entity.providerLinked) return NpcOperationResult.Failure(NpcFailure.NOT_LINKED, id) + + hologramRenderer.destroy(config) + renderer.despawn(config) + repository.delete(config.id) + + return NpcOperationResult.Success(config) + } + + fun delete(id: String): NpcOperationResult { + val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) + if (config.entity.providerLinked) return unlink(id) + hologramRenderer.destroy(config) + renderer.despawn(config) + + val remaining = resolveDormantReferences(config, readoptLinkOnly = false).entity.dormantProviderReferences + if (remaining.isNotEmpty()) { + val leftovers = remaining.entries.joinToString { (provider, reference) -> "$provider=$reference" } + logger.warning( + "Deleted NPC '$id' still has provider-side NPCs that could not be cleaned up " + + "because their plugins aren't installed: $leftovers. " + + "Remove them manually if those plugins are ever installed again.", + ) + } + + repository.delete(config.id) + + return NpcOperationResult.Success(config) + } + + fun reconcileProvider(config: NpcConfig): NpcConfig { + if (config.entity.providerLinked) return config + + var current = adoptLegacyProviderNpc(resolveDormantReferences(config, readoptLinkOnly = true)) + if (current.entity.needsRelocation) return current + + val legacyReference = current.entity.providerReference + if (legacyReference != null && current.entity.provider.lowercase() in providers.creationCapableProviders) { + deleteProviderNpc(current, current.entity.provider.lowercase(), legacyReference) + current = current.copy(entity = current.entity.copy(providerReference = null)) + } + + val active = current.entity.provider.lowercase() + val pinned = active in providers.availableProviders && active !in providers.creationCapableProviders + val target = if (pinned) active else providers.defaultProvider.lowercase() + if (active == target) return current + + logger.info("NPC '${current.id}': provider auto-selection moves it from '$active' to '$target'.") + hologramRenderer.destroy(current) + + val reference = current.entity.providerReference + var dormant = current.entity.dormantProviderReferences + if (reference != null) { + if (active in providers.availableProviders) { + deleteProviderNpc(current, active, reference) + } else { + dormant = dormant + (active to reference) + } + } + + return current.copy( + entity = current.entity.copy( + provider = target, + providerReference = null, + dormantProviderReferences = dormant, + ), + ) + } + + private fun adoptLegacyProviderNpc(config: NpcConfig): NpcConfig { + if (!config.entity.needsRelocation) return config + + val storedReference = config.entity.providerReference + val candidates = if (storedReference != null) { + listOf(config.entity.provider.lowercase() to storedReference) + } else { + providers.creationCapableProviders + .filter { it != Providers.STANDALONE && it != Providers.MANNEQUIN } + .map { it to config.id } + } + + for ((provider, reference) in candidates) { + if (provider !in providers.availableProviders) continue + val probe = config.copy(entity = config.entity.copy(provider = provider, providerReference = reference)) + val snapshot = runCatching { renderer.snapshotOf(probe) } + .onFailure { + logger.warning("Could not read $provider NPC '$reference' for NPC '${config.id}': ${it.message}") + } + .getOrNull() + ?: continue + + val location = snapshot.location ?: run { + logger.warning( + "NPC '${config.id}': its $provider NPC '$reference' exists but has no location, " + + "keeping the relocation warning.", + ) + continue + } + + logger.info("NPC '${config.id}': adopted location, skin and name from $provider NPC '$reference'.") + return config.copy( + entity = config.entity.copy( + provider = provider, + providerReference = reference, + location = location, + skin = snapshot.skin ?: config.entity.skin, + customName = snapshot.customName ?: config.entity.customName, + needsRelocation = false, + ), + ) + } + + return config + } + + private fun resolveDormantReferences(config: NpcConfig, readoptLinkOnly: Boolean): NpcConfig { + var entity = config.entity + val actionable = entity.dormantProviderReferences.filterKeys { it in providers.availableProviders } + if (actionable.isEmpty()) return config + + var dormant = entity.dormantProviderReferences - actionable.keys + actionable.forEach { (provider, reference) -> + val linkOnly = provider !in providers.creationCapableProviders + if (linkOnly && readoptLinkOnly) { + val previousProvider = entity.provider.lowercase() + val previousReference = entity.providerReference + if (previousReference != null && previousProvider != provider) { + if (previousProvider in providers.availableProviders) { + deleteProviderNpc(config, previousProvider, previousReference) + } else { + dormant = dormant + (previousProvider to previousReference) + } + } + + hologramRenderer.destroy(config.copy(entity = entity)) + logger.info( + "NPC '${config.id}': re-adopting its $provider NPC '$reference' now that $provider " + + "is installed again.", + ) + entity = entity.copy(provider = provider, providerReference = reference) + } else { + deleteProviderNpc(config, provider, reference) + } + } + + return config.copy(entity = entity.copy(dormantProviderReferences = dormant)) + } + + private fun deleteProviderNpc(config: NpcConfig, provider: String, reference: String) { + logger.info("Cleaning up leftover $provider NPC '$reference' owned by NPC '${config.id}'.") + runCatching { + renderer.despawn( + config.copy(entity = config.entity.copy(provider = provider, providerReference = reference)), + ) + }.onFailure { logger.warning("Could not clean up leftover $provider NPC '$reference': ${it.message}") } + } + + fun teleport(id: String, location: NpcLocation): NpcOperationResult { + val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) + val moved = config.copy(entity = config.entity.copy(location = location, needsRelocation = false)) + val updated = if (config.entity.needsRelocation) { + val settled = reconcileProvider(moved) + renderer.spawn(settled.copy(entity = settled.entity.copy(location = location, needsRelocation = false))) + } else { + moved.also { renderer.teleport(it) } + } + + repository.save(updated) + hologramRenderer.createOrUpdate(updated) + + return NpcOperationResult.Success(updated) + } + + fun updateSkin(id: String, skin: NpcConfig.SkinConfiguration): NpcOperationResult { + val config = repository.find(id) ?: return NpcOperationResult.Failure(NpcFailure.NOT_FOUND, id) + val updated = config.copy(entity = config.entity.copy(skin = skin)) + + repository.save(updated) + renderer.refresh(updated) + + return NpcOperationResult.Success(updated) + } + + private fun validateId(id: String): NpcOperationResult.Failure? = + if (ConfigIds.isValid(id)) null else NpcOperationResult.Failure(NpcFailure.INVALID_ID, id) + + private fun createDefaultConfig(id: String, target: String, entity: NpcConfig.NpcEntityConfiguration): NpcConfig = + NpcConfig( + id = id, + entity = entity, + targetServers = mutableListOf(target), + hologram = NpcConfig.HologramConfigurationRoot( + layouts = mutableListOf( + NpcConfig.HologramLayout( + joinState = NpcConfig.DEFAULT_JOIN_STATE, + lines = mutableListOf( + HologramLine("<#0ea5e9><bold><target_name>"), + HologramLine("<#94a3b8><target_online_players>/<target_max_players> online"), + ), + ), + NpcConfig.HologramLayout( + joinState = "maintenance", + lines = mutableListOf( + HologramLine("<#dc2626><bold><target_name>"), + HologramLine("<#94a3b8>Currently under maintenance"), + ), + ), + NpcConfig.HologramLayout( + joinState = JoinStateResolver.OFFLINE, + lines = mutableListOf( + HologramLine("<#64748b><bold><target_name>"), + HologramLine("${Msg.ERROR}<bold>Offline"), + ), + ), + ), + ), + actions = mutableListOf( + NpcConfig.ActionConfiguration( + interactionType = PlayerInteraction.RIGHT_CLICK, + joinState = NpcConfig.DEFAULT_JOIN_STATE, + joinTarget = true, + ), + NpcConfig.ActionConfiguration( + interactionType = PlayerInteraction.RIGHT_CLICK, + joinState = "maintenance", + sendMessage = "<#dc2626>This server is currently under maintenance.", + ), + NpcConfig.ActionConfiguration( + interactionType = PlayerInteraction.RIGHT_CLICK, + joinState = JoinStateResolver.OFFLINE, + sendMessage = "<#dc2626>This server is currently offline.", + ), + ), + ) + + private sealed interface TargetLookup { + data class Found(val target: ResolvedTarget) : TargetLookup + data class Failed(val failure: NpcOperationResult.Failure) : TargetLookup + } + + private companion object { + private const val LOCATION_DRIFT_TOLERANCE = 0.01 + + private val logger = NpcLog.logger + } +} + +sealed interface NpcOperationResult { + data class Success(val config: NpcConfig) : NpcOperationResult + data class Failure(val failure: NpcFailure, val detail: String? = null) : NpcOperationResult +} + +enum class NpcStatus(val label: String) { + NEEDS_RELOCATION("needs relocation"), + TARGET_MISSING("target missing"), + TARGET_AMBIGUOUS("target ambiguous"), + COMPLETE("complete"), +} + +enum class NpcFailure { + INVALID_ID, + ALREADY_EXISTS, + NOT_FOUND, + TARGET_NOT_FOUND, + TARGET_AMBIGUOUS, + TARGET_ALREADY_USED, + TARGET_LIMIT, + CREATE_FAILED, + PROVIDER_UNAVAILABLE, + PROVIDER_NPC_NOT_FOUND, + PROVIDER_NPC_ALREADY_LINKED, + NOT_LINKED, +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcRayTrace.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcRayTrace.kt new file mode 100644 index 0000000..e4656f6 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/manager/NpcRayTrace.kt @@ -0,0 +1,71 @@ +package app.simplecloud.npc.common.manager + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.repository.NpcRepository +import kotlin.math.cos +import kotlin.math.sin + +object NpcRayTrace { + private const val MAX_REACH = 6.0 + private const val HIT_RADIUS = 0.6 + private const val EYE_HEIGHT = 1.62 + private const val NPC_CENTER_HEIGHT = 1.1 + + fun findLookedAt(repository: NpcRepository, player: NpcPlayer): NpcConfig? { + val eye = player.location() + val originY = eye.y + EYE_HEIGHT + val direction = directionOf(eye.yaw, eye.pitch) + + return repository.findAll() + .asSequence() + .filter { it.entity.location.world.equals(eye.world, true) } + .mapNotNull { config -> + rayHitDistance(eye.x, originY, eye.z, direction, config)?.let { config to it } + } + .minByOrNull { it.second } + ?.first + } + + private fun rayHitDistance( + originX: Double, + originY: Double, + originZ: Double, + direction: DoubleArray, + config: NpcConfig, + ): Double? { + val location = config.entity.location + val centerX = location.x + val centerY = location.y + NPC_CENTER_HEIGHT + val centerZ = location.z + + val toX = centerX - originX + val toY = centerY - originY + val toZ = centerZ - originZ + + val t = toX * direction[0] + toY * direction[1] + toZ * direction[2] + if (t !in 0.0..MAX_REACH) return null + + val closestX = originX + direction[0] * t + val closestY = originY + direction[1] * t + val closestZ = originZ + direction[2] * t + + val dx = closestX - centerX + val dy = closestY - centerY + val dz = closestZ - centerZ + val distanceSquared = dx * dx + dy * dy + dz * dz + + return if (distanceSquared <= HIT_RADIUS * HIT_RADIUS) t else null + } + + private fun directionOf(yaw: Float, pitch: Float): DoubleArray { + val yawRad = Math.toRadians(yaw.toDouble()) + val pitchRad = Math.toRadians(pitch.toDouble()) + + return doubleArrayOf( + -sin(yawRad) * cos(pitchRad), + -sin(pitchRad), + cos(yawRad) * cos(pitchRad), + ) + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/EditorMenuOpener.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/EditorMenuOpener.kt new file mode 100644 index 0000000..d700702 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/EditorMenuOpener.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.core.platform.NpcPlayer + +interface EditorMenuOpener { + fun runSync(task: () -> Unit) + fun runLater(delayTicks: Long, task: () -> Unit) + + fun open(player: NpcPlayer, menu: EditorMenu, onClick: (slot: Int, click: MenuClick) -> Unit, onClose: () -> Unit) + fun isShowing(player: NpcPlayer, menu: EditorMenu): Boolean + + fun detach(player: NpcPlayer) = Unit +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/Editors.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/Editors.kt new file mode 100644 index 0000000..e0efec4 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/Editors.kt @@ -0,0 +1,20 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer + +interface Editors { + fun openNpc(player: NpcPlayer, config: NpcConfig) + fun openInventory(player: NpcPlayer, config: InventoryConfiguration) + fun openInventoryCreate(player: NpcPlayer) + fun refreshOpenEditors(players: List<NpcPlayer>) = Unit + + companion object { + val NOOP: Editors = object : Editors { + override fun openNpc(player: NpcPlayer, config: NpcConfig) = Unit + override fun openInventory(player: NpcPlayer, config: InventoryConfiguration) = Unit + override fun openInventoryCreate(player: NpcPlayer) = Unit + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InteractionHooks.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InteractionHooks.kt new file mode 100644 index 0000000..1d8350d --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InteractionHooks.kt @@ -0,0 +1,14 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer + +interface InteractionHooks { + fun interact(npcId: String, player: NpcPlayer, interaction: PlayerInteraction, joinState: String): Boolean = true + fun join(npcId: String?, player: NpcPlayer, destination: String): String? = destination + fun menuClick(menuId: String, slot: Int, player: NpcPlayer, click: PlayerInteraction): Boolean = true + + companion object { + val NONE = object : InteractionHooks {} + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InventoryViewRenderer.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InventoryViewRenderer.kt new file mode 100644 index 0000000..048060a --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/InventoryViewRenderer.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.core.platform.NpcPlayer +import java.util.UUID + +data class RenderedInventory( + val title: String, + val size: Int, + val slots: Map<Int, NpcItem>, +) + +interface InventoryViewRenderer { + fun runSync(task: () -> Unit) + fun openView(player: NpcPlayer, viewId: UUID, view: RenderedInventory): Boolean + fun updateSlots(viewId: UUID, changes: Map<Int, NpcItem?>) + fun updateTitle(viewId: UUID, title: String) + fun closeView(viewId: UUID) + + companion object { + val NOOP: InventoryViewRenderer = object : InventoryViewRenderer { + override fun runSync(task: () -> Unit) = task() + override fun openView(player: NpcPlayer, viewId: UUID, view: RenderedInventory) = true + override fun updateSlots(viewId: UUID, changes: Map<Int, NpcItem?>) = Unit + override fun updateTitle(viewId: UUID, title: String) = Unit + override fun closeView(viewId: UUID) = Unit + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcEffects.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcEffects.kt new file mode 100644 index 0000000..fadf3b2 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcEffects.kt @@ -0,0 +1,18 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.core.config.NpcAnimation +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import net.kyori.adventure.text.Component + +interface NpcEffects { + fun animate(npc: NpcConfig, player: NpcPlayer, animation: NpcAnimation) + fun speak(npc: NpcConfig, player: NpcPlayer, text: Component) + + companion object { + val NONE = object : NpcEffects { + override fun animate(npc: NpcConfig, player: NpcPlayer, animation: NpcAnimation) = Unit + override fun speak(npc: NpcConfig, player: NpcPlayer, text: Component) = Unit + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcPlayerDirectory.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcPlayerDirectory.kt new file mode 100644 index 0000000..d54d18c --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/NpcPlayerDirectory.kt @@ -0,0 +1,8 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.core.platform.NpcPlayer + +interface NpcPlayerDirectory { + fun findOnlinePlayer(name: String): NpcPlayer? + fun onlinePlayers(): List<NpcPlayer> +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/PlatformCatalogs.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/PlatformCatalogs.kt new file mode 100644 index 0000000..6f37869 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/platform/PlatformCatalogs.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.common.platform + +import app.simplecloud.npc.common.editor.inventory.CommonMaterials + +interface PlatformCatalogs { + fun materials(): List<String> + fun sounds(): List<String> + + companion object { + val DEFAULT: PlatformCatalogs = object : PlatformCatalogs { + override fun materials(): List<String> = CommonMaterials.COMMON + override fun sounds(): List<String> = emptyList() + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/plugin/NpcPluginContext.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/plugin/NpcPluginContext.kt new file mode 100644 index 0000000..2e8d5d2 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/plugin/NpcPluginContext.kt @@ -0,0 +1,86 @@ +package app.simplecloud.npc.common.plugin + +import app.simplecloud.api.CloudApi +import app.simplecloud.npc.common.action.InteractionExecutor +import app.simplecloud.npc.common.cloud.CloudEventHandler +import app.simplecloud.npc.common.inventory.source.InventoryDataSources +import app.simplecloud.npc.common.inventory.view.HeadOwners +import app.simplecloud.npc.common.inventory.view.InventoryViewController +import app.simplecloud.npc.common.manager.InventoryManager +import app.simplecloud.npc.common.manager.NpcManager +import app.simplecloud.npc.common.platform.Editors +import app.simplecloud.npc.common.platform.InteractionHooks +import app.simplecloud.npc.common.platform.InventoryViewRenderer +import app.simplecloud.npc.common.platform.NpcEffects +import app.simplecloud.npc.common.platform.NpcPlayerDirectory +import app.simplecloud.npc.common.platform.PlatformCatalogs +import app.simplecloud.npc.common.render.ProviderCapabilities +import app.simplecloud.npc.common.utils.InputChecks +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.render.HologramRenderer +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.repository.NpcRepository + +class NpcPluginContext( + val renderer: NpcRenderer, + val hologramRenderer: HologramRenderer, + private val inventoryViewRenderer: InventoryViewRenderer = InventoryViewRenderer.NOOP, + val playerDirectory: NpcPlayerDirectory, + val inventoryRepository: InventoryRepository, + val npcRepository: NpcRepository, + val providers: ProviderCapabilities = ProviderCapabilities.STANDALONE_ONLY, + val catalogs: PlatformCatalogs = PlatformCatalogs.DEFAULT, + val editors: Editors = Editors.NOOP, + cloudApi: CloudApi? = null, + effects: NpcEffects = NpcEffects.NONE, +) { + @Volatile + var hooks: InteractionHooks = InteractionHooks.NONE + + val npcManager = NpcManager(npcRepository, renderer, hologramRenderer, providers) + val inventoryManager = InventoryManager(inventoryRepository) + val inventoryViews: InventoryViewController = InventoryViewController( + repository = inventoryRepository, + renderer = inventoryViewRenderer, + sources = InventoryDataSources.builtIns(), + hooks = { hooks }, + executorProvider = { interactionExecutor }, + ) + val interactionExecutor: InteractionExecutor = InteractionExecutor( + npcRepository = npcRepository, + inventoryOpener = inventoryViews, + effects = effects, + hooks = { hooks }, + ) + private val cloudEvents: CloudEventHandler? = cloudApi?.let(::CloudEventHandler) + + init { + npcRepository.setExternalChangeListener(npcManager::apply) + + inventoryRepository.onAnyChange { inventoryViews.requestRefresh() } + HeadOwners.lookup = { owner -> + InputChecks.parseUuid(owner)?.let { renderer.captureSkinByUuid(it) } + ?: renderer.captureSkinByUsername(owner) + } + HeadOwners.onResolved = { inventoryViews.requestRefresh() } + } + + fun runSync(task: () -> Unit) = inventoryViewRenderer.runSync(task) + + fun onEnable() { + renderer.onEnable() + npcRepository.loadAndWatch() + inventoryRepository.loadAndWatch() + cloudEvents?.registerEvents(this) + } + + fun onDisable() { + cloudEvents?.unregisterEvents() + npcRepository.stopWatching() + inventoryRepository.stopWatching() + inventoryViews.shutdown() + interactionExecutor.shutdown() + hologramRenderer.destroyAll() + renderer.onDisable() + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/HologramRotation.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/HologramRotation.kt new file mode 100644 index 0000000..6e56cb3 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/HologramRotation.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.common.render + +import app.simplecloud.npc.core.config.NpcConfig +import java.util.concurrent.ConcurrentHashMap + +class HologramRotation { + + private val shownFrames = ConcurrentHashMap<String, List<Int>>() + + fun due(configs: Collection<NpcConfig>, now: Long): List<NpcConfig> { + val rotating = configs.filter { config -> + config.hologram.layouts.any { layout -> layout.lines.any { it.rotating } } + } + shownFrames.keys.retainAll(rotating.mapTo(hashSetOf()) { it.id }) + + return rotating.filter { config -> + val frames = config.hologram.layouts.flatMap { layout -> layout.lines.map { it.frameIndex(now) } } + shownFrames.put(config.id, frames) != frames + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/ProviderCapabilities.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/ProviderCapabilities.kt new file mode 100644 index 0000000..af5ab48 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/ProviderCapabilities.kt @@ -0,0 +1,24 @@ +package app.simplecloud.npc.common.render + +import app.simplecloud.npc.core.render.Providers + +interface ProviderCapabilities { + val defaultProvider: String + val availableProviders: Set<String> + val creationCapableProviders: Set<String> + val fixedSkinProviders: Set<String> + val linkingProviders: Set<String> + + fun linkableReferences(provider: String): List<String> + + companion object { + val STANDALONE_ONLY: ProviderCapabilities = object : ProviderCapabilities { + override val defaultProvider: String = Providers.STANDALONE + override val availableProviders: Set<String> = setOf(Providers.STANDALONE) + override val creationCapableProviders: Set<String> = setOf(Providers.STANDALONE) + override val fixedSkinProviders: Set<String> = setOf(Providers.STANDALONE) + override val linkingProviders: Set<String> = emptySet() + override fun linkableReferences(provider: String): List<String> = emptyList() + } + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/RoutingNpcRenderer.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/RoutingNpcRenderer.kt new file mode 100644 index 0000000..5afb4f2 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/render/RoutingNpcRenderer.kt @@ -0,0 +1,127 @@ +package app.simplecloud.npc.common.render + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import app.simplecloud.npc.core.render.Providers +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +class RoutingNpcRenderer( + private val renderers: Map<String, NpcRenderer>, +) : NpcRenderer, ProviderCapabilities { + + private val warnedMissingProvider = ConcurrentHashMap.newKeySet<String>() + + override val defaultProvider: String + get() = if (Providers.MANNEQUIN in renderers) Providers.MANNEQUIN else Providers.STANDALONE + + override val availableProviders: Set<String> get() = renderers.keys + + override val creationCapableProviders: Set<String> + get() = renderers.filterValues(NpcRenderer::supportsCreation).keys + + override val fixedSkinProviders: Set<String> + get() = renderers.filterValues(NpcRenderer::supportsFixedSkin).keys + + val providersNotReady: List<String> + get() = renderers.filterValues { !it.isReady() }.keys.sorted() + + override val linkingProviders: Set<String> + get() = renderers.filterValues(NpcRenderer::supportsLinking).keys + + override fun linkableReferences(provider: String): List<String> = + renderers[provider.lowercase()]?.takeIf { it.supportsLinking }?.linkableReferences().orEmpty() + + override fun onEnable() = renderers.values.forEach(NpcRenderer::onEnable) + override fun onDisable() = renderers.values.forEach(NpcRenderer::onDisable) + + override fun spawn(config: NpcConfig): NpcConfig { + renderers[providerKey(config)]?.let { return it.spawn(config) } + + if (config.entity.providerLinked) { + logger.log( + Level.WARNING, + "NPC '${config.id}' is linked to a '${config.entity.provider}' NPC, but that plugin isn't " + + "installed; the NPC stays inactive until it is.", + ) + return config + } + + logger.log( + Level.WARNING, + "NPC '${config.id}' used provider '${config.entity.provider}', which is no longer installed; " + + "switching it to '${Providers.STANDALONE}'. Its provider-side NPC will be cleaned up " + + "if '${config.entity.provider}' is ever installed again.", + ) + + val dormantReferences = config.entity.dormantProviderReferences + + listOfNotNull(config.entity.providerReference?.let { config.entity.provider.lowercase() to it }) + val migratedConfig = config.copy( + entity = config.entity.copy( + provider = Providers.STANDALONE, + providerReference = null, + dormantProviderReferences = dormantReferences, + ), + ) + + return renderers[Providers.STANDALONE]?.spawn(migratedConfig) ?: migratedConfig + } + + override fun canonicalReference(config: NpcConfig): String? = rendererFor(config)?.canonicalReference(config) + + override fun despawn(config: NpcConfig) { + rendererFor(config)?.despawn(config) + } + + override fun refresh(config: NpcConfig) { + rendererFor(config)?.refresh(config) + } + + override fun teleport(config: NpcConfig) { + rendererFor(config)?.teleport(config) + } + + override fun locationOf(config: NpcConfig): NpcLocation? = rendererFor(config)?.locationOf(config) + + override fun snapshotOf(config: NpcConfig): ProviderNpcSnapshot? = + renderers[providerKey(config)]?.snapshotOf(config) + + override fun captureSkin(player: NpcPlayer): NpcConfig.SkinConfiguration = + renderers[Providers.STANDALONE]?.captureSkin(player) + ?: NpcConfig.SkinConfiguration(sourcePlayer = player.name) + + override suspend fun captureSkinByUsername(username: String): NpcConfig.SkinConfiguration? = + renderers[Providers.STANDALONE]?.captureSkinByUsername(username) + + override suspend fun captureSkinByUuid(uuid: UUID): NpcConfig.SkinConfiguration? = + renderers[Providers.STANDALONE]?.captureSkinByUuid(uuid) + + private fun providerKey(config: NpcConfig): String = config.entity.provider.lowercase() + + private fun rendererFor(config: NpcConfig): NpcRenderer? { + val warnKey = config.id.lowercase() + renderers[providerKey(config)]?.let { renderer -> + warnedMissingProvider.remove(warnKey) + return renderer + } + + if (warnedMissingProvider.add(warnKey)) { + logger.log( + Level.WARNING, + "NPC '${config.id}' uses provider '${config.entity.provider}', which isn't currently " + + "available (its plugin may not be installed or enabled), skipping.", + ) + } + + return null + } + + private companion object { + private val logger = NpcLog.logger + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/Msg.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/Msg.kt new file mode 100644 index 0000000..915098f --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/Msg.kt @@ -0,0 +1,68 @@ +package app.simplecloud.npc.common.text + +import app.simplecloud.npc.core.platform.NpcCommandSender +import net.kyori.adventure.text.minimessage.MiniMessage + +object Msg { + private const val PREFIX = "<#0ea5e9>⚡ NPC <#475569>|" + + const val ERROR = "<#dc2626>" + const val WARNING = "<#f59e0b>" + const val SUCCESS = "<#a3e635>" + const val INFO = "<#0ea5e9>" + + const val ACCENT = "<#f8fafc>" + const val MUTED = "<#94a3b8>" + const val SUBTLE = "<#475569>" + + private const val ICON_ERROR = "✖" + private const val ICON_WARNING = "⚠" + private const val ICON_SUCCESS = "✔" + private const val ICON_INFO = "ℹ" + + private val PLACEHOLDER = Regex.fromLiteral("{}") + + val miniMessage: MiniMessage = MiniMessage.miniMessage() + + fun error(template: String, vararg args: Any?) = compose(ERROR, ICON_ERROR, template, args) + fun warning(template: String, vararg args: Any?) = compose(WARNING, ICON_WARNING, template, args) + fun success(template: String, vararg args: Any?) = compose(SUCCESS, ICON_SUCCESS, template, args) + fun info(template: String, vararg args: Any?) = compose(INFO, ICON_INFO, template, args) + + fun errorPrefix() = prefix(ERROR, ICON_ERROR) + fun successPrefix() = prefix(SUCCESS, ICON_SUCCESS) + fun infoPrefix() = prefix(INFO, ICON_INFO) + + private fun prefix(color: String, icon: String) = "$PREFIX $color$icon " + + private fun compose(color: String, icon: String, template: String, args: Array<out Any?>): String = + prefix(color, icon) + highlight(template, args, color) + + fun highlight(template: String, args: Array<out Any?>, baseColor: String): String { + if (args.isEmpty()) return template + var argIndex = 0 + + return PLACEHOLDER.replace(template) { match -> + if (argIndex < args.size) { + "$ACCENT${miniMessage.escapeTags(args[argIndex++].toString())}$baseColor" + } else { + match.value + } + } + } +} + +fun String.substitute(substitutions: Map<String, String>): String = + substitutions.entries.fold(this) { acc, (key, replacement) -> acc.replace(key, replacement) } + +fun NpcCommandSender.sendError(template: String, vararg args: Any?) = + sendMessage(Msg.miniMessage.deserialize(Msg.error(template, *args))) + +fun NpcCommandSender.sendWarning(template: String, vararg args: Any?) = + sendMessage(Msg.miniMessage.deserialize(Msg.warning(template, *args))) + +fun NpcCommandSender.sendSuccess(template: String, vararg args: Any?) = + sendMessage(Msg.miniMessage.deserialize(Msg.success(template, *args))) + +fun NpcCommandSender.sendInfo(template: String, vararg args: Any?) = + sendMessage(Msg.miniMessage.deserialize(Msg.info(template, *args))) diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/PlayerMessages.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/PlayerMessages.kt new file mode 100644 index 0000000..f3fe562 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/text/PlayerMessages.kt @@ -0,0 +1,18 @@ +package app.simplecloud.npc.common.text + +import app.simplecloud.npc.common.command.CommandMessages +import app.simplecloud.npc.core.platform.NpcPlayer + +object PlayerMessages { + const val NO_PERMISSION = "player.action.no-permission" + + val PATHS = setOf(NO_PERMISSION) + + fun denied(player: NpcPlayer, custom: String?) { + if (custom != null) { + if (custom.isNotBlank()) player.sendMessage(Msg.miniMessage.deserialize(custom)) + return + } + CommandMessages.send(player, NO_PERMISSION, "${Msg.errorPrefix()}You are not allowed to do that.") + } +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/BackgroundTasks.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/BackgroundTasks.kt new file mode 100644 index 0000000..fbea919 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/BackgroundTasks.kt @@ -0,0 +1,26 @@ +package app.simplecloud.npc.common.utils + +import app.simplecloud.npc.core.NpcLog +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.logging.Level + +object BackgroundTasks { + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.log(Level.WARNING, "Unhandled background task failure", throwable) + }, + ) + + fun launch(block: suspend () -> Unit) { + scope.launch { block() } + } + + fun shutdown() = scope.cancel() + + private val logger = NpcLog.logger +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/InputChecks.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/InputChecks.kt new file mode 100644 index 0000000..12700db --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/InputChecks.kt @@ -0,0 +1,33 @@ +package app.simplecloud.npc.common.utils + +import java.util.Base64 +import java.util.UUID + +object InputChecks { + private val UNDASHED_UUID_PATTERN = Regex("^[0-9a-fA-F]{32}$") + private val HOST_PORT_PATTERN = Regex("^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$") + private val SOUND_PATTERN = Regex("^[A-Za-z0-9_./-]+(?::[a-z0-9_./-]+)?$") + + fun isValidBase64(value: String): Boolean = runCatching { Base64.getDecoder().decode(value) }.isSuccess + fun isValidSound(value: String): Boolean = value.isNotBlank() && SOUND_PATTERN.matches(value) + fun isValidHostPort(value: String): Boolean = parseHostPort(value) != null + + fun parseHostPort(value: String): Pair<String, Int>? { + if (!HOST_PORT_PATTERN.matches(value)) return null + val port = value.substringAfter(':', DEFAULT_PORT.toString()).toIntOrNull()?.takeIf { it in 1..65535 } + ?: return null + + return value.substringBefore(':') to port + } + + fun parseUuid(input: String): UUID? { + runCatching { UUID.fromString(input) }.getOrNull()?.let { return it } + if (!UNDASHED_UUID_PATTERN.matches(input)) return null + val dashed = "${input.substring(0, 8)}-${input.substring(8, 12)}-${input.substring(12, 16)}-" + + "${input.substring(16, 20)}-${input.substring(20, 32)}" + + return UUID.fromString(dashed) + } + + private const val DEFAULT_PORT = 25565 +} diff --git a/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/SkinInput.kt b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/SkinInput.kt new file mode 100644 index 0000000..60e2442 --- /dev/null +++ b/platform/common/src/main/kotlin/app/simplecloud/npc/common/utils/SkinInput.kt @@ -0,0 +1,31 @@ +package app.simplecloud.npc.common.utils + +import java.util.UUID + +sealed interface SkinInput { + data class Uuid(val uuid: UUID) : SkinInput + data class Username(val name: String) : SkinInput + data class Texture(val value: String, val signature: String?) : SkinInput + + companion object { + private const val MIN_TEXTURE_LENGTH = 40 + private val USERNAME = Regex("[A-Za-z0-9_]{3,16}") + private val WHITESPACE = Regex("\\s+") + + fun parse(input: String): SkinInput? { + val parts = input.trim().split(WHITESPACE).filter { it.isNotEmpty() } + val first = parts.singleOrNull() ?: return texture(parts) + + return InputChecks.parseUuid(first)?.let(::Uuid) + ?: first.takeIf(USERNAME::matches)?.let(::Username) + ?: texture(parts) + } + + private fun texture(parts: List<String>): Texture? { + if (parts.size !in 1..2 || !parts.all(InputChecks::isValidBase64)) return null + if (parts.first().length < MIN_TEXTURE_LENGTH) return null + + return Texture(parts.first(), parts.getOrNull(1)) + } + } +} diff --git a/platform/common/src/main/resources/inventories/example.yml b/platform/common/src/main/resources/inventories/example.yml new file mode 100644 index 0000000..bdb9f09 --- /dev/null +++ b/platform/common/src/main/resources/inventories/example.yml @@ -0,0 +1,158 @@ +# Example SimpleCloud NPC menu. +# Reference it from an NPC via: /scnpcs edit <npc-id> action open-inventory right_click public example +# or open it directly, e.g. from a compass item: /scnpcs inventory open example +# This file is only written once, on the very first start, edit or delete it freely. + +version: 1 +id: example +title: "<#0ea5e9><bold>Server Browser" +rows: 5 +# Optional: only players with this permission can open the menu. +# open-permission: lobby.servers +# Optional: played once when the menu opens. +open-sound: UI_BUTTON_CLICK + +# Every slot is just an item. Give one a `live-group` and it becomes a "Live Slot": +# its own material/name/lore/actions become the template for whatever `live` matches, +# and every other slot sharing the same live-group forms one paginated, live-updating +# list with it; servers appearing/stopping and player counts tick live while the menu +# is open. In-game, open the canvas (/scnpcs inventory edit <id>), click "New Live +# Group" in the toolbox under the menu, and drag the swatch across the slots it +# should fill; or hand-author them here. +items: + # A player head can show whoever opens the menu (`viewer: true`), a player + # (`owner: Notch` or a UUID; looked up once and cached) or a fixed `texture`. + - slot: 0 + material: PLAYER_HEAD + head: + viewer: true + name: "<#0ea5e9>Server Browser" + lore: + - "<#94a3b8>Hey <#f8fafc><playername><#94a3b8>, pick a server" + - "<#94a3b8>below, or let the star pick one." + - "<#475569>Page <page>/<max_page>" + - slot: 4 + material: NETHER_STAR + name: "<#f8fafc>Join a Server" + glowing: true + lore: + - "<#94a3b8>Joins the emptiest server of" + - "<#94a3b8>this NPC's target group." + actions: + # join-target uses the target servers of the NPC that opened this menu. + - join-target: true + - slot: 40 + material: BARRIER + name: "<#dc2626>Close" + actions: + - close-menu: true + # Only players with the permission see this item at all. + # - slot: 8 + # material: DIAMOND + # name: "<#38bdf8>VIP Lounge" + # view-permission: lobby.vip + # actions: + # - send-to-server: "VIP-1" + # cooldown: 3000 + + # The 17 slots below are one live group ("servers"). Only the first slot needs the + # `live:` block, it's the shared filter for the whole group. Every slot has its own + # look, resolved per matched server via <entry_*> placeholders: + # <entry_name>, <entry_numerical_id>, <entry_state>, <entry_online_players>, + # <entry_max_players>, <entry_motd>, <entry_property:key>, ... + - slot: 9 + live-group: servers + live: + # Built-in sources: group-servers, persistent-servers, groups, static. + type: group-servers + # "<target>" (or omitting `group`) resolves the opening NPC's first target group; + # put an explicit group name here to always show that group instead. + group: "<target>" + # ServerState filter; empty = everything except STOPPING/CLEANUP. + states: [AVAILABLE, INGAME] + # NUMERICAL_ID (default) or PLAYER_COUNT. + sort: NUMERICAL_ID + # How an entry looks per server state; unset fields keep the slot's own look. + # States: AVAILABLE, FULL, STARTING, INGAME (and OFFLINE for groups/persistent servers). + looks: + FULL: + material: RED_CONCRETE + lore: + - "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>" + - "" + - "<#dc2626>This server is full" + INGAME: + material: ORANGE_CONCRETE + lore: + - "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>" + - "" + - "<#f59e0b>A round is running" + material: GRASS_BLOCK + name: "<#a3e635><entry_group_name>-<entry_numerical_id>" + lore: + - "<#94a3b8>Players: <#f8fafc><entry_online_players>/<entry_max_players>" + - "<#94a3b8>State: <#f8fafc><entry_state>" + - "" + - "<#475569>Click to join this server" + # Optional: what to click-do per entry. Omitted here, so the source default + # applies; for server sources that is: connect to exactly the clicked server. + # actions: + # - send-to-server: "<entry_server>" + + # Filler for slots no server currently occupies; an empty slot in a live group + # usually means a server is starting, so say that instead of "free". Also only + # meaningful on the first slot. + when-empty: + material: GRAY_STAINED_GLASS_PANE + name: "<#475569>Spinning up..." + - { slot: 10, live-group: servers, material: GRASS_BLOCK } + - { slot: 11, live-group: servers, material: GRASS_BLOCK } + - { slot: 12, live-group: servers, material: GRASS_BLOCK } + - { slot: 13, live-group: servers, material: GRASS_BLOCK } + - { slot: 14, live-group: servers, material: GRASS_BLOCK } + - { slot: 15, live-group: servers, material: GRASS_BLOCK } + - { slot: 16, live-group: servers, material: GRASS_BLOCK } + - { slot: 17, live-group: servers, material: GRASS_BLOCK } + - { slot: 18, live-group: servers, material: GRASS_BLOCK } + - { slot: 19, live-group: servers, material: GRASS_BLOCK } + - { slot: 20, live-group: servers, material: GRASS_BLOCK } + - { slot: 21, live-group: servers, material: GRASS_BLOCK } + - { slot: 22, live-group: servers, material: GRASS_BLOCK } + - { slot: 23, live-group: servers, material: GRASS_BLOCK } + - { slot: 24, live-group: servers, material: GRASS_BLOCK } + - { slot: 25, live-group: servers, material: GRASS_BLOCK } + + # Shown only when there are more servers than group slots. + - slot: 37 + live-group: servers + page-control: previous + material: ARROW + name: "<#0ea5e9>Previous page" + - slot: 43 + live-group: servers + page-control: next + material: ARROW + name: "<#0ea5e9>Next page" + + # More examples (commented out): + # + # A live group listing every group; clicking joins the emptiest AVAILABLE server of it. + # - slot: 28 + # live-group: groups + # live: + # type: groups + # material: COMPASS + # name: "<#0ea5e9><entry_name>" + # lore: + # - "<#94a3b8>Online: <#f8fafc><entry_online_players>" + # + # A hand-authored paged static list. + # - slot: 28 + # live-group: content + # live: + # type: static + # entries: + # - material: DIAMOND_SWORD + # name: "<#f8fafc>PvP" + # actions: + # - send-to-server: "PvP-1" diff --git a/platform/common/src/main/resources/messages.yml b/platform/common/src/main/resources/messages.yml new file mode 100644 index 0000000..33e598d --- /dev/null +++ b/platform/common/src/main/resources/messages.yml @@ -0,0 +1,53 @@ +variables: + prefix: '<#0EA5E9>⚡ NPC <#475569>|' + +command: + help: + title: '<prefix> <#0EA5E9>ℹ Commands' + topic-title: '<prefix> <#0EA5E9>ℹ Help <#475569>- <#F8FAFC><topic>' + unknown: '<prefix> <#DC2626>✖ Unknown help topic. <#94A3B8>Try: <#F8FAFC><topics>' + reload: + success: '<prefix> <#A3E635>✔ SimpleCloud NPCs was reloaded.' + failed: '<prefix> <#DC2626>✖ SimpleCloud NPCs could not be reloaded.' + npc: + create: + success: '<prefix> <#A3E635>✔ Created NPC <#F8FAFC><id><#A3E635>.' + failed: '<prefix> <#DC2626>✖ Could not create the NPC<#F8FAFC><detail-suffix>' + link: + success: '<prefix> <#A3E635>✔ Linked NPC <#F8FAFC><id><#A3E635>.' + unlink: + success: '<prefix> <#A3E635>✔ Unlinked NPC <#F8FAFC><id><#A3E635>.' + duplicate: + success: '<prefix> <#A3E635>✔ Duplicated NPC as <#F8FAFC><id><#A3E635>.' + delete: + success: '<prefix> <#A3E635>✔ Deleted NPC <#F8FAFC><id><#A3E635>.' + update: + success: '<prefix> <#A3E635>✔ Updated NPC <#F8FAFC><id><#A3E635>.' + error: + not-found: '<prefix> <#DC2626>✖ NPC <#F8FAFC><detail> <#DC2626>was not found.' + already-exists: '<prefix> <#DC2626>✖ NPC <#F8FAFC><detail> <#DC2626>already exists.' + invalid-id: '<prefix> <#DC2626>✖ NPC id <#F8FAFC><detail> <#DC2626>is invalid.' + not-linked: '<prefix> <#DC2626>✖ NPC <#F8FAFC><detail> <#DC2626>is not a linked NPC, use delete.' + provider: + error: + unavailable: '<prefix> <#DC2626>✖ Provider <#F8FAFC><detail> <#DC2626>is unavailable.' + external-npc-missing: '<prefix> <#DC2626>✖ Provider NPC <#F8FAFC><detail> <#DC2626>was not found.' + already-linked: '<prefix> <#DC2626>✖ Provider NPC <#F8FAFC><detail> <#DC2626>is already linked.' + target: + error: + not-found: '<prefix> <#DC2626>✖ Target <#F8FAFC><detail> <#DC2626>was not found.' + ambiguous: '<prefix> <#DC2626>✖ Target <#F8FAFC><detail> <#DC2626>exists as both a group and persistent server. Rename one of them.' + already-used: '<prefix> <#DC2626>✖ Target <#F8FAFC><detail> <#DC2626>is already used by this NPC.' + limit: '<prefix> <#DC2626>✖ This NPC already has the maximum of <#F8FAFC><detail> <#DC2626>targets.' + +player: + action: + no-permission: '<prefix> <#DC2626>✖ You are not allowed to do that.' + +# Help pages can be overridden per topic; the plugin's built-in pages are used when a +# topic is missing here. Topics: main, create, link, edit, target, hologram, action, +# pushback, admin, inventory. +# help: +# pages: +# main: +# - '<prefix> <#0EA5E9>/scnpcs create <id> <target>' diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/TestFakes.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/TestFakes.kt new file mode 100644 index 0000000..883a048 --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/TestFakes.kt @@ -0,0 +1,114 @@ +package app.simplecloud.npc.common + +import app.simplecloud.npc.common.platform.NpcPlayerDirectory +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.HologramRenderer +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import net.kyori.adventure.text.Component +import java.util.UUID + +class RecordingNpcRenderer(override val supportsCreation: Boolean = true) : NpcRenderer { + val spawned = mutableListOf<String>() + val despawned = mutableListOf<String>() + val refreshed = mutableListOf<String>() + + override fun onEnable() = Unit + override fun onDisable() = Unit + override fun spawn(config: NpcConfig): NpcConfig = config.also { spawned += it.id } + override fun despawn(config: NpcConfig) { + despawned += config.id + } + + override fun refresh(config: NpcConfig) { + refreshed += config.id + } + + override fun teleport(config: NpcConfig) = Unit + + override fun locationOf(config: NpcConfig): NpcLocation? = null + override fun snapshotOf(config: NpcConfig): ProviderNpcSnapshot? = null +} + +class RecordingHolograms : HologramRenderer { + val updated = mutableListOf<String>() + val destroyed = mutableListOf<String>() + + override fun createOrUpdate(config: NpcConfig) { + updated += config.id + } + + override fun destroy(config: NpcConfig) { + destroyed += config.id + } + + override fun destroyAll() = Unit +} + +object NoHolograms : HologramRenderer { + override fun createOrUpdate(config: NpcConfig) = Unit + override fun destroy(config: NpcConfig) = Unit + override fun destroyAll() = Unit +} + +object NoPlayers : NpcPlayerDirectory { + override fun findOnlinePlayer(name: String): NpcPlayer? = null + override fun onlinePlayers(): List<NpcPlayer> = emptyList() +} + +class FakePlayer(override val name: String = "Tester") : NpcPlayer { + override val uniqueId: UUID = UUID.randomUUID() + val messages = mutableListOf<Component>() + var closedInventories = 0 + + val calls = mutableListOf<String>() + + var soundFails = false + + override fun location(): NpcLocation = NpcLocation("world", 0.0, 64.0, 0.0, 0f, 0f) + override fun performCommand(command: String) { + calls += "command:$command" + } + + override fun playSound(key: String, options: NpcConfig.SoundOptions) { + calls += "sound:$key" + if (soundFails) error("no such sound") + } + + override fun showTitle(title: Component, subtitle: Component, fadeInTicks: Int, stayTicks: Int, fadeOutTicks: Int) { + calls += "title" + } + + override fun teleport(location: NpcLocation): Boolean { + calls += "teleport" + return true + } + + override fun push(from: NpcLocation, strength: Double, vertical: Double) = Unit + val permissions = mutableSetOf<String>() + override fun wornArmor(): NpcConfig.EquipmentConfiguration = NpcConfig.EquipmentConfiguration() + + override fun hasPermission(permission: String): Boolean = permission in permissions + + override fun sendActionBar(component: Component) { + calls += "actionbar" + } + + override fun sendToServer(serverName: String) { + calls += "send:$serverName" + } + + override fun transferToServer(host: String, port: Int) { + calls += "transfer:$host:$port" + } + override fun closeInventory() { + closedInventories++ + } + + override fun sendMessage(component: Component) { + calls += "message" + messages += component + } +} diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/action/InteractionExecutorTest.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/action/InteractionExecutorTest.kt new file mode 100644 index 0000000..939d580 --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/action/InteractionExecutorTest.kt @@ -0,0 +1,75 @@ +package app.simplecloud.npc.common.action + +import app.simplecloud.npc.common.FakePlayer +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.inventory.view.InventoryOpener +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.repository.NpcRepository +import kotlinx.coroutines.runBlocking +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class InteractionExecutorTest { + + private object NoInventories : InventoryOpener { + override fun open(player: NpcPlayer, inventoryId: String, context: InventoryOpenContext) = Unit + } + + private fun executor() = + InteractionExecutor(NpcRepository(createTempDirectory("interaction-executor")), NoInventories) + + @Test + fun `everything the player sees runs before they are sent away`() = runBlocking { + val player = FakePlayer() + val action = NpcConfig.ActionConfiguration( + playSound = "ENTITY_PLAYER_LEVELUP", + sendMessage = "Bye", + sendToServer = "lobby-1", + ) + + executor().executeAction(emptyList(), action, player) + + assertEquals(listOf("sound:ENTITY_PLAYER_LEVELUP", "message", "send:lobby-1"), player.calls) + } + + @Test + fun `a failing action does not stop the ones after it`() = runBlocking { + val player = FakePlayer().apply { soundFails = true } + val action = NpcConfig.ActionConfiguration(playSound = "broken", sendMessage = "Still here") + + executor().executeAction(emptyList(), action, player) + + assertTrue("message" in player.calls, player.calls.toString()) + } + + @Test + fun `joining with nowhere to go sends nobody and leaves the message to the offline action`() = runBlocking { + val player = FakePlayer() + + executor().executeAction(emptyList(), NpcConfig.ActionConfiguration(joinTarget = true), player) + + assertTrue(player.calls.none { it.startsWith("send:") }, player.calls.toString()) + assertTrue(player.messages.isEmpty(), player.messages.toString()) + } + + @Test + fun `without the permission nothing runs and the player is told`() = runBlocking { + val player = FakePlayer() + val action = NpcConfig.ActionConfiguration( + permission = "vip.join", + sendMessage = "Welcome", + sendToServer = "vip-1", + ) + + executor().executeAction(emptyList(), action, player) + assertEquals(listOf("message"), player.calls) + + player.calls.clear() + player.permissions += "vip.join" + executor().executeAction(emptyList(), action, player) + assertEquals(listOf("message", "send:vip-1"), player.calls) + } +} diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/command/MessagesResourceTest.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/command/MessagesResourceTest.kt new file mode 100644 index 0000000..ba30848 --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/command/MessagesResourceTest.kt @@ -0,0 +1,55 @@ +package app.simplecloud.npc.common.command + +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.yaml.YamlConfigurationLoader +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class MessagesResourceTest { + + private fun load(): ConfigurationNode { + val resource = assertNotNull(javaClass.getResourceAsStream("/messages.yml"), "resource missing") + val file = createTempDirectory("npc-messages").resolve("messages.yml") + file.writeBytes(resource.readBytes()) + + return YamlConfigurationLoader.builder().path(file).build().load() + } + + private fun leaves(node: ConfigurationNode, prefix: String = ""): Set<String> = + if (node.isMap) { + node.childrenMap().flatMap { (key, child) -> + leaves(child, if (prefix.isEmpty()) "$key" else "$prefix.$key") + }.toSet() + } else { + setOf(prefix) + } + + @Test + fun `code paths and resource leaves match`() { + val root = load() + val fileLeaves = leaves(root) + .filterNot { it.startsWith("variables.") || it.startsWith("command.help.pages.") } + .toSet() + assertEquals(CommandMessages.resourcePaths(), fileLeaves) + } + + @Test + fun `old default prefixes are replaced, custom ones kept`() { + val dir = createTempDirectory("npc-prefix") + val old = dir.resolve("old.yml") + old.writeText("variables:\n prefix: '<#0EA5E9><bold>NPCs</bold> <#475569>|'\n") + val custom = dir.resolve("custom.yml") + custom.writeText("variables:\n prefix: '<red>Mine |'\n") + + CommandMessages.updateDefaultPrefix(old) + CommandMessages.updateDefaultPrefix(custom) + + assertEquals("variables:\n prefix: '<#0EA5E9>⚡ NPC <#475569>|'\n", old.readText()) + assertEquals("variables:\n prefix: '<red>Mine |'\n", custom.readText()) + } +} diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/inventory/ExampleInventoryTest.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/inventory/ExampleInventoryTest.kt new file mode 100644 index 0000000..1c0260f --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/inventory/ExampleInventoryTest.kt @@ -0,0 +1,35 @@ +package app.simplecloud.npc.common.inventory + +import app.simplecloud.npc.core.inventory.InventoryConfiguration +import app.simplecloud.npc.core.inventory.InventoryRepository +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ExampleInventoryTest { + + private fun load(): InventoryConfiguration { + val directory = createTempDirectory("npc-example-inventory") + val resource = assertNotNull(javaClass.getResourceAsStream("/inventories/example.yml"), "resource missing") + directory.resolve("example.yml").writeBytes(resource.readBytes()) + + return assertNotNull(InventoryRepository(directory).load().singleOrNull()) + } + + @Test + fun `example loads with every documented slot`() { + val config = load() + assertEquals("example", config.id) + assertEquals(1, config.version) + assertEquals(5, config.rows) + assertEquals(22, config.items.size) + assertTrue(config.items.all { it.slot in 0 until config.size() }) + assertEquals(config.items.size, config.items.map { it.slot }.distinct().size, "no duplicate slots") + + val star = assertNotNull(config.items.firstOrNull { it.slot == 4 }) + assertTrue(star.actions.single().joinTarget, "the star joins the NPC's target") + } +} diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerReloadTest.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerReloadTest.kt new file mode 100644 index 0000000..6a6461e --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerReloadTest.kt @@ -0,0 +1,80 @@ +package app.simplecloud.npc.common.manager + +import app.simplecloud.npc.common.NoPlayers +import app.simplecloud.npc.common.RecordingHolograms +import app.simplecloud.npc.common.RecordingNpcRenderer +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.render.RoutingNpcRenderer +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.render.Providers +import app.simplecloud.npc.core.repository.NpcRepository +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NpcManagerReloadTest { + + private val directory = createTempDirectory("npcs") + private val standalone = RecordingNpcRenderer() + private val citizens = RecordingNpcRenderer() + private val holograms = RecordingHolograms() + private val routing = RoutingNpcRenderer( + linkedMapOf(Providers.STANDALONE to standalone, Providers.CITIZENS to citizens), + ) + private val context = NpcPluginContext( + renderer = routing, + hologramRenderer = holograms, + playerDirectory = NoPlayers, + inventoryRepository = InventoryRepository(createTempDirectory("inventories")), + npcRepository = NpcRepository(directory), + providers = routing, + ) + + private fun yaml(id: String, target: String, provider: String = Providers.STANDALONE) = """ + version: ${NpcConfig.CURRENT_VERSION} + id: $id + entity: + provider: $provider + target-servers: + - $target + """.trimIndent() + + @Test + fun `reload spawns new files, despawns removed ones and refreshes edited ones`() { + val repository = context.npcRepository + repository.save(NpcConfig(id = "keep", targetServers = mutableListOf("Keep"))) + repository.save(NpcConfig(id = "edit", targetServers = mutableListOf("Old"))) + repository.save(NpcConfig(id = "gone", targetServers = mutableListOf("Gone"))) + repository.save( + NpcConfig( + id = "moved", + entity = NpcConfig.NpcEntityConfiguration(provider = Providers.STANDALONE), + targetServers = mutableListOf("Moved"), + ), + ) + + directory.resolve("edit.yml").writeText(yaml("edit", "New")) + directory.resolve("gone.yml").deleteIfExists() + directory.resolve("fresh.yml").writeText(yaml("fresh", "Fresh")) + directory.resolve("moved.yml").writeText(yaml("moved", "Moved", provider = Providers.CITIZENS)) + + val diff = context.npcManager.reload() + + assertEquals(listOf("fresh"), diff.added.map { it.id }) + assertEquals(listOf("gone"), diff.removed.map { it.id }) + assertEquals(setOf("edit", "moved"), diff.changed.map { it.current.id }.toSet()) + + assertEquals(listOf("fresh"), standalone.spawned) + assertEquals(listOf("moved"), citizens.spawned, "a provider switch spawns at the new provider") + assertEquals(setOf("gone", "moved"), standalone.despawned.toSet()) + assertEquals(listOf("edit"), standalone.refreshed) + assertEquals(setOf("fresh", "edit", "moved"), holograms.updated.toSet()) + assertEquals(setOf("gone", "moved"), holograms.destroyed.toSet()) + assertNull(repository.find("gone")) + assertEquals(listOf("New"), repository.find("edit")!!.targetServers.toList()) + } +} diff --git a/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerUpgradeTest.kt b/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerUpgradeTest.kt new file mode 100644 index 0000000..aac319e --- /dev/null +++ b/platform/common/src/test/kotlin/app/simplecloud/npc/common/manager/NpcManagerUpgradeTest.kt @@ -0,0 +1,136 @@ +package app.simplecloud.npc.common.manager + +import app.simplecloud.npc.common.NoHolograms +import app.simplecloud.npc.common.NoPlayers +import app.simplecloud.npc.common.RecordingNpcRenderer +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.render.RoutingNpcRenderer +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import app.simplecloud.npc.core.repository.NpcRepository +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NpcManagerUpgradeTest { + + private class FakeProvider(private val persisted: MutableMap<String, ProviderNpcSnapshot>) : NpcRenderer { + val spawned = mutableListOf<NpcConfig>() + val deletedReferences = mutableListOf<String>() + + override fun onEnable() = Unit + override fun onDisable() = Unit + override fun spawn(config: NpcConfig): NpcConfig = config.also(spawned::add) + override fun despawn(config: NpcConfig) { + if (config.entity.providerLinked) return + config.entity.providerReference?.let { if (persisted.remove(it) != null) deletedReferences += it } + } + + override fun refresh(config: NpcConfig) = Unit + override fun teleport(config: NpcConfig) = Unit + override fun locationOf(config: NpcConfig): NpcLocation? = null + override fun snapshotOf(config: NpcConfig): ProviderNpcSnapshot? = + config.entity.providerReference?.let(persisted::get) + } + + private fun context(citizens: FakeProvider): NpcPluginContext { + val routing = RoutingNpcRenderer(linkedMapOf("citizens" to citizens, "standalone" to RecordingNpcRenderer())) + return NpcPluginContext( + renderer = routing, + hologramRenderer = NoHolograms, + playerDirectory = NoPlayers, + inventoryRepository = InventoryRepository(createTempDirectory("inventories")), + npcRepository = NpcRepository(createTempDirectory("npcs")), + providers = routing, + ) + } + + private val lobbyLocation = NpcLocation("lobby", 10.5, 64.0, -3.5, 90f, 0f) + private val skin = NpcConfig.SkinConfiguration("texture", "signature", "Notch") + + @Test + fun `a version five npc keeps its citizens location, skin and name`() { + val citizens = FakeProvider(mutableMapOf("42" to ProviderNpcSnapshot(lobbyLocation, skin, "Lobby Guide"))) + val manager = context(citizens).npcManager + + val migrated = NpcConfig( + id = "lobby", + entity = NpcConfig.NpcEntityConfiguration( + provider = "citizens", + providerReference = "42", + needsRelocation = true, + ), + ) + val reconciled = manager.reconcileProvider(migrated) + + assertEquals(lobbyLocation, reconciled.entity.location) + assertEquals(skin, reconciled.entity.skin) + assertEquals("Lobby Guide", reconciled.entity.customName) + assertFalse(reconciled.entity.needsRelocation) + assertEquals("standalone", reconciled.entity.provider) + assertNull(reconciled.entity.providerReference) + assertEquals(listOf("42"), citizens.deletedReferences) + } + + @Test + fun `an npc whose provider npc is gone keeps the relocation warning`() { + val citizens = FakeProvider(mutableMapOf()) + val manager = context(citizens).npcManager + + val migrated = NpcConfig( + id = "lobby", + entity = NpcConfig.NpcEntityConfiguration( + provider = "citizens", + providerReference = "42", + needsRelocation = true, + ), + ) + val reconciled = manager.reconcileProvider(migrated) + + assertTrue(reconciled.entity.needsRelocation) + assertEquals(NpcLocation(), reconciled.entity.location) + assertTrue(citizens.deletedReferences.isEmpty()) + } + + @Test + fun `a linked npc is never adopted or replaced`() { + val citizens = FakeProvider(mutableMapOf("42" to ProviderNpcSnapshot(lobbyLocation, skin, "Shopkeeper"))) + val manager = context(citizens).npcManager + + val linked = NpcConfig( + id = "shop", + entity = NpcConfig.NpcEntityConfiguration( + provider = "citizens", + providerReference = "42", + providerLinked = true, + ), + ) + + assertEquals(linked, manager.reconcileProvider(linked)) + assertTrue(citizens.deletedReferences.isEmpty()) + } + + @Test + fun `a managed npc of another plugin becomes an own npc in place`() { + val citizens = FakeProvider(mutableMapOf("42" to ProviderNpcSnapshot(lobbyLocation, skin))) + val manager = context(citizens).npcManager + val hub = NpcLocation("hub", 1.0, 2.0, 3.0) + + val managed = NpcConfig( + id = "lobby", + entity = NpcConfig.NpcEntityConfiguration(provider = "citizens", providerReference = "42", location = hub), + ) + val reconciled = manager.reconcileProvider(managed) + + assertEquals("standalone", reconciled.entity.provider) + assertEquals(hub, reconciled.entity.location) + assertNull(reconciled.entity.providerReference) + assertEquals(listOf("42"), citizens.deletedReferences) + } +} diff --git a/platform/paper/build.gradle.kts b/platform/paper/build.gradle.kts new file mode 100644 index 0000000..02e1912 --- /dev/null +++ b/platform/paper/build.gradle.kts @@ -0,0 +1,135 @@ +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.shadow) + alias(libs.plugins.minotaur) +} + +dependencies { + compileOnly(libs.paper.api) + + implementation(libs.cloud.paper) + + compileOnly(libs.simplecloud.api) + implementation(project(":libs:packetevents", "shadowRuntimeElements")) + + implementation(project(":api")) + implementation(project(":platform:common")) + implementation(project(":provider:base")) + implementation(project(":provider:standalone")) + implementation(project(":provider:citizens")) + implementation(project(":provider:fancynpcs")) + implementation(project(":provider:mythicmobs")) + implementation(project(":provider:znpcsplus")) + + runtimeOnly(project(":provider:mannequin")) + + testImplementation(libs.paper.mannequin.api) +} + +tasks.processResources { + val pluginVersion = project.version.toString() + inputs.property("version", pluginVersion) + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} + +tasks.shadowJar { + mergeServiceFiles() + archiveFileName.set("simplecloud-npc.jar") + + val prefix = "app.simplecloud.npc.relocate" + relocate("kotlin", "$prefix.kotlin") + relocate("kotlinx", "$prefix.kotlinx") + relocate("_COROUTINE", "$prefix._COROUTINE") + relocate("org.incendo.cloud", "$prefix.cloud") + relocate("org.spongepowered.configurate", "$prefix.configurate") + relocate("io.leangen.geantyref", "$prefix.geantyref") + relocate("app.simplecloud.plugin", "$prefix.simplecloud.plugin") + relocate("io.github.retrooper.packetevents", "$prefix.packetevents") + relocate("com.github.retrooper.packetevents", "$prefix.packetevents.api") + relocate("me.tofaa.entitylib", "$prefix.entitylib") + relocate("net.kyori.option", "$prefix.option") + + dependencies { + exclude(dependency("org.jetbrains:annotations")) + } + + exclude("META-INF/maven/**") + exclude("META-INF/services/javax.annotation.processing.Processor") + exclude("META-INF/gradle/**") + exclude("META-INF/com.android.tools/**") + exclude("META-INF/proguard/**") + exclude("META-INF/*.version") + exclude("META-INF/*.kotlin_module") + exclude("**/module-info.class") + exclude("DebugProbesKt.bin") +} + +val floorAdventure = libs.versions.adventure.floor.get() +val serverAdventureVersions = listOf(floorAdventure, "4.24.0", "5.2.0") +val serverAdventureClasspaths = serverAdventureVersions.associateWith { version -> + configurations.create("serverAdventure${version.replace(".", "")}") { + isCanBeConsumed = false + isCanBeResolved = true + }.also { configuration -> + dependencies { + configuration(libs.paper.api) { isTransitive = false } + listOf( + "adventure-api", + "adventure-text-minimessage", + "adventure-text-serializer-gson", + "adventure-text-serializer-legacy", + "adventure-text-serializer-plain", + ).forEach { configuration("net.kyori:$it:$version") } + } + } +} + +tasks.test { + val pluginJar = tasks.shadowJar.flatMap { it.archiveFile } + inputs.file(pluginJar).withPropertyName("pluginJar") + serverAdventureClasspaths.forEach { (version, classpath) -> + inputs.files(classpath).withPropertyName("serverAdventure$version") + } + doFirst { + systemProperty("npc.pluginJar", pluginJar.get().asFile.absolutePath) + serverAdventureClasspaths.forEach { (version, classpath) -> + systemProperty("npc.serverClasspath.$version", classpath.asPath) + } + } + systemProperty("npc.floorAdventure", floorAdventure) + systemProperty("npc.gatedClasses.$floorAdventure", "app/simplecloud/npc/provider/mannequin/") + systemProperty("npc.gatedClasses.4.24.0", "app/simplecloud/npc/provider/mannequin/") +} + +modrinth { + token.set(project.findProperty("modrinthToken") as String? ?: System.getenv("MODRINTH_TOKEN")) + projectId.set("eyNPY9oJ") + versionNumber.set(rootProject.version.toString()) + versionType.set(if (rootProject.version.toString().contains("-dev.")) "beta" else "release") + uploadFile.set(tasks.shadowJar) + gameVersions.addAll( + "1.20.6", + "1.21", + "1.21.1", + "1.21.2", + "1.21.3", + "1.21.4", + "1.21.5", + "1.21.6", + "1.21.7", + "1.21.8", + "1.21.9", + "1.21.10", + "1.21.11", + "26.1", + "26.1.1", + "26.1.2", + "26.2", + "26.3", + ) + loaders.addAll("paper", "purpur") + changelog.set("https://docs.simplecloud.app/changelog") + syncBodyFrom.set(rootProject.file("README.md").readText()) +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperCatalogs.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperCatalogs.kt new file mode 100644 index 0000000..3291e49 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperCatalogs.kt @@ -0,0 +1,10 @@ +package app.simplecloud.npc.paper + +import app.simplecloud.npc.common.platform.PlatformCatalogs +import app.simplecloud.npc.paper.item.MaterialResolver +import app.simplecloud.npc.paper.sound.SoundCatalog + +object PaperCatalogs : PlatformCatalogs { + override fun materials(): List<String> = MaterialResolver.itemMaterialNames() + override fun sounds(): List<String> = SoundCatalog.names() +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperPlugin.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperPlugin.kt new file mode 100644 index 0000000..a302894 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/PaperPlugin.kt @@ -0,0 +1,144 @@ +package app.simplecloud.npc.paper + +import app.simplecloud.npc.api.SimpleCloudNpcs +import app.simplecloud.npc.bukkit.glow.GlowTeams +import app.simplecloud.npc.bukkit.interact.InteractableEntities +import app.simplecloud.npc.bukkit.interact.NpcInteractPacketListener +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.interact.RealEntityClickGuard +import app.simplecloud.npc.common.command.COMMAND_LABEL +import app.simplecloud.npc.common.command.CommandMessages +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.common.utils.BackgroundTasks +import app.simplecloud.npc.core.cloud.CloudListCache +import app.simplecloud.npc.core.config.migration.NpcConfigMigration +import app.simplecloud.npc.core.inventory.InventoryRepository +import app.simplecloud.npc.core.repository.NpcRepository +import app.simplecloud.npc.paper.api.PaperInteractionHooks +import app.simplecloud.npc.paper.api.PaperSimpleCloudNpcs +import app.simplecloud.npc.paper.bootstrap.Listeners +import app.simplecloud.npc.paper.bootstrap.PacketStack +import app.simplecloud.npc.paper.bootstrap.ProviderReadyWait +import app.simplecloud.npc.paper.command.CommandHandler +import app.simplecloud.npc.paper.editor.EditorWiring +import app.simplecloud.npc.paper.effects.PaperNpcEffects +import app.simplecloud.npc.paper.inventory.AttachableInventoryHolder +import app.simplecloud.npc.paper.inventory.PaperInventoryViewRenderer +import app.simplecloud.npc.paper.placeholder.PlaceholderApiHook +import app.simplecloud.npc.paper.player.PaperNpcPlayer +import app.simplecloud.npc.paper.player.PaperPlayerDirectory +import app.simplecloud.npc.paper.provider.ProviderWiring +import app.simplecloud.npc.paper.pushback.PushbackListener +import app.simplecloud.npc.paper.update.UpdateChecker +import org.bukkit.plugin.ServicePriority +import org.bukkit.plugin.java.JavaPlugin +import java.util.logging.Level +import kotlin.io.path.exists + +class PaperPlugin : JavaPlugin() { + + private lateinit var pluginContext: NpcPluginContext + private var editorWiring: EditorWiring? = null + private var npcEffects: PaperNpcEffects? = null + private var providerWiring: ProviderWiring? = null + + override fun onLoad() = PacketStack.load(this) + + override fun onEnable() { + try { + enable() + } catch (throwable: Throwable) { + logger.log(Level.SEVERE, "Could not start SimpleCloud NPCs; disabling it.", throwable) + server.pluginManager.disablePlugin(this) + } + } + + private fun enable() { + PacketStack.start(this) + server.messenger.registerOutgoingPluginChannel(this, "BungeeCord") + CommandMessages.initialize(resource("messages.yml")) + PlaceholderApiHook.install(this) + + val npcDirectory = dataFolder.toPath().resolve("npcs") + val onInteract: OnNpcInteract = { id, player, interaction -> + val resolved = if (player.isSneaking) interaction.shifted() else interaction + pluginContext.interactionExecutor.execute(id, PaperNpcPlayer(this, player), resolved) + } + val providers = ProviderWiring.load(this, onInteract).also { providerWiring = it } + val inventoryViews = PaperInventoryViewRenderer(this) + val effects = PaperNpcEffects(this).also { npcEffects = it } + val editors = EditorWiring.create(this) { pluginContext }.also { editorWiring = it } + + pluginContext = NpcPluginContext( + renderer = providers.routing, + hologramRenderer = providers.holograms, + inventoryViewRenderer = inventoryViews, + playerDirectory = PaperPlayerDirectory(this), + inventoryRepository = InventoryRepository(dataFolder.toPath().resolve("inventories")), + npcRepository = NpcRepository(npcDirectory), + providers = providers.routing, + catalogs = PaperCatalogs, + editors = editors.editors, + cloudApi = CloudListCache.cloudApi, + effects = effects, + ) + inventoryViews.controller = pluginContext.inventoryViews + pluginContext.hooks = PaperInteractionHooks + UpdateChecker.check(this) + server.servicesManager.register( + SimpleCloudNpcs::class.java, + PaperSimpleCloudNpcs(this) { pluginContext }, + this, + ServicePriority.Normal, + ) + + NpcConfigMigration.backupOutdated(npcDirectory) + if (!dataFolder.toPath().resolve("inventories").exists()) saveResource("inventories/example.yml", false) + pluginContext.onEnable() + + GlowTeams.install(this) + Listeners.register(this, NpcInteractPacketListener(this, onInteract), RealEntityClickGuard(), inventoryViews) + Listeners.register(this, *editors.listeners.toTypedArray()) + Listeners.register(this, PushbackListener(pluginContext.npcRepository)) + CommandHandler(pluginContext, this).parseCommands() + BackgroundTasks.launch { runCatching { CloudListCache.warmUp() } } + + ProviderReadyWait.schedule(this, providers.routing) { + providers.reconcileWorlds { pluginContext.npcRepository.findAll() } + pluginContext.npcManager.reconcileOnBoot().forEach { id -> + logger.warning( + "NPC '$id' has no location yet (its previous NPC provider or provider NPC could not be " + + "found), so it isn't shown; stand where it should appear and run " + + "/$COMMAND_LABEL edit $id teleport", + ) + } + } + } + + override fun onDisable() { + PlaceholderApiHook.uninstall() + server.servicesManager.unregisterAll(this) + npcEffects?.shutdown() + server.onlinePlayers.forEach { player -> + if (player.openInventory.topInventory.holder is AttachableInventoryHolder) player.closeInventory() + } + if (::pluginContext.isInitialized) { + try { + pluginContext.onDisable() + } catch (throwable: Throwable) { + logger.log(Level.WARNING, "Could not cleanly shut the plugin context down", throwable) + } + } + + providerWiring?.shutdown() + BackgroundTasks.shutdown() + CloudListCache.shutdown() + GlowTeams.shutdown() + InteractableEntities.clear() + PacketStack.shutdown() + } + + private fun resource(name: String) = dataFolder.toPath().resolve(name).also { + if (!it.exists()) saveResource(name, false) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperInteractionHooks.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperInteractionHooks.kt new file mode 100644 index 0000000..beb0f79 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperInteractionHooks.kt @@ -0,0 +1,41 @@ +package app.simplecloud.npc.paper.api + +import app.simplecloud.npc.api.event.MenuClickEvent +import app.simplecloud.npc.api.event.NpcInteractEvent +import app.simplecloud.npc.api.event.NpcJoinEvent +import app.simplecloud.npc.common.platform.InteractionHooks +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer +import org.bukkit.Bukkit +import org.bukkit.event.Event + +object PaperInteractionHooks : InteractionHooks { + + override fun interact( + npcId: String, + player: NpcPlayer, + interaction: PlayerInteraction, + joinState: String, + ): Boolean { + val bukkit = Bukkit.getPlayer(player.uniqueId) ?: return true + val event = fire(NpcInteractEvent(bukkit, npcId, interaction.name, joinState, !Bukkit.isPrimaryThread())) + + return !event.isCancelled + } + + override fun join(npcId: String?, player: NpcPlayer, destination: String): String? { + val bukkit = Bukkit.getPlayer(player.uniqueId) ?: return destination + val event = fire(NpcJoinEvent(bukkit, npcId, destination, !Bukkit.isPrimaryThread())) + + return if (event.isCancelled) null else event.destination + } + + override fun menuClick(menuId: String, slot: Int, player: NpcPlayer, click: PlayerInteraction): Boolean { + val bukkit = Bukkit.getPlayer(player.uniqueId) ?: return true + val event = fire(MenuClickEvent(bukkit, menuId, slot, click.name, !Bukkit.isPrimaryThread())) + + return !event.isCancelled + } + + private fun <E : Event> fire(event: E): E = event.also(Bukkit.getPluginManager()::callEvent) +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperSimpleCloudNpcs.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperSimpleCloudNpcs.kt new file mode 100644 index 0000000..ed9b0b4 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/api/PaperSimpleCloudNpcs.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.paper.api + +import app.simplecloud.npc.api.SimpleCloudNpcs +import app.simplecloud.npc.common.inventory.source.InventoryOpenContext +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.paper.player.PaperNpcPlayer +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin + +class PaperSimpleCloudNpcs( + private val plugin: Plugin, + private val context: () -> NpcPluginContext, +) : SimpleCloudNpcs { + + override fun npcIds(): Set<String> = context().npcRepository.findAll().map { it.id }.toSet() + + override fun menuIds(): Set<String> = context().inventoryRepository.ids().toSet() + + override fun openMenu(player: Player, menuId: String): Boolean { + val menu = context().inventoryRepository.find(menuId) ?: return false + context().inventoryViews.open(PaperNpcPlayer(plugin, player), menu.id, InventoryOpenContext(emptyList())) + + return true + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/Listeners.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/Listeners.kt new file mode 100644 index 0000000..317b6c0 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/Listeners.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.paper.bootstrap + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.event.PacketListenerAbstract +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin + +object Listeners { + fun register(plugin: Plugin, vararg listeners: Any) { + listeners.forEach { listener -> + (listener as? Listener)?.let { plugin.server.pluginManager.registerEvents(it, plugin) } + (listener as? PacketListenerAbstract)?.let { PacketEvents.getAPI().eventManager.registerListener(it) } + } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/PacketStack.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/PacketStack.kt new file mode 100644 index 0000000..7db2e45 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/PacketStack.kt @@ -0,0 +1,46 @@ +package app.simplecloud.npc.paper.bootstrap + +import app.simplecloud.npc.bukkit.compat.EntityIds +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.PacketEventsAPI +import com.github.retrooper.packetevents.util.logger.JulLegacyLogManager +import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder +import me.tofaa.entitylib.APIConfig +import me.tofaa.entitylib.EntityLib +import me.tofaa.entitylib.spigot.SpigotEntityLibPlatform +import org.bukkit.plugin.java.JavaPlugin +import java.util.logging.Level + +object PacketStack { + + fun load(plugin: JavaPlugin) { + PacketEvents.setAPI(SpigotPacketEventsBuilder.build(plugin).also(::quiet)) + PacketEvents.getAPI().apply { + settings.checkForUpdates(false) + load() + } + } + + fun start(plugin: JavaPlugin) { + PacketEvents.getAPI().init() + val platform = SpigotEntityLibPlatform(plugin) + EntityLib.init(platform, APIConfig(PacketEvents.getAPI()).usePlatformLogger()) + platform.setEntityIdProvider { _, _ -> EntityIds.next() } + } + + private fun quiet(api: PacketEventsAPI<*>) { + runCatching { + val field = api.javaClass.getDeclaredField("logManager").apply { isAccessible = true } + field.set(api, JulLegacyLogManager(api)) + JulLegacyLogManager.getLogger().level = Level.WARNING + } + } + + fun shutdown() { + val api = PacketEvents.getAPI() ?: return + when { + api.isInitialized -> api.terminate() + api.isLoaded && !api.isTerminated -> api.injector.uninject() + } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/ProviderReadyWait.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/ProviderReadyWait.kt new file mode 100644 index 0000000..709b8da --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/bootstrap/ProviderReadyWait.kt @@ -0,0 +1,37 @@ +package app.simplecloud.npc.paper.bootstrap + +import app.simplecloud.npc.common.render.RoutingNpcRenderer +import org.bukkit.plugin.Plugin +import org.bukkit.scheduler.BukkitTask +import java.util.function.Consumer + +object ProviderReadyWait { + + fun schedule(plugin: Plugin, routing: RoutingNpcRenderer, onReady: () -> Unit) { + var waitedTicks = 0L + plugin.server.scheduler.runTaskTimer( + plugin, + Consumer<BukkitTask> { task -> + val notReady = routing.providersNotReady + if (notReady.isNotEmpty() && waitedTicks < TIMEOUT_TICKS) { + waitedTicks += POLL_TICKS + return@Consumer + } + task.cancel() + if (notReady.isNotEmpty()) { + plugin.logger.warning( + "NPC providers ${notReady.joinToString()} did not finish loading their NPCs within " + + "${TIMEOUT_TICKS / 20} seconds; continuing without them.", + ) + } + onReady() + }, + INITIAL_DELAY_TICKS, + POLL_TICKS, + ) + } + + private const val INITIAL_DELAY_TICKS = 2L + private const val POLL_TICKS = 5L + private const val TIMEOUT_TICKS = 600L +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/CanvasClickDecoder.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/CanvasClickDecoder.kt new file mode 100644 index 0000000..55f3622 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/CanvasClickDecoder.kt @@ -0,0 +1,69 @@ +package app.simplecloud.npc.paper.canvas + +import app.simplecloud.npc.common.editor.canvas.CanvasInput +import app.simplecloud.npc.common.editor.canvas.DragKind +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow.WindowClickType + +class CanvasClickDecoder { + + private var dragKind: DragKind? = null + private val dragSlots = mutableListOf<Int>() + + fun decode(type: WindowClickType, button: Int, slot: Int): CanvasInput? { + if (type != WindowClickType.QUICK_CRAFT) resetDrag() + + return when (type) { + WindowClickType.PICKUP -> when (slot) { + OUTSIDE -> CanvasInput.Outside(right = button == 1) + in 0..Int.MAX_VALUE -> CanvasInput.Click(slot, right = button == 1, shift = false) + else -> null + } + + WindowClickType.QUICK_MOVE -> slotOrNull(slot)?.let { + CanvasInput.Click(it, right = button == 1, shift = true) + } + WindowClickType.SWAP -> slotOrNull(slot)?.let { CanvasInput.Key(it, button) } + WindowClickType.CLONE -> slotOrNull(slot)?.let { CanvasInput.Clone(it) } + WindowClickType.THROW -> slotOrNull(slot)?.let { CanvasInput.Drop(it, wholeStack = button == 1) } + WindowClickType.PICKUP_ALL -> slotOrNull(slot)?.let { CanvasInput.DoubleClick(it) } + WindowClickType.QUICK_CRAFT -> drag(button, slot) + WindowClickType.UNKNOWN -> null + } + } + + private fun drag(button: Int, slot: Int): CanvasInput? { + val kind = DRAG_KINDS.getOrNull((button shr 2) and 3) + when (button and 3) { + STAGE_START -> { + resetDrag() + dragKind = kind + } + + STAGE_ADD -> if (kind != null && kind == dragKind && slot >= 0 && slot !in dragSlots) dragSlots += slot + + STAGE_END -> { + val started = dragKind + val slots = dragSlots.toList() + resetDrag() + if (started != null && started == kind && slots.isNotEmpty()) return CanvasInput.Drag(slots, started) + } + } + + return null + } + + private fun resetDrag() { + dragKind = null + dragSlots.clear() + } + + private fun slotOrNull(slot: Int): Int? = slot.takeIf { it >= 0 } + + private companion object { + const val OUTSIDE = -999 + const val STAGE_START = 0 + const val STAGE_ADD = 1 + const val STAGE_END = 2 + val DRAG_KINDS = listOf(DragKind.SPLIT, DragKind.ONE_EACH, DragKind.CLONE) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/ItemLooks.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/ItemLooks.kt new file mode 100644 index 0000000..bd5968a --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/ItemLooks.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.paper.canvas + +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.inventory.InventoryConfiguration.HeadConfiguration +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.SkullMeta + +object ItemLooks { + + fun of(stack: ItemStack): InventoryItemConfiguration { + val meta = stack.itemMeta + val customModelData = meta?.takeIf { it.hasCustomModelData() }?.customModelData + val head = (meta as? SkullMeta)?.playerProfile?.properties + ?.firstOrNull { it.name == "textures" } + ?.let { HeadConfiguration(texture = it.value, signature = it.signature) } + val glintOverride = meta != null && meta.hasEnchantmentGlintOverride() && meta.enchantmentGlintOverride + + return InventoryItemConfiguration( + material = stack.type.name, + name = meta?.takeIf { it.hasDisplayName() }?.displayName()?.let(Msg.miniMessage::serialize), + lore = meta?.lore()?.map(Msg.miniMessage::serialize).orEmpty().toMutableList(), + amount = stack.amount, + glowing = meta?.hasEnchants() == true || glintOverride, + customModelData = customModelData, + head = head, + ) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/PacketCanvasSurface.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/PacketCanvasSurface.kt new file mode 100644 index 0000000..14e0a2e --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/canvas/PacketCanvasSurface.kt @@ -0,0 +1,264 @@ +package app.simplecloud.npc.paper.canvas + +import app.simplecloud.npc.bukkit.equipment.ArmorLooks +import app.simplecloud.npc.common.editor.canvas.CanvasInput +import app.simplecloud.npc.common.editor.canvas.CanvasSurface +import app.simplecloud.npc.common.editor.canvas.CanvasView +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.inventory.InventoryConfiguration.InventoryItemConfiguration +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.paper.inventory.AttachableInventoryHolder +import app.simplecloud.npc.paper.item.PaperNpcItem +import com.github.retrooper.packetevents.event.PacketListenerAbstract +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import com.github.retrooper.packetevents.event.PacketSendEvent +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.packettype.PacketType +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetPlayerInventory +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems +import io.github.retrooper.packetevents.util.SpigotConversionUtil +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.inventory.ItemStack as BukkitItemStack +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class PacketCanvasSurface(private val plugin: Plugin) : PacketListenerAbstract(), CanvasSurface, Listener { + + private class Holder : AttachableInventoryHolder() + + private class Session( + val holder: Holder, + val title: String, + val size: Int, + @Volatile var toolbox: List<ItemStack>, + @Volatile var showInventory: Boolean, + @Volatile var carried: ItemStack, + @Volatile var onInput: (CanvasInput) -> Unit, + @Volatile var onClose: () -> Unit, + ) { + @Volatile + var windowId = -1 + val decoder = CanvasClickDecoder() + } + + private val sessions = ConcurrentHashMap<UUID, Session>() + + private val awaitingResync = ConcurrentHashMap.newKeySet<UUID>() + + override fun open(player: NpcPlayer, view: CanvasView, onInput: (CanvasInput) -> Unit, onClose: () -> Unit) { + val bukkitPlayer = Bukkit.getPlayer(player.uniqueId) ?: return + val toolbox = toolboxStacks(view.toolbox) + val carried = view.carried?.let(::packetStack) ?: ItemStack.EMPTY + + val current = sessions[player.uniqueId] + val showing = bukkitPlayer.openInventory.topInventory + val sameWindow = current != null && + showing.holder === current.holder && + current.size == view.size && + current.title == view.title + if (sameWindow) { + current.toolbox = toolbox + current.showInventory = view.showInventory + current.carried = carried + current.onInput = onInput + current.onClose = onClose + for (slot in 0 until view.size) showing.setItem(slot, view.items[slot]?.let(PaperNpcItem::toItemStack)) + bukkitPlayer.updateInventory() + return + } + + val holder = Holder() + val inventory = Bukkit.createInventory(holder, view.size, Msg.miniMessage.deserialize(view.title)) + holder.attach(inventory) + view.items.forEach { (slot, item) -> inventory.setItem(slot, PaperNpcItem.toItemStack(item)) } + + sessions[player.uniqueId] = + Session(holder, view.title, view.size, toolbox, view.showInventory, carried, onInput, onClose) + awaitingResync += player.uniqueId + bukkitPlayer.openInventory(inventory) + } + + override fun detach(player: NpcPlayer) { + if (sessions.remove(player.uniqueId) != null) scheduleResync(player.uniqueId) + } + + override fun onPacketReceive(event: PacketReceiveEvent) { + val uuid = event.user.uuid ?: return + when (event.packetType) { + PacketType.Play.Client.CLICK_WINDOW -> { + val session = sessions[uuid] ?: return + val click = WrapperPlayClientClickWindow(event) + if (click.windowId != session.windowId) return + + event.isCancelled = true + val input = session.decoder.decode(click.windowClickType, click.button, click.slot) + runOnMain(uuid) { player -> + if (sessions[uuid] !== session) return@runOnMain + try { + input?.let(session.onInput) + } finally { + if (sessions[uuid] === session) player.updateInventory() + } + } + } + + PacketType.Play.Client.CREATIVE_INVENTORY_ACTION -> + if (sessions.containsKey(uuid) || uuid in awaitingResync) event.isCancelled = true + + else -> Unit + } + } + + override fun onPacketSend(event: PacketSendEvent) { + val uuid = event.user.uuid ?: return + val session = sessions[uuid] ?: return + + when (event.packetType) { + PacketType.Play.Server.OPEN_WINDOW -> if (session.windowId == -1) { + session.windowId = WrapperPlayServerOpenWindow(event).containerId + } + + PacketType.Play.Server.WINDOW_ITEMS -> { + val packet = WrapperPlayServerWindowItems(event) + when (packet.windowId) { + session.windowId -> { + if (!session.showInventory) { + packet.items = packet.items.toMutableList().also { items -> + session.toolbox.forEachIndexed { index, stack -> + if (session.size + index < items.size) items[session.size + index] = stack + } + } + } + packet.setCarriedItem(session.carried) + event.markForReEncode(true) + } + + PLAYER_WINDOW -> if (!session.showInventory) { + packet.items = packet.items.toMutableList().also { items -> + for (slot in PLAYER_WINDOW_STORAGE) { + if (slot < items.size) items[slot] = toolboxForPlayerWindow(session, slot) + } + } + event.markForReEncode(true) + } + } + } + + PacketType.Play.Server.SET_SLOT -> { + val packet = WrapperPlayServerSetSlot(event) + val replacement = when { + session.showInventory && !(packet.windowId == CURSOR_WINDOW && packet.slot == CURSOR_SLOT) -> null + packet.windowId == session.windowId && packet.slot >= session.size -> + session.toolbox.getOrNull(packet.slot - session.size) + + packet.windowId == PLAYER_WINDOW && packet.slot in PLAYER_WINDOW_STORAGE -> + toolboxForPlayerWindow(session, packet.slot) + + packet.windowId == CURSOR_WINDOW && packet.slot == CURSOR_SLOT -> session.carried + else -> null + } ?: return + packet.item = replacement + event.markForReEncode(true) + } + + PacketType.Play.Server.SET_PLAYER_INVENTORY -> { + if (session.showInventory) return + val packet = WrapperPlayServerSetPlayerInventory(event) + val index = toolboxIndexForInventorySlot(packet.slot) ?: return + packet.stack = session.toolbox.getOrNull(index) ?: return + event.markForReEncode(true) + } + + PacketType.Play.Server.SET_CURSOR_ITEM -> { + WrapperPlayServerSetCursorItem(event).stack = session.carried + event.markForReEncode(true) + } + + else -> Unit + } + } + + override fun inventoryItem(player: NpcPlayer, index: Int): InventoryItemConfiguration? = + stackAt(player, index)?.let(ItemLooks::of) + + override fun equipmentItem(player: NpcPlayer, index: Int): NpcConfig.EquipmentItem? = + stackAt(player, index)?.let(ArmorLooks::read) + + private fun stackAt(player: NpcPlayer, index: Int): BukkitItemStack? { + val inventory = Bukkit.getPlayer(player.uniqueId)?.inventory ?: return null + val stack = when (index) { + in 0..26 -> inventory.getItem(index + 9) + in 27..35 -> inventory.getItem(index - 27) + CanvasInput.OFFHAND_KEY -> inventory.itemInOffHand + else -> return null + } + + return stack?.takeUnless { it.type.isAir } + } + + @EventHandler + fun onClose(event: InventoryCloseEvent) { + val uuid = event.player.uniqueId + val session = sessions[uuid] ?: return + if (event.inventory.holder !== session.holder) return + + sessions.remove(uuid, session) + scheduleResync(uuid) + val player = event.player + Bukkit.getScheduler().runTask(plugin, Runnable { if (player is Player && player.isOnline) session.onClose() }) + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + sessions.remove(event.player.uniqueId) + awaitingResync.remove(event.player.uniqueId) + } + + private fun scheduleResync(uuid: UUID) { + awaitingResync += uuid + runOnMain(uuid) { player -> + if (sessions.containsKey(uuid)) return@runOnMain + player.updateInventory() + awaitingResync.remove(uuid) + } + } + + private fun runOnMain(uuid: UUID, task: (Player) -> Unit) { + if (!plugin.isEnabled) return + Bukkit.getScheduler().runTask(plugin, Runnable { Bukkit.getPlayer(uuid)?.let(task) }) + } + + private fun toolboxStacks(toolbox: Map<Int, NpcItem>): List<ItemStack> = + List(CanvasView.TOOLBOX_SIZE) { index -> toolbox[index]?.let(::packetStack) ?: ItemStack.EMPTY } + + private fun packetStack(item: NpcItem): ItemStack = + SpigotConversionUtil.fromBukkitItemStack(PaperNpcItem.toItemStack(item)) + + private fun toolboxForPlayerWindow(session: Session, slot: Int): ItemStack = + session.toolbox.getOrNull(slot - PLAYER_WINDOW_STORAGE.first) ?: ItemStack.EMPTY + + private fun toolboxIndexForInventorySlot(slot: Int): Int? = when (slot) { + in 0..8 -> 27 + slot + in 9..35 -> slot - 9 + else -> null + } + + private companion object { + const val PLAYER_WINDOW = 0 + const val CURSOR_WINDOW = -1 + const val CURSOR_SLOT = -1 + val PLAYER_WINDOW_STORAGE = 9..44 + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/CommandHandler.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/CommandHandler.kt new file mode 100644 index 0000000..6adea73 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/CommandHandler.kt @@ -0,0 +1,35 @@ +package app.simplecloud.npc.paper.command + +import app.simplecloud.npc.common.command.NpcCommandExceptionHandler +import app.simplecloud.npc.common.command.NpcCommandRegistrar +import app.simplecloud.npc.common.plugin.NpcPluginContext +import org.bukkit.plugin.java.JavaPlugin +import org.incendo.cloud.annotations.AnnotationParser +import org.incendo.cloud.brigadier.BrigadierSetting +import org.incendo.cloud.execution.ExecutionCoordinator +import org.incendo.cloud.paper.PaperCommandManager +import org.incendo.cloud.parser.standard.StringParser + +class CommandHandler( + private val pluginContext: NpcPluginContext, + javaPlugin: JavaPlugin, +) { + private val commandManager = PaperCommandManager + .builder(PaperCommandSenderMapper(javaPlugin)) + .executionCoordinator(ExecutionCoordinator.simpleCoordinator()) + .buildOnEnable(javaPlugin) + + private val annotationParser = AnnotationParser(commandManager, PaperCommandSender::class.java) + + fun parseCommands() { + commandManager.parserRegistry().registerNamedParser( + "greedyString", + StringParser.greedyStringParser(), + ) + + commandManager.brigadierManager().settings().set(BrigadierSetting.FORCE_EXECUTABLE, true) + + NpcCommandExceptionHandler.registerTo(commandManager) + annotationParser.parse(NpcCommandRegistrar.commands<PaperCommandSender>(pluginContext)) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSender.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSender.kt new file mode 100644 index 0000000..224b69d --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSender.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.paper.command + +import app.simplecloud.npc.core.platform.NpcCommandSender +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.paper.player.PaperNpcPlayer +import io.papermc.paper.command.brigadier.CommandSourceStack +import net.kyori.adventure.text.Component +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin + +class PaperCommandSender( + private val plugin: Plugin, + val stack: CommandSourceStack, +) : NpcCommandSender { + private val sender: CommandSender get() = stack.sender + + override fun sendMessage(component: Component) = sender.sendMessage(component) + override fun asPlayer(): NpcPlayer? = (sender as? Player)?.let { PaperNpcPlayer(plugin, it) } + override fun hasPermission(permission: String): Boolean = sender.hasPermission(permission) +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSenderMapper.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSenderMapper.kt new file mode 100644 index 0000000..e01845c --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/command/PaperCommandSenderMapper.kt @@ -0,0 +1,12 @@ +package app.simplecloud.npc.paper.command + +import io.papermc.paper.command.brigadier.CommandSourceStack +import org.bukkit.plugin.Plugin +import org.incendo.cloud.SenderMapper + +class PaperCommandSenderMapper( + private val plugin: Plugin, +) : SenderMapper<CommandSourceStack, PaperCommandSender> { + override fun map(base: CommandSourceStack): PaperCommandSender = PaperCommandSender(plugin, base) + override fun reverse(mapped: PaperCommandSender): CommandSourceStack = mapped.stack +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorChatListener.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorChatListener.kt new file mode 100644 index 0000000..03a0efc --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorChatListener.kt @@ -0,0 +1,30 @@ +package app.simplecloud.npc.paper.editor + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import io.papermc.paper.event.player.AsyncChatEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.Bukkit +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin + +class EditorChatListener( + private val prompts: ChatInputPrompts, + private val plugin: Plugin, +) : Listener { + + @EventHandler(priority = EventPriority.LOWEST) + fun onChat(event: AsyncChatEvent) { + if (!prompts.isPending(event.player.uniqueId)) return + event.isCancelled = true + if (!plugin.isEnabled) return + val text = PlainTextComponentSerializer.plainText().serialize(event.originalMessage()) + val uuid = event.player.uniqueId + + Bukkit.getScheduler().runTask( + plugin, + Runnable { prompts.submit(uuid, text) }, + ) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorMenuHolder.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorMenuHolder.kt new file mode 100644 index 0000000..2cfee85 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorMenuHolder.kt @@ -0,0 +1,11 @@ +package app.simplecloud.npc.paper.editor + +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.paper.inventory.AttachableInventoryHolder + +class EditorMenuHolder( + @Volatile var menu: EditorMenu, + @Volatile var onClick: (slot: Int, click: MenuClick) -> Unit, + @Volatile var onClose: () -> Unit, +) : AttachableInventoryHolder() diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorSessionCleanup.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorSessionCleanup.kt new file mode 100644 index 0000000..b2f7288 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorSessionCleanup.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.paper.editor + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.inventory.InventoryEditor +import app.simplecloud.npc.common.editor.npc.NpcEditor +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent + +class EditorSessionCleanup( + private val chatPrompts: ChatInputPrompts, + private val npcEditor: NpcEditor, + private val inventoryEditor: InventoryEditor, +) : Listener { + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + val uuid = event.player.uniqueId + npcEditor.sessions.forgetPlayer(uuid) + inventoryEditor.sessions.forgetPlayer(uuid) + chatPrompts.cancel(uuid) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorWiring.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorWiring.kt new file mode 100644 index 0000000..230e845 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/EditorWiring.kt @@ -0,0 +1,40 @@ +package app.simplecloud.npc.paper.editor + +import app.simplecloud.npc.common.editor.ChatInputPrompts +import app.simplecloud.npc.common.editor.EditorSet +import app.simplecloud.npc.common.editor.inventory.InventoryEditor +import app.simplecloud.npc.common.editor.npc.NpcEditor +import app.simplecloud.npc.common.plugin.NpcPluginContext +import app.simplecloud.npc.paper.canvas.PacketCanvasSurface +import org.bukkit.Bukkit +import org.bukkit.plugin.Plugin + +class EditorWiring private constructor( + val editors: EditorSet, + val listeners: List<Any>, +) { + + companion object { + private const val HUD_TICKS = 40L + + fun create(plugin: Plugin, context: () -> NpcPluginContext): EditorWiring { + val chatPrompts = ChatInputPrompts() + Bukkit.getScheduler().runTaskTimer(plugin, Runnable { chatPrompts.refreshHud() }, HUD_TICKS, HUD_TICKS) + val menuOpener = PaperEditorMenuOpener(plugin) + val canvasSurface = PacketCanvasSurface(plugin) + + val npcEditor = NpcEditor(menuOpener, chatPrompts, canvasSurface, pluginContextProvider = context) + val inventoryEditor = InventoryEditor(menuOpener, chatPrompts, canvasSurface, context) + + return EditorWiring( + EditorSet(npcEditor, inventoryEditor), + listOf( + menuOpener, + canvasSurface, + EditorChatListener(chatPrompts, plugin), + EditorSessionCleanup(chatPrompts, npcEditor, inventoryEditor), + ), + ) + } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/PaperEditorMenuOpener.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/PaperEditorMenuOpener.kt new file mode 100644 index 0000000..39d5ebe --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/editor/PaperEditorMenuOpener.kt @@ -0,0 +1,100 @@ +package app.simplecloud.npc.paper.editor + +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.common.editor.menu.EditorMenu +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.platform.EditorMenuOpener +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.paper.item.PaperNpcItem +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.plugin.Plugin +import java.util.UUID + +class PaperEditorMenuOpener(private val plugin: Plugin) : EditorMenuOpener, Listener { + + private val navigating = mutableSetOf<UUID>() + + override fun runSync(task: () -> Unit) = plugin.sync(task) + + override fun runLater(delayTicks: Long, task: () -> Unit) { + Bukkit.getScheduler().runTaskLater(plugin, Runnable(task), delayTicks) + } + + override fun open( + player: NpcPlayer, + menu: EditorMenu, + onClick: (slot: Int, click: MenuClick) -> Unit, + onClose: () -> Unit, + ) { + val bukkitPlayer = Bukkit.getPlayer(player.uniqueId) ?: return + val current = bukkitPlayer.openInventory.topInventory + val currentHolder = current.holder as? EditorMenuHolder + if (currentHolder != null && current.size == menu.size && currentHolder.menu.title == menu.title) { + currentHolder.menu = menu + currentHolder.onClick = onClick + currentHolder.onClose = onClose + for (slot in 0 until menu.size) { + current.setItem(slot, menu.items[slot]?.let(PaperNpcItem::toItemStack)) + } + return + } + + val holder = EditorMenuHolder(menu, onClick, onClose) + val inventory = Bukkit.createInventory(holder, menu.size, Msg.miniMessage.deserialize(menu.title)) + holder.attach(inventory) + menu.items.forEach { (slot, icon: NpcItem) -> inventory.setItem(slot, PaperNpcItem.toItemStack(icon)) } + + navigating.add(player.uniqueId) + try { + bukkitPlayer.openInventory(inventory) + } finally { + navigating.remove(player.uniqueId) + } + } + + override fun isShowing(player: NpcPlayer, menu: EditorMenu): Boolean = + (Bukkit.getPlayer(player.uniqueId)?.openInventory?.topInventory?.holder as? EditorMenuHolder)?.menu === menu + + override fun detach(player: NpcPlayer) { + val bukkitPlayer = Bukkit.getPlayer(player.uniqueId) ?: return + val holder = bukkitPlayer.openInventory.topInventory.holder as? EditorMenuHolder ?: return + holder.onClose = {} + holder.onClick = { _, _ -> } + } + + @EventHandler + fun onDrag(event: InventoryDragEvent) { + if (event.inventory.holder !is EditorMenuHolder) return + if (event.rawSlots.any { it < event.inventory.size }) event.isCancelled = true + } + + @EventHandler + fun onClick(event: InventoryClickEvent) { + val holder = event.inventory.holder as? EditorMenuHolder ?: return + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + if (event.click == ClickType.SWAP_OFFHAND) { + Bukkit.getScheduler().runTask(plugin, Runnable { if (player.isOnline) player.updateInventory() }) + } + + holder.onClick(event.rawSlot, PaperNpcItem.menuClickOf(event.click)) + } + + @EventHandler + fun onClose(event: InventoryCloseEvent) { + val holder = event.inventory.holder as? EditorMenuHolder ?: return + val bukkitPlayer = event.player as? Player ?: return + if (bukkitPlayer.uniqueId in navigating || !plugin.isEnabled) return + + Bukkit.getScheduler().runTask(plugin, Runnable { if (bukkitPlayer.isOnline) holder.onClose() }) + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/effects/PaperNpcEffects.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/effects/PaperNpcEffects.kt new file mode 100644 index 0000000..ca25c16 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/effects/PaperNpcEffects.kt @@ -0,0 +1,122 @@ +package app.simplecloud.npc.paper.effects + +import app.simplecloud.npc.bukkit.compat.EntityIds +import app.simplecloud.npc.bukkit.hologram.HologramGeometry +import app.simplecloud.npc.bukkit.interact.InteractableEntities +import app.simplecloud.npc.bukkit.packet.PacketComponents +import app.simplecloud.npc.bukkit.packet.PacketPoses +import app.simplecloud.npc.bukkit.packet.PacketTextDisplays +import app.simplecloud.npc.common.platform.NpcEffects +import app.simplecloud.npc.core.config.NpcAnimation +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.platform.NpcPlayer +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.protocol.entity.data.EntityData +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import com.github.retrooper.packetevents.protocol.entity.pose.EntityPose +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityAnimation +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityAnimation.EntityAnimationType +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata +import net.kyori.adventure.text.Component +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class PaperNpcEffects(private val plugin: Plugin) : NpcEffects { + + private val bubbles = ConcurrentHashMap<Pair<UUID, String>, Int>() + + override fun animate(npc: NpcConfig, player: NpcPlayer, animation: NpcAnimation) { + val viewer = Bukkit.getPlayer(player.uniqueId) ?: return + val entityIds = InteractableEntities.bodiesOf(npc.id) + when (animation) { + NpcAnimation.SWING_MAIN_HAND -> swing(viewer, entityIds, EntityAnimationType.SWING_MAIN_ARM) + NpcAnimation.SWING_OFF_HAND -> swing(viewer, entityIds, EntityAnimationType.SWING_OFF_HAND) + NpcAnimation.CROUCH -> crouch(viewer, entityIds, PacketPoses.of(npc.entity.pose)) + } + } + + override fun speak(npc: NpcConfig, player: NpcPlayer, text: Component) { + Bukkit.getScheduler().runTask(plugin, Runnable { showBubble(npc, player.uniqueId, text) }) + } + + fun shutdown() { + bubbles.forEach { (key, entityId) -> + Bukkit.getPlayer(key.first)?.let { send(it, PacketTextDisplays.destroy(listOf(entityId))) } + } + bubbles.clear() + } + + private fun swing(viewer: Player, entityIds: List<Int>, type: EntityAnimationType) { + entityIds.forEach { send(viewer, WrapperPlayServerEntityAnimation(it, type)) } + } + + private fun crouch(viewer: Player, entityIds: List<Int>, restingPose: EntityPose) { + entityIds.forEach { send(viewer, pose(it, EntityPose.CROUCHING)) } + Bukkit.getScheduler().runTaskLater( + plugin, + Runnable { + if (viewer.isOnline) entityIds.forEach { send(viewer, pose(it, restingPose)) } + }, + CROUCH_TICKS, + ) + } + + private fun showBubble(npc: NpcConfig, playerId: UUID, text: Component) { + val viewer = Bukkit.getPlayer(playerId) ?: return + val location = npc.entity.location + val world = Bukkit.getWorld(location.world) ?: return + if (viewer.world != world) return + + val key = viewer.uniqueId to npc.id + bubbles.remove(key)?.let { send(viewer, PacketTextDisplays.destroy(listOf(it))) } + + val entityId = EntityIds.next(world) + val y = location.y + bubbleHeight(npc) + PacketTextDisplays.send( + viewer, + listOf( + PacketTextDisplays.spawn(entityId, UUID.randomUUID(), location.x, y, location.z), + PacketTextDisplays.metadata(entityId, BUBBLE_STYLE, PacketComponents.of(text)), + ), + ) + bubbles[key] = entityId + + Bukkit.getScheduler().runTaskLater( + plugin, + Runnable { + if (!bubbles.remove(key, entityId)) return@Runnable + Bukkit.getPlayer(playerId)?.let { send(it, PacketTextDisplays.destroy(listOf(entityId))) } + }, + BUBBLE_TICKS, + ) + } + + private fun bubbleHeight(npc: NpcConfig): Double { + val hologram = npc.hologram + if (!hologram.enabled) return HEAD_HEIGHT * npc.entity.heightFactor() + GAP + val lines = hologram.layouts.maxOfOrNull { it.lines.size } ?: 0 + + return HologramGeometry.topHeight(npc, lines) + GAP + } + + private fun pose(entityId: Int, pose: EntityPose) = + WrapperPlayServerEntityMetadata(entityId, listOf(EntityData(POSE_INDEX, EntityDataTypes.ENTITY_POSE, pose))) + + private fun send(player: Player, packet: PacketWrapper<*>) { + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } + + private companion object { + const val POSE_INDEX = 6 + const val CROUCH_TICKS = 8L + const val BUBBLE_TICKS = 60L + const val HEAD_HEIGHT = 2.0 + const val GAP = 0.35 + val BUBBLE_STYLE = HologramLine() + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/AttachableInventoryHolder.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/AttachableInventoryHolder.kt new file mode 100644 index 0000000..7787891 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/AttachableInventoryHolder.kt @@ -0,0 +1,14 @@ +package app.simplecloud.npc.paper.inventory + +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.InventoryHolder + +open class AttachableInventoryHolder : InventoryHolder { + private var backing: Inventory? = null + + override fun getInventory(): Inventory = backing ?: error("Inventory not yet attached") + + fun attach(inventory: Inventory) { + backing = inventory + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/PaperInventoryViewRenderer.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/PaperInventoryViewRenderer.kt new file mode 100644 index 0000000..1595dd3 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/inventory/PaperInventoryViewRenderer.kt @@ -0,0 +1,112 @@ +package app.simplecloud.npc.paper.inventory + +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.common.inventory.view.InventoryViewController +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.platform.InventoryViewRenderer +import app.simplecloud.npc.common.platform.RenderedInventory +import app.simplecloud.npc.common.text.Msg +import app.simplecloud.npc.core.interaction.PlayerInteraction +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.paper.item.PaperNpcItem +import app.simplecloud.npc.paper.player.PaperNpcPlayer +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.Inventory +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class InventoryViewHolder(val viewId: UUID) : AttachableInventoryHolder() + +class PaperInventoryViewRenderer(private val plugin: Plugin) : InventoryViewRenderer, Listener { + + lateinit var controller: InventoryViewController + + private val inventories = ConcurrentHashMap<UUID, Inventory>() + + override fun runSync(task: () -> Unit) = plugin.sync(task) + + override fun openView(player: NpcPlayer, viewId: UUID, view: RenderedInventory): Boolean { + val bukkitPlayer = Bukkit.getPlayer(player.uniqueId) ?: return false + val holder = InventoryViewHolder(viewId) + val inventory = Bukkit.createInventory(holder, view.size, Msg.miniMessage.deserialize(view.title)) + holder.attach(inventory) + view.slots.forEach { (slot, icon) -> inventory.setItem(slot, PaperNpcItem.toItemStack(icon)) } + inventories[viewId] = inventory + bukkitPlayer.openInventory(inventory) + + return true + } + + override fun updateSlots(viewId: UUID, changes: Map<Int, NpcItem?>) { + val inventory = inventories[viewId] ?: return + changes.forEach { (slot, icon) -> + if (slot in 0 until inventory.size) { + inventory.setItem(slot, icon?.let(PaperNpcItem::toItemStack)) + } + } + } + + override fun updateTitle(viewId: UUID, title: String) { + val inventory = inventories[viewId] ?: return + val legacyTitle = LEGACY.serialize(Msg.miniMessage.deserialize(title)) + inventory.viewers.toList().forEach { viewer -> + viewer.openInventory.takeIf { it.topInventory === inventory }?.title = legacyTitle + } + } + + override fun closeView(viewId: UUID) { + inventories.remove(viewId)?.let { inventory -> + runSync { inventory.viewers.toList().forEach { it.closeInventory() } } + } + } + + @EventHandler + fun onDrag(event: InventoryDragEvent) { + if (event.inventory.holder !is InventoryViewHolder) return + if (event.rawSlots.any { it < event.inventory.size }) event.isCancelled = true + } + + @EventHandler + fun onClick(event: InventoryClickEvent) { + val holder = event.inventory.holder as? InventoryViewHolder ?: return + event.isCancelled = true + val slot = event.rawSlot + if (slot !in 0 until event.inventory.size) return + val bukkitPlayer = event.whoClicked as? Player ?: return + + val click = when (event.click) { + ClickType.LEFT -> PlayerInteraction.LEFT_CLICK + ClickType.SHIFT_LEFT -> PlayerInteraction.SHIFT_LEFT_CLICK + ClickType.SHIFT_RIGHT -> PlayerInteraction.SHIFT_RIGHT_CLICK + ClickType.RIGHT -> PlayerInteraction.RIGHT_CLICK + else -> return + } + controller.handleClick(PaperNpcPlayer(plugin, bukkitPlayer), holder.viewId, slot, click) + } + + @EventHandler + fun onClose(event: InventoryCloseEvent) { + val holder = event.inventory.holder as? InventoryViewHolder ?: return + inventories.remove(holder.viewId) + if (!plugin.isEnabled) return + + Bukkit.getScheduler().runTask(plugin, Runnable { controller.viewClosed(holder.viewId) }) + } + + private companion object { + val LEGACY: LegacyComponentSerializer = LegacyComponentSerializer.builder() + .character(LegacyComponentSerializer.SECTION_CHAR) + .hexColors() + .useUnusualXRepeatedCharacterHexFormat() + .build() + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/MaterialResolver.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/MaterialResolver.kt new file mode 100644 index 0000000..d76de7f --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/MaterialResolver.kt @@ -0,0 +1,14 @@ +package app.simplecloud.npc.paper.item + +import org.bukkit.Material +import org.bukkit.inventory.ItemStack + +object MaterialResolver { + + fun itemStack(materialName: String, amount: Int): ItemStack = + ItemStack(Material.matchMaterial(materialName) ?: Material.STONE, amount.coerceIn(1, MAX_STACK)) + + fun itemMaterialNames(): List<String> = Material.entries.filter { it.isItem }.map { it.name } + + private const val MAX_STACK = 64 +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/PaperNpcItem.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/PaperNpcItem.kt new file mode 100644 index 0000000..68acd5c --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/item/PaperNpcItem.kt @@ -0,0 +1,63 @@ +package app.simplecloud.npc.paper.item + +import app.simplecloud.npc.bukkit.equipment.ArmorLooks +import app.simplecloud.npc.bukkit.skin.HeadTextures +import app.simplecloud.npc.common.editor.menu.MenuClick +import app.simplecloud.npc.common.item.NpcItem +import app.simplecloud.npc.common.text.Msg +import com.google.common.collect.ImmutableMultimap +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.TextDecoration +import org.bukkit.Bukkit +import org.bukkit.event.inventory.ClickType +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.SkullMeta + +object PaperNpcItem { + + fun toItemStack(item: NpcItem): ItemStack = MaterialResolver.itemStack(item.material, item.amount).apply { + editMeta { meta -> + val texture = item.skinTexture + + if (texture != null) { + HeadTextures.apply(meta, texture, item.skinSignature) + } else { + item.playerHeadOwner?.let { owner -> + val profile = Bukkit.getPlayer(owner)?.playerProfile ?: Bukkit.createProfile(owner) + (meta as? SkullMeta)?.playerProfile = profile + } + } + + meta.isHideTooltip = item.hideTooltip + if (!item.hideTooltip) { + meta.displayName(withoutDefaultItalic(item.name)) + if (item.lore.isNotEmpty()) meta.lore(item.lore.map(::withoutDefaultItalic)) + } + + item.customModelData?.let(meta::setCustomModelData) + item.maxStackSize?.let(meta::setMaxStackSize) + ArmorLooks.apply(meta, item.armorColor, item.armorTrim, null) + if (item.glowing) meta.setEnchantmentGlintOverride(true) + meta.attributeModifiers = ImmutableMultimap.of() + meta.addItemFlags(*ItemFlag.entries.toTypedArray()) + } + } + + private fun withoutDefaultItalic(miniMessageText: String): Component = Component.text() + .decoration(TextDecoration.ITALIC, false) + .append(if (miniMessageText.isEmpty()) Component.empty() else Msg.miniMessage.deserialize(miniMessageText)) + .build() + + fun menuClickOf(click: ClickType): MenuClick = when (click) { + ClickType.LEFT -> MenuClick.LEFT + ClickType.SHIFT_LEFT -> MenuClick.SHIFT_LEFT + ClickType.RIGHT -> MenuClick.RIGHT + ClickType.SHIFT_RIGHT -> MenuClick.SHIFT_RIGHT + ClickType.DROP, ClickType.CONTROL_DROP -> MenuClick.DROP + ClickType.MIDDLE -> MenuClick.MIDDLE + ClickType.SWAP_OFFHAND -> MenuClick.OFFHAND + ClickType.DOUBLE_CLICK -> MenuClick.DOUBLE_CLICK + else -> MenuClick.OTHER + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/placeholder/PlaceholderApiHook.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/placeholder/PlaceholderApiHook.kt new file mode 100644 index 0000000..a10dff1 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/placeholder/PlaceholderApiHook.kt @@ -0,0 +1,31 @@ +package app.simplecloud.npc.paper.placeholder + +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.core.text.PlayerPlaceholders +import org.bukkit.Bukkit +import org.bukkit.OfflinePlayer +import org.bukkit.plugin.Plugin +import java.util.UUID + +object PlaceholderApiHook { + + fun install(plugin: Plugin) { + if (plugin.server.pluginManager.getPlugin("PlaceholderAPI")?.isEnabled != true) return + val setPlaceholders = runCatching { + Class.forName("me.clip.placeholderapi.PlaceholderAPI") + .getMethod("setPlaceholders", OfflinePlayer::class.java, String::class.java) + }.getOrElse { + plugin.logger.warning("PlaceholderAPI is installed but could not be hooked: ${it.message}") + return + } + + PlayerPlaceholders.external = { uuid: UUID, text: String -> + plugin.sync { setPlaceholders.invoke(null, Bukkit.getOfflinePlayer(uuid), text) as? String ?: text } + } + plugin.logger.info("Hooked into PlaceholderAPI.") + } + + fun uninstall() { + PlayerPlaceholders.external = null + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperNpcPlayer.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperNpcPlayer.kt new file mode 100644 index 0000000..de37b82 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperNpcPlayer.kt @@ -0,0 +1,105 @@ +package app.simplecloud.npc.paper.player + +import app.simplecloud.npc.bukkit.equipment.ArmorLooks +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.paper.pushback.Pushback +import app.simplecloud.npc.paper.sound.SoundPlayer +import com.google.common.io.ByteStreams +import net.kyori.adventure.text.Component +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.plugin.Plugin +import org.bukkit.util.Vector +import java.util.UUID + +class PaperNpcPlayer( + private val plugin: Plugin, + private val player: Player, +) : NpcPlayer { + + override val uniqueId: UUID get() = player.uniqueId + override val name: String get() = player.name + + override fun sendMessage(component: Component) = + plugin.sync { player.sendMessage(component) } + + override fun location(): NpcLocation = plugin.sync { + val location = player.location + val world = location.world?.name ?: player.world.name + + NpcLocation(world, location.x, location.y, location.z, location.yaw, location.pitch) + } + + override fun closeInventory() { + plugin.sync { player.closeInventory() } + } + + override fun hasPermission(permission: String): Boolean = plugin.sync { player.hasPermission(permission) } + + override fun wornArmor(): NpcConfig.EquipmentConfiguration = plugin.sync { + val inventory = player.inventory + fun look(stack: ItemStack?) = stack?.takeUnless { it.type.isAir }?.let(ArmorLooks::read) + + NpcConfig.EquipmentConfiguration( + helmet = look(inventory.helmet), + chestplate = look(inventory.chestplate), + leggings = look(inventory.leggings), + boots = look(inventory.boots), + ) + } + + override fun sendActionBar(component: Component) { + plugin.sync { player.sendActionBar(component) } + } + + override fun performCommand(command: String) { + plugin.sync { player.performCommand(command) } + } + + override fun playSound(key: String, options: NpcConfig.SoundOptions) { + plugin.sync { SoundPlayer.play(player, key, options) } + } + + override fun showTitle(title: Component, subtitle: Component, fadeInTicks: Int, stayTicks: Int, fadeOutTicks: Int) { + plugin.sync { TitleSender.show(player, title, subtitle, fadeInTicks, stayTicks, fadeOutTicks) } + } + + override fun teleport(location: NpcLocation): Boolean = plugin.sync { + val world = Bukkit.getWorld(location.world) ?: return@sync false + player.teleport(Location(world, location.x, location.y, location.z, location.yaw, location.pitch)) + } + + override fun push(from: NpcLocation, strength: Double, vertical: Double) { + plugin.sync { + if (!player.world.name.equals(from.world, true)) return@sync + + Pushback.push( + player, + player.location.toVector(), + player.location.yaw, + Vector(from.x, from.y, from.z), + strength, + vertical, + ) + } + } + + override fun sendToServer(serverName: String) { + plugin.sync { + val out = ByteStreams.newDataOutput().apply { + writeUTF("Connect") + writeUTF(serverName) + } + player.sendPluginMessage(plugin, "BungeeCord", out.toByteArray()) + } + } + + override fun transferToServer(host: String, port: Int) { + plugin.sync { player.transfer(host, port) } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperPlayerDirectory.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperPlayerDirectory.kt new file mode 100644 index 0000000..76e60ba --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/PaperPlayerDirectory.kt @@ -0,0 +1,16 @@ +package app.simplecloud.npc.paper.player + +import app.simplecloud.npc.common.platform.NpcPlayerDirectory +import app.simplecloud.npc.core.platform.NpcPlayer +import org.bukkit.Bukkit +import org.bukkit.plugin.Plugin + +class PaperPlayerDirectory( + private val plugin: Plugin, +) : NpcPlayerDirectory { + + override fun findOnlinePlayer(name: String): NpcPlayer? = + Bukkit.getPlayerExact(name)?.let { PaperNpcPlayer(plugin, it) } + + override fun onlinePlayers(): List<NpcPlayer> = Bukkit.getOnlinePlayers().map { PaperNpcPlayer(plugin, it) } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/TitleSender.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/TitleSender.kt new file mode 100644 index 0000000..d81afc3 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/player/TitleSender.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.paper.player + +import net.kyori.adventure.text.Component +import net.kyori.adventure.title.Title +import org.bukkit.entity.Player +import java.time.Duration + +object TitleSender { + + fun show( + player: Player, + title: Component, + subtitle: Component, + fadeInTicks: Int, + stayTicks: Int, + fadeOutTicks: Int, + ) { + val times = Title.Times.times(ticks(fadeInTicks), ticks(stayTicks), ticks(fadeOutTicks)) + player.showTitle(Title.title(title, subtitle, times)) + } + + private fun ticks(ticks: Int): Duration = Duration.ofMillis(ticks * 50L) +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderCatalog.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderCatalog.kt new file mode 100644 index 0000000..b0b0081 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderCatalog.kt @@ -0,0 +1,42 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.bukkit.compat.ServerCompat +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.Providers +import app.simplecloud.npc.provider.citizens.CitizensRenderer +import app.simplecloud.npc.provider.fancynpcs.FancyNpcsRenderer +import app.simplecloud.npc.provider.mythicmobs.MythicMobsRenderer +import app.simplecloud.npc.provider.standalone.StandaloneNpcRenderer +import app.simplecloud.npc.provider.znpcsplus.ZNpcsPlusRenderer +import org.bukkit.plugin.Plugin + +object ProviderCatalog { + fun descriptors(standalone: StandaloneNpcRenderer): List<ProviderDescriptor> = listOf( + ProviderDescriptor(Providers.FANCYNPCS, requiredPlugin = "FancyNpcs") { plugin, onInteract -> + FancyNpcsRenderer(plugin, onInteract) + }, + + ProviderDescriptor(Providers.CITIZENS, requiredPlugin = "Citizens") { plugin, onInteract -> + CitizensRenderer(plugin, onInteract) + }, + + ProviderDescriptor(Providers.ZNPCSPLUS, requiredPlugin = "ZNPCsPlus") { plugin, onInteract -> + ZNpcsPlusRenderer(plugin, onInteract) + }, + + ProviderDescriptor(Providers.MYTHICMOBS, requiredPlugin = "MythicMobs") { plugin, onInteract -> + MythicMobsRenderer(plugin, onInteract) + }, + + ProviderDescriptor(Providers.MANNEQUIN, gate = { ServerCompat.hasMannequin }) { plugin, _ -> + ReflectiveLoader.load<NpcRenderer>( + ReflectiveSeams.MANNEQUIN_RENDERER, + "the Mannequin NPC provider", + arrayOf(Plugin::class.java), + arrayOf(plugin), + ) + }, + + ProviderDescriptor(Providers.STANDALONE) { _, _ -> standalone }, + ) +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderDescriptor.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderDescriptor.kt new file mode 100644 index 0000000..e183ac8 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderDescriptor.kt @@ -0,0 +1,12 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.core.render.NpcRenderer +import org.bukkit.plugin.Plugin + +class ProviderDescriptor( + val name: String, + val requiredPlugin: String? = null, + val gate: () -> Boolean = { true }, + val create: (plugin: Plugin, onInteract: OnNpcInteract) -> NpcRenderer?, +) diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderLoader.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderLoader.kt new file mode 100644 index 0000000..a015e13 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderLoader.kt @@ -0,0 +1,19 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.core.render.NpcRenderer +import org.bukkit.Bukkit +import org.bukkit.plugin.Plugin + +object ProviderLoader { + fun load(plugin: Plugin, onInteract: OnNpcInteract, catalog: List<ProviderDescriptor>): Map<String, NpcRenderer> = + buildMap { + catalog.forEach { descriptor -> + val required = descriptor.requiredPlugin + if (required != null && Bukkit.getPluginManager().getPlugin(required)?.isEnabled != true) return@forEach + if (!descriptor.gate()) return@forEach + + descriptor.create(plugin, onInteract)?.let { put(descriptor.name, it) } + } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderWiring.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderWiring.kt new file mode 100644 index 0000000..eb3a850 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ProviderWiring.kt @@ -0,0 +1,52 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.bukkit.hologram.LegacyHologramCleanup +import app.simplecloud.npc.bukkit.hologram.PacketHologramRenderer +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.packet.PacketViewerTracker +import app.simplecloud.npc.bukkit.provider.WorldReconciler +import app.simplecloud.npc.common.render.RoutingNpcRenderer +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.Providers +import app.simplecloud.npc.provider.standalone.StandaloneNpcRenderer +import org.bukkit.Bukkit +import org.bukkit.plugin.Plugin + +class ProviderWiring private constructor( + private val standalone: StandaloneNpcRenderer, + private val tracker: PacketViewerTracker, + val holograms: PacketHologramRenderer, + private val legacyHolograms: LegacyHologramCleanup, + private val renderers: Map<String, NpcRenderer>, +) { + val routing = RoutingNpcRenderer(renderers) + + fun reconcileWorlds(configs: () -> Collection<NpcConfig>) { + legacyHolograms.sweep() + val mannequin = renderers[Providers.MANNEQUIN] as? WorldReconciler + mannequin?.knownIdsProvider = { + configs().filter { it.entity.provider.equals(Providers.MANNEQUIN, ignoreCase = true) } + .mapTo(HashSet()) { it.id.lowercase() } + } + mannequin?.reconcileLoadedWorlds() + standalone.cleanupStaleHitboxes() + } + + fun shutdown() { + holograms.texts.shutdown() + tracker.stop() + } + + companion object { + fun load(plugin: Plugin, onInteract: OnNpcInteract): ProviderWiring { + val tracker = PacketViewerTracker(plugin).also { it.start() } + val standalone = StandaloneNpcRenderer(plugin, tracker) + val renderers = ProviderLoader.load(plugin, onInteract, ProviderCatalog.descriptors(standalone)) + val holograms = PacketHologramRenderer(plugin, tracker).also { it.texts.start() } + val legacy = LegacyHologramCleanup().also { Bukkit.getPluginManager().registerEvents(it, plugin) } + + return ProviderWiring(standalone, tracker, holograms, legacy, renderers) + } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveLoader.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveLoader.kt new file mode 100644 index 0000000..eecccdf --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveLoader.kt @@ -0,0 +1,21 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.core.NpcLog +import java.util.logging.Level + +object ReflectiveLoader { + + inline fun <reified T> load( + className: String, + what: String, + parameterTypes: Array<Class<*>>, + arguments: Array<Any?>, + ): T? = instantiate(className, what, parameterTypes, arguments) as? T + + fun instantiate(className: String, what: String, parameterTypes: Array<Class<*>>, arguments: Array<Any?>): Any? = + runCatching { + Class.forName(className).getDeclaredConstructor(*parameterTypes).newInstance(*arguments) + }.onFailure { + NpcLog.logger.log(Level.WARNING, "Could not load $what", it) + }.getOrNull() +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeams.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeams.kt new file mode 100644 index 0000000..521687c --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeams.kt @@ -0,0 +1,5 @@ +package app.simplecloud.npc.paper.provider + +object ReflectiveSeams { + const val MANNEQUIN_RENDERER = "app.simplecloud.npc.provider.mannequin.MannequinRenderer" +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/Pushback.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/Pushback.kt new file mode 100644 index 0000000..58764fe --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/Pushback.kt @@ -0,0 +1,24 @@ +package app.simplecloud.npc.paper.pushback + +import org.bukkit.entity.Player +import org.bukkit.util.Vector +import kotlin.math.cos +import kotlin.math.sin + +object Pushback { + + fun push(player: Player, playerPosition: Vector, yaw: Float, npc: Vector, strength: Double, vertical: Double) { + player.velocity = direction(playerPosition, yaw, npc).multiply(strength).setY(vertical) + } + + fun direction(playerPosition: Vector, yaw: Float, npc: Vector): Vector { + val away = playerPosition.clone().subtract(npc).setY(0.0) + if (away.lengthSquared() >= EPSILON) return away.normalize() + + val yawRadians = Math.toRadians(yaw.toDouble()) + + return Vector(sin(yawRadians), 0.0, -cos(yawRadians)) + } + + private const val EPSILON = 1.0E-6 +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/PushbackListener.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/PushbackListener.kt new file mode 100644 index 0000000..af29247 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/pushback/PushbackListener.kt @@ -0,0 +1,78 @@ +package app.simplecloud.npc.paper.pushback + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.repository.NpcRepository +import app.simplecloud.npc.paper.sound.SoundPlayer +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerMoveEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.util.Vector +import java.util.UUID + +class PushbackListener( + private val npcRepository: NpcRepository, +) : Listener { + + private val cooldowns = hashMapOf<UUID, Long>() + + @Volatile + private var pushbackNpcs: List<NpcConfig> = emptyList() + + init { + npcRepository.onAnyChange(::rebuildSnapshot) + rebuildSnapshot() + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + fun onMove(event: PlayerMoveEvent) { + val npcs = pushbackNpcs + if (npcs.isEmpty()) return + + val destination = event.to + val from = event.from + if (from.x == destination.x && from.y == destination.y && from.z == destination.z) return + + val player = event.player + val now = System.currentTimeMillis() + if ((cooldowns[player.uniqueId] ?: 0L) > now) return + + val destinationWorld = destination.world ?: return + val config = npcs.firstOrNull { candidate -> + val location = candidate.entity.location + val dx = location.x - destination.x + val dy = location.y - destination.y + val dz = location.z - destination.z + val radius = candidate.pushback.radius + val outOfRange = dx * dx + dy * dy + dz * dz > radius * radius + destinationWorld.name.equals(location.world, ignoreCase = true) && !outOfRange + } ?: return + + val location = config.entity.location + Pushback.push( + player, + destination.toVector(), + destination.yaw, + Vector(location.x, location.y, location.z), + config.pushback.strength, + config.pushback.vertical, + ) + + config.pushback.sound?.let { SoundPlayer.play(player, it, config.pushback.soundOptions) } + cooldowns[player.uniqueId] = now + COOLDOWN_MILLIS + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + cooldowns.remove(event.player.uniqueId) + } + + private fun rebuildSnapshot() { + pushbackNpcs = npcRepository.findAll().filter { it.pushback.enabled && !it.entity.needsRelocation } + } + + private companion object { + const val COOLDOWN_MILLIS = 500L + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundCatalog.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundCatalog.kt new file mode 100644 index 0000000..f1bd781 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundCatalog.kt @@ -0,0 +1,20 @@ +package app.simplecloud.npc.paper.sound + +import org.bukkit.Keyed +import org.bukkit.NamespacedKey +import org.bukkit.Registry + +object SoundCatalog { + + private val keysByName: Map<String, NamespacedKey> by lazy { + val registry: Registry<out Keyed> = Registry.SOUNDS + registry.associate { sound -> nameOf(sound.key) to sound.key }.toSortedMap() + } + + fun names(): List<String> = keysByName.keys.toList() + + fun resolve(name: String): NamespacedKey? = keysByName[name.uppercase()] + ?: NamespacedKey.fromString(name.lowercase())?.takeIf { keysByName[nameOf(it)] == it } + + private fun nameOf(key: NamespacedKey): String = key.key.replace('.', '_').uppercase() +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundPlayer.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundPlayer.kt new file mode 100644 index 0000000..3093f63 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/sound/SoundPlayer.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.paper.sound + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import org.bukkit.entity.Player +import java.util.concurrent.ConcurrentHashMap + +object SoundPlayer { + + private val warned = ConcurrentHashMap.newKeySet<String>() + + fun play(player: Player, name: String, options: NpcConfig.SoundOptions) { + val key = SoundCatalog.resolve(name)?.toString() + ?: name.takeIf { '.' in it || ':' in it } + ?: run { + if (warned.add(name)) { + NpcLog.logger.warning("Unknown sound '$name'; pick one in the editor's sound picker.") + } + return + } + runCatching { player.playSound(player.location, key, options.volume.toFloat(), options.pitch.toFloat()) } + } +} diff --git a/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/update/UpdateChecker.kt b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/update/UpdateChecker.kt new file mode 100644 index 0000000..0894645 --- /dev/null +++ b/platform/paper/src/main/kotlin/app/simplecloud/npc/paper/update/UpdateChecker.kt @@ -0,0 +1,57 @@ +package app.simplecloud.npc.paper.update + +import com.google.gson.JsonParser +import org.bukkit.plugin.Plugin +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +object UpdateChecker { + + private const val PROJECT = "eyNPY9oJ" + private const val VERSIONS_URL = "https://api.modrinth.com/v2/project/$PROJECT/version" + private const val DOWNLOAD_URL = "https://modrinth.com/project/$PROJECT" + private val TIMEOUT = Duration.ofSeconds(10) + + fun check(plugin: Plugin) { + val current = plugin.pluginMeta.version + val request = HttpRequest.newBuilder(URI.create(VERSIONS_URL)) + .timeout(TIMEOUT) + .header("User-Agent", "simplecloud-npc/$current") + .build() + + HttpClient.newBuilder().connectTimeout(TIMEOUT).build() + .sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenAccept { response -> + if (response.statusCode() != 200) return@thenAccept + val newest = newestVersion(response.body()) ?: return@thenAccept + if (isNewer(newest, current)) { + plugin.logger.info("SimpleCloud NPCs $newest is available (running $current): $DOWNLOAD_URL") + } + }.exceptionally { null } + } + + fun newestVersion(json: String): String? = runCatching { + JsonParser.parseString(json).asJsonArray + .map { it.asJsonObject } + .maxByOrNull { it.get("date_published").asString } + ?.get("version_number")?.asString + }.getOrNull() + + fun isNewer(candidate: String, current: String): Boolean { + val a = parts(candidate) + val b = parts(current) + for (index in 0 until maxOf(a.size, b.size)) { + val left = a.getOrElse(index) { 0 } + val right = b.getOrElse(index) { 0 } + if (left != right) return left > right + } + + return '-' in current && '-' !in candidate + } + + private fun parts(version: String): List<Int> = + version.substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 } +} diff --git a/platform/paper/src/main/resources/plugin.yml b/platform/paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..6ffc03c --- /dev/null +++ b/platform/paper/src/main/resources/plugin.yml @@ -0,0 +1,29 @@ +name: simplecloud-npc +prefix: SimpleCloud-NPC +provides: + - SimpleCloud-NPC +version: '${version}' +authors: [PrexorJustin, MrManHD] +main: app.simplecloud.npc.paper.PaperPlugin +api-version: '1.20.6' + +depend: + - simplecloud-api + +softdepend: + - Citizens + - FancyNpcs + - MythicMobs + - PlaceholderAPI + - ZNPCsPlus + +permissions: + simplecloud.npcs.command: + description: Manage SimpleCloud NPCs + default: op + simplecloud.npcs.inventory.open: + description: Open menus with /scnpcs inventory open + default: true + simplecloud.npcs.inventory.open.others: + description: Open menus for other players + default: op diff --git a/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/PluginJarTest.kt b/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/PluginJarTest.kt new file mode 100644 index 0000000..cfac48c --- /dev/null +++ b/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/PluginJarTest.kt @@ -0,0 +1,192 @@ +package app.simplecloud.npc.paper + +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.io.File +import java.net.URLClassLoader +import java.util.jar.JarFile +import kotlin.test.Test +import kotlin.test.fail + +class PluginJarTest { + + private val jar = File(requireNotNull(System.getProperty("npc.pluginJar")) { "run through gradle" }) + private val entries = JarFile(jar).use { archive -> archive.entries().asSequence().map { it.name }.toSet() } + private val floorAdventure = requireNotNull(System.getProperty("npc.floorAdventure")) + private val shipped = entries.filter { it.endsWith(".class") }.map { it.removeSuffix(".class") }.toSet() + + @Test + fun `ships only the relocated adventure`() { + val forbidden = listOf( + "net/kyori/adventure/", + "net/kyori/examination/", + "net/kyori/option/", + "META-INF/services/net.kyori.", + ) + val required = listOf( + "app/simplecloud/npc/relocate/packets/kyori/adventure/text/Component.class", + "app/simplecloud/npc/relocate/packets/kyori/adventure/nbt/CompoundBinaryTag.class", + "app/simplecloud/npc/relocate/packets/kyori/examination/Examinable.class", + ) + + val problems = forbidden.mapNotNull { prefix -> + entries.filter { it.startsWith(prefix) }.takeIf { it.isNotEmpty() } + ?.let { "'$prefix' must not be in the jar, but ${it.size} entries are, e.g. ${it.take(3)}" } + } + required.filterNot { it in entries }.map { "'$it' has to be in the jar and is not" } + + if (problems.isNotEmpty()) fail("${jar.name} has the wrong shape:\n" + problems.joinToString("\n") { " $it" }) + } + + @Test + fun `every relocated class it names is shipped`() = assertResolves( + prefix = "app/simplecloud/npc/relocate/", + provided = emptySet(), + allowed = setOf( + "app/simplecloud/npc/relocate/configurate/kotlin/ObjectMappingKt", + "app/simplecloud/npc/relocate/packets/kyori/adventure/text/logger/slf4j/ComponentLogger", + ), + ) + + @Test + fun `every adventure class it names is on the floor server`() = assertResolves( + prefix = "net/kyori/", + provided = classNamesIn(serverClasspath(floorAdventure)), + skipped = gatedClasses(floorAdventure), + ) + + @Test + fun `packet stack never names the server's adventure`() = assertResolves( + prefix = "net/kyori/", + provided = emptySet(), + scanned = listOf( + "app/simplecloud/npc/relocate/packetevents/", + "app/simplecloud/npc/relocate/entitylib/", + "app/simplecloud/npc/relocate/packets/", + ), + ) + + @Test + fun `links against floor adventure`() = assertLinks(floorAdventure) + + @Test + fun `links against adventure 4_24 of paper 1_21_8`() = assertLinks("4.24.0") + + @Test + fun `links against adventure 5 of 26_x`() = assertLinks("5.2.0") + + private fun assertResolves( + prefix: String, + provided: Set<String>, + allowed: Set<String> = emptySet(), + scanned: List<String> = emptyList(), + skipped: List<String> = emptyList(), + ) { + val referenced = sortedSetOf<String>() + JarFile(jar).use { archive -> + archive.entries().asSequence() + .filter { it.name.endsWith(".class") } + .filter { entry -> scanned.isEmpty() || scanned.any { entry.name.startsWith(it) } } + .filterNot { entry -> skipped.any { entry.name.startsWith(it) } } + .forEach { entry -> + val bytes = archive.getInputStream(entry).use { it.readBytes() } + referenced += referencesIn(bytes, prefix) + } + } + + val missing = referenced - shipped - provided - allowed + if (missing.isNotEmpty()) { + fail( + "${missing.size} class(es) under '$prefix' are named by ${jar.name} but neither shipped " + + "in it nor provided by the server. Either ship them, or allow them here with the " + + "reason they cannot be hit at runtime:\n" + missing.joinToString("\n") { " $it" }, + ) + } + } + + private fun assertLinks(adventureVersion: String) { + val skipped = gatedClasses(adventureVersion) + val failures = sortedMapOf<String, String>() + val classpath = (listOf(jar) + serverClasspath(adventureVersion)).map { it.toURI().toURL() } + URLClassLoader(classpath.toTypedArray(), ClassLoader.getPlatformClassLoader()).use { loader -> + shipped.asSequence() + .filterNot { it.endsWith("module-info") || '-' in it } + .filterNot { name -> skipped.any { name.startsWith(it) } } + .forEach { name -> + adventureLinkageProblem(name.replace('/', '.'), loader)?.let { failures[name] = it } + } + } + + if (failures.isNotEmpty()) { + fail( + "${failures.size} class(es) do not link against the server's Adventure $adventureVersion:\n" + + failures.entries.joinToString("\n") { (name, problem) -> " $name -> $problem" }, + ) + } + } + + private fun adventureLinkageProblem(name: String, loader: ClassLoader): String? { + val error = runCatching { Class.forName(name, true, loader) }.exceptionOrNull() ?: return null + + val root = generateSequence(error) { it.cause }.last() + if (root !is LinkageError && root !is ClassNotFoundException) return null + val message = root.message.orEmpty() + if (message.startsWith("Could not initialize class")) return null + + return if ("net.kyori" in message || "net/kyori" in message) "${root.javaClass.simpleName}: $message" else null + } + + private fun serverClasspath(adventureVersion: String): List<File> = + System.getProperty("npc.serverClasspath.$adventureVersion").split(File.pathSeparator).map(::File) + + private fun gatedClasses(adventureVersion: String): List<String> = + System.getProperty("npc.gatedClasses.$adventureVersion").orEmpty().split(',').filter { it.isNotBlank() } + + private fun classNamesIn(files: List<File>): Set<String> = files.filter { it.isFile }.flatMapTo(HashSet()) { file -> + JarFile(file).use { archive -> + archive.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".class") } + .map { it.removeSuffix(".class") } + .toList() + } + } + + private fun referencesIn(classBytes: ByteArray, prefix: String): Set<String> = + constantPoolClasses(classBytes) + .map { it.trimStart('[').removePrefix("L").removeSuffix(";") } + .filter { it.startsWith(prefix) } + .toSet() + + private fun constantPoolClasses(classBytes: ByteArray): List<String> { + val input = DataInputStream(ByteArrayInputStream(classBytes)) + if (input.readInt() != CLASS_FILE_MAGIC) return emptyList() + input.skipBytes(4) + + val entryCount = input.readUnsignedShort() + val utf8 = arrayOfNulls<String>(entryCount) + val classNameIndexes = mutableListOf<Int>() + + var index = 1 + while (index < entryCount) { + when (input.readUnsignedByte()) { + 1 -> utf8[index] = input.readUTF() + 7 -> classNameIndexes += input.readUnsignedShort() + 8, 16, 19, 20 -> input.skipBytes(2) + 15 -> input.skipBytes(3) + 3, 4, 9, 10, 11, 12, 17, 18 -> input.skipBytes(4) + 5, 6 -> { + input.skipBytes(8) + index++ + } + else -> fail("unknown constant pool tag in a class of ${jar.name}") + } + index++ + } + + return classNameIndexes.mapNotNull { utf8.getOrNull(it) } + } + + private companion object { + const val CLASS_FILE_MAGIC = -0x35014542 + } +} diff --git a/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeamsTest.kt b/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeamsTest.kt new file mode 100644 index 0000000..e5c78ab --- /dev/null +++ b/platform/paper/src/test/kotlin/app/simplecloud/npc/paper/provider/ReflectiveSeamsTest.kt @@ -0,0 +1,16 @@ +package app.simplecloud.npc.paper.provider + +import app.simplecloud.npc.core.render.NpcRenderer +import org.bukkit.plugin.Plugin +import kotlin.test.Test +import kotlin.test.assertTrue + +class ReflectiveSeamsTest { + + @Test + fun `mannequin renderer exists with a plugin constructor`() { + val clazz = Class.forName(ReflectiveSeams.MANNEQUIN_RENDERER) + clazz.getDeclaredConstructor(Plugin::class.java) + assertTrue(NpcRenderer::class.java.isAssignableFrom(clazz)) + } +} diff --git a/provider/base/build.gradle.kts b/provider/base/build.gradle.kts new file mode 100644 index 0000000..117c0f8 --- /dev/null +++ b/provider/base/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + api(project(":core")) + compileOnly(libs.paper.api) + compileOnly(project(":libs:packetevents", "shadowRuntimeElements")) + testImplementation(libs.paper.api) + testImplementation(project(":libs:packetevents", "shadowRuntimeElements")) +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/NpcKeys.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/NpcKeys.kt new file mode 100644 index 0000000..606d3e3 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/NpcKeys.kt @@ -0,0 +1,9 @@ +package app.simplecloud.npc.bukkit + +import org.bukkit.NamespacedKey + +object NpcKeys { + val HOLOGRAM = NamespacedKey("simplecloud", "npc.hologram") + val HITBOX = NamespacedKey("simplecloud", "npc.hitbox") + val MANNEQUIN = NamespacedKey("simplecloud", "npc.mannequin") +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/EntityIds.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/EntityIds.kt new file mode 100644 index 0000000..c5ca297 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/EntityIds.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.bukkit.compat + +import org.bukkit.Bukkit +import org.bukkit.UnsafeValues +import org.bukkit.World +import java.lang.reflect.Method + +object EntityIds { + + private val perWorld: Method? by lazy { + runCatching { UnsafeValues::class.java.getMethod("nextEntityId", World::class.java) }.getOrNull() + } + + private val global: Method? by lazy { + runCatching { UnsafeValues::class.java.getMethod("nextEntityId") }.getOrNull() + } + + fun next(world: World? = null): Int { + val unsafe = Bukkit.getUnsafe() + perWorld?.let { return it.invoke(unsafe, world ?: Bukkit.getWorlds().first()) as Int } + val method = global ?: error("This server offers no way to reserve entity ids.") + + return method.invoke(unsafe) as Int + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/ServerCompat.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/ServerCompat.kt new file mode 100644 index 0000000..ab8a69b --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/compat/ServerCompat.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.bukkit.compat + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.manager.server.ServerVersion + +object ServerCompat { + + val version: ServerVersion by lazy { PacketEvents.getAPI().serverManager.version } + + private fun atLeast(minimum: ServerVersion): Boolean = version.isNewerThanOrEquals(minimum) + + val hasMannequin: Boolean get() = atLeast(ServerVersion.V_1_21_9) +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/ArmorLooks.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/ArmorLooks.kt new file mode 100644 index 0000000..a00a9c8 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/ArmorLooks.kt @@ -0,0 +1,62 @@ +package app.simplecloud.npc.bukkit.equipment + +import app.simplecloud.npc.bukkit.skin.HeadTextures +import app.simplecloud.npc.core.config.NpcConfig +import org.bukkit.Color +import org.bukkit.NamespacedKey +import org.bukkit.Registry +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.ArmorMeta +import org.bukkit.inventory.meta.ItemMeta +import org.bukkit.inventory.meta.LeatherArmorMeta +import org.bukkit.inventory.meta.SkullMeta +import org.bukkit.inventory.meta.trim.ArmorTrim + +object ArmorLooks { + + fun apply(meta: ItemMeta, color: String?, trim: NpcConfig.ArmorTrim?, customModelData: Int?) { + color?.let(::parseColor)?.let { (meta as? LeatherArmorMeta)?.setColor(it) } + trim?.let(::armorTrim)?.let { (meta as? ArmorMeta)?.trim = it } + customModelData?.let(meta::setCustomModelData) + } + + fun read(stack: ItemStack): NpcConfig.EquipmentItem { + val meta = stack.itemMeta + val glintOverride = meta != null && meta.hasEnchantmentGlintOverride() && meta.enchantmentGlintOverride + val texture = (meta as? SkullMeta)?.playerProfile?.properties?.firstOrNull { it.name == "textures" } + val dyed = (meta as? LeatherArmorMeta)?.color?.takeUnless { it == DEFAULT_LEATHER } + val trim = (meta as? ArmorMeta)?.takeIf { it.hasTrim() }?.trim + val customModelData = meta?.takeIf { it.hasCustomModelData() }?.customModelData + + return NpcConfig.EquipmentItem( + material = stack.type.name, + glowing = meta?.hasEnchants() == true || glintOverride, + headTexture = texture?.value, + color = dyed?.let { String.format("#%06x", it.asRGB()) }, + trim = trim?.let { + NpcConfig.ArmorTrim(material = it.material.key.toString(), pattern = it.pattern.key.toString()) + }, + customModelData = customModelData, + ) + } + + fun toItemStack(item: NpcConfig.EquipmentItem, base: ItemStack): ItemStack = base.apply { + editMeta { meta -> + val texture = item.headTexture + if (!texture.isNullOrBlank()) HeadTextures.apply(meta, texture) + if (item.glowing) meta.setEnchantmentGlintOverride(true) + apply(meta, item.color, item.trim, item.customModelData) + } + } + + private fun parseColor(hex: String): Color? = + hex.removePrefix("#").toIntOrNull(16)?.takeIf { it in 0..0xFFFFFF }?.let(Color::fromRGB) + + private fun armorTrim(trim: NpcConfig.ArmorTrim): ArmorTrim? = runCatching { + val material = NamespacedKey.fromString(trim.material)?.let(Registry.TRIM_MATERIAL::get) + val pattern = NamespacedKey.fromString(trim.pattern)?.let(Registry.TRIM_PATTERN::get) + if (material == null || pattern == null) null else ArmorTrim(material, pattern) + }.getOrNull() + + private val DEFAULT_LEATHER: Color = Color.fromRGB(0xA06540) +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/EquipmentItems.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/EquipmentItems.kt new file mode 100644 index 0000000..e1022ae --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/equipment/EquipmentItems.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.bukkit.equipment + +import app.simplecloud.npc.core.config.NpcConfig +import org.bukkit.Material +import org.bukkit.inventory.ItemStack + +object EquipmentItems { + + fun toItemStack(item: NpcConfig.EquipmentItem): ItemStack? { + val material = Material.matchMaterial(item.material)?.takeIf { it.isItem && !it.isAir } ?: return null + return ArmorLooks.toItemStack(item, ItemStack(material)) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeamPackets.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeamPackets.kt new file mode 100644 index 0000000..5c9dad1 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeamPackets.kt @@ -0,0 +1,97 @@ +package app.simplecloud.npc.bukkit.glow + +import app.simplecloud.npc.bukkit.packet.PacketComponents +import app.simplecloud.npc.core.config.GlowColors +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.CollisionRule +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.NameTagVisibility +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.Component as PacketComponent + +object GlowTeamPackets { + + private const val HIDDEN_NAME_TAG_TEAM = "scnpcs_hidden" + + private const val HIDDEN_NAME_TAG_GLOW_PREFIX = "scnpcs_glow_" + private const val OUTLINE_PREFIX = "scnpcs_outline_" + + fun hiddenNameTagTeamFor(glowColor: String?): String = + GlowColors.resolve(glowColor)?.let { HIDDEN_NAME_TAG_GLOW_PREFIX + glowColor!!.lowercase() } + ?: HIDDEN_NAME_TAG_TEAM + + fun outlineTeamFor(glowColor: String?): String? = + GlowColors.resolve(glowColor)?.let { OUTLINE_PREFIX + glowColor!!.lowercase() } + + fun create(teamName: String, color: NamedTextColor?, hideNameTag: Boolean): WrapperPlayServerTeams { + val nameTagVisibility = if (hideNameTag) NameTagVisibility.NEVER else NameTagVisibility.ALWAYS + val collisionRule = if (hideNameTag) CollisionRule.NEVER else CollisionRule.ALWAYS + val info = WrapperPlayServerTeams.ScoreBoardTeamInfo( + PacketComponent.empty(), + PacketComponent.empty(), + PacketComponent.empty(), + nameTagVisibility, + collisionRule, + PacketComponents.of(color ?: NamedTextColor.WHITE), + WrapperPlayServerTeams.OptionData.NONE, + ) + + return WrapperPlayServerTeams(teamName, WrapperPlayServerTeams.TeamMode.CREATE, info, emptyList()) + } + + fun addEntry(teamName: String, entry: String): WrapperPlayServerTeams = + WrapperPlayServerTeams( + teamName, + WrapperPlayServerTeams.TeamMode.ADD_ENTITIES, + null as WrapperPlayServerTeams.ScoreBoardTeamInfo?, + listOf(entry), + ) + + fun removeEntry(teamName: String, entry: String): WrapperPlayServerTeams = + WrapperPlayServerTeams( + teamName, + WrapperPlayServerTeams.TeamMode.REMOVE_ENTITIES, + null as WrapperPlayServerTeams.ScoreBoardTeamInfo?, + listOf(entry), + ) + + fun remove(teamName: String): WrapperPlayServerTeams = + WrapperPlayServerTeams( + teamName, + WrapperPlayServerTeams.TeamMode.REMOVE, + null as WrapperPlayServerTeams.ScoreBoardTeamInfo?, + emptyList<String>(), + ) + + fun send(player: Player, packet: PacketWrapper<*>) { + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } + + class CreationTracker { + private val created = ConcurrentHashMap<UUID, MutableSet<String>>() + + fun ensure(player: Player, teamName: String, color: NamedTextColor?, hideNameTag: Boolean) { + val teams = created.getOrPut(player.uniqueId) { ConcurrentHashMap.newKeySet() } + if (!teams.add(teamName)) return + + send(player, create(teamName, color, hideNameTag)) + } + + fun forget(playerId: UUID) { + created.remove(playerId) + } + + fun removeAll() { + created.forEach { (playerId, teams) -> + val player = Bukkit.getPlayer(playerId) ?: return@forEach + teams.forEach { runCatching { send(player, remove(it)) } } + } + created.clear() + } + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeams.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeams.kt new file mode 100644 index 0000000..c55cda9 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/glow/GlowTeams.kt @@ -0,0 +1,95 @@ +package app.simplecloud.npc.bukkit.glow + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.GlowColors +import org.bukkit.Bukkit +import org.bukkit.entity.Entity +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +object GlowTeams : Listener { + + private class Member(val scoreboardEntry: String, val glowColor: String) + + private val members = ConcurrentHashMap<UUID, Member>() + private val created = GlowTeamPackets.CreationTracker() + private val warnedEntries = ConcurrentHashMap.newKeySet<String>() + private val logger = NpcLog.logger + + fun install(plugin: Plugin) { + Bukkit.getPluginManager().registerEvents(this, plugin) + } + + fun shutdown() { + HandlerList.unregisterAll(this) + created.removeAll() + members.clear() + warnedEntries.clear() + } + + private fun scoreboardEntryOf(entity: Entity): String = + (entity as? Player)?.name ?: entity.uniqueId.toString() + + fun set(entity: Entity, glowColor: String?) { + val entry = scoreboardEntryOf(entity) + if (Bukkit.getOnlinePlayers().any { it.name == entry && it.uniqueId != entity.uniqueId }) { + if (warnedEntries.add(entry)) { + logger.warning( + "Not applying a glow color to the NPC named '$entry': a player with that name is online, " + + "and they would glow instead. Give the NPC a different name.", + ) + } + clear(entity.uniqueId) + return + } + + warnedEntries.remove(entry) + set(entity.uniqueId, entry, glowColor) + } + + private fun set(entityId: UUID, scoreboardEntry: String, glowColor: String?) { + val color = glowColor?.lowercase()?.takeIf { GlowColors.resolve(it) != null } + val previous = + if (color == null) members.remove(entityId) else members.put(entityId, Member(scoreboardEntry, color)) + if (previous?.glowColor == color && previous?.scoreboardEntry == scoreboardEntry) return + + val next = color?.let { Member(scoreboardEntry, it) } + Bukkit.getOnlinePlayers().forEach { player -> + previous?.let { remove(player, it) } + next?.let { add(player, it) } + } + } + + fun clear(entityId: UUID) { + val previous = members.remove(entityId) ?: return + Bukkit.getOnlinePlayers().forEach { remove(it, previous) } + } + + @EventHandler + fun onJoin(event: PlayerJoinEvent) { + members.values.forEach { add(event.player, it) } + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + created.forget(event.player.uniqueId) + } + + private fun add(player: Player, member: Member) { + val teamName = GlowTeamPackets.outlineTeamFor(member.glowColor) ?: return + created.ensure(player, teamName, GlowColors.resolve(member.glowColor), hideNameTag = false) + GlowTeamPackets.send(player, GlowTeamPackets.addEntry(teamName, member.scoreboardEntry)) + } + + private fun remove(player: Player, member: Member) { + val teamName = GlowTeamPackets.outlineTeamFor(member.glowColor) ?: return + GlowTeamPackets.send(player, GlowTeamPackets.removeEntry(teamName, member.scoreboardEntry)) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometry.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometry.kt new file mode 100644 index 0000000..a528fd7 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometry.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.core.config.NpcConfig + +object HologramGeometry { + const val LINE_SPACING = 0.3 + + fun baseHeight(config: NpcConfig): Double = config.hologram.startHeight * config.entity.heightFactor() + + fun lineHeights(config: NpcConfig, lineCount: Int): List<Double> { + val base = baseHeight(config) + + return List(lineCount) { index -> base + LINE_SPACING * (lineCount - 1 - index) } + } + + fun topHeight(config: NpcConfig, lineCount: Int): Double = baseHeight(config) + LINE_SPACING * lineCount +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/LegacyHologramCleanup.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/LegacyHologramCleanup.kt new file mode 100644 index 0000000..f9bbeea --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/LegacyHologramCleanup.kt @@ -0,0 +1,32 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.bukkit.NpcKeys +import app.simplecloud.npc.core.NpcLog +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.entity.Entity +import org.bukkit.entity.TextDisplay +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.world.EntitiesLoadEvent + +class LegacyHologramCleanup : Listener { + + fun sweep() { + Bukkit.getWorlds().forEach { world -> remove(world, world.entities) } + } + + @EventHandler + fun onEntitiesLoad(event: EntitiesLoadEvent) { + remove(event.world, event.entities) + } + + private fun remove(world: World, entities: Collection<Entity>) { + val legacy = entities.filterIsInstance<TextDisplay>() + .filter { it.persistentDataContainer.has(NpcKeys.HOLOGRAM) } + if (legacy.isEmpty()) return + + legacy.forEach(TextDisplay::remove) + NpcLog.logger.info("Removed ${legacy.size} old hologram entities in world '${world.name}'.") + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/PacketHologramRenderer.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/PacketHologramRenderer.kt new file mode 100644 index 0000000..a547e05 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/PacketHologramRenderer.kt @@ -0,0 +1,188 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.bukkit.compat.EntityIds +import app.simplecloud.npc.bukkit.packet.PacketComponents +import app.simplecloud.npc.bukkit.packet.PacketTextDisplays +import app.simplecloud.npc.bukkit.packet.PacketViewerTracker +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.hologram.HologramLine +import app.simplecloud.npc.core.hologram.HologramLineResolver +import app.simplecloud.npc.core.hologram.HologramLineResolver.ResolvedLine +import app.simplecloud.npc.core.render.HologramRenderer +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.Component as PacketComponent + +class PacketHologramRenderer( + private val plugin: Plugin, + private val tracker: PacketViewerTracker, +) : HologramRenderer { + + private data class Line( + val key: HologramLineKey, + val entityId: Int, + val uuid: UUID, + val y: Double, + val style: HologramLine, + val shared: PacketComponent, + ) + + private val refreshScope = RefreshScope("hologram") + private val holograms = ConcurrentHashMap<String, Hologram>() + + val texts = ViewerTexts(plugin, ::viewersOf, ::pushText) + + override fun createOrUpdate(config: NpcConfig) { + if (!config.hologram.enabled) { + destroy(config) + return + } + refreshScope.launch(config.id) { rebuild(config) } + } + + override fun destroy(config: NpcConfig) { + refreshScope.launch(config.id) { plugin.sync { remove(config.id) } } + } + + override fun destroyAll() { + refreshScope.cancel() + plugin.sync { holograms.keys.toList().forEach(::remove) } + } + + private suspend fun rebuild(config: NpcConfig) { + val lines = when (val resolved = HologramLineResolver.resolve(config)) { + is HologramLineResolver.Result.Lines -> resolved.lines + HologramLineResolver.Result.Unavailable -> return + HologramLineResolver.Result.None -> { + plugin.sync { remove(config.id) } + return + } + } + + plugin.sync { apply(config, lines) } + } + + private fun apply(config: NpcConfig, resolved: List<ResolvedLine>) { + val id = config.id.lowercase(Locale.ROOT) + val location = config.entity.location + val world = Bukkit.getWorld(location.world) ?: return remove(id) + val heights = HologramGeometry.lineHeights(config, resolved.size) + val existing = holograms[id] + + resolved.forEachIndexed { index, line -> + val personal = line.personal?.let { PersonalLine(it, line.line.refreshSeconds) } + texts.track(HologramLineKey.of(id, index), personal) + } + texts.retain(id, resolved.size) + + val lines = resolved.mapIndexed { index, line -> + val previous = existing?.lines?.getOrNull(index) + Line( + key = HologramLineKey.of(id, index), + entityId = previous?.entityId ?: EntityIds.next(world), + uuid = previous?.uuid ?: UUID.randomUUID(), + y = location.y + heights[index], + style = line.line, + shared = PacketComponents.of(line.text), + ) + } + + if (existing == null) { + val hologram = Hologram(id, location.world, location.x, location.y, location.z, config.entity.viewDistance) + hologram.lines = lines + holograms[id] = hologram + tracker.add(hologram) + return + } + + existing.range = config.entity.viewDistance + val moved = existing.world != location.world || existing.x != location.x || existing.z != location.z + val reshaped = moved || existing.lines.size != lines.size || + existing.lines.zip(lines).any { (old, new) -> old.y != new.y } + val viewers = existing.viewers.mapNotNull(Bukkit::getPlayer) + + if (reshaped) { + val stale = existing.lines.map(Line::entityId) + viewers.forEach { PacketTextDisplays.send(it, listOf(PacketTextDisplays.destroy(stale))) } + existing.lines = lines.map { it.copy(entityId = EntityIds.next(world), uuid = UUID.randomUUID()) } + existing.moveTo(location.world, location.x, location.y, location.z) + viewers.forEach { existing.show(it) } + return + } + + val previous = existing.lines + existing.lines = lines + viewers.forEach { player -> + val updates = lines.zip(previous).mapNotNull { (line, old) -> update(line, old, player) } + PacketTextDisplays.send(player, updates) + } + } + + private fun update(line: Line, old: Line, player: Player): PacketWrapper<*>? { + val personal = texts.isPersonal(line.key) + if (line.style != old.style) { + return PacketTextDisplays.metadata(line.entityId, line.style, textFor(line, player)) + } + if (!personal && line.shared != old.shared) return PacketTextDisplays.text(line.entityId, line.shared) + + return null + } + + private fun textFor(line: Line, player: Player): PacketComponent = texts.textFor(line.key, player) ?: line.shared + + private fun remove(npcId: String) { + val id = npcId.lowercase(Locale.ROOT) + val hologram = holograms.remove(id) + texts.forget(id) + hologram ?: return + + val ids = hologram.lines.map(Line::entityId) + tracker.remove(hologram).forEach { PacketTextDisplays.send(it, listOf(PacketTextDisplays.destroy(ids))) } + } + + private fun viewersOf(key: HologramLineKey): Collection<Player> = + holograms[key.npcId]?.viewers?.mapNotNull(Bukkit::getPlayer).orEmpty() + + private fun pushText(key: HologramLineKey, player: Player, text: PacketComponent) { + val hologram = holograms[key.npcId] ?: return + if (player.uniqueId !in hologram.viewers) return + val line = hologram.lines.getOrNull(key.index) ?: return + + PacketTextDisplays.send(player, listOf(PacketTextDisplays.text(line.entityId, text))) + } + + private inner class Hologram( + private val npcId: String, + world: String, + x: Double, + y: Double, + z: Double, + range: Double, + ) : PacketViewerTracker.Entry("Hologram $npcId", world, x, y, z, range) { + @Volatile + var lines: List<Line> = emptyList() + + override fun show(player: Player) { + val packets = lines.flatMap { line -> + listOf( + PacketTextDisplays.spawn(line.entityId, line.uuid, x, line.y, z), + PacketTextDisplays.metadata(line.entityId, line.style, textFor(line, player)), + ) + } + PacketTextDisplays.send(player, packets) + } + + override fun hide(player: Player) { + PacketTextDisplays.send(player, listOf(PacketTextDisplays.destroy(lines.map(Line::entityId)))) + texts.viewerGone(npcId, player.uniqueId) + } + + override fun forget(uuid: UUID) = texts.viewerGone(npcId, uuid) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/RefreshScope.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/RefreshScope.kt new file mode 100644 index 0000000..7b7e2db --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/RefreshScope.kt @@ -0,0 +1,40 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.core.NpcLog +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.logging.Level + +class RefreshScope(logLabel: String) { + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + NpcLog.logger.log(Level.WARNING, "Unhandled $logLabel refresh failure", throwable) + }, + ) + + private class Lock { + val mutex = Mutex() + var users = 0 + } + + private val locks = HashMap<String, Lock>() + + fun launch(id: String, block: suspend () -> Unit) { + scope.launch { + val lock = synchronized(locks) { locks.getOrPut(id, ::Lock).also { it.users++ } } + try { + lock.mutex.withLock { block() } + } finally { + synchronized(locks) { if (--lock.users == 0) locks.remove(id) } + } + } + } + + fun cancel() = scope.cancel() +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/ViewerTexts.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/ViewerTexts.kt new file mode 100644 index 0000000..19bf8ba --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/hologram/ViewerTexts.kt @@ -0,0 +1,237 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.bukkit.packet.PacketComponents +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.cloud.CloudOutageLog +import app.simplecloud.npc.core.hologram.PersonalText +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin +import org.bukkit.scheduler.BukkitTask +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import java.util.logging.Level +import kotlin.math.roundToInt +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.Component as PacketComponent + +data class HologramLineKey(val npcId: String, val index: Int) { + companion object { + fun of(npcId: String, index: Int) = HologramLineKey(npcId.lowercase(Locale.ROOT), index) + } +} + +class PersonalLine(val text: PersonalText, val refreshSeconds: Double) + +class ViewerTexts( + private val plugin: Plugin, + private val viewersOf: (HologramLineKey) -> Collection<Player>, + private val push: (HologramLineKey, Player, PacketComponent) -> Unit, +) { + + private class LineState(val key: HologramLineKey, val text: PersonalText, val refreshTicks: Int) { + val phase: Int = Math.floorMod(key.hashCode(), refreshTicks) + val components = ConcurrentHashMap<String, PacketComponent>() + } + + private class Rendered( + val ticket: Long, + val source: PersonalText, + val resolved: String, + val component: PacketComponent, + ) + + private class Line(@Volatile var state: LineState) { + val viewers = ConcurrentHashMap<UUID, Rendered>() + } + + private data class Work(val key: HologramLineKey, val viewer: UUID) + + private val lines = ConcurrentHashMap<HologramLineKey, Line>() + private val tickets = AtomicLong() + + private val queue = ArrayDeque<Work>() + private val queued = HashSet<Work>() + private var tick = 0L + private var inlineSpent = 0L + private var task: BukkitTask? = null + + private val worker: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "SimpleCloud-NPC hologram text").apply { isDaemon = true } + } + + fun start() { + task = Bukkit.getScheduler().runTaskTimer(plugin, Runnable(::tick), 1L, 1L) + } + + fun shutdown() { + task?.cancel() + task = null + worker.shutdownNow() + lines.clear() + } + + fun track(key: HologramLineKey, personal: PersonalLine?) { + val existing = lines[key] + if (personal == null) { + if (existing != null) lines.remove(key, existing) + return + } + + val refreshTicks = (personal.refreshSeconds * TICKS_PER_SECOND).roundToInt().coerceAtLeast(MIN_REFRESH_TICKS) + val previous = existing?.state + if (previous != null && previous.text == personal.text && previous.refreshTicks == refreshTicks) return + + val state = LineState(key, personal.text, refreshTicks) + val target = existing ?: Line(state).also { lines[key] = it } + target.state = state + if (previous?.text == personal.text) return + + viewersOf(key).forEach { player -> + if (state.text.external) enqueue(Work(key, player.uniqueId)) else refresh(target, state, player) + } + } + + fun isPersonal(key: HologramLineKey): Boolean = lines.containsKey(key) + + fun textFor(key: HologramLineKey, player: Player): PacketComponent? { + val line = lines[key] ?: return null + val state = line.state + line.viewers[player.uniqueId]?.takeIf { it.source == state.text }?.let { return it.component } + if (state.text.external && inlineSpent >= BUDGET_NANOS) { + enqueue(Work(key, player.uniqueId)) + return null + } + + val started = System.nanoTime() + val resolved = resolve(state, player) + val component = runCatching { componentFor(state, resolved) }.getOrNull() + if (state.text.external) inlineSpent += System.nanoTime() - started + component ?: return null + store(line, player.uniqueId, Rendered(tickets.incrementAndGet(), state.text, resolved, component)) + + return component + } + + fun retain(npcId: String, lineCount: Int) { + val id = npcId.lowercase(Locale.ROOT) + lines.keys.removeIf { it.npcId == id && it.index >= lineCount } + } + + fun forget(npcId: String) { + val id = npcId.lowercase(Locale.ROOT) + lines.keys.removeIf { it.npcId == id } + } + + fun viewerGone(npcId: String, viewer: UUID) { + val id = npcId.lowercase(Locale.ROOT) + lines.forEach { (key, line) -> if (key.npcId == id) line.viewers.remove(viewer) } + } + + private fun tick() { + tick++ + inlineSpent = 0L + if (lines.isEmpty()) return + + lines.values.forEach { line -> + val state = line.state + if (!state.text.external || (tick + state.phase) % state.refreshTicks != 0L) return@forEach + viewersOf(state.key).forEach { enqueue(Work(state.key, it.uniqueId)) } + } + + drain() + } + + private fun enqueue(work: Work) { + if (queued.add(work)) queue.addLast(work) + } + + private fun drain() { + if (queue.isEmpty()) return + val deadline = System.nanoTime() + BUDGET_NANOS + + do { + val work = queue.removeFirst() + queued.remove(work) + val line = lines[work.key] ?: continue + val player = Bukkit.getPlayer(work.viewer) ?: continue + refresh(line, line.state, player) + } while (queue.isNotEmpty() && System.nanoTime() < deadline) + + if (queue.size > BACKLOG_WARNING) { + CloudOutageLog.warnThrottled( + "Hologram placeholders are falling behind (${queue.size} updates waiting). A PlaceholderAPI " + + "placeholder in a hologram line is slow; raise that line's refresh interval.", + key = "hologram-backlog", + ) + } + } + + private fun refresh(line: Line, state: LineState, player: Player) { + val ticket = tickets.incrementAndGet() + val resolved = resolve(state, player) + val current = line.viewers[player.uniqueId] + if (current != null && current.source == state.text && current.resolved == resolved) return + + if (worker.isShutdown) return + val viewer = player.uniqueId + worker.execute { + val component = runCatching { componentFor(state, resolved) } + .onFailure { + NpcLog.logger.log(Level.WARNING, "Could not render a hologram line of '${state.key.npcId}'", it) + } + .getOrNull() ?: return@execute + if (line.state !== state || !store(line, viewer, Rendered(ticket, state.text, resolved, component))) { + return@execute + } + if (!plugin.isEnabled) return@execute + Bukkit.getScheduler().runTask(plugin, Runnable { + if (lines[state.key]?.state !== state) return@Runnable + Bukkit.getPlayer(viewer)?.let { push(state.key, it, component) } + }) + } + } + + private fun resolve(state: LineState, player: Player): String { + if (!state.text.external) return state.text.resolve(player.uniqueId, player.name) + val started = System.nanoTime() + val resolved = state.text.resolve(player.uniqueId, player.name) + val took = System.nanoTime() - started + if (took > SLOW_NANOS) { + CloudOutageLog.warnThrottled( + "A PlaceholderAPI placeholder in hologram line ${state.key.index + 1} of NPC '${state.key.npcId}' " + + "took ${took / 1_000_000} ms. Slow placeholders hold up the server; raise its refresh interval.", + key = "hologram-slow:${state.key}", + ) + } + + return resolved + } + + private fun componentFor(state: LineState, resolved: String): PacketComponent { + if (state.components.size > COMPONENT_CACHE_LIMIT) state.components.clear() + + return state.components.computeIfAbsent(resolved) { PacketComponents.of(state.text.render(it)) } + } + + private fun store(line: Line, viewer: UUID, rendered: Rendered): Boolean { + var stored = false + line.viewers.compute(viewer) { _, current -> + if (current == null || current.ticket < rendered.ticket) rendered.also { stored = true } else current + } + + return stored + } + + private companion object { + const val TICKS_PER_SECOND = 20 + const val MIN_REFRESH_TICKS = 5 + const val BUDGET_NANOS = 1_500_000L + const val SLOW_NANOS = 5_000_000L + const val BACKLOG_WARNING = 2_000 + const val COMPONENT_CACHE_LIMIT = 512 + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickDedupeTracker.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickDedupeTracker.kt new file mode 100644 index 0000000..0f8898c --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickDedupeTracker.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.bukkit.interact + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class ClickDedupeTracker<T>(private val windowMillis: Long = 150L) { + + private val lastInteract = ConcurrentHashMap<Pair<UUID, T>, Long>() + + internal val size: Int get() = lastInteract.size + + fun shouldHandle(playerId: UUID, targetId: T): Boolean { + val now = System.currentTimeMillis() + lastInteract.values.removeIf { now - it >= windowMillis } + + return lastInteract.putIfAbsent(playerId to targetId, now) == null + } + + fun forgetPlayer(playerId: UUID) { + lastInteract.keys.removeIf { it.first == playerId } + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickPackets.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickPackets.kt new file mode 100644 index 0000000..c9c8e5e --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ClickPackets.kt @@ -0,0 +1,30 @@ +package app.simplecloud.npc.bukkit.interact + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import com.github.retrooper.packetevents.protocol.packettype.PacketType +import com.github.retrooper.packetevents.protocol.player.InteractionHand +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientAttack +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity.InteractAction + +object ClickPackets { + + class Click(val entityId: Int, val interaction: PlayerInteraction?) + + fun read(event: PacketReceiveEvent): Click? = when (event.packetType) { + PacketType.Play.Client.ATTACK -> Click(WrapperPlayClientAttack(event).entityId, PlayerInteraction.LEFT_CLICK) + PacketType.Play.Client.INTERACT_ENTITY -> { + val wrapper = WrapperPlayClientInteractEntity(event) + Click(wrapper.entityId, interactionOf(wrapper.action, wrapper.hand)) + } + + else -> null + } + + fun interactionOf(action: InteractAction, hand: InteractionHand): PlayerInteraction? = when (action) { + InteractAction.ATTACK -> PlayerInteraction.LEFT_CLICK + InteractAction.INTERACT_AT -> if (hand == InteractionHand.MAIN_HAND) PlayerInteraction.RIGHT_CLICK else null + InteractAction.INTERACT -> null + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/InteractableEntities.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/InteractableEntities.kt new file mode 100644 index 0000000..c1deb68 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/InteractableEntities.kt @@ -0,0 +1,27 @@ +package app.simplecloud.npc.bukkit.interact + +import java.util.concurrent.ConcurrentHashMap + +object InteractableEntities { + + class Target(val npcId: String, val realEntity: Boolean, val hitbox: Boolean) + + private val byEntityId = ConcurrentHashMap<Int, Target>() + + fun register(entityId: Int, npcId: String, realEntity: Boolean, hitbox: Boolean = false) { + byEntityId[entityId] = Target(npcId, realEntity, hitbox) + } + + fun unregister(entityId: Int) { + byEntityId.remove(entityId) + } + + fun find(entityId: Int): Target? = byEntityId[entityId] + + fun bodiesOf(npcId: String): List<Int> = + byEntityId.filterValues { it.npcId == npcId && !it.hitbox }.keys.toList() + + fun isRealEntity(entityId: Int): Boolean = byEntityId[entityId]?.realEntity == true + + fun clear() = byEntityId.clear() +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/NpcInteractPacketListener.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/NpcInteractPacketListener.kt new file mode 100644 index 0000000..11ed03f --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/NpcInteractPacketListener.kt @@ -0,0 +1,39 @@ +package app.simplecloud.npc.bukkit.interact + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import com.github.retrooper.packetevents.event.PacketListenerAbstract +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.plugin.Plugin + +class NpcInteractPacketListener( + private val plugin: Plugin, + private val onInteract: OnNpcInteract, +) : PacketListenerAbstract(), Listener { + + private val dedupe = ClickDedupeTracker<Pair<Int, PlayerInteraction>>() + + override fun onPacketReceive(event: PacketReceiveEvent) { + val click = ClickPackets.read(event) ?: return + val target = InteractableEntities.find(click.entityId) ?: return + if (target.realEntity) event.isCancelled = true + + val interaction = click.interaction ?: return + val player = event.getPlayer<Player>() + if (!dedupe.shouldHandle(player.uniqueId, click.entityId to interaction)) return + + Bukkit.getScheduler().runTask( + plugin, + Runnable { if (player.isOnline) onInteract(target.npcId, player, interaction) }, + ) + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + dedupe.forgetPlayer(event.player.uniqueId) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/OnNpcInteract.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/OnNpcInteract.kt new file mode 100644 index 0000000..7ef8364 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/OnNpcInteract.kt @@ -0,0 +1,6 @@ +package app.simplecloud.npc.bukkit.interact + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import org.bukkit.entity.Player + +typealias OnNpcInteract = (id: String, player: Player, interaction: PlayerInteraction) -> Unit diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ProviderInteractListener.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ProviderInteractListener.kt new file mode 100644 index 0000000..e9e1372 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/ProviderInteractListener.kt @@ -0,0 +1,15 @@ +package app.simplecloud.npc.bukkit.interact + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import org.bukkit.entity.Player +import org.bukkit.event.Listener + +abstract class ProviderInteractListener( + private val configIdForEventKey: (eventKey: String) -> String?, + private val onInteract: OnNpcInteract, +) : Listener { + protected fun dispatch(eventKey: String, player: Player, interaction: PlayerInteraction) { + val id = configIdForEventKey(eventKey) ?: return + onInteract(id, player, interaction) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/RealEntityClickGuard.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/RealEntityClickGuard.kt new file mode 100644 index 0000000..75ba647 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/interact/RealEntityClickGuard.kt @@ -0,0 +1,29 @@ +package app.simplecloud.npc.bukkit.interact + +import org.bukkit.entity.Entity +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.player.PlayerInteractAtEntityEvent +import org.bukkit.event.player.PlayerInteractEntityEvent + +class RealEntityClickGuard : Listener { + + @EventHandler(priority = EventPriority.LOWEST) + fun onDamage(event: EntityDamageEvent) { + if (isOurs(event.entity)) event.isCancelled = true + } + + @EventHandler(priority = EventPriority.LOWEST) + fun onInteract(event: PlayerInteractEntityEvent) { + if (isOurs(event.rightClicked)) event.isCancelled = true + } + + @EventHandler(priority = EventPriority.LOWEST) + fun onInteractAt(event: PlayerInteractAtEntityEvent) { + if (isOurs(event.rightClicked)) event.isCancelled = true + } + + private fun isOurs(entity: Entity): Boolean = InteractableEntities.isRealEntity(entity.entityId) +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/location/Locations.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/location/Locations.kt new file mode 100644 index 0000000..ba7630d --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/location/Locations.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.bukkit.location + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import org.bukkit.Location +import org.bukkit.World +import java.util.logging.Level +import java.util.logging.Logger + +fun NpcLocation.toBukkitLocation(world: World): Location = Location(world, x, y, z, yaw, pitch) + +fun Location.toNpcLocation(): NpcLocation? = world?.name?.let { NpcLocation(it, x, y, z, yaw, pitch) } + +fun requireLoadedWorld( + providerName: String, + config: NpcConfig, + logger: Logger, + worldLookup: (name: String) -> World?, +): World? { + val worldName = config.entity.location.world + return worldLookup(worldName) ?: run { + logger.log(Level.WARNING, "Cannot create $providerName NPC '${config.id}': world '$worldName' isn't loaded.") + null + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAngles.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAngles.kt new file mode 100644 index 0000000..91835f7 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAngles.kt @@ -0,0 +1,24 @@ +package app.simplecloud.npc.bukkit.look + +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.sqrt + +object LookAngles { + + fun anglesTowards(dx: Double, dy: Double, dz: Double): FloatArray { + val yaw = Math.toDegrees(atan2(-dx, dz)).toFloat() + val horizontalDistance = sqrt(dx * dx + dz * dz) + val pitch = Math.toDegrees(-atan2(dy, horizontalDistance)).toFloat() + + return floatArrayOf(yaw, pitch) + } + + fun angleDelta(a: Float, b: Float): Float { + var diff = (a - b) % 360F + if (diff > 180F) diff -= 360F + if (diff < -180F) diff += 360F + + return abs(diff) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerDriver.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerDriver.kt new file mode 100644 index 0000000..53af0f7 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerDriver.kt @@ -0,0 +1,70 @@ +package app.simplecloud.npc.bukkit.look + +import app.simplecloud.npc.core.NpcLog +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.plugin.Plugin +import org.bukkit.scheduler.BukkitTask +import java.util.logging.Level + +class LookAtPlayerDriver( + private val plugin: Plugin, + private val ticker: LookAtPlayerTicker, +) { + + class Target( + val npcId: String, + val entityId: Int, + val x: Double, + val y: Double, + val z: Double, + val viewers: Iterable<Player>, + val maxDistance: Double, + ) + + private var task: BukkitTask? = null + private val quitListener = object : Listener { + @EventHandler + fun onQuit(event: PlayerQuitEvent) = ticker.forgetViewer(event.player.uniqueId) + } + + fun start(targets: () -> Iterable<Target>) { + stop() + Bukkit.getPluginManager().registerEvents(quitListener, plugin) + task = Bukkit.getScheduler().runTaskTimer(plugin, Runnable { tick(targets) }, INITIAL_DELAY, PERIOD) + } + + fun stop() { + task?.cancel() + task = null + HandlerList.unregisterAll(quitListener) + ticker.clear() + } + + private fun tick(targets: () -> Iterable<Target>) { + val current = try { + targets() + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to collect the look-at-player targets", exception) + return + } + + current.forEach { target -> + try { + ticker.tick(target.entityId, target.x, target.y, target.z, target.viewers, target.maxDistance) + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to update look-at-player for NPC ${target.npcId}", exception) + } + } + } + + private companion object { + private const val PERIOD = 2L + private const val INITIAL_DELAY = 20L + private val logger = NpcLog.logger + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerTicker.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerTicker.kt new file mode 100644 index 0000000..4164e58 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/look/LookAtPlayerTicker.kt @@ -0,0 +1,58 @@ +package app.simplecloud.npc.bukkit.look + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityHeadLook +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityRotation +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class LookAtPlayerTicker { + + private val lastAngles = ConcurrentHashMap<Pair<Int, UUID>, FloatArray>() + + fun tick(entityId: Int, x: Double, y: Double, z: Double, viewers: Iterable<Player>, maxDistance: Double) { + val playerManager = PacketEvents.getAPI().playerManager + val maxDistanceSquared = maxDistance * maxDistance + + viewers.forEach { player -> + val location = player.location + val dx = location.x - x + val dy = player.eyeLocation.y - (y + EYE_HEIGHT) + val dz = location.z - z + if (dx * dx + dy * dy + dz * dz > maxDistanceSquared) return@forEach + + val angles = LookAngles.anglesTowards(dx, dy, dz) + val key = entityId to player.uniqueId + val last = lastAngles[key] + val unchanged = last != null && + LookAngles.angleDelta(last[0], angles[0]) < ANGLE_EPSILON && + LookAngles.angleDelta(last[1], angles[1]) < ANGLE_EPSILON + if (unchanged) return@forEach + + lastAngles[key] = angles + + playerManager.sendPacket(player, WrapperPlayServerEntityHeadLook(entityId, angles[0])) + playerManager.sendPacket(player, WrapperPlayServerEntityRotation(entityId, angles[0], angles[1], true)) + } + } + + fun forgetViewer(uuid: UUID) { + lastAngles.keys.removeIf { it.second == uuid } + } + + fun forgetEntity(entityId: Int) { + lastAngles.keys.removeIf { it.first == entityId } + } + + fun forgetPair(entityId: Int, uuid: UUID) { + lastAngles.remove(entityId to uuid) + } + + fun clear() = lastAngles.clear() + + private companion object { + private const val ANGLE_EPSILON = 1.0F + private const val EYE_HEIGHT = 1.62 + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketComponents.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketComponents.kt new file mode 100644 index 0000000..dbfc0ea --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketComponents.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.bukkit.packet + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.Component as PacketComponent +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.format.NamedTextColor as PacketNamedTextColor +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.serializer.gson.GsonComponentSerializer as PacketGsonSerializer + +object PacketComponents { + + fun of(component: Component): PacketComponent = + if (component == Component.empty()) { + PacketComponent.empty() + } else { + PacketGsonSerializer.gson().deserialize(GsonComponentSerializer.gson().serialize(component)) + } + + fun of(color: NamedTextColor): PacketNamedTextColor = + requireNotNull(PacketNamedTextColor.NAMES.value(NamedTextColor.NAMES.keyOrThrow(color))) { + "no packet colour for $color" + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketPoses.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketPoses.kt new file mode 100644 index 0000000..bb3c6ee --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketPoses.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.bukkit.packet + +import app.simplecloud.npc.core.config.NpcPose +import com.github.retrooper.packetevents.protocol.entity.pose.EntityPose + +object PacketPoses { + fun of(pose: NpcPose): EntityPose = when (pose) { + NpcPose.STANDING -> EntityPose.STANDING + NpcPose.SNEAKING -> EntityPose.CROUCHING + NpcPose.SWIMMING -> EntityPose.SWIMMING + NpcPose.SLEEPING -> EntityPose.SLEEPING + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketTextDisplays.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketTextDisplays.kt new file mode 100644 index 0000000..98454bb --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketTextDisplays.kt @@ -0,0 +1,74 @@ +package app.simplecloud.npc.bukkit.packet + +import app.simplecloud.npc.core.hologram.HologramAlignment +import app.simplecloud.npc.core.hologram.HologramLine +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.protocol.entity.data.EntityData +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import com.github.retrooper.packetevents.protocol.entity.type.EntityTypes +import com.github.retrooper.packetevents.util.Vector3d +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBundle +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnEntity +import me.tofaa.entitylib.meta.EntityMeta +import me.tofaa.entitylib.meta.display.AbstractDisplayMeta +import me.tofaa.entitylib.meta.display.TextDisplayMeta +import org.bukkit.entity.Player +import java.util.Optional +import java.util.UUID +import app.simplecloud.npc.relocate.packets.kyori.adventure.text.Component as PacketComponent + +object PacketTextDisplays { + + private val TEXT_INDEX = TextDisplayMeta.OFFSET.toInt() + + fun spawn(entityId: Int, uuid: UUID, x: Double, y: Double, z: Double): PacketWrapper<*> = + WrapperPlayServerSpawnEntity( + entityId, + Optional.of(uuid), + EntityTypes.TEXT_DISPLAY, + Vector3d(x, y, z), + 0f, + 0f, + 0f, + 0, + Optional.empty(), + ) + + fun metadata(entityId: Int, style: HologramLine, text: PacketComponent): PacketWrapper<*> = + WrapperPlayServerEntityMetadata(entityId, entries(entityId, style, text)) + + fun text(entityId: Int, text: PacketComponent): PacketWrapper<*> = + WrapperPlayServerEntityMetadata(entityId, listOf(EntityData(TEXT_INDEX, EntityDataTypes.ADV_COMPONENT, text))) + + fun destroy(entityIds: Collection<Int>): PacketWrapper<*> = + WrapperPlayServerDestroyEntities(*entityIds.toIntArray()) + + fun send(player: Player, packets: List<PacketWrapper<*>>) { + if (packets.isEmpty()) return + val manager = PacketEvents.getAPI().playerManager + if (packets.size == 1) return manager.sendPacket(player, packets.single()) + + manager.sendPacket(player, WrapperPlayServerBundle()) + packets.forEach { manager.sendPacket(player, it) } + manager.sendPacket(player, WrapperPlayServerBundle()) + } + + fun entries(entityId: Int, style: HologramLine, text: PacketComponent): List<EntityData<*>> { + val meta = EntityMeta.createMeta(entityId, EntityTypes.TEXT_DISPLAY) as TextDisplayMeta + meta.setText(text) + meta.billboardConstraints = AbstractDisplayMeta.BillboardConstraints.valueOf(style.billboard.name) + meta.isAlignLeft = style.alignment == HologramAlignment.LEFT + meta.isAlignRight = style.alignment == HologramAlignment.RIGHT + style.shadow?.let { meta.isShadow = it } + style.lineWidth?.let { meta.lineWidth = it } + style.viewRange?.let { meta.viewRange = it } + style.shadowRadius?.let { meta.shadowRadius = it } + style.displayHeight?.let { meta.height = it } + style.displayWidth?.let { meta.width = it } + + return meta.createPacket().entityMetadata + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketViewerTracker.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketViewerTracker.kt new file mode 100644 index 0000000..3a7971a --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/packet/PacketViewerTracker.kt @@ -0,0 +1,161 @@ +package app.simplecloud.npc.bukkit.packet + +import app.simplecloud.npc.core.NpcLog +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerChangedWorldEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.event.player.PlayerRespawnEvent +import org.bukkit.event.player.PlayerTeleportEvent +import org.bukkit.plugin.Plugin +import org.bukkit.scheduler.BukkitTask +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +class PacketViewerTracker(private val plugin: Plugin) : Listener { + + abstract class Entry( + val label: String, + @Volatile var world: String, + @Volatile var x: Double, + @Volatile var y: Double, + @Volatile var z: Double, + @Volatile var range: Double, + ) { + val viewers: MutableSet<UUID> = ConcurrentHashMap.newKeySet() + + fun moveTo(world: String, x: Double, y: Double, z: Double) { + this.world = world + this.x = x + this.y = y + this.z = z + } + + abstract fun show(player: Player) + + abstract fun hide(player: Player) + + open fun forget(uuid: UUID) = Unit + } + + private val entries = ConcurrentHashMap.newKeySet<Entry>() + private val quitHooks = mutableListOf<(UUID) -> Unit>() + private var task: BukkitTask? = null + + fun start() { + Bukkit.getPluginManager().registerEvents(this, plugin) + task = Bukkit.getScheduler().runTaskTimer(plugin, Runnable(::tick), 20L, TICK_PERIOD) + } + + fun stop() { + task?.cancel() + task = null + HandlerList.unregisterAll(this) + entries.clear() + } + + fun onQuit(hook: (UUID) -> Unit) { + quitHooks += hook + } + + fun add(entry: Entry) { + entries += entry + Bukkit.getOnlinePlayers().forEach { if (inRange(entry, it)) show(entry, it) } + } + + fun remove(entry: Entry): List<Player> { + entries -= entry + val viewers = entry.viewers.mapNotNull(Bukkit::getPlayer) + entry.viewers.clear() + + return viewers + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + val uuid = event.player.uniqueId + forget(uuid) + quitHooks.forEach { it(uuid) } + } + + @EventHandler + fun onRespawn(event: PlayerRespawnEvent) = forget(event.player.uniqueId) + + @EventHandler + fun onWorldChange(event: PlayerChangedWorldEvent) = forget(event.player.uniqueId) + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun onTeleport(event: PlayerTeleportEvent) { + if (event.to.world != event.from.world) return + val player = event.player + Bukkit.getScheduler().runTask(plugin, Runnable { if (player.isOnline) update(player) }) + } + + private fun forget(uuid: UUID) { + entries.forEach { entry -> + if (entry.viewers.remove(uuid)) entry.forget(uuid) + } + } + + private fun tick() { + val players = Bukkit.getOnlinePlayers().toList() + + entries.forEach { entry -> + try { + players.forEach { update(entry, it) } + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to update visibility for ${entry.label}", exception) + } + } + } + + private fun update(player: Player) { + entries.forEach { entry -> + try { + update(entry, player) + } catch (exception: Exception) { + logger.log(Level.WARNING, "Failed to update visibility for ${entry.label}", exception) + } + } + } + + private fun update(entry: Entry, player: Player) { + val withinRange = inRange(entry, player) + val visible = player.uniqueId in entry.viewers + + when { + withinRange && !visible -> show(entry, player) + !withinRange && visible -> hide(entry, player) + } + } + + private fun show(entry: Entry, player: Player) { + if (!entry.viewers.add(player.uniqueId)) return + entry.show(player) + } + + private fun hide(entry: Entry, player: Player) { + if (!entry.viewers.remove(player.uniqueId)) return + entry.hide(player) + } + + private fun inRange(entry: Entry, player: Player): Boolean { + if (!player.world.name.equals(entry.world, ignoreCase = true)) return false + val location = player.location + val dx = entry.x - location.x + val dy = entry.y - location.y + val dz = entry.z - location.z + + return dx * dx + dy * dy + dz * dz <= entry.range * entry.range + } + + private companion object { + const val TICK_PERIOD = 10L + val logger = NpcLog.logger + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRenderer.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRenderer.kt new file mode 100644 index 0000000..e4ac5de --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRenderer.kt @@ -0,0 +1,204 @@ +package app.simplecloud.npc.bukkit.provider + +import app.simplecloud.npc.bukkit.location.requireLoadedWorld +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.render.NpcRenderer +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger + +abstract class LinkedProviderRenderer<T : Any>( + protected val plugin: Plugin, + private val providerLabel: String, + private val worldLookup: (name: String) -> World? = Bukkit::getWorld, + private val sync: (block: () -> Unit) -> Unit = { block -> plugin.sync(block) }, +) : NpcRenderer { + + override val supportsLinking: Boolean = true + + private val trackedByConfigId = ConcurrentHashMap<String, T>() + private val configIdByEventKey = ConcurrentHashMap<String, String>() + private val linkedConfigIds = ConcurrentHashMap.newKeySet<String>() + private val listeners = mutableListOf<Listener>() + + protected val logger: Logger = NpcLog.logger + + protected abstract fun eventKeyOf(tracked: T): String + protected abstract fun findByReference(reference: String): T? + protected abstract fun createListener(): Listener + + protected abstract fun linkCandidates(): List<Pair<String, T>> + + protected open fun create(config: NpcConfig, world: World): T? = null + protected open fun destroy(tracked: T) = Unit + protected open fun deleteLegacy(legacy: T) = Unit + + protected open fun createReadinessListener(): Listener? = null + protected open fun refreshOwned(config: NpcConfig, tracked: T) = Unit + protected open fun teleportOwned(config: NpcConfig, tracked: T, world: World) = Unit + + protected abstract fun locationOf(tracked: T): NpcLocation? + + protected open fun snapshotOf(found: T, config: NpcConfig): ProviderNpcSnapshot? = + ProviderNpcSnapshot(locationOf(found)) + + protected open fun onEnabled() = Unit + protected open fun onDisabling() = Unit + protected open fun onDisabled() = Unit + + fun configIdForEventKey(key: String): String? = configIdByEventKey[key] + + protected fun tracked(configId: String): T? = trackedByConfigId[configId] + protected fun isLinked(configId: String): Boolean = configId in linkedConfigIds + protected fun worldOf(config: NpcConfig): World? = worldLookup(config.entity.location.world) + + final override fun linkableReferences(): List<String> { + val owned = ownedEventKeys() + return linkCandidates() + .filter { (_, candidate) -> eventKeyOf(candidate) !in owned } + .map { it.first } + .distinct() + } + + private fun ownedEventKeys(): Set<String> = + trackedByConfigId.filterKeys { it !in linkedConfigIds }.values.mapTo(hashSetOf(), ::eventKeyOf) + + final override fun onEnable() { + listeners += listOfNotNull(createListener(), createReadinessListener()) + listeners.forEach { Bukkit.getPluginManager().registerEvents(it, plugin) } + onEnabled() + } + + final override fun onDisable() { + listeners.forEach(HandlerList::unregisterAll) + listeners.clear() + onDisabling() + + trackedByConfigId.forEach { (configId, tracked) -> + if (configId !in linkedConfigIds) runCatching { destroy(tracked) } + } + trackedByConfigId.clear() + configIdByEventKey.clear() + linkedConfigIds.clear() + onDisabled() + } + + final override fun spawn(config: NpcConfig): NpcConfig { + sync { spawnNow(config) } + return config + } + + private fun spawnNow(config: NpcConfig) { + untrack(config.id) + if (config.entity.providerLinked || !supportsCreation) { + attach(config) + return + } + + val world = requireLoadedWorld(providerLabel, config, logger, worldLookup) ?: return + val created = create(config, world) ?: return + track(config.id, created) + } + + final override fun despawn(config: NpcConfig) = sync { despawnNow(config) } + + private fun despawnNow(config: NpcConfig) { + if (trackedByConfigId.containsKey(config.id)) { + untrack(config.id) + return + } + if (config.entity.providerLinked || !supportsCreation) return + + val reference = config.entity.providerReference ?: return + val legacy = findByReference(reference) ?: return + + logger.info("Deleting legacy persisted $providerLabel NPC '$reference' (NPC '${config.id}').") + deleteLegacy(legacy) + } + + final override fun refresh(config: NpcConfig) { + if (isLinked(config.id)) return + val tracked = tracked(config.id) ?: return + sync { refreshOwned(config, tracked) } + } + + final override fun teleport(config: NpcConfig) { + if (isLinked(config.id)) return + val tracked = tracked(config.id) ?: return + sync { + val world = worldOf(config) ?: return@sync + teleportOwned(config, tracked, world) + } + } + + final override fun locationOf(config: NpcConfig): NpcLocation? = tracked(config.id)?.let(::locationOf) + + final override fun snapshotOf(config: NpcConfig): ProviderNpcSnapshot? { + var result: ProviderNpcSnapshot? = null + sync { + val reference = config.entity.providerReference ?: return@sync + val found = findByReference(reference) ?: return@sync + result = snapshotOf(found, config) + } + + return result + } + + final override fun canonicalReference(config: NpcConfig): String? = + if (isLinked(config.id)) tracked(config.id)?.let(::referenceOf) else null + + protected open fun referenceOf(tracked: T): String? = null + + private fun attach(config: NpcConfig) { + val reference = config.entity.providerReference ?: run { + logger.warning( + if (supportsCreation) { + "NPC '${config.id}' is marked as linked but has no $providerLabel reference; " + + "the link stays inactive." + } else { + "NPC '${config.id}' uses provider $providerLabel, which can only link existing NPCs, " + + "but has no reference; skipping." + }, + ) + return + } + + val existing = findByReference(reference) ?: run { + logger.warning( + "NPC '${config.id}' is linked to $providerLabel NPC '$reference', which doesn't exist or " + + "isn't loaded; the link stays inactive.", + ) + return + } + + if (eventKeyOf(existing) in ownedEventKeys()) { + logger.warning( + "NPC '${config.id}' is linked to $providerLabel NPC '$reference', which is one this plugin " + + "created for another config; the link stays inactive.", + ) + return + } + + track(config.id, existing) + linkedConfigIds += config.id + } + + private fun track(configId: String, tracked: T) { + trackedByConfigId[configId] = tracked + configIdByEventKey[eventKeyOf(tracked)] = configId + } + + protected fun untrack(configId: String) { + val tracked = trackedByConfigId.remove(configId) ?: return + configIdByEventKey.remove(eventKeyOf(tracked)) + if (!linkedConfigIds.remove(configId)) destroy(tracked) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/WorldReconciler.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/WorldReconciler.kt new file mode 100644 index 0000000..10dafd3 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/provider/WorldReconciler.kt @@ -0,0 +1,6 @@ +package app.simplecloud.npc.bukkit.provider + +interface WorldReconciler { + var knownIdsProvider: (() -> Set<String>)? + fun reconcileLoadedWorlds() +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/scheduling/Scheduling.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/scheduling/Scheduling.kt new file mode 100644 index 0000000..d6ce8c5 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/scheduling/Scheduling.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.bukkit.scheduling + +import org.bukkit.Bukkit +import org.bukkit.plugin.Plugin +import java.util.concurrent.Callable +import java.util.concurrent.CancellationException + +fun <T> Plugin.sync(function: () -> T): T { + if (Bukkit.isPrimaryThread()) return function() + if (!isEnabled) throw CancellationException("Plugin $name is disabled") + + return Bukkit.getScheduler().callSyncMethod(this, Callable(function)).get() +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/FixedSkinRenderer.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/FixedSkinRenderer.kt new file mode 100644 index 0000000..69cc500 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/FixedSkinRenderer.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.bukkit.skin + +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import app.simplecloud.npc.core.render.NpcRenderer +import java.util.UUID + +interface FixedSkinRenderer : NpcRenderer { + override val supportsFixedSkin: Boolean get() = true + + override fun captureSkin(player: NpcPlayer): NpcConfig.SkinConfiguration = PlayerSkins.capture(player) + + override suspend fun captureSkinByUsername(username: String): NpcConfig.SkinConfiguration? = + PlayerSkins.byUsername(username) + + override suspend fun captureSkinByUuid(uuid: UUID): NpcConfig.SkinConfiguration? = PlayerSkins.byUuid(uuid) +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/HeadTextures.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/HeadTextures.kt new file mode 100644 index 0000000..5615d31 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/HeadTextures.kt @@ -0,0 +1,16 @@ +package app.simplecloud.npc.bukkit.skin + +import com.destroystokyo.paper.profile.ProfileProperty +import org.bukkit.Bukkit +import org.bukkit.inventory.meta.ItemMeta +import org.bukkit.inventory.meta.SkullMeta +import java.util.UUID + +object HeadTextures { + fun apply(meta: ItemMeta, texture: String, signature: String? = null) { + if (meta !is SkullMeta) return + meta.playerProfile = Bukkit.createProfile(UUID.nameUUIDFromBytes("npc-head:$texture".toByteArray())).apply { + setProperty(ProfileProperty("textures", texture, signature)) + } + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/NpcProfiles.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/NpcProfiles.kt new file mode 100644 index 0000000..77b636e --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/NpcProfiles.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.bukkit.skin + +import app.simplecloud.npc.core.config.NpcConfig +import java.util.UUID + +object NpcProfiles { + + fun uuidFor(config: NpcConfig): UUID = + UUID.nameUUIDFromBytes("npc:${config.id}:${config.entity.skin.texture.orEmpty()}".toByteArray()) + + fun nameFor(id: String): String { + val normalized = id.lowercase() + val suffix = Integer.toHexString(normalized.hashCode()).padStart(8, '0').takeLast(4) + + return "npc_${normalized.take(7)}_$suffix".take(16) + } +} diff --git a/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/PlayerSkins.kt b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/PlayerSkins.kt new file mode 100644 index 0000000..43c7d13 --- /dev/null +++ b/provider/base/src/main/kotlin/app/simplecloud/npc/bukkit/skin/PlayerSkins.kt @@ -0,0 +1,37 @@ +package app.simplecloud.npc.bukkit.skin + +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.platform.NpcPlayer +import com.destroystokyo.paper.profile.PlayerProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bukkit.Bukkit +import java.util.UUID +import java.util.logging.Level + +object PlayerSkins { + + fun capture(player: NpcPlayer): NpcConfig.SkinConfiguration = + Bukkit.getPlayer(player.uniqueId)?.let { skinOf(it.playerProfile, player.name) } + ?: NpcConfig.SkinConfiguration(sourcePlayer = player.name) + + suspend fun byUsername(username: String): NpcConfig.SkinConfiguration? = + lookUp(Bukkit.createProfile(username), username) + + suspend fun byUuid(uuid: UUID): NpcConfig.SkinConfiguration? = lookUp(Bukkit.createProfile(uuid), null) + + private suspend fun lookUp(profile: PlayerProfile, requestedName: String?): NpcConfig.SkinConfiguration? = + withContext(Dispatchers.IO) { + runCatching { if (profile.complete(true, true)) skinOf(profile, requestedName) else null } + .onFailure { + NpcLog.logger.log(Level.WARNING, "Skin lookup failed for ${requestedName ?: profile}", it) + } + .getOrNull() + } + + private fun skinOf(profile: PlayerProfile, requestedName: String?): NpcConfig.SkinConfiguration? = + profile.properties.firstOrNull { it.name == "textures" }?.let { + NpcConfig.SkinConfiguration(it.value, it.signature, requestedName ?: profile.name) + } +} diff --git a/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/TestProxies.kt b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/TestProxies.kt new file mode 100644 index 0000000..90548c8 --- /dev/null +++ b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/TestProxies.kt @@ -0,0 +1,8 @@ +package app.simplecloud.npc.bukkit + +import java.lang.reflect.Proxy + +inline fun <reified T> proxy(): T = Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java), +) { _, method, _ -> error("Unexpected call to ${T::class.simpleName}.${method.name}") } as T diff --git a/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometryTest.kt b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometryTest.kt new file mode 100644 index 0000000..d62e217 --- /dev/null +++ b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/hologram/HologramGeometryTest.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.bukkit.hologram + +import app.simplecloud.npc.core.config.NpcConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +class HologramGeometryTest { + + private val config = NpcConfig(id = "lobby").apply { hologram.startHeight = 2.0 } + + @Test + fun `first line is on top and the last sits at the start height`() { + val heights = HologramGeometry.lineHeights(config, 3) + + assertEquals(listOf(2.6, 2.3, 2.0), heights.map { Math.round(it * 100) / 100.0 }) + } + + @Test + fun `scale moves the whole hologram`() { + val scaled = config.copy(entity = config.entity.copy(scale = 2.0)) + + assertEquals(4.0, HologramGeometry.baseHeight(scaled), 1e-9) + assertEquals(4.6, HologramGeometry.topHeight(scaled, 2), 1e-9) + } +} diff --git a/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/interact/ClickPacketsTest.kt b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/interact/ClickPacketsTest.kt new file mode 100644 index 0000000..e64d5a3 --- /dev/null +++ b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/interact/ClickPacketsTest.kt @@ -0,0 +1,30 @@ +package app.simplecloud.npc.bukkit.interact + +import app.simplecloud.npc.core.interaction.PlayerInteraction +import com.github.retrooper.packetevents.protocol.player.InteractionHand +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity.InteractAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ClickPacketsTest { + + @Test + fun `attack action is a left click`() { + val interaction = ClickPackets.interactionOf(InteractAction.ATTACK, InteractionHand.MAIN_HAND) + assertEquals(PlayerInteraction.LEFT_CLICK, interaction) + } + + @Test + fun `main-hand interact-at is the one right click`() { + val interaction = ClickPackets.interactionOf(InteractAction.INTERACT_AT, InteractionHand.MAIN_HAND) + assertEquals(PlayerInteraction.RIGHT_CLICK, interaction) + } + + @Test + fun `off-hand and plain interact packets of the same right click are not dispatched`() { + assertNull(ClickPackets.interactionOf(InteractAction.INTERACT_AT, InteractionHand.OFF_HAND)) + assertNull(ClickPackets.interactionOf(InteractAction.INTERACT, InteractionHand.MAIN_HAND)) + assertNull(ClickPackets.interactionOf(InteractAction.INTERACT, InteractionHand.OFF_HAND)) + } +} diff --git a/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRendererTest.kt b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRendererTest.kt new file mode 100644 index 0000000..bbbb7c4 --- /dev/null +++ b/provider/base/src/test/kotlin/app/simplecloud/npc/bukkit/provider/LinkedProviderRendererTest.kt @@ -0,0 +1,129 @@ +package app.simplecloud.npc.bukkit.provider + +import app.simplecloud.npc.bukkit.proxy +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import org.bukkit.World +import org.bukkit.event.Listener +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LinkedProviderRendererTest { + + private class FakeNpc(val key: String) + + private class FakeRenderer( + override val supportsCreation: Boolean = true, + ) : LinkedProviderRenderer<FakeNpc>( + plugin = proxy(), + providerLabel = "Fake", + worldLookup = { name -> if (name == "world") proxy<World>() else null }, + sync = { block -> block() }, + ) { + val registry = mutableMapOf<String, FakeNpc>() + val created = mutableListOf<String>() + val destroyed = mutableListOf<String>() + val deletedLegacy = mutableListOf<String>() + val refreshed = mutableListOf<String>() + val teleported = mutableListOf<String>() + + override fun eventKeyOf(tracked: FakeNpc): String = "event:${tracked.key}" + override fun findByReference(reference: String): FakeNpc? = registry[reference] + val createdNpcs = mutableMapOf<String, FakeNpc>() + + override fun create(config: NpcConfig, world: World): FakeNpc = + FakeNpc("created:${config.id}").also { + created += config.id + createdNpcs[config.id] = it + } + + override fun destroy(tracked: FakeNpc) { + destroyed += tracked.key + } + + override fun deleteLegacy(legacy: FakeNpc) { + deletedLegacy += legacy.key + } + + override fun linkCandidates(): List<Pair<String, FakeNpc>> = registry.toList() + + override fun createListener(): Listener = object : Listener {} + override fun refreshOwned(config: NpcConfig, tracked: FakeNpc) { + refreshed += tracked.key + } + + override fun teleportOwned(config: NpcConfig, tracked: FakeNpc, world: World) { + teleported += tracked.key + } + + override fun locationOf(tracked: FakeNpc): NpcLocation = NpcLocation("world", 1.0, 2.0, 3.0) + + fun isTracked(configId: String): Boolean = tracked(configId) != null + fun linked(configId: String): Boolean = isLinked(configId) + } + + private fun config(id: String, reference: String? = null, linked: Boolean = false) = + NpcConfig( + id = id, + entity = NpcConfig.NpcEntityConfiguration( + location = NpcLocation("world"), + providerReference = reference, + providerLinked = linked, + ), + ) + + @Test + fun `an owned npc is created on spawn and destroyed on despawn`() { + val renderer = FakeRenderer() + + renderer.spawn(config("shop")) + + assertEquals(listOf("shop"), renderer.created) + assertTrue(renderer.isTracked("shop")) + assertFalse(renderer.linked("shop")) + assertEquals("shop", renderer.configIdForEventKey("event:created:shop")) + + renderer.despawn(config("shop")) + + assertEquals(listOf("created:shop"), renderer.destroyed) + assertFalse(renderer.isTracked("shop")) + assertNull(renderer.configIdForEventKey("event:created:shop")) + } + + @Test + fun `a linked npc attaches to the provider's own npc and is never destroyed`() { + val renderer = FakeRenderer() + renderer.registry["42"] = FakeNpc("theirs") + + renderer.spawn(config("shop", reference = "42", linked = true)) + + assertTrue(renderer.created.isEmpty()) + assertTrue(renderer.linked("shop")) + assertEquals("shop", renderer.configIdForEventKey("event:theirs")) + + renderer.despawn(config("shop", reference = "42", linked = true)) + + assertTrue(renderer.destroyed.isEmpty()) + assertTrue(renderer.deletedLegacy.isEmpty()) + assertFalse(renderer.isTracked("shop")) + } + + @Test + fun `an npc we own for one config cannot be linked by another`() { + val renderer = FakeRenderer() + renderer.spawn(config("owner")) + val owned = renderer.createdNpcs.getValue("owner") + renderer.registry["owners-npc"] = owned + renderer.registry["42"] = FakeNpc("theirs") + + assertEquals(listOf("42"), renderer.linkableReferences()) + + renderer.spawn(config("thief", reference = "owners-npc", linked = true)) + + assertFalse(renderer.isTracked("thief")) + assertEquals("owner", renderer.configIdForEventKey("event:created:owner")) + } +} diff --git a/provider/citizens/build.gradle.kts b/provider/citizens/build.gradle.kts new file mode 100644 index 0000000..4df3369 --- /dev/null +++ b/provider/citizens/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + implementation(project(":provider:base")) + compileOnly(libs.paper.api) + compileOnly(libs.citizens.main) { + exclude(group = "net.byteflux") + } +} diff --git a/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensInteractListener.kt b/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensInteractListener.kt new file mode 100644 index 0000000..af900ad --- /dev/null +++ b/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensInteractListener.kt @@ -0,0 +1,22 @@ +package app.simplecloud.npc.provider.citizens + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.interact.ProviderInteractListener +import app.simplecloud.npc.core.interaction.PlayerInteraction +import net.citizensnpcs.api.event.NPCLeftClickEvent +import net.citizensnpcs.api.event.NPCRightClickEvent +import org.bukkit.event.EventHandler + +class CitizensInteractListener( + configIdForEventKey: (npcUuid: String) -> String?, + onInteract: OnNpcInteract, +) : ProviderInteractListener(configIdForEventKey, onInteract) { + + @EventHandler + fun handleLeftClick(event: NPCLeftClickEvent) = + dispatch(event.npc.uniqueId.toString(), event.clicker, PlayerInteraction.LEFT_CLICK) + + @EventHandler + fun handleRightClick(event: NPCRightClickEvent) = + dispatch(event.npc.uniqueId.toString(), event.clicker, PlayerInteraction.RIGHT_CLICK) +} diff --git a/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensRenderer.kt b/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensRenderer.kt new file mode 100644 index 0000000..8e1ad1d --- /dev/null +++ b/provider/citizens/src/main/kotlin/app/simplecloud/npc/provider/citizens/CitizensRenderer.kt @@ -0,0 +1,152 @@ +package app.simplecloud.npc.provider.citizens + +import app.simplecloud.npc.bukkit.glow.GlowTeams +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.location.toBukkitLocation +import app.simplecloud.npc.bukkit.location.toNpcLocation +import app.simplecloud.npc.bukkit.provider.LinkedProviderRenderer +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import net.citizensnpcs.api.CitizensAPI +import net.citizensnpcs.api.event.CitizensEnableEvent +import net.citizensnpcs.api.npc.NPC +import net.citizensnpcs.trait.LookClose +import net.citizensnpcs.trait.MirrorTrait +import net.citizensnpcs.trait.SkinTrait +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.entity.EntityType +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause +import org.bukkit.plugin.Plugin + +class CitizensRenderer( + plugin: Plugin, + private val onInteract: OnNpcInteract, +) : LinkedProviderRenderer<NPC>(plugin, "Citizens") { + + override val supportsFixedSkin: Boolean = true + + private val registry by lazy { CitizensAPI.createInMemoryNPCRegistry(REGISTRY_NAME) } + + @Volatile + private var citizensEnabled = false + + override fun isReady(): Boolean = citizensEnabled + + override fun onEnabled() { + Bukkit.getScheduler().runTaskLater(plugin, Runnable { citizensEnabled = true }, READY_FALLBACK_TICKS) + } + + override fun createListener(): Listener = CitizensInteractListener(::configIdForEventKey, onInteract) + + override fun createReadinessListener(): Listener = object : Listener { + @EventHandler + fun onCitizensEnabled(event: CitizensEnableEvent) { + citizensEnabled = true + } + } + + override fun onDisabled() { + runCatching { CitizensAPI.removeNamedNPCRegistry(REGISTRY_NAME) } + } + + override fun eventKeyOf(tracked: NPC): String = tracked.uniqueId.toString() + + override fun findByReference(reference: String): NPC? = + reference.toIntOrNull()?.let(CitizensAPI.getNPCRegistry()::getById) + + override fun create(config: NpcConfig, world: World): NPC { + val npc = registry.createNPC(EntityType.PLAYER, config.entity.customName ?: config.id) + applyAppearance(npc, config) + npc.spawn(config.entity.location.toBukkitLocation(world)) + syncGlowColor(npc, config) + + return npc + } + + override fun destroy(tracked: NPC) { + tracked.entity?.let { GlowTeams.clear(it.uniqueId) } + runCatching { tracked.destroy() } + } + + override fun deleteLegacy(legacy: NPC) { + legacy.destroy() + CitizensAPI.getNPCRegistry().saveToStore() + } + + override fun refreshOwned(config: NpcConfig, tracked: NPC) { + val skinChanged = config.entity.skin.texture != null && + tracked.getOrAddTrait(SkinTrait::class.java).texture != config.entity.skin.texture + applyAppearance(tracked, config) + + if (skinChanged && tracked.isSpawned) { + val world = worldOf(config) ?: return + tracked.despawn() + tracked.spawn(config.entity.location.toBukkitLocation(world)) + } + + syncGlowColor(tracked, config) + } + + override fun teleportOwned(config: NpcConfig, tracked: NPC, world: World) { + tracked.teleport(config.entity.location.toBukkitLocation(world), TeleportCause.PLUGIN) + } + + override fun locationOf(tracked: NPC): NpcLocation? = tracked.storedLocation?.toNpcLocation() + + override fun linkCandidates(): List<Pair<String, NPC>> = CitizensAPI.getNPCRegistry().map { it.id.toString() to it } + + override fun snapshotOf(found: NPC, config: NpcConfig): ProviderNpcSnapshot { + val skin = found.getTraitNullable(SkinTrait::class.java)?.let(::skinOf) + val nameplateVisible = found.data().get(NPC.Metadata.NAMEPLATE_VISIBLE, true) + val customName = found.rawName.takeIf { nameplateVisible && it.isNotBlank() && it != config.id } + + return ProviderNpcSnapshot(locationOf(found), skin, customName) + } + + private fun skinOf(trait: SkinTrait): NpcConfig.SkinConfiguration? { + val texture = trait.texture + val signature = trait.signature + if (texture.isNullOrBlank() || signature.isNullOrBlank()) return null + + return NpcConfig.SkinConfiguration(texture, signature, trait.skinName?.takeIf(String::isNotBlank)) + } + + private fun syncGlowColor(npc: NPC, config: NpcConfig) { + npc.entity?.let { GlowTeams.set(it, config.entity.glowColor) } + } + + private fun applyAppearance(npc: NPC, config: NpcConfig) { + npc.name = config.entity.customName ?: config.id + npc.data().setPersistent(NPC.Metadata.NAMEPLATE_VISIBLE, config.entity.customName != null) + npc.data().setPersistent(NPC.Metadata.GLOWING, config.entity.glowing) + applySkin(npc, config.entity.skin) + npc.removeTrait(LookClose::class.java) + npc.getOrAddTrait(LookClose::class.java).apply { + lookClose(config.entity.lookAtPlayer) + setPerPlayer(true) + setRange(config.entity.lookAtPlayerDistance) + } + } + + private fun applySkin(npc: NPC, skin: NpcConfig.SkinConfiguration) { + val texture = skin.texture + val signature = skin.signature + if (texture == null || signature == null) { + npc.removeTrait(SkinTrait::class.java) + npc.getOrAddTrait(MirrorTrait::class.java).isEnabled = true + return + } + + npc.removeTrait(MirrorTrait::class.java) + npc.getOrAddTrait(SkinTrait::class.java).setSkinPersistent(skin.sourcePlayer ?: npc.name, signature, texture) + } + + private companion object { + private const val REGISTRY_NAME = "simplecloud-npc" + private const val READY_FALLBACK_TICKS = 2L + } +} diff --git a/provider/fancynpcs/build.gradle.kts b/provider/fancynpcs/build.gradle.kts new file mode 100644 index 0000000..2b1ab84 --- /dev/null +++ b/provider/fancynpcs/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + implementation(project(":provider:base")) + compileOnly(libs.paper.api) + compileOnly(libs.fancynpcs) +} diff --git a/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyGlowColor.kt b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyGlowColor.kt new file mode 100644 index 0000000..420919d --- /dev/null +++ b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyGlowColor.kt @@ -0,0 +1,13 @@ +package app.simplecloud.npc.provider.fancynpcs + +import app.simplecloud.npc.core.config.GlowColors +import de.oliver.fancynpcs.api.NpcData +import net.kyori.adventure.text.format.NamedTextColor + +internal object FancyGlowColor { + + fun apply(data: NpcData, colorName: String?) { + val name = colorName?.lowercase()?.takeIf(GlowColors.NAMES::contains) ?: "white" + NamedTextColor.NAMES.value(name)?.let(data::setGlowingColor) + } +} diff --git a/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsInteractListener.kt b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsInteractListener.kt new file mode 100644 index 0000000..b826231 --- /dev/null +++ b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsInteractListener.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.provider.fancynpcs + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.interact.ProviderInteractListener +import app.simplecloud.npc.core.interaction.PlayerInteraction +import de.oliver.fancynpcs.api.actions.ActionTrigger +import de.oliver.fancynpcs.api.events.NpcInteractEvent +import org.bukkit.event.EventHandler + +class FancyNpcsInteractListener( + configIdForEventKey: (npcDataId: String) -> String?, + onInteract: OnNpcInteract, +) : ProviderInteractListener(configIdForEventKey, onInteract) { + + @EventHandler + fun handleInteract(event: NpcInteractEvent) { + val interaction = when (event.interactionType) { + ActionTrigger.LEFT_CLICK -> PlayerInteraction.LEFT_CLICK + ActionTrigger.RIGHT_CLICK -> PlayerInteraction.RIGHT_CLICK + else -> return + } + + dispatch(event.npc.data.id, event.player, interaction) + } +} diff --git a/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsRenderer.kt b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsRenderer.kt new file mode 100644 index 0000000..12d2aee --- /dev/null +++ b/provider/fancynpcs/src/main/kotlin/app/simplecloud/npc/provider/fancynpcs/FancyNpcsRenderer.kt @@ -0,0 +1,121 @@ +package app.simplecloud.npc.provider.fancynpcs + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.location.toBukkitLocation +import app.simplecloud.npc.bukkit.location.toNpcLocation +import app.simplecloud.npc.bukkit.provider.LinkedProviderRenderer +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.core.render.ProviderNpcSnapshot +import de.oliver.fancynpcs.api.FancyNpcsPlugin +import de.oliver.fancynpcs.api.Npc +import de.oliver.fancynpcs.api.NpcData +import de.oliver.fancynpcs.api.events.NpcCreateEvent +import de.oliver.fancynpcs.api.events.NpcsLoadedEvent +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin +import java.util.UUID + +class FancyNpcsRenderer( + plugin: Plugin, + private val onInteract: OnNpcInteract, +) : LinkedProviderRenderer<Npc>(plugin, "FancyNpcs") { + + private val fancyNpcs get() = FancyNpcsPlugin.get() + + @Volatile + private var npcsLoaded = false + + override fun isReady(): Boolean = npcsLoaded || runCatching { fancyNpcs.npcManager.isLoaded }.getOrDefault(false) + + override fun createListener(): Listener = FancyNpcsInteractListener(::configIdForEventKey, onInteract) + + override fun createReadinessListener(): Listener = object : Listener { + @EventHandler + fun onNpcsLoaded(event: NpcsLoadedEvent) { + npcsLoaded = true + } + } + + override fun eventKeyOf(tracked: Npc): String = tracked.data.id + + override fun findByReference(reference: String): Npc? = + fancyNpcs.npcManager.getNpcById(reference) ?: fancyNpcs.npcManager.getNpc(reference) + + override fun create(config: NpcConfig, world: World): Npc? { + val npcData = NpcData(config.id, ownerUuid(config.id), config.entity.location.toBukkitLocation(world)).apply { + applyAppearance(config) + setMirrorSkin(true) + } + + val npc = fancyNpcs.npcAdapter.apply(npcData) + npc.setSaveToFile(false) + + val createEvent = NpcCreateEvent(npc, Bukkit.getConsoleSender()).also(Bukkit.getPluginManager()::callEvent) + if (createEvent.isCancelled) { + logger.warning("FancyNpcs NPC creation for '${config.id}' was cancelled by another plugin.") + return null + } + + npc.create() + fancyNpcs.npcManager.registerNpc(npc) + npc.spawnForAll() + + return npc + } + + override fun destroy(tracked: Npc) { + runCatching { + tracked.removeForAll() + fancyNpcs.npcManager.removeNpc(tracked) + } + } + + override fun deleteLegacy(legacy: Npc) { + legacy.removeForAll() + fancyNpcs.npcManager.removeNpc(legacy) + fancyNpcs.npcManager.saveNpcs(true) + } + + override fun linkCandidates(): List<Pair<String, Npc>> = + fancyNpcs.npcManager.allNpcs.flatMap { listOf(it.data.name to it, it.data.id to it) } + + override fun refreshOwned(config: NpcConfig, tracked: Npc) { + tracked.data.applyAppearance(config) + tracked.updateForAll() + } + + override fun teleportOwned(config: NpcConfig, tracked: Npc, world: World) { + tracked.data.setLocation(config.entity.location.toBukkitLocation(world)) + tracked.moveForAll() + } + + override fun locationOf(tracked: Npc): NpcLocation? = tracked.data.location?.toNpcLocation() + + override fun snapshotOf(found: Npc, config: NpcConfig): ProviderNpcSnapshot { + val data = found.data + val skin = data.skinData?.takeIf { !data.isMirrorSkin && it.hasTexture() }?.let { + NpcConfig.SkinConfiguration(it.textureValue, it.textureSignature, it.identifier?.takeIf(String::isNotBlank)) + } + val customName = data.displayName?.takeIf { it.isNotBlank() && it != EMPTY_NAME && it != config.id } + + return ProviderNpcSnapshot(locationOf(found), skin, customName) + } + + private fun NpcData.applyAppearance(config: NpcConfig) { + setDisplayName(config.entity.customName ?: EMPTY_NAME) + setTurnToPlayer(config.entity.lookAtPlayer) + setTurnToPlayerDistance(config.entity.lookAtPlayerDistance.toInt()) + setGlowing(config.entity.glowing) + FancyGlowColor.apply(this, config.entity.glowColor) + } + + private fun ownerUuid(id: String): UUID = UUID.nameUUIDFromBytes("simplecloud-npc:$id".toByteArray()) + + private companion object { + private const val EMPTY_NAME = "<empty>" + } +} diff --git a/provider/mannequin/build.gradle.kts b/provider/mannequin/build.gradle.kts new file mode 100644 index 0000000..58d6bd2 --- /dev/null +++ b/provider/mannequin/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.kotlin) +} + +ext["npc.adventureVersion"] = libs.versions.adventure.mannequin.get() + +dependencies { + compileOnly(libs.paper.mannequin.api) + + implementation(project(":provider:base")) +} diff --git a/provider/mannequin/src/main/kotlin/app/simplecloud/npc/provider/mannequin/MannequinRenderer.kt b/provider/mannequin/src/main/kotlin/app/simplecloud/npc/provider/mannequin/MannequinRenderer.kt new file mode 100644 index 0000000..e14b089 --- /dev/null +++ b/provider/mannequin/src/main/kotlin/app/simplecloud/npc/provider/mannequin/MannequinRenderer.kt @@ -0,0 +1,325 @@ +package app.simplecloud.npc.provider.mannequin + +import app.simplecloud.npc.bukkit.NpcKeys +import app.simplecloud.npc.bukkit.equipment.EquipmentItems +import app.simplecloud.npc.bukkit.glow.GlowTeams +import app.simplecloud.npc.bukkit.interact.InteractableEntities +import app.simplecloud.npc.bukkit.location.requireLoadedWorld +import app.simplecloud.npc.bukkit.location.toBukkitLocation +import app.simplecloud.npc.bukkit.location.toNpcLocation +import app.simplecloud.npc.bukkit.look.LookAtPlayerDriver +import app.simplecloud.npc.bukkit.look.LookAtPlayerTicker +import app.simplecloud.npc.bukkit.packet.PacketViewerTracker +import app.simplecloud.npc.bukkit.provider.WorldReconciler +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.bukkit.skin.FixedSkinRenderer +import app.simplecloud.npc.bukkit.skin.NpcProfiles +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.NpcPose +import app.simplecloud.npc.core.location.NpcLocation +import com.destroystokyo.paper.SkinParts +import com.destroystokyo.paper.profile.PlayerProfile +import com.destroystokyo.paper.profile.ProfileProperty +import io.papermc.paper.datacomponent.item.ResolvableProfile +import io.papermc.paper.event.player.PlayerTrackEntityEvent +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.attribute.Attribute +import org.bukkit.entity.Entity +import org.bukkit.entity.Mannequin +import org.bukkit.entity.Player +import org.bukkit.entity.Pose +import org.bukkit.event.EventHandler +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.event.world.EntitiesLoadEvent +import org.bukkit.event.world.EntitiesUnloadEvent +import org.bukkit.persistence.PersistentDataType +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import kotlin.math.floor + +class MannequinRenderer( + private val plugin: Plugin, +) : FixedSkinRenderer, WorldReconciler, Listener { + + override var knownIdsProvider: (() -> Set<String>)? = null + + private val entityIdByNpcId = ConcurrentHashMap<String, UUID>() + private val ownedEntityIds: MutableSet<UUID> = ConcurrentHashMap.newKeySet() + + private val networkIdByEntityId = ConcurrentHashMap<UUID, Int>() + private val configsById = ConcurrentHashMap<String, NpcConfig>() + private val lookTicker = LookAtPlayerTicker() + private val lookDriver = LookAtPlayerDriver(plugin, lookTicker) + private val viewers = PacketViewerTracker(plugin) + private val ranges = ConcurrentHashMap<String, RangeEntry>() + + override fun onEnable() { + Bukkit.getPluginManager().registerEvents(this, plugin) + viewers.start() + lookDriver.start { lookTargets() } + } + + override fun onDisable() { + lookDriver.stop() + viewers.stop() + ranges.clear() + HandlerList.unregisterAll(this) + + networkIdByEntityId.values.forEach(InteractableEntities::unregister) + networkIdByEntityId.clear() + entityIdByNpcId.clear() + ownedEntityIds.clear() + configsById.clear() + } + + override fun spawn(config: NpcConfig): NpcConfig = plugin.sync { + val world = requireLoadedWorld("Mannequin", config, logger, Bukkit::getWorld) ?: return@sync config + val location = config.entity.location.toBukkitLocation(world) + + val existing = trackedEntity(config.id) ?: findTaggedMannequin(world, config) + val mannequin = existing ?: world.spawn(location, Mannequin::class.java) + + val moved = existing != null && + (existing.world != world || existing.location.distanceSquared(location) > TELEPORT_EPSILON) + if (moved) existing.teleport(location) + + configure(mannequin, config) + track(config.id, mannequin) + configsById[config.id] = config + updateRange(config) + + config + } + + override fun despawn(config: NpcConfig) { + plugin.sync { + ranges.remove(config.id)?.let(viewers::remove) + untrack(config.id)?.let { entityId -> + GlowTeams.clear(entityId) + Bukkit.getEntity(entityId)?.remove() + } + } + } + + override fun refresh(config: NpcConfig) { + spawn(config) + } + + override fun teleport(config: NpcConfig) { + plugin.sync { + val entity = trackedEntity(config.id) ?: run { + spawn(config) + return@sync + } + + val world = Bukkit.getWorld(config.entity.location.world) ?: return@sync + entity.teleport(config.entity.location.toBukkitLocation(world)) + updateRange(config) + } + } + + override fun locationOf(config: NpcConfig): NpcLocation? = trackedEntity(config.id)?.location?.toNpcLocation() + + override fun reconcileLoadedWorlds() { + plugin.sync { + Bukkit.getWorlds().forEach { world -> + world.entities.filterIsInstance<Mannequin>().forEach(::reconcileEntity) + } + } + } + + @EventHandler + fun onEntitiesLoad(event: EntitiesLoadEvent) { + event.entities.filterIsInstance<Mannequin>().forEach(::reconcileEntity) + } + + @EventHandler + fun onEntitiesUnload(event: EntitiesUnloadEvent) { + event.entities.filterIsInstance<Mannequin>().forEach { mannequin -> + networkIdByEntityId.remove(mannequin.uniqueId)?.let(::forgetNetworkId) + } + } + + @EventHandler + fun onPlayerTrack(event: PlayerTrackEntityEvent) { + if (event.entity.uniqueId !in ownedEntityIds) return + lookTicker.forgetPair(event.entity.entityId, event.player.uniqueId) + } + + private fun reconcileEntity(mannequin: Mannequin) { + val npcId = taggedNpcId(mannequin) ?: return + + val knownIds = knownIdsProvider?.invoke() + if (knownIds != null && npcId.lowercase() !in knownIds) { + GlowTeams.clear(mannequin.uniqueId) + mannequin.remove() + logger.info("Removed a leftover mannequin of NPC '$npcId': the NPC is gone or no longer a mannequin.") + return + } + + val tracked = entityIdByNpcId[npcId] + if (tracked != null && tracked != mannequin.uniqueId) { + GlowTeams.clear(mannequin.uniqueId) + mannequin.remove() + logger.info("Removed duplicate mannequin of NPC '$npcId'.") + return + } + + track(npcId, mannequin) + } + + private fun configure(mannequin: Mannequin, config: NpcConfig) { + mannequin.apply { + setProfile(ResolvableProfile.resolvableProfile(buildProfile(config))) + setSkinParts(SkinParts.allParts()) + isImmovable = true + isInvulnerable = true + isCollidable = false + isSilent = true + isPersistent = true + isVisibleByDefault = false + isGlowing = config.entity.glowing + } + GlowTeams.set(mannequin, config.entity.glowColor) + hideDescription(mannequin) + + val customName = config.entity.customName + mannequin.customName(customName?.let(MiniMessage.miniMessage()::deserialize)) + mannequin.isCustomNameVisible = customName != null + + mannequin.persistentDataContainer.set(NpcKeys.MANNEQUIN, PersistentDataType.STRING, config.id) + applyBody(mannequin, config) + } + + private fun applyBody(mannequin: Mannequin, config: NpcConfig) { + val configured = config.entity.equipment + mannequin.equipment.apply { + setItemInMainHand(configured.mainHand?.let(EquipmentItems::toItemStack)) + setItemInOffHand(configured.offHand?.let(EquipmentItems::toItemStack)) + helmet = configured.helmet?.let(EquipmentItems::toItemStack) + chestplate = configured.chestplate?.let(EquipmentItems::toItemStack) + leggings = configured.leggings?.let(EquipmentItems::toItemStack) + boots = configured.boots?.let(EquipmentItems::toItemStack) + } + + val pose = when (config.entity.pose) { + NpcPose.STANDING -> Pose.STANDING + NpcPose.SNEAKING -> Pose.SNEAKING + NpcPose.SWIMMING -> Pose.SWIMMING + NpcPose.SLEEPING -> Pose.SLEEPING + } + runCatching { mannequin.setPose(pose, pose != Pose.STANDING) } + .onFailure { logger.log(Level.FINE, "Could not set the mannequin pose", it) } + + mannequin.getAttribute(Attribute.SCALE)?.baseValue = config.entity.effectiveScale() + } + + private fun updateRange(config: NpcConfig) { + val location = config.entity.location + val entry = ranges.getOrPut(config.id) { + RangeEntry(config.id, location.world, location.x, location.y, location.z, config.entity.viewDistance) + .also(viewers::add) + } + entry.range = config.entity.viewDistance + entry.moveTo(location.world, location.x, location.y, location.z) + } + + private inner class RangeEntry( + private val npcId: String, + world: String, + x: Double, + y: Double, + z: Double, + range: Double, + ) : PacketViewerTracker.Entry("Mannequin $npcId", world, x, y, z, range) { + override fun show(player: Player) { + trackedEntity(npcId)?.let { player.showEntity(plugin, it) } + } + + override fun hide(player: Player) { + trackedEntity(npcId)?.let { player.hideEntity(plugin, it) } + } + + override fun forget(uuid: UUID) { + Bukkit.getPlayer(uuid)?.let(::hide) + } + } + + private fun buildProfile(config: NpcConfig): PlayerProfile = + Bukkit.createProfile(NpcProfiles.uuidFor(config), NpcProfiles.nameFor(config.id)).apply { + val skin = config.entity.skin + if (skin.texture != null && skin.signature != null) { + setProperty(ProfileProperty("textures", skin.texture!!, skin.signature)) + } + } + + private fun hideDescription(mannequin: Mannequin) { + runCatching { mannequin.setDescription(null) } + .onFailure { logger.log(Level.FINE, "Could not hide the mannequin description", it) } + } + + private fun lookTargets(): List<LookAtPlayerDriver.Target> = configsById.mapNotNull { (id, config) -> + if (!config.entity.lookAtPlayer) return@mapNotNull null + val entity = trackedEntity(id) ?: return@mapNotNull null + val location = entity.location + + LookAtPlayerDriver.Target( + npcId = id, + entityId = entity.entityId, + x = location.x, + y = location.y, + z = location.z, + viewers = entity.trackedBy.filter { it.world == entity.world }, + maxDistance = config.entity.lookAtPlayerDistance, + ) + } + + private fun findTaggedMannequin(world: World, config: NpcConfig): Mannequin? { + val location = config.entity.location + val chunk = world.getChunkAt(floor(location.x).toInt() shr 4, floor(location.z).toInt() shr 4) + + return chunk.entities.filterIsInstance<Mannequin>().firstOrNull { taggedNpcId(it) == config.id } + } + + private fun taggedNpcId(entity: Entity): String? = + entity.persistentDataContainer.get(NpcKeys.MANNEQUIN, PersistentDataType.STRING) + + private fun trackedEntity(npcId: String): Mannequin? = + entityIdByNpcId[npcId]?.let(Bukkit::getEntity) as? Mannequin + + private fun track(npcId: String, mannequin: Mannequin) { + entityIdByNpcId.put(npcId, mannequin.uniqueId)?.let { previous -> + if (previous != mannequin.uniqueId) ownedEntityIds.remove(previous) + } + ownedEntityIds += mannequin.uniqueId + + networkIdByEntityId.put(mannequin.uniqueId, mannequin.entityId)?.let { previous -> + if (previous != mannequin.entityId) forgetNetworkId(previous) + } + InteractableEntities.register(mannequin.entityId, npcId, realEntity = true) + } + + private fun untrack(npcId: String): UUID? { + configsById.remove(npcId) + return entityIdByNpcId.remove(npcId)?.also { entityId -> + ownedEntityIds.remove(entityId) + networkIdByEntityId.remove(entityId)?.let(::forgetNetworkId) + } + } + + private fun forgetNetworkId(networkId: Int) { + InteractableEntities.unregister(networkId) + lookTicker.forgetEntity(networkId) + } + + private companion object { + private const val TELEPORT_EPSILON = 0.0001 + private val logger = NpcLog.logger + } +} diff --git a/provider/mythicmobs/build.gradle.kts b/provider/mythicmobs/build.gradle.kts new file mode 100644 index 0000000..f1a2a29 --- /dev/null +++ b/provider/mythicmobs/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + implementation(project(":provider:base")) + compileOnly(libs.paper.api) + compileOnly(libs.mythic.dist) + testImplementation(libs.mythic.dist) +} diff --git a/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MobReference.kt b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MobReference.kt new file mode 100644 index 0000000..431056b --- /dev/null +++ b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MobReference.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.provider.mythicmobs + +import io.lumine.mythic.core.mobs.ActiveMob +import java.util.UUID + +object MobReference { + + fun of(mob: ActiveMob): String = of(mob.type.internalName, mob.uniqueId) + + fun matches(mob: ActiveMob, reference: String): Boolean = + matches(mob.type.internalName, mob.uniqueId, reference) + + internal fun of(mobType: String, uuid: UUID): String = "$mobType-$uuid" + + internal fun matches(mobType: String, uuid: UUID, reference: String): Boolean = + reference.equals(of(mobType, uuid), ignoreCase = true) || + reference.equals(legacyReference(mobType, uuid), ignoreCase = true) + + private fun legacyReference(mobType: String, uuid: UUID): String = + "$mobType-${uuid.toString().take(LEGACY_UUID_PREFIX_LENGTH)}" + + private const val LEGACY_UUID_PREFIX_LENGTH = 6 +} diff --git a/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsInteractListener.kt b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsInteractListener.kt new file mode 100644 index 0000000..4d5d9c4 --- /dev/null +++ b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsInteractListener.kt @@ -0,0 +1,17 @@ +package app.simplecloud.npc.provider.mythicmobs + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.interact.ProviderInteractListener +import app.simplecloud.npc.core.interaction.PlayerInteraction +import io.lumine.mythic.bukkit.events.MythicMobInteractEvent +import org.bukkit.event.EventHandler + +class MythicMobsInteractListener( + configIdForEventKey: (mobReference: String) -> String?, + onInteract: OnNpcInteract, +) : ProviderInteractListener(configIdForEventKey, onInteract) { + + @EventHandler + fun handleInteract(event: MythicMobInteractEvent) = + dispatch(MobReference.of(event.activeMob), event.player, PlayerInteraction.RIGHT_CLICK) +} diff --git a/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsRenderer.kt b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsRenderer.kt new file mode 100644 index 0000000..7bf1e1a --- /dev/null +++ b/provider/mythicmobs/src/main/kotlin/app/simplecloud/npc/provider/mythicmobs/MythicMobsRenderer.kt @@ -0,0 +1,33 @@ +package app.simplecloud.npc.provider.mythicmobs + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.provider.LinkedProviderRenderer +import app.simplecloud.npc.core.location.NpcLocation +import io.lumine.mythic.bukkit.MythicBukkit +import io.lumine.mythic.core.mobs.ActiveMob +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin + +class MythicMobsRenderer( + plugin: Plugin, + private val onInteract: OnNpcInteract, +) : LinkedProviderRenderer<ActiveMob>(plugin, "MythicMobs") { + + override val supportsCreation: Boolean = false + + override fun createListener(): Listener = MythicMobsInteractListener(::configIdForEventKey, onInteract) + + override fun linkCandidates(): List<Pair<String, ActiveMob>> = + MythicBukkit.inst().mobManager.activeMobs.filterNot { it.isDead }.map { MobReference.of(it) to it } + + override fun eventKeyOf(tracked: ActiveMob): String = MobReference.of(tracked) + + override fun referenceOf(tracked: ActiveMob): String = MobReference.of(tracked) + + override fun findByReference(reference: String): ActiveMob? = MythicBukkit.inst().mobManager.activeMobs + .firstOrNull { !it.isDead && MobReference.matches(it, reference) } + + override fun locationOf(tracked: ActiveMob): NpcLocation? = tracked.location?.let { + NpcLocation(it.world.name, it.x, it.y, it.z, it.yaw, it.pitch) + } +} diff --git a/provider/standalone/build.gradle.kts b/provider/standalone/build.gradle.kts new file mode 100644 index 0000000..7689f5a --- /dev/null +++ b/provider/standalone/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + implementation(project(":provider:base")) + compileOnly(libs.paper.api) + compileOnly(project(":libs:packetevents", "shadowRuntimeElements")) + testImplementation(project(":libs:packetevents", "shadowRuntimeElements")) +} diff --git a/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/SkinLayers.kt b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/SkinLayers.kt new file mode 100644 index 0000000..2d2a448 --- /dev/null +++ b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/SkinLayers.kt @@ -0,0 +1,23 @@ +package app.simplecloud.npc.provider.standalone + +import com.github.retrooper.packetevents.manager.server.ServerVersion +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import me.tofaa.entitylib.meta.types.AvatarMeta +import me.tofaa.entitylib.meta.types.LivingEntityMeta +import me.tofaa.entitylib.meta.types.PlayerMeta + +internal object SkinLayers { + + private const val ALL: Byte = 0x7F + + fun showAll(meta: PlayerMeta, server: ServerVersion) { + meta.setIndex(indexFor(server), EntityDataTypes.BYTE, ALL) + } + + fun indexFor(server: ServerVersion): Byte = + if (server.isNewerThanOrEquals(ServerVersion.V_1_21_9)) { + (AvatarMeta.OFFSET + 1).toByte() + } else { + (LivingEntityMeta.MAX_OFFSET + 2).toByte() + } +} diff --git a/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/StandaloneNpcRenderer.kt b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/StandaloneNpcRenderer.kt new file mode 100644 index 0000000..b5751f0 --- /dev/null +++ b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/StandaloneNpcRenderer.kt @@ -0,0 +1,270 @@ +package app.simplecloud.npc.provider.standalone + +import app.simplecloud.npc.bukkit.compat.EntityIds +import app.simplecloud.npc.bukkit.compat.ServerCompat +import app.simplecloud.npc.bukkit.equipment.EquipmentItems +import app.simplecloud.npc.bukkit.glow.GlowTeamPackets +import app.simplecloud.npc.bukkit.interact.InteractableEntities +import app.simplecloud.npc.bukkit.location.requireLoadedWorld +import app.simplecloud.npc.bukkit.look.LookAtPlayerDriver +import app.simplecloud.npc.bukkit.look.LookAtPlayerTicker +import app.simplecloud.npc.bukkit.packet.PacketComponents +import app.simplecloud.npc.bukkit.packet.PacketViewerTracker +import app.simplecloud.npc.bukkit.packet.PacketPoses +import app.simplecloud.npc.bukkit.scheduling.sync +import app.simplecloud.npc.bukkit.skin.FixedSkinRenderer +import app.simplecloud.npc.bukkit.skin.NpcProfiles +import app.simplecloud.npc.core.NpcLog +import app.simplecloud.npc.core.config.EquipmentSlot +import app.simplecloud.npc.core.config.GlowColors +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import app.simplecloud.npc.provider.standalone.interact.InteractionHitboxes +import com.github.retrooper.packetevents.protocol.attribute.Attributes +import com.github.retrooper.packetevents.protocol.player.TextureProperty +import com.github.retrooper.packetevents.protocol.player.UserProfile +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoRemove +import io.github.retrooper.packetevents.util.SpigotConversionUtil +import me.tofaa.entitylib.meta.types.PlayerMeta +import me.tofaa.entitylib.wrapper.WrapperPlayer +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import com.github.retrooper.packetevents.protocol.player.EquipmentSlot as PacketEquipmentSlot +import com.github.retrooper.packetevents.protocol.world.Location as PacketLocation + +class StandaloneNpcRenderer( + private val plugin: Plugin, + private val tracker: PacketViewerTracker, +) : FixedSkinRenderer { + + private val npcs = ConcurrentHashMap<String, TrackedNpc>() + private val teams = GlowTeamPackets.CreationTracker() + private val lookTicker = LookAtPlayerTicker() + private val lookDriver = LookAtPlayerDriver(plugin, lookTicker) + private val hitboxes = InteractionHitboxes(plugin) + + override fun onEnable() { + hitboxes.onEnable() + tracker.onQuit(teams::forget) + lookDriver.start { lookTargets() } + } + + override fun onDisable() { + lookDriver.stop() + hitboxes.onDisable() + + npcs.values.toList().forEach(::removeEntity) + npcs.clear() + teams.removeAll() + } + + fun cleanupStaleHitboxes() { + hitboxes.cleanupStale() + } + + override fun spawn(config: NpcConfig): NpcConfig = plugin.sync { + despawnNow(config.id) + requireLoadedWorld("Standalone", config, logger, Bukkit::getWorld) ?: return@sync config + + val tracked = buildTrackedNpc(config) + + npcs[config.id] = tracked + InteractableEntities.register(tracked.entity.entityId, config.id, realEntity = false) + tracked.entity.spawn(toPacketLocation(config.entity.location)) + tracker.add(tracked.visibility) + + tracked.hitboxId = hitboxes.spawn(config) + + config + } + + override fun despawn(config: NpcConfig) { + plugin.sync { despawnNow(config.id) } + } + + private fun despawnNow(id: String) { + val tracked = npcs.remove(id) ?: return + val viewers = removeEntity(tracked) + + val teamName = GlowTeamPackets.hiddenNameTagTeamFor(tracked.glowColor) + viewers.forEach { player -> + GlowTeamPackets.send(player, GlowTeamPackets.removeEntry(teamName, tracked.entity.username)) + } + lookTicker.forgetEntity(tracked.entity.entityId) + + tracked.hitboxId?.let(hitboxes::remove) + } + + private fun removeEntity(tracked: TrackedNpc): List<Player> { + InteractableEntities.unregister(tracked.entity.entityId) + val viewers = tracker.remove(tracked.visibility) + tracked.entity.remove() + viewers.forEach { removeProfile(tracked, it) } + + return viewers + } + + private fun removeProfile(tracked: TrackedNpc, player: Player) { + GlowTeamPackets.send(player, WrapperPlayServerPlayerInfoRemove(tracked.entity.uuid)) + } + + override fun refresh(config: NpcConfig) { + spawn(config) + } + + override fun teleport(config: NpcConfig) { + plugin.sync { + val tracked = npcs[config.id] + ?.takeIf { it.world.equals(config.entity.location.world, ignoreCase = true) } + ?: run { + spawn(config) + return@sync + } + + val location = config.entity.location + tracked.entity.teleport(toPacketLocation(location)) + tracked.visibility.moveTo(location.world, location.x, location.y, location.z) + tracked.hitboxId?.let { hitboxes.teleport(config, it) } + } + } + + override fun locationOf(config: NpcConfig): NpcLocation? { + val tracked = npcs[config.id] ?: return null + val entity = tracked.entity + + return NpcLocation(tracked.world, entity.x, entity.y, entity.z, entity.yaw, entity.pitch) + } + + private fun buildTrackedNpc(config: NpcConfig): TrackedNpc { + val texture = config.entity.skin.texture + val signature = config.entity.skin.signature + val textures = if (texture != null && signature != null) { + listOf(TextureProperty("textures", texture, signature)) + } else { + emptyList() + } + + val profile = UserProfile(NpcProfiles.uuidFor(config), NpcProfiles.nameFor(config.id), textures) + val entity = WrapperPlayer(profile, EntityIds.next(Bukkit.getWorld(config.entity.location.world))) + + entity.isInTablist = false + entity.getEntityMeta(PlayerMeta::class.java).apply { + isGlowing = config.entity.glowing + config.entity.customName?.let { + customName = PacketComponents.of(MiniMessage.miniMessage().deserialize(it)) + isCustomNameVisible = true + } + SkinLayers.showAll(this, ServerCompat.version) + pose = PacketPoses.of(config.entity.pose) + } + applyEquipment(entity, config) + val scale = config.entity.effectiveScale() + if (scale != 1.0) entity.attributes.setAttribute(Attributes.SCALE, scale) + + return TrackedNpc( + id = config.id, + entity = entity, + location = config.entity.location, + viewDistance = config.entity.viewDistance, + lookAtPlayer = config.entity.lookAtPlayer, + lookAtPlayerDistance = config.entity.lookAtPlayerDistance, + glowColor = config.entity.glowColor, + ) + } + + private fun applyEquipment(entity: WrapperPlayer, config: NpcConfig) { + config.entity.equipment.filled().forEach { (slot, item) -> + val stack = EquipmentItems.toItemStack(item) ?: return@forEach + entity.equipment.setItem(packetSlot(slot), SpigotConversionUtil.fromBukkitItemStack(stack)) + } + } + + private fun packetSlot(slot: EquipmentSlot): PacketEquipmentSlot = when (slot) { + EquipmentSlot.MAIN_HAND -> PacketEquipmentSlot.MAIN_HAND + EquipmentSlot.OFF_HAND -> PacketEquipmentSlot.OFF_HAND + EquipmentSlot.HELMET -> PacketEquipmentSlot.HELMET + EquipmentSlot.CHESTPLATE -> PacketEquipmentSlot.CHEST_PLATE + EquipmentSlot.LEGGINGS -> PacketEquipmentSlot.LEGGINGS + EquipmentSlot.BOOTS -> PacketEquipmentSlot.BOOTS + } + + private fun showFor(tracked: TrackedNpc, player: Player) { + val teamName = GlowTeamPackets.hiddenNameTagTeamFor(tracked.glowColor) + teams.ensure(player, teamName, GlowColors.resolve(tracked.glowColor), hideNameTag = true) + tracked.entity.addViewer(player.uniqueId) + GlowTeamPackets.send(player, GlowTeamPackets.addEntry(teamName, tracked.entity.username)) + } + + private fun hideFor(tracked: TrackedNpc, player: Player) { + tracked.entity.removeViewer(player.uniqueId) + removeProfile(tracked, player) + + GlowTeamPackets.send( + player, + GlowTeamPackets.removeEntry( + GlowTeamPackets.hiddenNameTagTeamFor(tracked.glowColor), + tracked.entity.username, + ), + ) + + lookTicker.forgetPair(tracked.entity.entityId, player.uniqueId) + } + + private fun lookTargets(): List<LookAtPlayerDriver.Target> = npcs.mapNotNull { (id, tracked) -> + if (!tracked.lookAtPlayer) return@mapNotNull null + val entity = tracked.entity + + LookAtPlayerDriver.Target( + npcId = id, + entityId = entity.entityId, + x = entity.x, + y = entity.y, + z = entity.z, + viewers = entity.viewers.mapNotNull(Bukkit::getPlayer), + maxDistance = tracked.lookAtPlayerDistance, + ) + } + + private fun toPacketLocation(location: NpcLocation): PacketLocation = + PacketLocation(location.x, location.y, location.z, location.yaw, location.pitch) + + private inner class TrackedNpc( + id: String, + val entity: WrapperPlayer, + location: NpcLocation, + viewDistance: Double, + val lookAtPlayer: Boolean, + val lookAtPlayerDistance: Double, + val glowColor: String?, + ) { + var hitboxId: UUID? = null + + val world: String get() = visibility.world + + val visibility = object : PacketViewerTracker.Entry( + "NPC $id", + location.world, + location.x, + location.y, + location.z, + viewDistance, + ) { + override fun show(player: Player) = showFor(this@TrackedNpc, player) + + override fun hide(player: Player) = hideFor(this@TrackedNpc, player) + + override fun forget(uuid: UUID) { + entity.removeViewerSilently(uuid) + lookTicker.forgetPair(entity.entityId, uuid) + } + } + } + + private companion object { + private val logger = NpcLog.logger + } +} diff --git a/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/interact/InteractionHitboxes.kt b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/interact/InteractionHitboxes.kt new file mode 100644 index 0000000..a2e8a33 --- /dev/null +++ b/provider/standalone/src/main/kotlin/app/simplecloud/npc/provider/standalone/interact/InteractionHitboxes.kt @@ -0,0 +1,104 @@ +package app.simplecloud.npc.provider.standalone.interact + +import app.simplecloud.npc.bukkit.NpcKeys +import app.simplecloud.npc.bukkit.interact.InteractableEntities +import app.simplecloud.npc.bukkit.location.toBukkitLocation +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.config.NpcPose +import org.bukkit.Bukkit +import org.bukkit.entity.Interaction +import org.bukkit.event.EventHandler +import org.bukkit.event.HandlerList +import org.bukkit.event.Listener +import org.bukkit.event.world.EntitiesLoadEvent +import org.bukkit.event.world.EntitiesUnloadEvent +import org.bukkit.persistence.PersistentDataType +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class InteractionHitboxes( + private val plugin: Plugin, +) : Listener { + + private val idByHitboxId = ConcurrentHashMap<UUID, String>() + + fun onEnable() { + Bukkit.getPluginManager().registerEvents(this, plugin) + } + + fun onDisable() { + HandlerList.unregisterAll(this) + + idByHitboxId.keys.toList().forEach(::remove) + idByHitboxId.clear() + } + + @EventHandler + fun onEntitiesLoad(event: EntitiesLoadEvent) { + event.entities.filterIsInstance<Interaction>() + .filter { it.persistentDataContainer.has(NpcKeys.HITBOX, PersistentDataType.STRING) } + .forEach { hitbox -> + val npcId = idByHitboxId[hitbox.uniqueId] + if (npcId == null) { + hitbox.remove() + } else { + InteractableEntities.register(hitbox.entityId, npcId, realEntity = true, hitbox = true) + } + } + } + + @EventHandler + fun onEntitiesUnload(event: EntitiesUnloadEvent) { + event.entities.filterIsInstance<Interaction>() + .filter { idByHitboxId.containsKey(it.uniqueId) } + .forEach { InteractableEntities.unregister(it.entityId) } + } + + fun spawn(config: NpcConfig): UUID? { + val world = Bukkit.getWorld(config.entity.location.world) ?: return null + val lying = config.entity.pose == NpcPose.SWIMMING || config.entity.pose == NpcPose.SLEEPING + val scale = config.entity.effectiveScale().toFloat() + val widthFactor = if (lying) LYING_WIDTH_FACTOR else 1F + val hitbox = world.spawn(config.entity.location.toBukkitLocation(world), Interaction::class.java).apply { + interactionWidth = HITBOX_WIDTH * scale * widthFactor + interactionHeight = HITBOX_HEIGHT * config.entity.heightFactor().toFloat() + isResponsive = true + setGravity(false) + persistentDataContainer.set(NpcKeys.HITBOX, PersistentDataType.STRING, config.id) + } + + idByHitboxId[hitbox.uniqueId] = config.id + InteractableEntities.register(hitbox.entityId, config.id, realEntity = true, hitbox = true) + + return hitbox.uniqueId + } + + fun teleport(config: NpcConfig, hitboxId: UUID) { + val world = Bukkit.getWorld(config.entity.location.world) ?: return + (Bukkit.getEntity(hitboxId) as? Interaction)?.teleport(config.entity.location.toBukkitLocation(world)) + } + + fun remove(hitboxId: UUID) { + idByHitboxId.remove(hitboxId) + (Bukkit.getEntity(hitboxId) as? Interaction)?.let { hitbox -> + InteractableEntities.unregister(hitbox.entityId) + hitbox.remove() + } + } + + fun cleanupStale() { + Bukkit.getWorlds().forEach { world -> + world.entities.filterIsInstance<Interaction>() + .filter { it.persistentDataContainer.has(NpcKeys.HITBOX, PersistentDataType.STRING) } + .filterNot { idByHitboxId.containsKey(it.uniqueId) } + .forEach(Interaction::remove) + } + } + + private companion object { + private const val HITBOX_WIDTH = 0.6F + private const val HITBOX_HEIGHT = 1.8F + private const val LYING_WIDTH_FACTOR = 3F + } +} diff --git a/provider/znpcsplus/build.gradle.kts b/provider/znpcsplus/build.gradle.kts new file mode 100644 index 0000000..59170e6 --- /dev/null +++ b/provider/znpcsplus/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.kotlin) +} + +dependencies { + implementation(project(":provider:base")) + compileOnly(libs.paper.api) + compileOnly(libs.znpcsplus.api) +} diff --git a/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusInteractListener.kt b/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusInteractListener.kt new file mode 100644 index 0000000..2736394 --- /dev/null +++ b/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusInteractListener.kt @@ -0,0 +1,25 @@ +package app.simplecloud.npc.provider.znpcsplus + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.interact.ProviderInteractListener +import app.simplecloud.npc.core.interaction.PlayerInteraction +import lol.pyr.znpcsplus.api.event.NpcInteractEvent +import lol.pyr.znpcsplus.api.interaction.InteractionType +import org.bukkit.event.EventHandler + +class ZNpcsPlusInteractListener( + configIdForEventKey: (entryId: String) -> String?, + onInteract: OnNpcInteract, +) : ProviderInteractListener(configIdForEventKey, onInteract) { + + @EventHandler + fun handleInteract(event: NpcInteractEvent) { + val interaction = when (event.clickType) { + InteractionType.RIGHT_CLICK -> PlayerInteraction.RIGHT_CLICK + InteractionType.LEFT_CLICK -> PlayerInteraction.LEFT_CLICK + else -> return + } + + dispatch(event.entry.id, event.player, interaction) + } +} diff --git a/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusRenderer.kt b/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusRenderer.kt new file mode 100644 index 0000000..57ddc3d --- /dev/null +++ b/provider/znpcsplus/src/main/kotlin/app/simplecloud/npc/provider/znpcsplus/ZNpcsPlusRenderer.kt @@ -0,0 +1,124 @@ +package app.simplecloud.npc.provider.znpcsplus + +import app.simplecloud.npc.bukkit.interact.OnNpcInteract +import app.simplecloud.npc.bukkit.location.toBukkitLocation +import app.simplecloud.npc.bukkit.provider.LinkedProviderRenderer +import app.simplecloud.npc.core.config.GlowColors +import app.simplecloud.npc.core.config.NpcConfig +import app.simplecloud.npc.core.location.NpcLocation +import lol.pyr.znpcsplus.api.NpcApiProvider +import lol.pyr.znpcsplus.api.npc.NpcEntry +import lol.pyr.znpcsplus.api.skin.SkinDescriptor +import lol.pyr.znpcsplus.util.LookType +import lol.pyr.znpcsplus.util.NamedColor +import org.bukkit.World +import org.bukkit.event.Listener +import org.bukkit.plugin.Plugin +import java.util.logging.Level +import lol.pyr.znpcsplus.util.NpcLocation as ZLocation + +class ZNpcsPlusRenderer( + plugin: Plugin, + private val onInteract: OnNpcInteract, +) : LinkedProviderRenderer<NpcEntry>(plugin, "ZNPCsPlus") { + + private val api get() = NpcApiProvider.get() + + override fun createListener(): Listener = ZNpcsPlusInteractListener(::configIdForEventKey, onInteract) + + override fun eventKeyOf(tracked: NpcEntry): String = tracked.id + + override fun findByReference(reference: String): NpcEntry? = api.npcRegistry.getById(reference) + + override fun create(config: NpcConfig, world: World): NpcEntry? { + val entryId = entryId(config.id) + api.npcRegistry.getById(entryId)?.let { api.npcRegistry.delete(entryId) } + + val playerType = api.npcTypeRegistry.getByName("player") ?: run { + logger.warning("Cannot create ZNPCsPlus NPC '${config.id}': the 'player' NPC type is unavailable.") + return null + } + + val entry = api.npcRegistry.create( + entryId, + world, + playerType, + ZLocation(config.entity.location.toBukkitLocation(world)), + ) + + return try { + applyAppearance(entry, config) + entry.apply { + setProcessed(true) + setSave(false) + setAllowCommandModification(false) + } + } catch (exception: Exception) { + api.npcRegistry.delete(entry.id) + logger.log( + Level.WARNING, + "Failed to configure ZNPCsPlus NPC '${config.id}', rolled back its creation.", + exception, + ) + null + } + } + + override fun destroy(tracked: NpcEntry) { + runCatching { api.npcRegistry.delete(tracked.id) } + } + + override fun deleteLegacy(legacy: NpcEntry) { + api.npcRegistry.delete(legacy.id) + api.npcRegistry.save() + } + + override fun linkCandidates(): List<Pair<String, NpcEntry>> = + api.npcRegistry.allIds.mapNotNull { id -> api.npcRegistry.getById(id)?.let { id to it } } + + override fun refreshOwned(config: NpcConfig, tracked: NpcEntry) = applyAppearance(tracked, config) + + override fun teleportOwned(config: NpcConfig, tracked: NpcEntry, world: World) { + tracked.npc.setWorld(world) + tracked.npc.location = ZLocation(config.entity.location.toBukkitLocation(world)) + } + + override fun locationOf(tracked: NpcEntry): NpcLocation? { + val world = tracked.npc.world ?: return null + val location = tracked.npc.location + + return NpcLocation(world.name, location.x, location.y, location.z, location.yaw, location.pitch) + } + + private fun applyAppearance(entry: NpcEntry, config: NpcConfig) { + val npc = entry.npc + val lookProperty = checkNotNull(api.propertyRegistry.getByName("look", LookType::class.java)) { + "ZNPCsPlus look property is unavailable" + } + npc.setProperty(lookProperty, if (config.entity.lookAtPlayer) LookType.PER_PLAYER else LookType.FIXED) + + val lookDistanceProperty = checkNotNull(api.propertyRegistry.getByName("look_distance", Double::class.java)) { + "ZNPCsPlus look-distance property is unavailable" + } + npc.setProperty(lookDistanceProperty, config.entity.lookAtPlayerDistance) + + val skinProperty = checkNotNull(api.propertyRegistry.getByName("skin", SkinDescriptor::class.java)) { + "ZNPCsPlus skin property is unavailable" + } + npc.setProperty(skinProperty, api.skinDescriptorFactory.createMirrorDescriptor()) + + val glowProperty = checkNotNull(api.propertyRegistry.getByName("glow", NamedColor::class.java)) { + "ZNPCsPlus glow property is unavailable" + } + npc.setProperty(glowProperty, glowColorOf(config)) + } + + private fun glowColorOf(config: NpcConfig): NamedColor? { + if (!config.entity.glowing) return null + val name = config.entity.glowColor?.lowercase()?.takeIf { GlowColors.resolve(it) != null } ?: "white" + + return NamedColor.valueOf(name.uppercase()) + } + + private fun entryId(configId: String): String = "scnpc_${configId.lowercase()}" +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d2e35b..5875aa2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,23 +1,49 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + + exclusive("https://repo.codemc.io/repository/maven-public/", "com.github.retrooper") + exclusive("https://maven.pvphub.me/tofaa", "io.github.tofaa2") + exclusive("https://buf.build/gen/maven", "build.buf.gen") + exclusive("https://repo.simplecloud.app/snapshots", "app.simplecloud.api", "app.simplecloud.plugin") + exclusive("https://repo.pyr.lol/snapshots", "lol.pyr") + exclusive("https://maven.citizensnpcs.co/repo", "net.citizensnpcs") + exclusive("https://repo.fancyinnovations.com/releases", "de.oliver") + exclusive("https://mvn.lumine.io/repository/maven-public/", "io.lumine") + } +} + +fun RepositoryHandler.exclusive(url: String, vararg groups: String) = exclusiveContent { + forRepository { maven(url) } + filter { groups.forEach(::includeGroup) } +} + rootProject.name = "npc-plugin" include( - "npc-paper", - "npc-shared", - "npc-namespace", - "npc-namespace:npc-namespace-citizens", - "npc-namespace:npc-namespace-fancynpcs", - "npc-namespace:npc-namespace-mythicmobs" + "api", + "core", + "libs:packetevents", + "platform:common", + "platform:paper", + "provider:base", + "provider:standalone", + "provider:mannequin", + "provider:citizens", + "provider:fancynpcs", + "provider:mythicmobs", + "provider:znpcsplus", ) - -findProject(":npc-namespace:npc-namespace-citizens")?.name = "npc-namespace-citizens" -findProject(":npc-namespace:npc-namespace-fancynpcs")?.name = "npc-namespace-fancynpcs" -findProject(":npc-namespace:npc-namespace-mythicmobs")?.name = "npc-namespace-mythicmobs" -include("npc-namespace:npc-namespace-znpcs-plus") -findProject(":npc-namespace:npc-namespace-znpcs-plus")?.name = "npc-namespace-znpcs-plus" - -//include("npc-namespace:npc-namespace-playernpc") -//findProject(":npc-namespace:npc-namespace-playernpc")?.name = "npc-namespace-playernpc"