This commit is contained in:
2026-06-04 16:52:06 -04:00
commit 3edddf172e
22 changed files with 2305 additions and 0 deletions
+30
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.kewwbec</groupId>
<artifactId>corenpc</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<name>CoreNpc</name>
<description>Reusable NPC service: handler-routed interactions + admin /npc editor for KweebecNet plugins</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hytale.server.jar>${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar</hytale.server.jar>
<networkcore.jar>${project.basedir}/../Core/target/NetworkCore-0.1.0.jar</networkcore.jar>
</properties>
<dependencies>
<dependency>
<groupId>com.hypixel.hytale</groupId>
<artifactId>hytale-server</artifactId>
<version>local</version>
<scope>system</scope>
<systemPath>${hytale.server.jar}</systemPath>
</dependency>
<dependency>
<groupId>net.kewwbec</groupId>
<artifactId>networkcore</artifactId>
<version>0.1.0</version>
<scope>system</scope>
<systemPath>${networkcore.jar}</systemPath>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
<artifactSet>
<excludes>
<exclude>com.hypixel.hytale:*</exclude>
<exclude>net.kewwbec:networkcore</exclude>
<exclude>com.google.code.findbugs:*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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() {
}
}
@@ -0,0 +1,9 @@
package net.kewwbec.corenpc;
public final class CoreNpcPermissions {
public static final String ADMIN = "corenpc.admin";
private CoreNpcPermissions() {
}
}
@@ -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;
}
}
@@ -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.
*
* <p>Mutations are admin-only and flow through the {@code /npc} command — there is
* deliberately no public setter API on this handle.</p>
*/
public interface NpcHandle {
@Nonnull String id();
@Nonnull String handlerId();
@Nonnull String namePlate();
boolean namePlateVisible();
@Nonnull String roleName();
@Nonnull Map<String, String> args();
@Nonnull String worldName();
@Nonnull Vector3d position();
float yaw();
/** Reference to the backing ECS entity; {@code null} if the world is unloaded. */
@Nullable
Ref<EntityStore> entityRef();
/** True when the backing entity is currently present in a loaded world. */
boolean isSpawned();
}
@@ -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.
*
* <p><b>Threading:</b> {@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.</p>
*
* <p>Register handlers from your plugin's {@code start()}:
* <pre>{@code
* Npcs npcs = NetworkCore.getInstance().findService(Npcs.class);
* npcs.registerHandler("queue:bridge_duels", (player, npc, args) -> {
* queueService.join(player, args.getOrDefault("gameId", "bridge_duels"));
* });
* }</pre>
*/
@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<String, String> args);
}
@@ -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)}.
*
* <p>NPCs are existing entities (Trork, Mannequin, animal, whatever) that an admin
* tagged via {@code /npc tag <handlerId>}. 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.</p>
*/
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. <b>Only safe to call from a
* non-world thread</b> (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<NpcHandle> inWorld(@Nonnull String worldName);
/** Every NPC the service knows about across all loaded worlds. */
@Nonnull
Collection<NpcHandle> all();
}
@@ -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<String> keyArg;
private final RequiredArg<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender,
@Nonnull World world) {
NpcHandle h = NpcCommand.requireSelected(npcs, selection, ctx, sender, world);
if (h == null) return;
Map<String, String> 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<String, String> e : args.entrySet()) {
ctx.sendMessage(Message.raw(" " + e.getKey() + " = " + e.getValue()));
}
}
}
}
@@ -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 <id> 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<String> handlerArg;
private final OptionalArg<List<String>> 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<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender,
@Nonnull World world) {
String handlerId = ctx.get(handlerArg);
java.util.Map<String, String> 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<String> handlerArg;
private final RequiredArg<Integer> entityIdArg;
private final OptionalArg<List<String>> 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<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender,
@Nonnull World world) {
int networkId = ctx.get(entityIdArg);
Ref<EntityStore> 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<String, String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <command>).").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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String, String> args = h.args();
if (args.isEmpty()) {
ctx.sendMessage(Message.raw("args: (none)"));
} else {
ctx.sendMessage(Message.raw("args:"));
for (Map.Entry<String, String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<Float> dxArg;
private final RequiredArg<Float> dyArg;
private final RequiredArg<Float> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<Double> xArg;
private final RequiredArg<Double> yArg;
private final RequiredArg<Double> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<Float> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<World> 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<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender,
@Nonnull World world) {
Collection<NpcHandle> 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)));
}
}
}
}
@@ -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 -&gt; currently selected NPC id.
* Selection is per-session: nothing is persisted across server restarts.
*/
public final class NpcSelectionStore {
private final ConcurrentMap<UUID, String> 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);
}
}
@@ -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 <handler>} 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<String, String> args;
public PendingTag(@Nonnull String handlerId, @Nonnull Map<String, String> args) {
this.handlerId = handlerId;
this.args = Collections.unmodifiableMap(new LinkedHashMap<>(args));
}
}
private final ConcurrentMap<UUID, PendingTag> 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);
}
}
@@ -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.
*
* <p>Usage from an admin:
* <pre>
* /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
* </pre>
*/
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<String, String> 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);
}
}
@@ -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.
*
* <p>Mirrors the pattern used by HylogramsFramework's HologramUseInteraction — no
* role/InteractionInstruction surgery needed; works on any entity we tag.</p>
*/
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<CoreNpcUseInteraction> 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<EntityStore> cb = context.getCommandBuffer();
if (cb == null) {
context.getState().state = InteractionState.Failed;
return;
}
Ref<EntityStore> 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<EntityStore> 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());
}
}
}
@@ -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.
*
* <p>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.</p>
*/
public final class EnsureInteractionsSystem extends EntityTickingSystem<EntityStore> {
@SuppressWarnings({"unchecked", "rawtypes"})
private static final Query<EntityStore> QUERY =
(Query<EntityStore>) (Query) NpcMarkerComponent.getComponentType();
public EnsureInteractionsSystem() {
}
@Override
@Nonnull
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void tick(float dt,
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
Interactions interactions = chunk.getComponent(index, Interactions.getComponentType());
if (interactions == null) {
Ref<EntityStore> 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);
}
}
}
@@ -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<EntityStore> targetRef = event.getTargetRef();
if (targetRef == null || !targetRef.isValid()) return;
Store<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> ref = event.getPlayerRef();
if (ref == null || !ref.isValid()) return null;
Store<EntityStore> store = ref.getStore();
if (store == null) return null;
return store.getComponent(ref, PlayerRef.getComponentType());
}
}
@@ -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<EntityStore> store;
private volatile Ref<EntityStore> ref;
NpcHandleImpl(@Nonnull String worldName,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
this.worldName = worldName;
this.store = store;
this.ref = ref;
}
void invalidate() {
this.ref = null;
}
@Nullable
NpcMarkerComponent marker() {
Ref<EntityStore> r = this.ref;
if (r == null || !r.isValid()) return null;
return store.getComponent(r, NpcMarkerComponent.getComponentType());
}
@Nullable
TransformComponent transform() {
Ref<EntityStore> r = this.ref;
if (r == null || !r.isValid()) return null;
return store.getComponent(r, TransformComponent.getComponentType());
}
@Nonnull
Store<EntityStore> 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<String, String> 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<EntityStore> entityRef() {
Ref<EntityStore> r = this.ref;
return (r != null && r.isValid()) ? r : null;
}
@Override
public boolean isSpawned() {
Ref<EntityStore> r = this.ref;
return r != null && r.isValid();
}
}
@@ -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<EntityStore> {
private static final StringCodec STRING_CODEC = new StringCodec();
private static final BooleanCodec BOOL_CODEC = new BooleanCodec();
public static final BuilderCodec<NpcMarkerComponent> 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<EntityStore, NpcMarkerComponent> TYPE;
public static void register(@Nonnull ComponentRegistryProxy<EntityStore> registry) {
if (TYPE != null) return;
TYPE = registry.registerComponent(NpcMarkerComponent.class, "kewwbec.corenpc.NpcMarker", CODEC);
}
@Nonnull
public static ComponentType<EntityStore, NpcMarkerComponent> getComponentType() {
ComponentType<EntityStore, NpcMarkerComponent> 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<String, String> 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<String, String> getArgs() { return args; }
public void setArgs(Map<String, String> args) {
this.args = (args == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(args);
}
@Override
public Component<EntityStore> 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;
}
}
@@ -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<String, NpcInteractionHandler> 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<EntityStore> targetRef,
@Nullable String id,
@Nonnull String handlerId,
@Nonnull Map<String, String> args) {
Store<EntityStore> 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<EntityStore> 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<EntityStore> ref = impl.entityRef();
if (ref == null) return;
Store<EntityStore> store = impl.store();
Holder<EntityStore> holder = store.removeEntity(ref, RemoveReason.REMOVE);
holder.removeComponent(NpcMarkerComponent.getComponentType());
store.addEntity(holder, AddReason.SPAWN);
impl.invalidate();
}
@Nonnull
private Ref<EntityStore> attachMarker(@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> originalRef,
@Nonnull NpcMarkerComponent marker) {
Holder<EntityStore> 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<EntityStore> 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<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> 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<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> ref = byIdInStore(store, id);
if (ref == null) return null;
return new NpcHandleImpl(world.getName(), store, ref);
}
@Override
@Nonnull
public Collection<NpcHandle> inWorld(@Nonnull String worldName) {
World world = findWorld(worldName);
if (world == null) return Collections.emptyList();
Store<EntityStore> store = world.getEntityStore().getStore();
List<NpcHandle> out = new ArrayList<>();
scanMarkers(store, (ref, marker) -> out.add(new NpcHandleImpl(worldName, store, ref)));
return out;
}
@Override
@Nonnull
public Collection<NpcHandle> all() {
List<NpcHandle> out = new ArrayList<>();
for (World world : universeOrEmpty()) {
Store<EntityStore> 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<EntityStore> 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<EntityStore> ref, @Nonnull NpcMarkerComponent marker);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static final Query<EntityStore> MARKER_QUERY =
(Query<EntityStore>) (Query) NpcMarkerComponent.getComponentType();
private static void scanMarkers(@Nonnull Store<EntityStore> 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<EntityStore> byIdInStore(@Nonnull Store<EntityStore> store, @Nonnull String id) {
@SuppressWarnings("unchecked")
Ref<EntityStore>[] match = (Ref<EntityStore>[]) 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<World> universeOrEmpty() {
Universe universe = Universe.get();
return (universe == null) ? Collections.emptyList() : universe.getWorlds().values();
}
@Nonnull
private static String resolveWorldName(@Nonnull Store<EntityStore> 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);
}
}
@@ -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}.
*
* <p>{@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.</p>
*/
public final class TagNpcInteraction extends SimpleInstantInteraction {
public static final String INTERACTION_ID = "CoreNpc_Tag_Interaction";
public static final BuilderCodec<TagNpcInteraction> 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<EntityStore> targetRef = ctx.getTargetEntity();
if (targetRef == null || !targetRef.isValid()) return;
CommandBuffer<EntityStore> cb = ctx.getCommandBuffer();
if (cb == null) return;
Ref<EntityStore> 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<EntityStore> 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));
}
});
}
}
@@ -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" }
]
}
}
}
+17
View File
@@ -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"
}