commit 3edddf172e064054251c30b9696866fab0e0cadc Author: ehko Date: Thu Jun 4 16:52:06 2026 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8254f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output +target/ +out/ +build/ +*.class +*.jar +dependency-reduced-pom.xml + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.project +.classpath +.settings/ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Local config / secrets +config.json.local +*.env +.env diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..985812e --- /dev/null +++ b/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + net.kewwbec + corenpc + 0.1.0 + jar + + CoreNpc + Reusable NPC service: handler-routed interactions + admin /npc editor for KweebecNet plugins + + + 21 + 21 + UTF-8 + ${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar + ${project.basedir}/../Core/target/NetworkCore-0.1.0.jar + + + + + com.hypixel.hytale + hytale-server + local + system + ${hytale.server.jar} + + + + net.kewwbec + networkcore + 0.1.0 + system + ${networkcore.jar} + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + false + + + com.hypixel.hytale:* + net.kewwbec:networkcore + com.google.code.findbugs:* + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + diff --git a/src/main/java/net/kewwbec/corenpc/CoreNpcItems.java b/src/main/java/net/kewwbec/corenpc/CoreNpcItems.java new file mode 100644 index 0000000..593afcb --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/CoreNpcItems.java @@ -0,0 +1,10 @@ +package net.kewwbec.corenpc; + +public final class CoreNpcItems { + + /** Held admin tool whose right-click action tags the focused entity instead of freeze/clone/remove. */ + public static final String TAG_WAND = "CoreNpc_Tag_Wand"; + + private CoreNpcItems() { + } +} diff --git a/src/main/java/net/kewwbec/corenpc/CoreNpcPermissions.java b/src/main/java/net/kewwbec/corenpc/CoreNpcPermissions.java new file mode 100644 index 0000000..2d64190 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/CoreNpcPermissions.java @@ -0,0 +1,9 @@ +package net.kewwbec.corenpc; + +public final class CoreNpcPermissions { + + public static final String ADMIN = "corenpc.admin"; + + private CoreNpcPermissions() { + } +} diff --git a/src/main/java/net/kewwbec/corenpc/CoreNpcPlugin.java b/src/main/java/net/kewwbec/corenpc/CoreNpcPlugin.java new file mode 100644 index 0000000..792c9aa --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/CoreNpcPlugin.java @@ -0,0 +1,130 @@ +package net.kewwbec.corenpc; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.event.events.player.PlayerInteractEvent; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.RootInteraction; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.corenpc.api.Npcs; +import net.kewwbec.corenpc.command.NpcCommand; +import net.kewwbec.corenpc.command.NpcSelectionStore; +import net.kewwbec.corenpc.command.TagModeStore; +import net.kewwbec.corenpc.handler.RunCommandHandler; +import net.kewwbec.corenpc.impl.CoreNpcUseInteraction; +import net.kewwbec.corenpc.impl.EnsureInteractionsSystem; +import net.kewwbec.corenpc.impl.InteractionRouter; +import net.kewwbec.corenpc.impl.NpcMarkerComponent; +import net.kewwbec.corenpc.impl.NpcsImpl; +import net.kewwbec.corenpc.impl.TagNpcInteraction; +import net.kewwbec.networkcore.NetworkCore; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.logging.Level; + +public final class CoreNpcPlugin extends JavaPlugin { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private static volatile CoreNpcPlugin instance; + + private NpcsImpl npcs; + private InteractionRouter router; + private NpcSelectionStore selectionStore; + private TagModeStore tagModeStore; + + public CoreNpcPlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Nonnull + public static CoreNpcPlugin getInstance() { + CoreNpcPlugin i = instance; + if (i == null) throw new IllegalStateException("CoreNpc not initialized"); + return i; + } + + @Override + protected void setup() { + instance = this; + NpcMarkerComponent.register(getEntityStoreRegistry()); + getCodecRegistry(Interaction.CODEC).register( + TagNpcInteraction.INTERACTION_ID, TagNpcInteraction.class, TagNpcInteraction.CODEC); + getCodecRegistry(Interaction.CODEC).register( + CoreNpcUseInteraction.CODEC_ID, CoreNpcUseInteraction.class, CoreNpcUseInteraction.CODEC); + registerUseInteractionAssets(); + this.npcs = new NpcsImpl(); + this.selectionStore = new NpcSelectionStore(); + this.tagModeStore = new TagModeStore(); + this.router = new InteractionRouter(npcs, tagModeStore); + LOGGER.at(Level.INFO).log("CoreNpc setup complete (marker + tag/use interactions registered)"); + } + + private void registerUseInteractionAssets() { + try { + Interaction.getAssetStore().loadAssets( + "ThirdParty:CoreNpc", + List.of(new CoreNpcUseInteraction(CoreNpcUseInteraction.ASSET_ID))); + RootInteraction.getAssetStore().loadAssets( + "ThirdParty:CoreNpc", + List.of(new RootInteraction(CoreNpcUseInteraction.ASSET_ID, CoreNpcUseInteraction.ASSET_ID))); + } catch (Exception e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Failed to load Use interaction assets"); + } + } + + @Override + protected void start() { + NetworkCore core = NetworkCore.getInstance(); + core.registerService(Npcs.class, npcs); + npcs.registerHandler(RunCommandHandler.HANDLER_ID, new RunCommandHandler()); + + getEventRegistry().registerGlobal(PlayerInteractEvent.class, router::handle); + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, this::onDisconnect); + getCommandRegistry().registerCommand(new NpcCommand(npcs, selectionStore, tagModeStore)); + getEntityStoreRegistry().registerSystem(new EnsureInteractionsSystem()); + LOGGER.at(Level.INFO).log("CoreNpc started (service + /cnpc command + runcmd handler + ensure-interactions)"); + } + + private void onDisconnect(@Nonnull PlayerDisconnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + if (selectionStore != null) selectionStore.clear(ref.getUuid()); + if (tagModeStore != null) tagModeStore.clear(ref.getUuid()); + } + + @Override + protected void shutdown() { + npcs = null; + router = null; + selectionStore = null; + tagModeStore = null; + instance = null; + LOGGER.at(Level.INFO).log("CoreNpc shutdown complete"); + } + + @Nonnull + public Npcs getNpcs() { + Npcs n = this.npcs; + if (n == null) throw new IllegalStateException("Npcs service not yet wired"); + return n; + } + + @Nonnull + public NpcsImpl getNpcsImpl() { + NpcsImpl n = this.npcs; + if (n == null) throw new IllegalStateException("Npcs not yet wired"); + return n; + } + + @Nonnull + public TagModeStore getTagModeStore() { + TagModeStore t = this.tagModeStore; + if (t == null) throw new IllegalStateException("TagModeStore not yet wired"); + return t; + } +} diff --git a/src/main/java/net/kewwbec/corenpc/api/NpcHandle.java b/src/main/java/net/kewwbec/corenpc/api/NpcHandle.java new file mode 100644 index 0000000..19aa6ab --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/api/NpcHandle.java @@ -0,0 +1,38 @@ +package net.kewwbec.corenpc.api; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; + +/** + * Read-only view of a CoreNpc-managed NPC. Backed by the {@code NpcMarkerComponent} + * on the underlying entity, refreshed on every getter call so changes made elsewhere + * (e.g. by the {@code /npc} command) are visible immediately. + * + *

Mutations are admin-only and flow through the {@code /npc} command — there is + * deliberately no public setter API on this handle.

+ */ +public interface NpcHandle { + + @Nonnull String id(); + @Nonnull String handlerId(); + @Nonnull String namePlate(); + boolean namePlateVisible(); + @Nonnull String roleName(); + @Nonnull Map args(); + + @Nonnull String worldName(); + @Nonnull Vector3d position(); + float yaw(); + + /** Reference to the backing ECS entity; {@code null} if the world is unloaded. */ + @Nullable + Ref entityRef(); + + /** True when the backing entity is currently present in a loaded world. */ + boolean isSpawned(); +} diff --git a/src/main/java/net/kewwbec/corenpc/api/NpcInteractionHandler.java b/src/main/java/net/kewwbec/corenpc/api/NpcInteractionHandler.java new file mode 100644 index 0000000..a066f38 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/api/NpcInteractionHandler.java @@ -0,0 +1,33 @@ +package net.kewwbec.corenpc.api; + +import com.hypixel.hytale.server.core.universe.PlayerRef; + +import javax.annotation.Nonnull; +import java.util.Map; + +/** + * Plugin-supplied callback that runs when a player interacts with an NPC routed to this + * handler id. The id is stored on the NPC's persisted definition and looked up at click time. + * + *

Threading: {@link #onInteract} runs on the world thread that owns the NPC's + * entity store. World/entity mutations are safe; HTTP, DB I/O, or other blocking work + * must be moved to a background executor.

+ * + *

Register handlers from your plugin's {@code start()}: + *

{@code
+ *   Npcs npcs = NetworkCore.getInstance().findService(Npcs.class);
+ *   npcs.registerHandler("queue:bridge_duels", (player, npc, args) -> {
+ *       queueService.join(player, args.getOrDefault("gameId", "bridge_duels"));
+ *   });
+ * }
+ */ +@FunctionalInterface +public interface NpcInteractionHandler { + + /** + * @param player the interacting player + * @param npc the NPC that was clicked + * @param args handler arguments stored on the NPC's definition (immutable view) + */ + void onInteract(@Nonnull PlayerRef player, @Nonnull NpcHandle npc, @Nonnull Map args); +} diff --git a/src/main/java/net/kewwbec/corenpc/api/Npcs.java b/src/main/java/net/kewwbec/corenpc/api/Npcs.java new file mode 100644 index 0000000..b1b4c97 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/api/Npcs.java @@ -0,0 +1,57 @@ +package net.kewwbec.corenpc.api; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collection; + +/** + * Service for interactive NPCs. Registered by {@code CoreNpc} and resolved through + * {@code NetworkCore.getInstance().findService(Npcs.class)}. + * + *

NPCs are existing entities (Trork, Mannequin, animal, whatever) that an admin + * tagged via {@code /npc tag }. The marker component carries the handler + * id + args and rides Hytale's world save automatically. Consumers register handlers; + * they don't spawn or place NPCs.

+ */ +public interface Npcs { + + /** + * Register a callback that fires when a player interacts with any NPC whose + * marker {@code handlerId} equals {@code handlerId}. Idempotent for the same id; + * a re-register replaces the previous handler. + */ + void registerHandler(@Nonnull String handlerId, @Nonnull NpcInteractionHandler handler); + + /** + * Remove a previously registered handler. NPCs routed to it no-op (with a warning) + * until re-registered. + */ + void unregisterHandler(@Nonnull String handlerId); + + /** Despawn the entity behind {@code handle}. Use {@code /npc untag} for marker-only removal. */ + void despawn(@Nonnull NpcHandle handle); + + /** + * Look up by id across every loaded world. Only safe to call from a + * non-world thread (e.g. an async task) because Hytale asserts that + * world stores are accessed from their own ticking thread. From inside a + * command or event handler, use {@link #byIdInWorld(String, World)}. + */ + @Nullable + NpcHandle byId(@Nonnull String id); + + /** + * Look up by id within a single world's store. Safe to call from that + * world's tick thread (e.g. inside a command's {@code execute}). + */ + @Nullable + NpcHandle byIdInWorld(@Nonnull String id, @Nonnull com.hypixel.hytale.server.core.universe.world.World world); + + /** All NPCs whose backing entity is in the given world. */ + @Nonnull + Collection inWorld(@Nonnull String worldName); + + /** Every NPC the service knows about across all loaded worlds. */ + @Nonnull + Collection all(); +} diff --git a/src/main/java/net/kewwbec/corenpc/command/NpcArgSub.java b/src/main/java/net/kewwbec/corenpc/command/NpcArgSub.java new file mode 100644 index 0000000..8bdca5b --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/command/NpcArgSub.java @@ -0,0 +1,123 @@ +package net.kewwbec.corenpc.command; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.CoreNpcPermissions; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.impl.NpcsImpl; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Map; + +/** + * Sub-collection for {@code /npc arg set|unset|list}: per-NPC handler arguments. + */ +public final class NpcArgSub extends AbstractCommandCollection { + + public NpcArgSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("arg", "Edit the selected NPC's handler args"); + addSubCommand(new SetSub(npcs, selection)); + addSubCommand(new UnsetSub(npcs, selection)); + addSubCommand(new ListSub(npcs, selection)); + } + + @Override protected boolean canGeneratePermission() { return false; } + + public static final class SetSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg keyArg; + private final RequiredArg valueArg; + + public SetSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("set", "Set an arg on the selected NPC"); + this.npcs = npcs; + this.selection = selection; + this.keyArg = withRequiredArg("key", "Arg key", ArgTypes.STRING); + this.valueArg = withRequiredArg("value", "Arg value", ArgTypes.GREEDY_STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = NpcCommand.requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + String k = ctx.get(keyArg); + String v = ctx.get(valueArg); + npcs.setArg(h, k, v); + ctx.sendMessage(Message.raw("Set " + k + " = " + v + " on " + h.id() + ".").color(Color.GREEN)); + } + } + + public static final class UnsetSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg keyArg; + + public UnsetSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("unset", "Remove an arg from the selected NPC"); + this.npcs = npcs; + this.selection = selection; + this.keyArg = withRequiredArg("key", "Arg key", ArgTypes.STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = NpcCommand.requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + String k = ctx.get(keyArg); + npcs.removeArg(h, k); + ctx.sendMessage(Message.raw("Removed " + k + " from " + h.id() + ".").color(Color.GREEN)); + } + } + + public static final class ListSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public ListSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("list", "List args on the selected NPC"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = NpcCommand.requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + Map args = h.args(); + if (args.isEmpty()) { + ctx.sendMessage(Message.raw("(no args)").color(Color.YELLOW)); + return; + } + ctx.sendMessage(Message.raw("--- args for " + h.id() + " ---").color(Color.GRAY)); + for (Map.Entry e : args.entrySet()) { + ctx.sendMessage(Message.raw(" " + e.getKey() + " = " + e.getValue())); + } + } + } +} diff --git a/src/main/java/net/kewwbec/corenpc/command/NpcCommand.java b/src/main/java/net/kewwbec/corenpc/command/NpcCommand.java new file mode 100644 index 0000000..e5a16de --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/command/NpcCommand.java @@ -0,0 +1,674 @@ +package net.kewwbec.corenpc.command; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.math.vector.Transform; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.CoreNpcItems; +import net.kewwbec.corenpc.CoreNpcPermissions; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.impl.NpcsImpl; +import org.joml.Vector3d; + +import java.util.LinkedHashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public final class NpcCommand extends AbstractCommandCollection { + + public NpcCommand(@Nonnull NpcsImpl npcs, + @Nonnull NpcSelectionStore selection, + @Nonnull TagModeStore tagMode) { + super("cnpc", "CoreNpc admin: tag entities as interactive NPCs and edit them"); + addSubCommand(new TagSub(tagMode)); + addSubCommand(new TagIdSub(npcs, selection)); + addSubCommand(new CancelSub(tagMode)); + addSubCommand(new UntagSub(npcs, selection)); + addSubCommand(new DeleteSub(npcs, selection)); + addSubCommand(new GetToolSub()); + addSubCommand(new SelectSub(npcs, selection)); + addSubCommand(new DeselectSub(selection)); + addSubCommand(new InfoSub(npcs, selection)); + addSubCommand(new TpHereSub(npcs, selection)); + addSubCommand(new TpToSub(npcs, selection)); + addSubCommand(new MoveSub(npcs, selection)); + addSubCommand(new MoveToSub(npcs, selection)); + addSubCommand(new RotateSub(npcs, selection)); + addSubCommand(new FaceSub(npcs, selection)); + addSubCommand(new NameSub(npcs, selection)); + addSubCommand(new HandlerSub(npcs, selection)); + addSubCommand(new NpcArgSub(npcs, selection)); + addSubCommand(new ListSub(npcs)); + } + + @Override protected boolean canGeneratePermission() { return false; } + + // ---- helpers ------------------------------------------------------------- + + @Nullable + static NpcHandle requireSelected(@Nonnull NpcsImpl npcs, + @Nonnull NpcSelectionStore selection, + @Nonnull CommandContext ctx, + @Nonnull PlayerRef sender, + @Nonnull World world) { + String id = selection.get(sender.getUuid()); + if (id == null) { + ctx.sendMessage(Message.raw("No NPC selected. Use /cnpc select first.").color(Color.YELLOW)); + return null; + } + NpcHandle handle = npcs.byIdInWorld(id, world); + if (handle == null) { + ctx.sendMessage(Message.raw("Selected NPC '" + id + "' is not in this world.").color(Color.YELLOW)); + return null; + } + return handle; + } + + static void requireSameWorld(@Nonnull CommandContext ctx, + @Nonnull World current, + @Nonnull NpcHandle target, + @Nonnull Runnable action) { + String currentName = current.getName(); + if (currentName == null || !currentName.equals(target.worldName())) { + ctx.sendMessage(Message.raw("That NPC is in world '" + target.worldName() + + "'. Cross-world is not supported yet.").color(Color.YELLOW)); + return; + } + action.run(); + } + + // ---- sub: tag ------------------------------------------------------------ + + public static final class TagSub extends AbstractPlayerCommand { + private final TagModeStore tagMode; + private final RequiredArg handlerArg; + private final OptionalArg> kvArgs; + + public TagSub(@Nonnull TagModeStore tagMode) { + super("tag", "Arm tag mode: next right-click on any entity tags it as a CoreNpc"); + this.tagMode = tagMode; + this.handlerArg = withRequiredArg("handlerId", "Handler id to route clicks to", ArgTypes.STRING); + this.kvArgs = withListOptionalArg("args", "Optional key=value pairs", ArgTypes.STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + String handlerId = ctx.get(handlerArg); + java.util.Map args = new LinkedHashMap<>(); + if (ctx.provided(kvArgs)) { + for (String pair : ctx.get(kvArgs)) { + int eq = pair.indexOf('='); + if (eq <= 0) { + ctx.sendMessage(Message.raw("Skipping malformed arg '" + pair + "', expected key=value") + .color(Color.YELLOW)); + continue; + } + args.put(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + tagMode.set(sender.getUuid(), new TagModeStore.PendingTag(handlerId, args)); + ctx.sendMessage(Message.raw("Tag mode armed (handler=" + handlerId + + "). Right-click an entity to tag it. /npc cancel to abort.").color(Color.GREEN)); + } + } + + // ---- sub: tagid (by entity network id, no right-click needed) ------------ + + public static final class TagIdSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg handlerArg; + private final RequiredArg entityIdArg; + private final OptionalArg> kvArgs; + + public TagIdSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("tagid", "Tag an entity by its network id (use the F-key entity tool to find it)"); + this.npcs = npcs; + this.selection = selection; + this.handlerArg = withRequiredArg("handlerId", "Handler id to route clicks to", ArgTypes.STRING); + this.entityIdArg = withRequiredArg("entityId", "Entity network id", ArgTypes.INTEGER); + this.kvArgs = withListOptionalArg("args", "Optional key=value pairs", ArgTypes.STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + int networkId = ctx.get(entityIdArg); + Ref targetRef = world.getEntityStore().getRefFromNetworkId(networkId); + if (targetRef == null || !targetRef.isValid()) { + ctx.sendMessage(Message.raw("No entity with network id " + networkId + " in this world.") + .color(Color.YELLOW)); + return; + } + + java.util.Map args = new LinkedHashMap<>(); + if (ctx.provided(kvArgs)) { + for (String pair : ctx.get(kvArgs)) { + int eq = pair.indexOf('='); + if (eq <= 0) { + ctx.sendMessage(Message.raw("Skipping malformed arg '" + pair + "', expected key=value") + .color(Color.YELLOW)); + continue; + } + args.put(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + + String handlerId = ctx.get(handlerArg); + try { + NpcHandle handle = npcs.tag(targetRef, null, handlerId, args); + selection.set(sender.getUuid(), handle.id()); + ctx.sendMessage(Message.raw("Tagged entity " + networkId + " as " + handle.id() + + " (handler=" + handlerId + "), selected.").color(Color.GREEN)); + } catch (RuntimeException e) { + ctx.sendMessage(Message.raw("Tag failed: " + e.getMessage()).color(Color.RED)); + } + } + } + + // ---- sub: gettool (give the Tag Wand) ------------------------------------ + + public static final class GetToolSub extends AbstractPlayerCommand { + + public GetToolSub() { + super("gettool", "Receive a Tag Wand: right-click an entity to tag it with the runcmd handler"); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + Player player = store.getComponent(ref, Player.getComponentType()); + if (player == null) { + ctx.sendMessage(Message.raw("Could not resolve your player entity.").color(Color.RED)); + return; + } + player.getInventory().getHotbar().addItemStack(new ItemStack(CoreNpcItems.TAG_WAND, 1)); + ctx.sendMessage(Message.raw("Tag Wand added to your hotbar. Right-click an entity to tag " + + "with runcmd (then /cnpc arg set cmd ).").color(Color.GREEN)); + } + } + + // ---- sub: cancel --------------------------------------------------------- + + public static final class CancelSub extends AbstractPlayerCommand { + private final TagModeStore tagMode; + + public CancelSub(@Nonnull TagModeStore tagMode) { + super("cancel", "Cancel any pending tag mode"); + this.tagMode = tagMode; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + tagMode.clear(sender.getUuid()); + ctx.sendMessage(Message.raw("Tag mode cancelled.").color(Color.GREEN)); + } + } + + // ---- sub: untag (marker-only removal) ------------------------------------ + + public static final class UntagSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public UntagSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("untag", "Remove the marker from the selected NPC; the entity stays in world"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + String id = h.id(); + npcs.untag(h); + selection.clear(sender.getUuid()); + ctx.sendMessage(Message.raw("Untagged NPC " + id + ". Entity still in world.").color(Color.GREEN)); + } + } + + // ---- sub: delete (full entity removal) ---------------------------------- + + public static final class DeleteSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public DeleteSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("delete", "Delete the selected NPC"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle handle = requireSelected(npcs, selection, ctx, sender, world); + if (handle == null) return; + String id = handle.id(); + npcs.despawn(handle); + selection.clear(sender.getUuid()); + ctx.sendMessage(Message.raw("Deleted NPC " + id + ".").color(Color.GREEN)); + } + } + + // ---- sub: select --------------------------------------------------------- + + public static final class SelectSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg idArg; + + public SelectSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("select", "Select an NPC by id"); + this.npcs = npcs; + this.selection = selection; + this.idArg = withRequiredArg("id", "NPC id", ArgTypes.STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + String id = ctx.get(idArg); + NpcHandle handle = npcs.byIdInWorld(id, world); + if (handle == null) { + ctx.sendMessage(Message.raw("No NPC with id '" + id + "' in this world.").color(Color.YELLOW)); + return; + } + selection.set(sender.getUuid(), id); + ctx.sendMessage(Message.raw("Selected " + id + " (handler=" + handle.handlerId() + + ", world=" + handle.worldName() + ")").color(Color.GREEN)); + } + } + + // ---- sub: deselect ------------------------------------------------------- + + public static final class DeselectSub extends AbstractPlayerCommand { + private final NpcSelectionStore selection; + + public DeselectSub(@Nonnull NpcSelectionStore selection) { + super("deselect", "Clear your NPC selection"); + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + selection.clear(sender.getUuid()); + ctx.sendMessage(Message.raw("Selection cleared.").color(Color.GREEN)); + } + } + + // ---- sub: info ----------------------------------------------------------- + + public static final class InfoSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public InfoSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("info", "Dump the selected NPC's fields"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + Vector3d p = h.position(); + ctx.sendMessage(Message.raw("--- NPC " + h.id() + " ---").color(Color.GRAY)); + ctx.sendMessage(Message.raw("handler: " + h.handlerId())); + ctx.sendMessage(Message.raw("role: " + h.roleName())); + ctx.sendMessage(Message.raw("world: " + h.worldName())); + ctx.sendMessage(Message.raw("pos: " + fmt(p.x) + " " + fmt(p.y) + " " + fmt(p.z) + + " yaw=" + fmt(h.yaw()))); + ctx.sendMessage(Message.raw("name: '" + h.namePlate() + "' (visible=" + h.namePlateVisible() + ")")); + Map args = h.args(); + if (args.isEmpty()) { + ctx.sendMessage(Message.raw("args: (none)")); + } else { + ctx.sendMessage(Message.raw("args:")); + for (Map.Entry e : args.entrySet()) { + ctx.sendMessage(Message.raw(" " + e.getKey() + " = " + e.getValue())); + } + } + } + + private static String fmt(double v) { return String.format("%.2f", v); } + } + + // ---- sub: tphere --------------------------------------------------------- + + public static final class TpHereSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public TpHereSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("tphere", "Teleport the selected NPC to your position"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + requireSameWorld(ctx, world, h, () -> { + Vector3d pos = sender.getTransform().getPosition(); + npcs.teleport(h, pos.x, pos.y, pos.z); + ctx.sendMessage(Message.raw("NPC " + h.id() + " teleported to you.").color(Color.GREEN)); + }); + } + } + + // ---- sub: tpto ----------------------------------------------------------- + + public static final class TpToSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public TpToSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("tpto", "Teleport yourself to the selected NPC"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + requireSameWorld(ctx, world, h, () -> { + Vector3d pos = h.position(); + Transform t = new Transform(pos, sender.getTransform().getRotation()); + sender.updatePosition(world, t, sender.getHeadRotation()); + ctx.sendMessage(Message.raw("Teleported to " + h.id() + ".").color(Color.GREEN)); + }); + } + } + + // ---- sub: move (relative) ------------------------------------------------ + + public static final class MoveSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg dxArg; + private final RequiredArg dyArg; + private final RequiredArg dzArg; + + public MoveSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("move", "Nudge the selected NPC by (dx dy dz)"); + this.npcs = npcs; + this.selection = selection; + this.dxArg = withRequiredArg("dx", "delta x", ArgTypes.FLOAT); + this.dyArg = withRequiredArg("dy", "delta y", ArgTypes.FLOAT); + this.dzArg = withRequiredArg("dz", "delta z", ArgTypes.FLOAT); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + Vector3d p = h.position(); + npcs.teleport(h, p.x + ctx.get(dxArg), p.y + ctx.get(dyArg), p.z + ctx.get(dzArg)); + ctx.sendMessage(Message.raw("NPC " + h.id() + " moved.").color(Color.GREEN)); + } + } + + // ---- sub: moveto (absolute) ---------------------------------------------- + + public static final class MoveToSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg xArg; + private final RequiredArg yArg; + private final RequiredArg zArg; + + public MoveToSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("moveto", "Place the selected NPC at (x y z)"); + this.npcs = npcs; + this.selection = selection; + this.xArg = withRequiredArg("x", "x", ArgTypes.DOUBLE); + this.yArg = withRequiredArg("y", "y", ArgTypes.DOUBLE); + this.zArg = withRequiredArg("z", "z", ArgTypes.DOUBLE); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + npcs.teleport(h, ctx.get(xArg), ctx.get(yArg), ctx.get(zArg)); + ctx.sendMessage(Message.raw("NPC " + h.id() + " placed.").color(Color.GREEN)); + } + } + + // ---- sub: rotate --------------------------------------------------------- + + public static final class RotateSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg yawArg; + + public RotateSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("rotate", "Set the selected NPC's yaw (degrees)"); + this.npcs = npcs; + this.selection = selection; + this.yawArg = withRequiredArg("yaw", "yaw in degrees", ArgTypes.FLOAT); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + npcs.rotate(h, ctx.get(yawArg)); + ctx.sendMessage(Message.raw("NPC " + h.id() + " yaw set.").color(Color.GREEN)); + } + } + + // ---- sub: face ----------------------------------------------------------- + + public static final class FaceSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + + public FaceSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("face", "Rotate the selected NPC to face you"); + this.npcs = npcs; + this.selection = selection; + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + requireSameWorld(ctx, world, h, () -> { + Vector3d npcPos = h.position(); + Vector3d playerPos = sender.getTransform().getPosition(); + double dx = playerPos.x - npcPos.x; + double dz = playerPos.z - npcPos.z; + float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz)); + npcs.rotate(h, yaw); + ctx.sendMessage(Message.raw("NPC " + h.id() + " now faces you.").color(Color.GREEN)); + }); + } + } + + // ---- sub: name ----------------------------------------------------------- + + public static final class NameSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg textArg; + + public NameSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("name", "Set the selected NPC's name plate text"); + this.npcs = npcs; + this.selection = selection; + this.textArg = withRequiredArg("text", "Name plate text (supports inline color codes)", + ArgTypes.GREEDY_STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + String text = ctx.get(textArg); + npcs.setNamePlate(h, text); + ctx.sendMessage(Message.raw("NPC " + h.id() + " name set.").color(Color.GREEN)); + } + } + + // ---- sub: handler -------------------------------------------------------- + + public static final class HandlerSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final NpcSelectionStore selection; + private final RequiredArg handlerArg; + + public HandlerSub(@Nonnull NpcsImpl npcs, @Nonnull NpcSelectionStore selection) { + super("handler", "Re-route the selected NPC to a different handler id"); + this.npcs = npcs; + this.selection = selection; + this.handlerArg = withRequiredArg("handlerId", "Handler id", ArgTypes.STRING); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + NpcHandle h = requireSelected(npcs, selection, ctx, sender, world); + if (h == null) return; + String handlerId = ctx.get(handlerArg); + npcs.setHandlerId(h, handlerId); + ctx.sendMessage(Message.raw("NPC " + h.id() + " handler set to " + handlerId + ".").color(Color.GREEN)); + } + } + + // ---- sub: list ----------------------------------------------------------- + + public static final class ListSub extends AbstractPlayerCommand { + private final NpcsImpl npcs; + private final OptionalArg worldArg; + + public ListSub(@Nonnull NpcsImpl npcs) { + super("list", "List NPCs (optionally filtered by world)"); + this.npcs = npcs; + this.worldArg = withOptionalArg("world", "World name", ArgTypes.WORLD); + requirePermission(CoreNpcPermissions.ADMIN); + } + + @Override protected boolean canGeneratePermission() { return false; } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef sender, + @Nonnull World world) { + Collection all = ctx.provided(worldArg) + ? npcs.inWorld(ctx.get(worldArg).getName()) + : npcs.inWorld(world.getName()); + if (all.isEmpty()) { + ctx.sendMessage(Message.raw("No NPCs found.").color(Color.YELLOW)); + return; + } + ctx.sendMessage(Message.raw("--- NPCs (" + all.size() + ") ---").color(Color.GRAY)); + for (NpcHandle h : all) { + Vector3d p = h.position(); + ctx.sendMessage(Message.raw(String.format("%s handler=%s world=%s (%.1f %.1f %.1f)", + h.id(), h.handlerId(), h.worldName(), p.x, p.y, p.z))); + } + } + } +} diff --git a/src/main/java/net/kewwbec/corenpc/command/NpcSelectionStore.java b/src/main/java/net/kewwbec/corenpc/command/NpcSelectionStore.java new file mode 100644 index 0000000..8f22efe --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/command/NpcSelectionStore.java @@ -0,0 +1,29 @@ +package net.kewwbec.corenpc.command; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * In-memory mapping of admin player UUID -> currently selected NPC id. + * Selection is per-session: nothing is persisted across server restarts. + */ +public final class NpcSelectionStore { + + private final ConcurrentMap selectionByAdmin = new ConcurrentHashMap<>(); + + @Nullable + public String get(@Nonnull UUID admin) { + return selectionByAdmin.get(admin); + } + + public void set(@Nonnull UUID admin, @Nonnull String npcId) { + selectionByAdmin.put(admin, npcId); + } + + public void clear(@Nonnull UUID admin) { + selectionByAdmin.remove(admin); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/command/TagModeStore.java b/src/main/java/net/kewwbec/corenpc/command/TagModeStore.java new file mode 100644 index 0000000..b69fcc6 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/command/TagModeStore.java @@ -0,0 +1,42 @@ +package net.kewwbec.corenpc.command; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * In-memory state for admins who have run {@code /npc tag } and are waiting + * to right-click an entity to attach the marker. + */ +public final class TagModeStore { + + public static final class PendingTag { + @Nonnull public final String handlerId; + @Nonnull public final Map args; + + public PendingTag(@Nonnull String handlerId, @Nonnull Map args) { + this.handlerId = handlerId; + this.args = Collections.unmodifiableMap(new LinkedHashMap<>(args)); + } + } + + private final ConcurrentMap byAdmin = new ConcurrentHashMap<>(); + + @Nullable + public PendingTag get(@Nonnull UUID admin) { + return byAdmin.get(admin); + } + + public void set(@Nonnull UUID admin, @Nonnull PendingTag pending) { + byAdmin.put(admin, pending); + } + + public void clear(@Nonnull UUID admin) { + byAdmin.remove(admin); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/handler/RunCommandHandler.java b/src/main/java/net/kewwbec/corenpc/handler/RunCommandHandler.java new file mode 100644 index 0000000..86c73df --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/handler/RunCommandHandler.java @@ -0,0 +1,50 @@ +package net.kewwbec.corenpc.handler; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.api.NpcInteractionHandler; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Map; +import java.util.logging.Level; + +/** + * Built-in handler: runs a slash command as the clicking player. The command string + * is read from the NPC's {@code cmd} arg. Leading slashes are stripped. + * + *

Usage from an admin: + *

+ *   /npc tag runcmd cmd=help
+ *   right-click entity
+ *   (player clicks NPC → command "help" runs as that player)
+ *
+ *   # For commands with spaces, set after tagging:
+ *   /npc tag runcmd
+ *   right-click entity
+ *   /npc arg set cmd queue bridge_duels
+ * 
+ */ +public final class RunCommandHandler implements NpcInteractionHandler { + + public static final String HANDLER_ID = "runcmd"; + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + @Override + public void onInteract(@Nonnull PlayerRef player, + @Nonnull NpcHandle npc, + @Nonnull Map args) { + String cmd = args.get("cmd"); + if (cmd == null || cmd.isBlank()) { + player.sendMessage(Message.raw("This NPC has no command configured.").color(Color.YELLOW)); + LOGGER.at(Level.WARNING).log("runcmd NPC %s is missing the 'cmd' arg", npc.id()); + return; + } + if (cmd.startsWith("/")) cmd = cmd.substring(1); + CommandManager.get().handleCommand(player, cmd); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/CoreNpcUseInteraction.java b/src/main/java/net/kewwbec/corenpc/impl/CoreNpcUseInteraction.java new file mode 100644 index 0000000..9d030eb --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/CoreNpcUseInteraction.java @@ -0,0 +1,110 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.InteractionState; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.entity.InteractionContext; +import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.CoreNpcPlugin; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.api.NpcInteractionHandler; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.logging.Level; + +/** + * Fires when a player presses F (or otherwise triggers {@code InteractionType.Use}) + * on an entity carrying our {@link NpcMarkerComponent}. The wiring is the entity's + * {@code Interactions} component, set during {@link NpcsImpl#tag} to route Use to + * this interaction's asset id. + * + *

Mirrors the pattern used by HylogramsFramework's HologramUseInteraction — no + * role/InteractionInstruction surgery needed; works on any entity we tag.

+ */ +public final class CoreNpcUseInteraction extends SimpleInstantInteraction { + + /** Asset id stored on tagged entities' {@code Interactions} component. */ + public static final String ASSET_ID = "*CoreNpcUse"; + + /** Codec-registration name (without the {@code *} prefix the runtime uses for synthetic ids). */ + public static final String CODEC_ID = "CoreNpcUse"; + + @Nonnull + @SuppressWarnings({"unchecked", "rawtypes"}) + public static final BuilderCodec CODEC = ((BuilderCodec.Builder) BuilderCodec + .builder(CoreNpcUseInteraction.class, CoreNpcUseInteraction::new, SimpleInstantInteraction.CODEC) + .documentation("Routes a Use action on a CoreNpc-tagged entity to its registered handler.")) + .build(); + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + public CoreNpcUseInteraction(@Nonnull String id) { + super(id); + } + + protected CoreNpcUseInteraction() { + } + + @Override + protected void firstRun(@Nonnull InteractionType type, + @Nonnull InteractionContext context, + @Nonnull CooldownHandler cooldown) { + CommandBuffer cb = context.getCommandBuffer(); + if (cb == null) { + context.getState().state = InteractionState.Failed; + return; + } + + Ref sourceRef = context.getEntity(); + if (sourceRef == null || !sourceRef.isValid()) { + context.getState().state = InteractionState.Failed; + return; + } + PlayerRef player = cb.getComponent(sourceRef, PlayerRef.getComponentType()); + if (player == null) { + context.getState().state = InteractionState.Failed; + return; + } + + Ref targetRef = context.getTargetEntity(); + if (targetRef == null || !targetRef.isValid()) { + context.getState().state = InteractionState.Failed; + return; + } + + NpcMarkerComponent marker = cb.getComponent(targetRef, NpcMarkerComponent.getComponentType()); + if (marker == null) { + context.getState().state = InteractionState.Failed; + return; + } + + String handlerId = marker.getHandlerId(); + if (handlerId == null || handlerId.isBlank()) return; + + NpcsImpl npcs = CoreNpcPlugin.getInstance().getNpcsImpl(); + NpcInteractionHandler handler = npcs.getHandler(handlerId); + if (handler == null) { + LOGGER.at(Level.WARNING).log( + "NPC %s routed to unknown handler '%s' — ignoring use from %s", + marker.getId(), handlerId, player.getUsername()); + return; + } + + try { + World world = cb.getExternalData() != null ? cb.getExternalData().getWorld() : null; + NpcHandle handle = world != null ? npcs.byIdInWorld(marker.getId(), world) : null; + handler.onInteract(player, handle, Collections.unmodifiableMap(marker.getArgs())); + } catch (Throwable t) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(t)) + .log("NPC handler '%s' threw on use from %s", handlerId, player.getUsername()); + } + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/EnsureInteractionsSystem.java b/src/main/java/net/kewwbec/corenpc/impl/EnsureInteractionsSystem.java new file mode 100644 index 0000000..c556c6a --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/EnsureInteractionsSystem.java @@ -0,0 +1,66 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.component.system.tick.EntityTickingSystem; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.modules.entity.component.Interactable; +import com.hypixel.hytale.server.core.modules.interaction.Interactions; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * Re-applies our {@code Use} interaction mapping to every entity carrying a + * {@link NpcMarkerComponent}. NPC entities derive their {@code Interactions} + * component from their role on load, which wipes the mapping we set at tag time — + * this system idempotently restores it each tick so F-key dispatch survives + * server restart. + * + *

Pure component mutation when the {@code Interactions} component is present + * (cheap, no structural change). When the component is missing entirely, the + * structural change is queued via {@link Store#ensureComponent} on the next + * world-thread pass — but in practice NPC entities always have one.

+ */ +public final class EnsureInteractionsSystem extends EntityTickingSystem { + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static final Query QUERY = + (Query) (Query) NpcMarkerComponent.getComponentType(); + + public EnsureInteractionsSystem() { + } + + @Override + @Nonnull + public Query getQuery() { + return QUERY; + } + + @Override + public void tick(float dt, + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + Interactions interactions = chunk.getComponent(index, Interactions.getComponentType()); + if (interactions == null) { + Ref ref = chunk.getReferenceTo(index); + if (ref == null || !ref.isValid()) return; + store.ensureComponent(ref, Interactable.getComponentType()); + store.ensureComponent(ref, Interactions.getComponentType()); + Interactions installed = store.getComponent(ref, Interactions.getComponentType()); + if (installed != null) { + installed.setInteractionId(InteractionType.Use, CoreNpcUseInteraction.ASSET_ID); + } + return; + } + String currentUse = interactions.getInteractionId(InteractionType.Use); + if (!CoreNpcUseInteraction.ASSET_ID.equals(currentUse)) { + interactions.setInteractionId(InteractionType.Use, CoreNpcUseInteraction.ASSET_ID); + } + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/InteractionRouter.java b/src/main/java/net/kewwbec/corenpc/impl/InteractionRouter.java new file mode 100644 index 0000000..09217c4 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/InteractionRouter.java @@ -0,0 +1,110 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.event.events.player.PlayerInteractEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.CoreNpcPermissions; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.api.NpcInteractionHandler; +import net.kewwbec.corenpc.command.TagModeStore; +import net.kewwbec.corenpc.command.TagModeStore.PendingTag; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Collections; +import java.util.logging.Level; + +public final class InteractionRouter { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final NpcsImpl npcs; + private final TagModeStore tagMode; + + public InteractionRouter(@Nonnull NpcsImpl npcs, @Nonnull TagModeStore tagMode) { + this.npcs = npcs; + this.tagMode = tagMode; + } + + public void handle(@Nonnull PlayerInteractEvent event) { + if (event.isCancelled()) return; + InteractionType type = event.getActionType(); + if (type != InteractionType.Use && type != InteractionType.Secondary) return; + + Ref targetRef = event.getTargetRef(); + if (targetRef == null || !targetRef.isValid()) return; + + Store store = targetRef.getStore(); + if (store == null) return; + + PlayerRef player = resolvePlayer(event); + if (player == null) return; + + PendingTag pending = tagMode.get(player.getUuid()); + if (pending != null && player.hasPermission(CoreNpcPermissions.ADMIN)) { + handleTag(event, store, targetRef, player, pending); + return; + } + + NpcMarkerComponent marker = store.getComponent(targetRef, NpcMarkerComponent.getComponentType()); + if (marker == null) return; + + String handlerId = marker.getHandlerId(); + if (handlerId == null || handlerId.isBlank()) return; + + NpcInteractionHandler handler = npcs.getHandler(handlerId); + if (handler == null) { + LOGGER.at(Level.WARNING).log( + "NPC %s routed to unknown handler '%s' — ignoring click from %s", + marker.getId(), handlerId, player.getUsername()); + event.setCancelled(true); + return; + } + + event.setCancelled(true); + try { + handler.onInteract(player, npcs.resolve(marker.getId()), + Collections.unmodifiableMap(marker.getArgs())); + } catch (Throwable t) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(t)) + .log("NPC handler '%s' threw on click from %s", handlerId, player.getUsername()); + } + } + + private void handleTag(@Nonnull PlayerInteractEvent event, + @Nonnull Store store, + @Nonnull Ref targetRef, + @Nonnull PlayerRef admin, + @Nonnull PendingTag pending) { + event.setCancelled(true); + + NpcMarkerComponent existing = store.getComponent(targetRef, NpcMarkerComponent.getComponentType()); + if (existing != null) { + admin.sendMessage(Message.raw("Entity is already a CoreNpc (" + existing.getId() + + "). Use /npc untag first if you want to retag.").color(Color.YELLOW)); + return; + } + + try { + NpcHandle handle = npcs.tag(targetRef, null, pending.handlerId, pending.args); + tagMode.clear(admin.getUuid()); + admin.sendMessage(Message.raw("Tagged entity as " + handle.id() + + " (handler=" + pending.handlerId + ").").color(Color.GREEN)); + } catch (RuntimeException e) { + admin.sendMessage(Message.raw("Tag failed: " + e.getMessage()).color(Color.RED)); + } + } + + private static PlayerRef resolvePlayer(@Nonnull PlayerInteractEvent event) { + Ref ref = event.getPlayerRef(); + if (ref == null || !ref.isValid()) return null; + Store store = ref.getStore(); + if (store == null) return null; + return store.getComponent(ref, PlayerRef.getComponentType()); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/NpcHandleImpl.java b/src/main/java/net/kewwbec/corenpc/impl/NpcHandleImpl.java new file mode 100644 index 0000000..406579f --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/NpcHandleImpl.java @@ -0,0 +1,126 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.api.NpcHandle; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +final class NpcHandleImpl implements NpcHandle { + + private final String worldName; + private final Store store; + private volatile Ref ref; + + NpcHandleImpl(@Nonnull String worldName, + @Nonnull Store store, + @Nonnull Ref ref) { + this.worldName = worldName; + this.store = store; + this.ref = ref; + } + + void invalidate() { + this.ref = null; + } + + @Nullable + NpcMarkerComponent marker() { + Ref r = this.ref; + if (r == null || !r.isValid()) return null; + return store.getComponent(r, NpcMarkerComponent.getComponentType()); + } + + @Nullable + TransformComponent transform() { + Ref r = this.ref; + if (r == null || !r.isValid()) return null; + return store.getComponent(r, TransformComponent.getComponentType()); + } + + @Nonnull + Store store() { + return store; + } + + @Override + @Nonnull + public String id() { + NpcMarkerComponent m = marker(); + return m != null ? m.getId() : ""; + } + + @Override + @Nonnull + public String handlerId() { + NpcMarkerComponent m = marker(); + return m != null ? m.getHandlerId() : ""; + } + + @Override + @Nonnull + public String namePlate() { + NpcMarkerComponent m = marker(); + return m != null ? m.getNamePlate() : ""; + } + + @Override + public boolean namePlateVisible() { + NpcMarkerComponent m = marker(); + return m != null && m.isNamePlateVisible(); + } + + @Override + @Nonnull + public String roleName() { + NpcMarkerComponent m = marker(); + return m != null ? m.getRoleName() : "Static_NPC"; + } + + @Override + @Nonnull + public Map args() { + NpcMarkerComponent m = marker(); + if (m == null) return Collections.emptyMap(); + return Collections.unmodifiableMap(new LinkedHashMap<>(m.getArgs())); + } + + @Override + @Nonnull + public String worldName() { + return worldName; + } + + @Override + @Nonnull + public Vector3d position() { + TransformComponent t = transform(); + return t != null ? new Vector3d(t.getPosition()) : new Vector3d(); + } + + @Override + public float yaw() { + TransformComponent t = transform(); + return t != null ? t.getRotation().yaw() : 0f; + } + + @Override + @Nullable + public Ref entityRef() { + Ref r = this.ref; + return (r != null && r.isValid()) ? r : null; + } + + @Override + public boolean isSpawned() { + Ref r = this.ref; + return r != null && r.isValid(); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/NpcMarkerComponent.java b/src/main/java/net/kewwbec/corenpc/impl/NpcMarkerComponent.java new file mode 100644 index 0000000..bd511b8 --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/NpcMarkerComponent.java @@ -0,0 +1,101 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.codec.codecs.map.MapCodec; +import com.hypixel.hytale.codec.codecs.simple.BooleanCodec; +import com.hypixel.hytale.codec.codecs.simple.StringCodec; +import com.hypixel.hytale.component.Component; +import com.hypixel.hytale.component.ComponentRegistryProxy; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Persisted ECS component tagging an entity as a CoreNpc-managed NPC. Carries the + * routing data read by InteractionRouter at click time. Position/yaw come from + * TransformComponent — not duplicated here to avoid drift. + */ +public final class NpcMarkerComponent implements Component { + + private static final StringCodec STRING_CODEC = new StringCodec(); + private static final BooleanCodec BOOL_CODEC = new BooleanCodec(); + + public static final BuilderCodec CODEC = BuilderCodec + .builder(NpcMarkerComponent.class, NpcMarkerComponent::new) + .addField(new KeyedCodec<>("Id", STRING_CODEC), + NpcMarkerComponent::setId, NpcMarkerComponent::getId) + .addField(new KeyedCodec<>("HandlerId", STRING_CODEC), + NpcMarkerComponent::setHandlerId, NpcMarkerComponent::getHandlerId) + .addField(new KeyedCodec<>("NamePlate", STRING_CODEC), + NpcMarkerComponent::setNamePlate, NpcMarkerComponent::getNamePlate) + .addField(new KeyedCodec<>("NamePlateVisible", BOOL_CODEC), + NpcMarkerComponent::setNamePlateVisibleBoxed, NpcMarkerComponent::getNamePlateVisibleBoxed) + .addField(new KeyedCodec<>("RoleName", STRING_CODEC), + NpcMarkerComponent::setRoleName, NpcMarkerComponent::getRoleName) + .addField(new KeyedCodec<>("Args", MapCodec.STRING_HASH_MAP_CODEC), + NpcMarkerComponent::setArgs, NpcMarkerComponent::getArgs) + .build(); + + private static volatile ComponentType TYPE; + + public static void register(@Nonnull ComponentRegistryProxy registry) { + if (TYPE != null) return; + TYPE = registry.registerComponent(NpcMarkerComponent.class, "kewwbec.corenpc.NpcMarker", CODEC); + } + + @Nonnull + public static ComponentType getComponentType() { + ComponentType t = TYPE; + if (t == null) throw new IllegalStateException("NpcMarkerComponent has not been registered yet"); + return t; + } + + private String id = ""; + private String handlerId = ""; + private String namePlate = ""; + private boolean namePlateVisible = true; + private String roleName = "Static_NPC"; + private Map args = new LinkedHashMap<>(); + + public NpcMarkerComponent() { + } + + public String getId() { return id; } + public void setId(String id) { this.id = Objects.requireNonNullElse(id, ""); } + + public String getHandlerId() { return handlerId; } + public void setHandlerId(String handlerId) { this.handlerId = Objects.requireNonNullElse(handlerId, ""); } + + public String getNamePlate() { return namePlate; } + public void setNamePlate(String namePlate) { this.namePlate = Objects.requireNonNullElse(namePlate, ""); } + + public boolean isNamePlateVisible() { return namePlateVisible; } + public void setNamePlateVisible(boolean v) { this.namePlateVisible = v; } + Boolean getNamePlateVisibleBoxed() { return namePlateVisible; } + void setNamePlateVisibleBoxed(Boolean v) { this.namePlateVisible = (v != null) ? v : true; } + + public String getRoleName() { return roleName; } + public void setRoleName(String roleName) { this.roleName = Objects.requireNonNullElse(roleName, "Static_NPC"); } + + public Map getArgs() { return args; } + public void setArgs(Map args) { + this.args = (args == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(args); + } + + @Override + public Component clone() { + NpcMarkerComponent c = new NpcMarkerComponent(); + c.id = this.id; + c.handlerId = this.handlerId; + c.namePlate = this.namePlate; + c.namePlateVisible = this.namePlateVisible; + c.roleName = this.roleName; + c.args = new LinkedHashMap<>(this.args); + return c; + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/NpcsImpl.java b/src/main/java/net/kewwbec/corenpc/impl/NpcsImpl.java new file mode 100644 index 0000000..97d3e5a --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/NpcsImpl.java @@ -0,0 +1,322 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.component.AddReason; +import com.hypixel.hytale.component.Holder; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.RemoveReason; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.modules.entity.component.Interactable; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.interaction.Interactions; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.api.NpcInteractionHandler; +import net.kewwbec.corenpc.api.Npcs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Level; + +public final class NpcsImpl implements Npcs { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final ConcurrentMap handlers = new ConcurrentHashMap<>(); + + @Override + public void registerHandler(@Nonnull String handlerId, @Nonnull NpcInteractionHandler handler) { + handlers.put(handlerId, handler); + LOGGER.at(Level.INFO).log("NPC handler registered: %s", handlerId); + } + + @Override + public void unregisterHandler(@Nonnull String handlerId) { + if (handlers.remove(handlerId) != null) { + LOGGER.at(Level.INFO).log("NPC handler unregistered: %s", handlerId); + } + } + + @Nullable + public NpcInteractionHandler getHandler(@Nonnull String handlerId) { + return handlers.get(handlerId); + } + + /** + * Attach a marker to an already-spawned entity. Used by tag-mode in InteractionRouter. + * + * @param targetRef entity to tag (must not already carry a marker) + * @param id marker id; auto-generated if null/blank + * @param handlerId handler this NPC routes to + * @param args per-NPC handler payload (copied) + * @return new handle backed by the modified entity + */ + @Nonnull + public NpcHandle tag(@Nonnull Ref targetRef, + @Nullable String id, + @Nonnull String handlerId, + @Nonnull Map args) { + Store store = targetRef.getStore(); + if (store == null) throw new IllegalStateException("entity has no store"); + + if (store.getComponent(targetRef, NpcMarkerComponent.getComponentType()) != null) { + throw new IllegalStateException("entity is already tagged as a CoreNpc"); + } + + String finalId = (id == null || id.isBlank()) ? generateId() : id; + if (byIdInStore(store, finalId) != null) { + throw new IllegalStateException("NPC id already in use: " + finalId); + } + + NpcMarkerComponent marker = new NpcMarkerComponent(); + marker.setId(finalId); + marker.setHandlerId(handlerId); + marker.setArgs(new LinkedHashMap<>(args)); + + Ref newRef = attachMarker(store, targetRef, marker); + NPCEntity npcEntity = store.getComponent(newRef, NPCEntity.getComponentType()); + if (npcEntity != null) npcEntity.markNeedsSave(); + String worldName = resolveWorldName(store); + LOGGER.at(Level.INFO).log("Tagged entity as NPC %s (handler=%s, world=%s)", + finalId, handlerId, worldName); + return new NpcHandleImpl(worldName, store, newRef); + } + + /** Strip the marker, leaving the entity intact. */ + public void untag(@Nonnull NpcHandle handle) { + NpcHandleImpl impl = expect(handle); + Ref ref = impl.entityRef(); + if (ref == null) return; + Store store = impl.store(); + Holder holder = store.removeEntity(ref, RemoveReason.REMOVE); + holder.removeComponent(NpcMarkerComponent.getComponentType()); + store.addEntity(holder, AddReason.SPAWN); + impl.invalidate(); + } + + @Nonnull + private Ref attachMarker(@Nonnull Store store, + @Nonnull Ref originalRef, + @Nonnull NpcMarkerComponent marker) { + Holder holder = store.removeEntity(originalRef, RemoveReason.REMOVE); + holder.addComponent(NpcMarkerComponent.getComponentType(), marker); + if (holder.getComponent(Interactable.getComponentType()) == null) { + holder.addComponent(Interactable.getComponentType(), Interactable.INSTANCE); + } + Interactions interactions = holder.getComponent(Interactions.getComponentType()); + if (interactions == null) { + interactions = new Interactions(); + holder.addComponent(Interactions.getComponentType(), interactions); + } + interactions.setInteractionId(InteractionType.Use, CoreNpcUseInteraction.ASSET_ID); + return store.addEntity(holder, AddReason.SPAWN); + } + + @Override + public void despawn(@Nonnull NpcHandle handle) { + NpcHandleImpl impl = expect(handle); + Ref ref = impl.entityRef(); + if (ref == null) return; + impl.store().removeEntity(ref, RemoveReason.REMOVE); + impl.invalidate(); + } + + @Override + @Nullable + public NpcHandle byId(@Nonnull String id) { + for (World world : universeOrEmpty()) { + Store store = world.getEntityStore().getStore(); + Ref ref = byIdInStore(store, id); + if (ref != null) { + return new NpcHandleImpl(world.getName(), store, ref); + } + } + return null; + } + + @Override + @Nullable + public NpcHandle byIdInWorld(@Nonnull String id, @Nonnull World world) { + Store store = world.getEntityStore().getStore(); + Ref ref = byIdInStore(store, id); + if (ref == null) return null; + return new NpcHandleImpl(world.getName(), store, ref); + } + + @Override + @Nonnull + public Collection inWorld(@Nonnull String worldName) { + World world = findWorld(worldName); + if (world == null) return Collections.emptyList(); + Store store = world.getEntityStore().getStore(); + List out = new ArrayList<>(); + scanMarkers(store, (ref, marker) -> out.add(new NpcHandleImpl(worldName, store, ref))); + return out; + } + + @Override + @Nonnull + public Collection all() { + List out = new ArrayList<>(); + for (World world : universeOrEmpty()) { + Store store = world.getEntityStore().getStore(); + String wn = world.getName(); + scanMarkers(store, (ref, marker) -> out.add(new NpcHandleImpl(wn, store, ref))); + } + return out; + } + + // ---- mutation helpers consumed by the /npc command ----------------------- + + @Nullable + NpcHandleImpl resolve(@Nonnull String id) { + NpcHandle h = byId(id); + return (h instanceof NpcHandleImpl impl) ? impl : null; + } + + public void teleport(@Nonnull NpcHandle handle, double x, double y, double z) { + NpcHandleImpl impl = expect(handle); + TransformComponent t = impl.transform(); + if (t == null) return; + t.teleportPosition(new org.joml.Vector3d(x, y, z)); + markNeedsSave(impl); + } + + public void rotate(@Nonnull NpcHandle handle, float yaw) { + NpcHandleImpl impl = expect(handle); + TransformComponent t = impl.transform(); + if (t == null) return; + Rotation3f current = t.getRotation(); + t.teleportRotation(new Rotation3f(current.pitch(), yaw, current.roll())); + markNeedsSave(impl); + } + + public void setNamePlate(@Nonnull NpcHandle handle, @Nonnull String text) { + NpcHandleImpl impl = expect(handle); + NpcMarkerComponent m = impl.marker(); + if (m == null) return; + m.setNamePlate(text); + markNeedsSave(impl); + } + + public void setNamePlateVisible(@Nonnull NpcHandle handle, boolean visible) { + NpcHandleImpl impl = expect(handle); + NpcMarkerComponent m = impl.marker(); + if (m == null) return; + m.setNamePlateVisible(visible); + markNeedsSave(impl); + } + + public void setHandlerId(@Nonnull NpcHandle handle, @Nonnull String handlerId) { + NpcHandleImpl impl = expect(handle); + NpcMarkerComponent m = impl.marker(); + if (m == null) return; + m.setHandlerId(handlerId); + markNeedsSave(impl); + } + + public void setArg(@Nonnull NpcHandle handle, @Nonnull String key, @Nonnull String value) { + NpcHandleImpl impl = expect(handle); + NpcMarkerComponent m = impl.marker(); + if (m == null) return; + m.getArgs().put(key, value); + markNeedsSave(impl); + } + + public void removeArg(@Nonnull NpcHandle handle, @Nonnull String key) { + NpcHandleImpl impl = expect(handle); + NpcMarkerComponent m = impl.marker(); + if (m == null) return; + m.getArgs().remove(key); + markNeedsSave(impl); + } + + private static void markNeedsSave(@Nonnull NpcHandleImpl handle) { + Ref ref = handle.entityRef(); + if (ref == null || !ref.isValid()) return; + NPCEntity npcEntity = handle.store().getComponent(ref, NPCEntity.getComponentType()); + if (npcEntity != null) npcEntity.markNeedsSave(); + } + + @Nonnull + private static NpcHandleImpl expect(@Nonnull NpcHandle handle) { + if (handle instanceof NpcHandleImpl impl) return impl; + throw new IllegalArgumentException("foreign NpcHandle implementation"); + } + + // ---- scanning ------------------------------------------------------------ + + private interface MarkerVisitor { + void visit(@Nonnull Ref ref, @Nonnull NpcMarkerComponent marker); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static final Query MARKER_QUERY = + (Query) (Query) NpcMarkerComponent.getComponentType(); + + private static void scanMarkers(@Nonnull Store store, @Nonnull MarkerVisitor visitor) { + store.forEachChunk(MARKER_QUERY, (chunk, cb) -> { + int n = chunk.size(); + for (int i = 0; i < n; i++) { + NpcMarkerComponent m = chunk.getComponent(i, NpcMarkerComponent.getComponentType()); + if (m != null) visitor.visit(chunk.getReferenceTo(i), m); + } + }); + } + + @Nullable + private static Ref byIdInStore(@Nonnull Store store, @Nonnull String id) { + @SuppressWarnings("unchecked") + Ref[] match = (Ref[]) new Ref[1]; + scanMarkers(store, (ref, marker) -> { + if (id.equals(marker.getId()) && match[0] == null) match[0] = ref; + }); + return match[0]; + } + + // ---- world resolution ---------------------------------------------------- + + @Nullable + private static World findWorld(@Nonnull String worldName) { + Universe universe = Universe.get(); + if (universe == null) return null; + return universe.getWorld(worldName); + } + + @Nonnull + private static Collection universeOrEmpty() { + Universe universe = Universe.get(); + return (universe == null) ? Collections.emptyList() : universe.getWorlds().values(); + } + + @Nonnull + private static String resolveWorldName(@Nonnull Store store) { + for (World w : universeOrEmpty()) { + if (w.getEntityStore().getStore() == store) { + String n = w.getName(); + return (n != null) ? n : ""; + } + } + return ""; + } + + @Nonnull + private static String generateId() { + return "npc_" + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/java/net/kewwbec/corenpc/impl/TagNpcInteraction.java b/src/main/java/net/kewwbec/corenpc/impl/TagNpcInteraction.java new file mode 100644 index 0000000..f8a573f --- /dev/null +++ b/src/main/java/net/kewwbec/corenpc/impl/TagNpcInteraction.java @@ -0,0 +1,107 @@ +package net.kewwbec.corenpc.impl; + +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.entity.InteractionContext; +import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.corenpc.CoreNpcPermissions; +import net.kewwbec.corenpc.CoreNpcPlugin; +import net.kewwbec.corenpc.api.NpcHandle; +import net.kewwbec.corenpc.command.TagModeStore; +import net.kewwbec.corenpc.command.TagModeStore.PendingTag; +import net.kewwbec.corenpc.handler.RunCommandHandler; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.Collections; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Custom item interaction triggered by the Tag Wand's right-click. Registered in + * {@link CoreNpcPlugin#setup()} as {@code CoreNpc_Tag_Interaction}. + * + *

{@code firstRun} runs inside the world's tick — structural ECS changes + * (removeEntity/addEntity) are forbidden there. We capture the inputs and defer + * the actual tag operation via {@code world.execute(...)}, which runs once the + * current tick releases its write lock.

+ */ +public final class TagNpcInteraction extends SimpleInstantInteraction { + + public static final String INTERACTION_ID = "CoreNpc_Tag_Interaction"; + + public static final BuilderCodec CODEC = BuilderCodec.builder( + TagNpcInteraction.class, TagNpcInteraction::new, SimpleInstantInteraction.CODEC + ).build(); + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + public TagNpcInteraction() { + super(); + } + + @Override + protected void firstRun(@Nonnull InteractionType type, + @Nonnull InteractionContext ctx, + @Nonnull CooldownHandler cooldown) { + Ref targetRef = ctx.getTargetEntity(); + if (targetRef == null || !targetRef.isValid()) return; + + CommandBuffer cb = ctx.getCommandBuffer(); + if (cb == null) return; + + Ref playerRef = ctx.getEntity(); + if (playerRef == null || !playerRef.isValid()) return; + PlayerRef player = cb.getComponent(playerRef, PlayerRef.getComponentType()); + if (player == null) return; + if (!player.hasPermission(CoreNpcPermissions.ADMIN)) return; + + NpcMarkerComponent existing = cb.getComponent(targetRef, NpcMarkerComponent.getComponentType()); + if (existing != null) { + player.sendMessage(Message.raw("Already tagged as " + existing.getId() + + ". /cnpc untag first if you want to retag.").color(Color.YELLOW)); + return; + } + + World world = cb.getExternalData().getWorld(); + if (world == null) return; + + CoreNpcPlugin plugin = CoreNpcPlugin.getInstance(); + NpcsImpl npcs = plugin.getNpcsImpl(); + TagModeStore tagMode = plugin.getTagModeStore(); + + UUID playerUuid = player.getUuid(); + PendingTag pending = tagMode.get(playerUuid); + final PendingTag finalPending = (pending != null) + ? pending + : new PendingTag(RunCommandHandler.HANDLER_ID, Collections.emptyMap()); + + final Ref finalTarget = targetRef; + final PlayerRef finalPlayer = player; + + world.execute(() -> { + if (!finalTarget.isValid()) { + finalPlayer.sendMessage(Message.raw("Target entity is no longer valid.").color(Color.YELLOW)); + return; + } + try { + NpcHandle handle = npcs.tag(finalTarget, null, finalPending.handlerId, finalPending.args); + tagMode.clear(playerUuid); + finalPlayer.sendMessage(Message.raw("Tagged entity as " + handle.id() + + " (handler=" + finalPending.handlerId + ").").color(Color.GREEN)); + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Tag failed for %s", finalPlayer.getUsername()); + finalPlayer.sendMessage(Message.raw("Tag failed: " + e.getMessage()).color(Color.RED)); + } + }); + } +} diff --git a/src/main/resources/Server/Item/Items/CoreNpc_Tag_Wand.json b/src/main/resources/Server/Item/Items/CoreNpc_Tag_Wand.json new file mode 100644 index 0000000..1e8f3ad --- /dev/null +++ b/src/main/resources/Server/Item/Items/CoreNpc_Tag_Wand.json @@ -0,0 +1,33 @@ +{ + "$Comment": "Held admin tool. Right-click on an entity runs our custom CoreNpc_Tag_Interaction, which attaches a marker via NpcsImpl.tag. /cnpc gettool grants one.", + "Id": "CoreNpc_Tag_Wand", + "TranslationProperties": { + "Name": "CoreNpc Tag Wand", + "Description": "Right-click an entity to tag it as a CoreNpc" + }, + "Icon": "Icons/Items/EditorTools/Entity.png", + "Model": "Items/Tools/Brush/Brush.blockymodel", + "Texture": "Items/Tools/Brush/Brush_Texture.png", + "Quality": "Tool", + "MaxStack": 1, + "PlayerAnimationsId": "Item", + "Categories": [ + "Tool.BuilderTool" + ], + "SubCategory": "BasicTools", + "InteractionConfig": { + "DebugOutlines": true, + "UseDistance": { + "Adventure": 128, + "Creative": 128 + }, + "AllEntities": true + }, + "Interactions": { + "Secondary": { + "Interactions": [ + { "Type": "CoreNpc_Tag_Interaction" } + ] + } + } +} \ No newline at end of file diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..ddf0fe1 --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,17 @@ +{ + "Group": "kewwbec", + "Name": "CoreNpc", + "Version": "0.1.0", + "Description": "Reusable NPC service: tag any builder-tools-spawned entity as an interactive NPC with handler-routed clicks", + "IncludesAssetPack": true, + "Authors": [ + {"Name": "kewwbec"} + ], + "ServerVersion": "*", + "Dependencies": { + "kewwbec:NetworkCore": "*" + }, + "OptionalDependencies": {}, + "DisabledByDefault": false, + "Main": "net.kewwbec.corenpc.CoreNpcPlugin" +}