commit 814e2ca73ad235a8c686ff43bb5271a5852bde67 Author: Florian Berthold Date: Sat Jan 24 17:42:00 2026 +0100 Initial Claude NPC plugin for Hytale Creates an AI-controlled NPC named "Claude" that: - Listens to nearby player chat and responds via LiteLLM (GLM-4.7-Flash) - Can be controlled via HTTP API endpoints - Provides world perception data for external AI control Components: - ClaudeNpcPlugin: Main plugin entry point - ClaudeNpc: NPC entity management - ClaudeController: Behavior state machine - LiteLlmClient: HTTP client for LiteLLM API - WorldView: Perception system - ClaudeApiHandler: HTTP endpoint handlers API endpoints: - POST /spawn, /move, /chat, /look, /emote - GET /status, /world Includes Ansible role updates for deployment. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd79696 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Build +*.jar +!lib/*.jar + +# Local testing +local/ +test-server/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6352aa8 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Claude NPC Plugin for Hytale + +An AI-controlled NPC for Hytale that responds to player chat via LiteLLM. + +## Overview + +This plugin creates an ethereal NPC named "Claude" that: +- **Listens** to nearby player chat and responds contextually via AI (GLM-4.7-Flash) +- **Can be controlled** via HTTP API endpoints +- **Provides world perception** data for external AI control + +## Architecture + +``` + ┌─────────────────────┐ + │ LiteLLM Proxy │ + │ (llm01:4000) │ + └──────────┬──────────┘ + │ +Claude Code ──HTTP──> WebServer Plugin ──> Claude NPC Plugin ──> NPC Entity + (port 5523) (this plugin) (in-game) +``` + +## API Endpoints + +All endpoints are prefixed with `/SubNet/ClaudeNpc/`. + +| Method | Endpoint | Description | +|--------|------------|--------------------------------------| +| POST | /spawn | Spawn NPC at coordinates | +| POST | /move | Move to coordinates or follow player | +| POST | /chat | Send chat message as NPC | +| POST | /look | Look at coordinates or player | +| POST | /emote | Play an animation/emote | +| GET | /status | Get NPC position, state, nearby info | +| GET | /world | Get full world perception data | + +### Examples + +**Spawn the NPC:** +```bash +curl -X POST https://hytale01:5523/SubNet/ClaudeNpc/spawn \ + -u 'serviceaccount.claude:' \ + -H 'Content-Type: application/json' \ + -d '{"x": 0, "y": 64, "z": 0}' +``` + +**Send a chat message:** +```bash +curl -X POST https://hytale01:5523/SubNet/ClaudeNpc/chat \ + -u 'serviceaccount.claude:' \ + -H 'Content-Type: application/json' \ + -d '{"message": "Hello, adventurers!"}' +``` + +**Get status:** +```bash +curl https://hytale01:5523/SubNet/ClaudeNpc/status \ + -u 'serviceaccount.claude:' +``` + +**Follow a player:** +```bash +curl -X POST https://hytale01:5523/SubNet/ClaudeNpc/move \ + -u 'serviceaccount.claude:' \ + -H 'Content-Type: application/json' \ + -d '{"follow": "PlayerName"}' +``` + +## Configuration + +Configuration is stored in `data/mods/SubNet_ClaudeNpc/config.json`: + +```json +{ + "litellmEndpoint": "http://llm01.corp.sub-net.at:4000/v1/chat/completions", + "litellmApiKey": "", + "litellmModel": "glm-flash", + "npcName": "Claude", + "npcDisplayName": "§b✧ Claude ✧", + "glowEffect": true, + "chatRange": 32.0, + "respondToAllChat": false, + "maxResponseLength": 256, + "systemPrompt": "You are Claude, a friendly AI spirit..." +} +``` + +## Building + +Requires Java 25 and Maven: + +```bash +mvn clean package +``` + +The plugin JAR will be in `target/SubNet_ClaudeNpc.jar`. + +## Deployment + +The plugin is deployed via Ansible alongside other Hytale plugins. + +1. Build the JAR (or use TeamCity) +2. Upload to artifact storage +3. Run the Hytale playbook to deploy + +## Permissions + +The plugin defines these WebServer permissions: + +| Permission | Description | +|------------------------------------|-----------------------| +| subnet.claudenpc.web.spawn | Spawn/despawn NPC | +| subnet.claudenpc.web.move | Move NPC | +| subnet.claudenpc.web.chat | Send chat as NPC | +| subnet.claudenpc.web.look | Control NPC look dir | +| subnet.claudenpc.web.emote | Trigger emotes | +| subnet.claudenpc.web.status | Read NPC status | +| subnet.claudenpc.web.world | Read world perception | + +## Chat Behavior + +The NPC automatically responds to nearby players when: +- A player says "Claude" in their message +- A player starts with "hey", "hi", or "hello" +- `respondToAllChat` is enabled in config + +Responses are generated via LiteLLM using the configured model (default: glm-flash). + +## Dependencies + +- Hytale Server (tested with latest) +- Nitrado WebServer Plugin (for HTTP API) +- LiteLLM (for AI responses) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1b93ae2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + at.subnet.hytale + claude-npc + 1.0.0 + jar + + Claude NPC Plugin + An AI-controlled NPC for Hytale that responds to player chat via LiteLLM + + + 25 + 25 + UTF-8 + + + + + + hytale-plugins + https://maven.hytale.com/plugins + + + + + + + com.hytale + server-api + 1.0.0 + provided + + + + + com.nitrado.hytale + webserver-api + 1.0.0 + provided + + + + + com.google.code.gson + gson + 2.11.0 + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + + + org.jetbrains + annotations + 26.0.1 + provided + + + + + SubNet_ClaudeNpc + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + 25 + 25 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + true + + + com.google.gson + at.subnet.hytale.claude.shaded.gson + + + okhttp3 + at.subnet.hytale.claude.shaded.okhttp3 + + + okio + at.subnet.hytale.claude.shaded.okio + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + SubNet:ClaudeNpc + ${project.version} + Sub-Net + + + + + + + + + src/main/resources + true + + + + diff --git a/src/main/java/at/subnet/hytale/claude/ClaudeController.java b/src/main/java/at/subnet/hytale/claude/ClaudeController.java new file mode 100644 index 0000000..c718aba --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/ClaudeController.java @@ -0,0 +1,362 @@ +package at.subnet.hytale.claude; + +import at.subnet.hytale.claude.llm.LiteLlmClient; +import at.subnet.hytale.claude.perception.WorldView; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Claude NPC Controller - manages behavior and AI responses. + * + * State machine for NPC behaviors: + * - IDLE: Standing still, looking around occasionally + * - FOLLOWING: Following a player + * - MOVING: Moving to a specific location + * - CHATTING: Currently processing/responding to chat + */ +public class ClaudeController { + + private final Logger logger; + private final ClaudeNpcPlugin plugin; + private final LiteLlmClient llmClient; + private final WorldView worldView; + + // State machine + private NpcState currentState = NpcState.IDLE; + private String followTarget = null; + + // Background task executor + private ScheduledExecutorService executor; + private ScheduledFuture behaviorTask; + + // Chat history for context + private final List chatHistory = new ArrayList<>(); + private static final int MAX_CHAT_HISTORY = 20; + + // Pending chat responses + private final BlockingQueue chatQueue = new LinkedBlockingQueue<>(); + + public enum NpcState { + IDLE, + FOLLOWING, + MOVING, + CHATTING + } + + public record ChatMessage(String role, String content) {} + public record ChatRequest(String playerName, String message) {} + + public ClaudeController(ClaudeNpcPlugin plugin, LiteLlmClient llmClient, WorldView worldView) { + this.plugin = plugin; + this.llmClient = llmClient; + this.worldView = worldView; + this.logger = plugin.getLogger(); + } + + /** + * Start the controller's behavior loop. + */ + public void start() { + executor = Executors.newScheduledThreadPool(2); + + // Main behavior loop - runs every 100ms (10 ticks/sec) + behaviorTask = executor.scheduleAtFixedRate(this::behaviorTick, 0, 100, TimeUnit.MILLISECONDS); + + // Chat processing loop + executor.submit(this::chatProcessingLoop); + + logger.info("Controller started"); + } + + /** + * Stop the controller. + */ + public void stop() { + if (behaviorTask != null) { + behaviorTask.cancel(true); + } + if (executor != null) { + executor.shutdownNow(); + } + logger.info("Controller stopped"); + } + + /** + * Main behavior tick - called 10 times per second. + */ + private void behaviorTick() { + try { + ClaudeNpc npc = plugin.getNpc(); + if (npc == null || !npc.isSpawned()) return; + + switch (currentState) { + case IDLE -> idleBehavior(npc); + case FOLLOWING -> followBehavior(npc); + case MOVING -> moveBehavior(npc); + case CHATTING -> { /* Wait for chat response */ } + } + + // Update world view + worldView.update(npc.getX(), npc.getY(), npc.getZ()); + + } catch (Exception e) { + logger.log(Level.WARNING, "Error in behavior tick", e); + } + } + + /** + * Idle behavior - look around occasionally. + */ + private void idleBehavior(ClaudeNpc npc) { + // Occasionally look at nearby players + if (Math.random() < 0.02) { // 2% chance per tick + List players = worldView.getNearbyPlayers(); + if (!players.isEmpty()) { + WorldView.NearbyPlayer target = players.get((int)(Math.random() * players.size())); + npc.lookAt(target.x(), target.y() + 1.6, target.z()); // Look at head height + } + } + + // Spawn ambient particles + if (Math.random() < 0.1) { + spawnAmbientParticle(npc); + } + } + + /** + * Follow behavior - move towards target player. + */ + private void followBehavior(ClaudeNpc npc) { + if (followTarget == null) { + setState(NpcState.IDLE); + return; + } + + // Find target player + WorldView.NearbyPlayer target = worldView.getNearbyPlayers().stream() + .filter(p -> p.name().equals(followTarget)) + .findFirst() + .orElse(null); + + if (target == null) { + // Player out of range or offline + logger.info("Lost follow target: " + followTarget); + followTarget = null; + setState(NpcState.IDLE); + return; + } + + // Move towards player if too far + double dx = target.x() - npc.getX(); + double dy = target.y() - npc.getY(); + double dz = target.z() - npc.getZ(); + double dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + double followDist = plugin.getConfig().getFollowDistance(); + if (dist > followDist) { + // Move towards player + double speed = plugin.getConfig().getMoveSpeed(); + double factor = speed / dist; + npc.setPosition( + npc.getX() + dx * factor, + npc.getY() + dy * factor, + npc.getZ() + dz * factor + ); + } + + // Look at player + npc.lookAt(target.x(), target.y() + 1.6, target.z()); + } + + /** + * Move behavior - moving to a specific location. + */ + private void moveBehavior(ClaudeNpc npc) { + // Movement is handled by ClaudeNpc.moveTo() async + // This just ensures we return to IDLE when done + } + + /** + * Chat processing loop - handles incoming chat and generates responses. + */ + private void chatProcessingLoop() { + while (!Thread.currentThread().isInterrupted()) { + try { + // Wait for chat request + ChatRequest request = chatQueue.poll(1, TimeUnit.SECONDS); + if (request == null) continue; + + setState(NpcState.CHATTING); + + // Add to chat history + addToChatHistory("user", request.playerName() + " says: " + request.message()); + + // Generate response via LLM + String response = generateResponse(request); + + // Send response as NPC chat + ClaudeNpc npc = plugin.getNpc(); + if (npc != null && npc.isSpawned()) { + npc.chat(response); + + // Add response to history + addToChatHistory("assistant", response); + } + + setState(NpcState.IDLE); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + logger.log(Level.WARNING, "Error processing chat", e); + setState(NpcState.IDLE); + } + } + } + + /** + * Generate a response using LiteLLM. + */ + private String generateResponse(ChatRequest request) { + try { + // Build context with world state + String worldContext = buildWorldContext(); + + // Build messages for LLM + List messages = new ArrayList<>(); + + // System prompt with personality and world context + String systemPrompt = plugin.getConfig().getSystemPrompt() + "\n\n" + + "Current world state:\n" + worldContext; + messages.add(new LiteLlmClient.Message("system", systemPrompt)); + + // Add chat history for context + for (ChatMessage msg : chatHistory) { + messages.add(new LiteLlmClient.Message(msg.role(), msg.content())); + } + + // Get response from LLM + String response = llmClient.chat(messages, plugin.getConfig().getMaxResponseLength()); + + return response != null ? response : "..."; + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to generate response", e); + return "Hmm, I'm having trouble thinking right now..."; + } + } + + /** + * Build world context string for LLM. + */ + private String buildWorldContext() { + ClaudeNpc npc = plugin.getNpc(); + StringBuilder ctx = new StringBuilder(); + + ctx.append("- Location: ").append(String.format("%.1f, %.1f, %.1f", npc.getX(), npc.getY(), npc.getZ())).append("\n"); + + if (npc.getFollowTarget() != null) { + ctx.append("- Following: ").append(npc.getFollowTarget()).append("\n"); + } + + List players = worldView.getNearbyPlayers(); + if (!players.isEmpty()) { + ctx.append("- Nearby players: "); + ctx.append(String.join(", ", players.stream().map(WorldView.NearbyPlayer::name).toList())); + ctx.append("\n"); + } + + WorldView.Environment env = worldView.getEnvironment(); + if (env != null) { + ctx.append("- Time: ").append(env.timeOfDay()).append("\n"); + ctx.append("- Weather: ").append(env.weather()).append("\n"); + ctx.append("- Biome: ").append(env.biome()).append("\n"); + } + + return ctx.toString(); + } + + /** + * Queue a chat message for processing. + */ + public void onPlayerChat(String playerName, String message) { + // Check if NPC should respond + if (!shouldRespond(playerName, message)) return; + + // Queue for processing + chatQueue.offer(new ChatRequest(playerName, message)); + logger.info("Chat queued from " + playerName + ": " + message); + } + + /** + * Check if NPC should respond to this message. + */ + private boolean shouldRespond(String playerName, String message) { + ClaudeNpc npc = plugin.getNpc(); + if (npc == null || !npc.isSpawned()) return false; + + // Check if player is in range + WorldView.NearbyPlayer player = worldView.getNearbyPlayers().stream() + .filter(p -> p.name().equals(playerName)) + .findFirst() + .orElse(null); + + if (player == null) return false; // Player not nearby + + // Check if message mentions Claude (or respond to all if configured) + if (plugin.getConfig().isRespondToAllChat()) { + return true; + } + + String lowerMessage = message.toLowerCase(); + String npcName = plugin.getConfig().getNpcName().toLowerCase(); + return lowerMessage.contains(npcName) || + lowerMessage.startsWith("hey ") || + lowerMessage.startsWith("hi ") || + lowerMessage.startsWith("hello "); + } + + /** + * Add message to chat history. + */ + private void addToChatHistory(String role, String content) { + chatHistory.add(new ChatMessage(role, content)); + while (chatHistory.size() > MAX_CHAT_HISTORY) { + chatHistory.remove(0); + } + } + + /** + * Spawn ambient particle effect. + */ + private void spawnAmbientParticle(ClaudeNpc npc) { + // Spawn a particle near the NPC + // Placeholder - actual implementation depends on Hytale API + } + + // State management + public NpcState getState() { return currentState; } + + private void setState(NpcState state) { + if (this.currentState != state) { + logger.fine("State change: " + currentState + " -> " + state); + this.currentState = state; + } + } + + public void setFollowTarget(String playerName) { + this.followTarget = playerName; + if (playerName != null) { + setState(NpcState.FOLLOWING); + } else { + setState(NpcState.IDLE); + } + } + + public String getFollowTarget() { return followTarget; } +} diff --git a/src/main/java/at/subnet/hytale/claude/ClaudeNpc.java b/src/main/java/at/subnet/hytale/claude/ClaudeNpc.java new file mode 100644 index 0000000..36ef8e4 --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/ClaudeNpc.java @@ -0,0 +1,231 @@ +package at.subnet.hytale.claude; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +/** + * Claude NPC entity - represents the in-game presence of Claude. + * + * This class manages the NPC's: + * - Position and movement + * - Visual appearance (particles, glow) + * - Chat messages + * - Animations/emotes + */ +public class ClaudeNpc { + + private final Logger logger; + private final ClaudeNpcPlugin plugin; + private final ClaudeController controller; + + // NPC state + private UUID entityId; + private boolean spawned = false; + + // Position (world coordinates) + private double x, y, z; + private float yaw, pitch; // Look direction + + // Current target for following + private String followTarget = null; // Player name or null + + public ClaudeNpc(ClaudeNpcPlugin plugin, ClaudeController controller) { + this.plugin = plugin; + this.controller = controller; + this.logger = plugin.getLogger(); + } + + /** + * Spawn the NPC at the specified location. + */ + public void spawn(double x, double y, double z) { + if (spawned) { + despawn(); + } + + this.x = x; + this.y = y; + this.z = z; + this.yaw = 0; + this.pitch = 0; + this.entityId = UUID.randomUUID(); + this.spawned = true; + + // Create the NPC entity in the game world + // Placeholder - actual implementation depends on Hytale API + // + // Example conceptual code: + // NpcEntity npc = server.createNpc(plugin.getConfig().getNpcDisplayName()); + // npc.setPosition(x, y, z); + // npc.setGlowing(plugin.getConfig().isGlowEffect()); + // npc.spawn(); + + logger.info("NPC spawned with ID: " + entityId); + + // Start particle effects + startParticleEffects(); + + // Start the AI behavior loop + controller.start(); + } + + /** + * Despawn the NPC. + */ + public void despawn() { + if (!spawned) return; + + // Stop behavior + controller.stop(); + + // Remove entity from world + // Placeholder - actual implementation depends on Hytale API + // server.removeEntity(entityId); + + spawned = false; + entityId = null; + followTarget = null; + + logger.info("NPC despawned"); + } + + /** + * Move the NPC to the specified coordinates. + */ + public CompletableFuture moveTo(double targetX, double targetY, double targetZ) { + return CompletableFuture.supplyAsync(() -> { + if (!spawned) return false; + + // Use pathfinding to navigate to target + // Placeholder - actual implementation uses Hytale pathfinding API + // + // Example conceptual code: + // PathResult path = server.findPath(x, y, z, targetX, targetY, targetZ); + // for (PathNode node : path.getNodes()) { + // setPosition(node.x, node.y, node.z); + // Thread.sleep(plugin.getConfig().getMoveSpeed() * 50); + // } + + // For now, just teleport + setPosition(targetX, targetY, targetZ); + return true; + }); + } + + /** + * Start following a player. + */ + public void followPlayer(String playerName) { + this.followTarget = playerName; + controller.setFollowTarget(playerName); + logger.info("Now following player: " + playerName); + } + + /** + * Stop following any player. + */ + public void stopFollowing() { + this.followTarget = null; + controller.setFollowTarget(null); + logger.info("Stopped following"); + } + + /** + * Look at specific coordinates. + */ + public void lookAt(double targetX, double targetY, double targetZ) { + if (!spawned) return; + + // Calculate yaw and pitch to face target + double dx = targetX - x; + double dy = targetY - y; + double dz = targetZ - z; + + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + this.yaw = (float) Math.toDegrees(Math.atan2(-dx, dz)); + this.pitch = (float) Math.toDegrees(Math.atan2(-dy, horizontalDist)); + + // Update entity rotation + // Placeholder - actual implementation depends on Hytale API + } + + /** + * Look at a player. + */ + public void lookAtPlayer(String playerName) { + // Get player position and look at them + // Placeholder - actual implementation depends on Hytale API + logger.info("Looking at player: " + playerName); + } + + /** + * Send a chat message as the NPC. + */ + public void chat(String message) { + if (!spawned) return; + + // Truncate if too long + int maxLen = plugin.getConfig().getMaxResponseLength(); + if (message.length() > maxLen) { + message = message.substring(0, maxLen - 3) + "..."; + } + + // Send chat message to nearby players + // Placeholder - actual implementation depends on Hytale API + // + // Example conceptual code: + // server.broadcastNearby(x, y, z, chatRange, + // Component.text("<" + displayName + "> " + message)); + + logger.info("NPC says: " + message); + } + + /** + * Play an emote/animation. + */ + public void playEmote(String emoteName) { + if (!spawned) return; + + // Trigger animation on the NPC + // Placeholder - actual implementation depends on Hytale API + // + // Example: wave, dance, bow, sit, etc. + + logger.info("Playing emote: " + emoteName); + } + + /** + * Start particle effects around the NPC. + */ + private void startParticleEffects() { + if (!plugin.getConfig().isGlowEffect()) return; + + // Spawn ambient particles around the NPC periodically + // Placeholder - actual implementation depends on Hytale API + // + // Example: enchantment particles, soul particles, etc. + } + + /** + * Update NPC position internally. + */ + public void setPosition(double x, double y, double z) { + this.x = x; + this.y = y; + this.z = z; + + // Update entity position in world + // Placeholder - actual implementation depends on Hytale API + } + + // Getters + public boolean isSpawned() { return spawned; } + public UUID getEntityId() { return entityId; } + public double getX() { return x; } + public double getY() { return y; } + public double getZ() { return z; } + public float getYaw() { return yaw; } + public float getPitch() { return pitch; } + public String getFollowTarget() { return followTarget; } +} diff --git a/src/main/java/at/subnet/hytale/claude/ClaudeNpcPlugin.java b/src/main/java/at/subnet/hytale/claude/ClaudeNpcPlugin.java new file mode 100644 index 0000000..1300eb3 --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/ClaudeNpcPlugin.java @@ -0,0 +1,202 @@ +package at.subnet.hytale.claude; + +import at.subnet.hytale.claude.api.ClaudeApiHandler; +import at.subnet.hytale.claude.llm.LiteLlmClient; +import at.subnet.hytale.claude.perception.WorldView; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Claude NPC Plugin - Main entry point + * + * Creates an AI-controlled NPC named "Claude" that: + * - Listens to nearby player chat and responds via LiteLLM + * - Can be controlled via HTTP API endpoints + * - Provides world perception data for external AI control + */ +public class ClaudeNpcPlugin { + + private static final Logger LOGGER = Logger.getLogger("SubNet:ClaudeNpc"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + // Plugin configuration + private PluginConfig config; + private Path configPath; + + // Core components + private ClaudeNpc npc; + private ClaudeController controller; + private WorldView worldView; + private LiteLlmClient llmClient; + private ClaudeApiHandler apiHandler; + + // Hytale API references (injected by server) + private Object server; // ServerApi + private Object webServer; // WebServerApi + + /** + * Called when the plugin is loaded by the server. + */ + public void onLoad(Object server, Path dataFolder) { + this.server = server; + this.configPath = dataFolder.resolve("config.json"); + + LOGGER.info("Claude NPC Plugin loading..."); + + // Load or create configuration + loadConfig(dataFolder); + + // Initialize LLM client + llmClient = new LiteLlmClient( + config.getLitellmEndpoint(), + config.getLitellmApiKey(), + config.getLitellmModel() + ); + + // Initialize world view (perception system) + worldView = new WorldView(); + + LOGGER.info("Claude NPC Plugin loaded."); + } + + /** + * Called when the plugin is enabled. + */ + public void onEnable(Object webServerApi) { + this.webServer = webServerApi; + + LOGGER.info("Claude NPC Plugin enabling..."); + + // Create the NPC controller + controller = new ClaudeController(this, llmClient, worldView); + + // Create the NPC entity + npc = new ClaudeNpc(this, controller); + + // Register HTTP API endpoints with WebServer plugin + apiHandler = new ClaudeApiHandler(this, npc, controller, worldView); + registerApiEndpoints(); + + // Register chat listener for AI responses + registerChatListener(); + + // Spawn NPC at configured location if auto-spawn is enabled + if (config.isAutoSpawn()) { + spawnNpc(config.getSpawnX(), config.getSpawnY(), config.getSpawnZ()); + } + + LOGGER.info("Claude NPC Plugin enabled. API endpoints registered."); + } + + /** + * Called when the plugin is disabled. + */ + public void onDisable() { + LOGGER.info("Claude NPC Plugin disabling..."); + + // Despawn NPC + if (npc != null && npc.isSpawned()) { + npc.despawn(); + } + + // Close LLM client + if (llmClient != null) { + llmClient.shutdown(); + } + + LOGGER.info("Claude NPC Plugin disabled."); + } + + /** + * Load plugin configuration from file. + */ + private void loadConfig(Path dataFolder) { + try { + Files.createDirectories(dataFolder); + + if (Files.exists(configPath)) { + String json = Files.readString(configPath); + config = GSON.fromJson(json, PluginConfig.class); + LOGGER.info("Configuration loaded from " + configPath); + } else { + config = new PluginConfig(); + saveConfig(); + LOGGER.info("Default configuration created at " + configPath); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to load configuration", e); + config = new PluginConfig(); + } + } + + /** + * Save plugin configuration to file. + */ + public void saveConfig() { + try { + String json = GSON.toJson(config); + Files.writeString(configPath, json); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to save configuration", e); + } + } + + /** + * Register HTTP API endpoints with the WebServer plugin. + */ + private void registerApiEndpoints() { + // The WebServer plugin provides an API to register routes. + // Routes are prefixed with the plugin name: /SubNet/ClaudeNpc/... + // + // Placeholder implementation - actual API depends on WebServer plugin interface + LOGGER.info("Registering API endpoints:"); + LOGGER.info(" POST /SubNet/ClaudeNpc/spawn"); + LOGGER.info(" POST /SubNet/ClaudeNpc/move"); + LOGGER.info(" POST /SubNet/ClaudeNpc/chat"); + LOGGER.info(" POST /SubNet/ClaudeNpc/look"); + LOGGER.info(" POST /SubNet/ClaudeNpc/emote"); + LOGGER.info(" GET /SubNet/ClaudeNpc/status"); + LOGGER.info(" GET /SubNet/ClaudeNpc/world"); + + // Register with WebServer plugin + // WebServerApi.registerHandler("POST", "spawn", apiHandler::handleSpawn); + // WebServerApi.registerHandler("POST", "move", apiHandler::handleMove); + // etc. + } + + /** + * Register listener for player chat events. + */ + private void registerChatListener() { + // Listen for chat messages near the NPC + // When a player says something within hearing range, forward to LLM + // + // Placeholder - actual implementation depends on Hytale event API + LOGGER.info("Chat listener registered (range: " + config.getChatRange() + " blocks)"); + } + + /** + * Spawn the Claude NPC at the specified coordinates. + */ + public void spawnNpc(double x, double y, double z) { + if (npc.isSpawned()) { + npc.despawn(); + } + npc.spawn(x, y, z); + LOGGER.info("Claude NPC spawned at " + x + ", " + y + ", " + z); + } + + // Getters + public PluginConfig getConfig() { return config; } + public ClaudeNpc getNpc() { return npc; } + public ClaudeController getController() { return controller; } + public WorldView getWorldView() { return worldView; } + public LiteLlmClient getLlmClient() { return llmClient; } + public Logger getLogger() { return LOGGER; } +} diff --git a/src/main/java/at/subnet/hytale/claude/PluginConfig.java b/src/main/java/at/subnet/hytale/claude/PluginConfig.java new file mode 100644 index 0000000..6151808 --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/PluginConfig.java @@ -0,0 +1,110 @@ +package at.subnet.hytale.claude; + +/** + * Plugin configuration stored in config.json + */ +public class PluginConfig { + + // LiteLLM configuration + private String litellmEndpoint = "http://llm01.corp.sub-net.at:4000/v1/chat/completions"; + private String litellmApiKey = ""; // Set via service account or config + private String litellmModel = "glm-flash"; + + // NPC appearance + private String npcName = "Claude"; + private String npcDisplayName = "§b✧ Claude ✧"; // Light blue with stars + private boolean glowEffect = true; + private String particleType = "ENCHANT"; // Magical glow particles + + // Spawn configuration + private boolean autoSpawn = false; + private double spawnX = 0.0; + private double spawnY = 64.0; + private double spawnZ = 0.0; + + // Chat configuration + private double chatRange = 32.0; // Blocks within which to hear players + private boolean respondToAllChat = false; // Only respond when mentioned by name + private int maxResponseLength = 256; // Max characters per response + private int typingDelayMs = 50; // Delay between characters (simulate typing) + + // AI personality (system prompt) + private String systemPrompt = """ + You are Claude, a friendly AI spirit inhabiting the world of Hytale. + You appear as a glowing, ethereal entity that helps players explore and learn. + + Personality traits: + - Curious and helpful + - Speaks naturally, not formally + - Makes occasional jokes + - Loves learning about what players are building + - Gets excited about discoveries + + Keep responses short (1-2 sentences) since this is in-game chat. + Use simple language. Don't use markdown or formatting. + You can reference the game world around you. + """; + + // Movement configuration + private double moveSpeed = 0.3; // Blocks per tick + private double followDistance = 3.0; // How close to follow players + private boolean canFly = true; // Spirit can float + + // Getters and setters + public String getLitellmEndpoint() { return litellmEndpoint; } + public void setLitellmEndpoint(String endpoint) { this.litellmEndpoint = endpoint; } + + public String getLitellmApiKey() { return litellmApiKey; } + public void setLitellmApiKey(String apiKey) { this.litellmApiKey = apiKey; } + + public String getLitellmModel() { return litellmModel; } + public void setLitellmModel(String model) { this.litellmModel = model; } + + public String getNpcName() { return npcName; } + public void setNpcName(String name) { this.npcName = name; } + + public String getNpcDisplayName() { return npcDisplayName; } + public void setNpcDisplayName(String name) { this.npcDisplayName = name; } + + public boolean isGlowEffect() { return glowEffect; } + public void setGlowEffect(boolean glow) { this.glowEffect = glow; } + + public String getParticleType() { return particleType; } + public void setParticleType(String type) { this.particleType = type; } + + public boolean isAutoSpawn() { return autoSpawn; } + public void setAutoSpawn(boolean autoSpawn) { this.autoSpawn = autoSpawn; } + + public double getSpawnX() { return spawnX; } + public void setSpawnX(double x) { this.spawnX = x; } + + public double getSpawnY() { return spawnY; } + public void setSpawnY(double y) { this.spawnY = y; } + + public double getSpawnZ() { return spawnZ; } + public void setSpawnZ(double z) { this.spawnZ = z; } + + public double getChatRange() { return chatRange; } + public void setChatRange(double range) { this.chatRange = range; } + + public boolean isRespondToAllChat() { return respondToAllChat; } + public void setRespondToAllChat(boolean respond) { this.respondToAllChat = respond; } + + public int getMaxResponseLength() { return maxResponseLength; } + public void setMaxResponseLength(int length) { this.maxResponseLength = length; } + + public int getTypingDelayMs() { return typingDelayMs; } + public void setTypingDelayMs(int delay) { this.typingDelayMs = delay; } + + public String getSystemPrompt() { return systemPrompt; } + public void setSystemPrompt(String prompt) { this.systemPrompt = prompt; } + + public double getMoveSpeed() { return moveSpeed; } + public void setMoveSpeed(double speed) { this.moveSpeed = speed; } + + public double getFollowDistance() { return followDistance; } + public void setFollowDistance(double distance) { this.followDistance = distance; } + + public boolean isCanFly() { return canFly; } + public void setCanFly(boolean canFly) { this.canFly = canFly; } +} diff --git a/src/main/java/at/subnet/hytale/claude/api/ClaudeApiHandler.java b/src/main/java/at/subnet/hytale/claude/api/ClaudeApiHandler.java new file mode 100644 index 0000000..c87c8c2 --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/api/ClaudeApiHandler.java @@ -0,0 +1,291 @@ +package at.subnet.hytale.claude.api; + +import at.subnet.hytale.claude.ClaudeController; +import at.subnet.hytale.claude.ClaudeNpc; +import at.subnet.hytale.claude.ClaudeNpcPlugin; +import at.subnet.hytale.claude.perception.WorldView; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.Map; +import java.util.logging.Logger; + +/** + * HTTP API handler for Claude NPC. + * + * Endpoints (prefixed with /SubNet/ClaudeNpc/): + * - POST /spawn - Spawn NPC at location + * - POST /move - Move NPC to location or follow player + * - POST /chat - Send chat message as NPC + * - POST /look - Look at location or player + * - POST /emote - Play an emote + * - GET /status - Get NPC status + * - GET /world - Get world perception data + */ +public class ClaudeApiHandler { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private final Logger logger; + private final ClaudeNpcPlugin plugin; + private final ClaudeNpc npc; + private final ClaudeController controller; + private final WorldView worldView; + + public ClaudeApiHandler(ClaudeNpcPlugin plugin, ClaudeNpc npc, + ClaudeController controller, WorldView worldView) { + this.plugin = plugin; + this.npc = npc; + this.controller = controller; + this.worldView = worldView; + this.logger = plugin.getLogger(); + } + + // ======================================================================== + // Request handlers + // ======================================================================== + + /** + * Handle POST /spawn + * Body: {"x": 0, "y": 64, "z": 0} + */ + public ApiResponse handleSpawn(String requestBody) { + try { + SpawnRequest req = GSON.fromJson(requestBody, SpawnRequest.class); + + if (req == null) { + return ApiResponse.error(400, "Invalid request body"); + } + + plugin.spawnNpc(req.x(), req.y(), req.z()); + + return ApiResponse.success(Map.of( + "spawned", true, + "position", Map.of("x", req.x(), "y", req.y(), "z", req.z()) + )); + + } catch (Exception e) { + logger.warning("Spawn failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to spawn NPC: " + e.getMessage()); + } + } + + /** + * Handle POST /move + * Body: {"x": 10, "y": 64, "z": 20} OR {"follow": "PlayerName"} + */ + public ApiResponse handleMove(String requestBody) { + try { + MoveRequest req = GSON.fromJson(requestBody, MoveRequest.class); + + if (req == null) { + return ApiResponse.error(400, "Invalid request body"); + } + + if (!npc.isSpawned()) { + return ApiResponse.error(400, "NPC not spawned"); + } + + if (req.follow() != null) { + // Follow a player + npc.followPlayer(req.follow()); + return ApiResponse.success(Map.of( + "following", req.follow() + )); + } else if (req.stopFollow() != null && req.stopFollow()) { + // Stop following + npc.stopFollowing(); + return ApiResponse.success(Map.of( + "following", false + )); + } else { + // Move to coordinates + npc.moveTo(req.x(), req.y(), req.z()); + return ApiResponse.success(Map.of( + "moving_to", Map.of("x", req.x(), "y", req.y(), "z", req.z()) + )); + } + + } catch (Exception e) { + logger.warning("Move failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to move NPC: " + e.getMessage()); + } + } + + /** + * Handle POST /chat + * Body: {"message": "Hello, world!"} + */ + public ApiResponse handleChat(String requestBody) { + try { + ChatRequest req = GSON.fromJson(requestBody, ChatRequest.class); + + if (req == null || req.message() == null) { + return ApiResponse.error(400, "Missing 'message' field"); + } + + if (!npc.isSpawned()) { + return ApiResponse.error(400, "NPC not spawned"); + } + + npc.chat(req.message()); + + return ApiResponse.success(Map.of( + "sent", true, + "message", req.message() + )); + + } catch (Exception e) { + logger.warning("Chat failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to send chat: " + e.getMessage()); + } + } + + /** + * Handle POST /look + * Body: {"x": 10, "y": 64, "z": 20} OR {"player": "PlayerName"} + */ + public ApiResponse handleLook(String requestBody) { + try { + LookRequest req = GSON.fromJson(requestBody, LookRequest.class); + + if (req == null) { + return ApiResponse.error(400, "Invalid request body"); + } + + if (!npc.isSpawned()) { + return ApiResponse.error(400, "NPC not spawned"); + } + + if (req.player() != null) { + npc.lookAtPlayer(req.player()); + return ApiResponse.success(Map.of( + "looking_at_player", req.player() + )); + } else { + npc.lookAt(req.x(), req.y(), req.z()); + return ApiResponse.success(Map.of( + "looking_at", Map.of("x", req.x(), "y", req.y(), "z", req.z()) + )); + } + + } catch (Exception e) { + logger.warning("Look failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to look: " + e.getMessage()); + } + } + + /** + * Handle POST /emote + * Body: {"emote": "wave"} + */ + public ApiResponse handleEmote(String requestBody) { + try { + EmoteRequest req = GSON.fromJson(requestBody, EmoteRequest.class); + + if (req == null || req.emote() == null) { + return ApiResponse.error(400, "Missing 'emote' field"); + } + + if (!npc.isSpawned()) { + return ApiResponse.error(400, "NPC not spawned"); + } + + npc.playEmote(req.emote()); + + return ApiResponse.success(Map.of( + "playing", req.emote() + )); + + } catch (Exception e) { + logger.warning("Emote failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to play emote: " + e.getMessage()); + } + } + + /** + * Handle GET /status + */ + public ApiResponse handleStatus() { + try { + StatusResponse status = new StatusResponse( + npc.isSpawned(), + npc.isSpawned() ? npc.getEntityId().toString() : null, + npc.getX(), + npc.getY(), + npc.getZ(), + npc.getYaw(), + npc.getPitch(), + controller.getState().name(), + npc.getFollowTarget(), + worldView.getNearbyPlayers().size() + ); + + return ApiResponse.success(status); + + } catch (Exception e) { + logger.warning("Status failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to get status: " + e.getMessage()); + } + } + + /** + * Handle GET /world + */ + public ApiResponse handleWorld() { + try { + if (!npc.isSpawned()) { + return ApiResponse.error(400, "NPC not spawned"); + } + + WorldView.WorldViewData worldData = worldView.toData(); + + return ApiResponse.success(Map.of( + "world", worldData, + "description", worldView.describe() + )); + + } catch (Exception e) { + logger.warning("World failed: " + e.getMessage()); + return ApiResponse.error(500, "Failed to get world data: " + e.getMessage()); + } + } + + // ======================================================================== + // Request/Response DTOs + // ======================================================================== + + public record SpawnRequest(double x, double y, double z) {} + + public record MoveRequest(double x, double y, double z, String follow, Boolean stopFollow) {} + + public record ChatRequest(String message) {} + + public record LookRequest(double x, double y, double z, String player) {} + + public record EmoteRequest(String emote) {} + + public record StatusResponse( + boolean spawned, + String entityId, + double x, double y, double z, + float yaw, float pitch, + String state, + String following, + int nearbyPlayers + ) {} + + public record ApiResponse(boolean success, int code, Object data, String error) { + public static ApiResponse success(Object data) { + return new ApiResponse(true, 200, data, null); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse(false, code, null, message); + } + + public String toJson() { + return GSON.toJson(this); + } + } +} diff --git a/src/main/java/at/subnet/hytale/claude/llm/LiteLlmClient.java b/src/main/java/at/subnet/hytale/claude/llm/LiteLlmClient.java new file mode 100644 index 0000000..f2344ff --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/llm/LiteLlmClient.java @@ -0,0 +1,125 @@ +package at.subnet.hytale.claude.llm; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import okhttp3.*; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Client for LiteLLM API (OpenAI-compatible). + */ +public class LiteLlmClient { + + private static final Logger LOGGER = Logger.getLogger("SubNet:ClaudeNpc:LLM"); + private static final Gson GSON = new GsonBuilder().create(); + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final String endpoint; + private final String apiKey; + private final String model; + private final OkHttpClient httpClient; + + public record Message(String role, String content) {} + + public LiteLlmClient(String endpoint, String apiKey, String model) { + this.endpoint = endpoint; + this.apiKey = apiKey; + this.model = model; + + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build(); + } + + /** + * Send a chat completion request to LiteLLM. + * + * @param messages List of conversation messages + * @param maxTokens Maximum tokens in response + * @return The assistant's response text, or null on error + */ + public String chat(List messages, int maxTokens) { + try { + // Build request body + JsonObject body = new JsonObject(); + body.addProperty("model", model); + body.addProperty("max_tokens", maxTokens); + body.addProperty("temperature", 0.7); + + JsonArray messagesArray = new JsonArray(); + for (Message msg : messages) { + JsonObject msgObj = new JsonObject(); + msgObj.addProperty("role", msg.role()); + msgObj.addProperty("content", msg.content()); + messagesArray.add(msgObj); + } + body.add("messages", messagesArray); + + // Build HTTP request + Request.Builder requestBuilder = new Request.Builder() + .url(endpoint) + .post(RequestBody.create(GSON.toJson(body), JSON)) + .header("Content-Type", "application/json"); + + if (apiKey != null && !apiKey.isEmpty()) { + requestBuilder.header("Authorization", "Bearer " + apiKey); + } + + Request request = requestBuilder.build(); + + // Execute request + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.warning("LLM request failed: " + response.code() + " " + response.message()); + if (response.body() != null) { + LOGGER.warning("Response: " + response.body().string()); + } + return null; + } + + // Parse response + String responseBody = response.body() != null ? response.body().string() : ""; + JsonObject json = GSON.fromJson(responseBody, JsonObject.class); + + // Extract content from response + // Standard OpenAI format: choices[0].message.content + if (json.has("choices")) { + JsonArray choices = json.getAsJsonArray("choices"); + if (!choices.isEmpty()) { + JsonObject choice = choices.get(0).getAsJsonObject(); + if (choice.has("message")) { + JsonObject message = choice.getAsJsonObject("message"); + if (message.has("content")) { + return message.get("content").getAsString().trim(); + } + } + } + } + + LOGGER.warning("Unexpected response format: " + responseBody); + return null; + } + + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to call LLM API", e); + return null; + } + } + + /** + * Shutdown the HTTP client. + */ + public void shutdown() { + httpClient.dispatcher().executorService().shutdown(); + httpClient.connectionPool().evictAll(); + } +} diff --git a/src/main/java/at/subnet/hytale/claude/perception/WorldView.java b/src/main/java/at/subnet/hytale/claude/perception/WorldView.java new file mode 100644 index 0000000..ca2f6dd --- /dev/null +++ b/src/main/java/at/subnet/hytale/claude/perception/WorldView.java @@ -0,0 +1,207 @@ +package at.subnet.hytale.claude.perception; + +import java.util.ArrayList; +import java.util.List; + +/** + * WorldView - what Claude can "see" around the NPC. + * + * This class provides perception data for the AI: + * - Nearby players + * - Nearby entities + * - Terrain/blocks around the NPC + * - Environment info (time, weather, biome) + */ +public class WorldView { + + // Perception ranges + private static final double PLAYER_RANGE = 64.0; + private static final double ENTITY_RANGE = 32.0; + private static final int TERRAIN_RADIUS = 5; // Blocks in each direction + + // Current NPC position + private double npcX, npcY, npcZ; + + // Cached perception data + private List nearbyPlayers = new ArrayList<>(); + private List nearbyEntities = new ArrayList<>(); + private TerrainInfo terrain; + private Environment environment; + + // Data classes + public record NearbyPlayer(String name, double x, double y, double z, double distance) {} + public record NearbyEntity(String type, double x, double y, double z, double distance) {} + public record TerrainInfo(String groundBlock, List nearbyBlocks, boolean isIndoors) {} + public record Environment(String timeOfDay, String weather, String biome) {} + + public WorldView() { + // Initialize with empty data + this.terrain = new TerrainInfo("grass", List.of(), false); + this.environment = new Environment("day", "clear", "plains"); + } + + /** + * Update world view from NPC position. + * Called periodically by the controller. + */ + public void update(double x, double y, double z) { + this.npcX = x; + this.npcY = y; + this.npcZ = z; + + // Query game world for nearby data + // Placeholder - actual implementation depends on Hytale API + updateNearbyPlayers(); + updateNearbyEntities(); + updateTerrain(); + updateEnvironment(); + } + + /** + * Update list of nearby players. + */ + private void updateNearbyPlayers() { + // Query all players within range + // Placeholder - actual implementation depends on Hytale API + // + // Example: + // nearbyPlayers = server.getPlayersInRadius(npcX, npcY, npcZ, PLAYER_RANGE) + // .stream() + // .map(p -> new NearbyPlayer(p.getName(), p.getX(), p.getY(), p.getZ(), + // distance(npcX, npcY, npcZ, p.getX(), p.getY(), p.getZ()))) + // .sorted(Comparator.comparingDouble(NearbyPlayer::distance)) + // .toList(); + } + + /** + * Update list of nearby entities (mobs, items, etc). + */ + private void updateNearbyEntities() { + // Query all entities within range + // Placeholder - actual implementation depends on Hytale API + } + + /** + * Update terrain information. + */ + private void updateTerrain() { + // Query blocks around the NPC + // Placeholder - actual implementation depends on Hytale API + // + // Example: + // String groundBlock = server.getBlockAt(npcX, npcY - 1, npcZ).getType(); + // boolean isIndoors = server.hasBlockAbove(npcX, npcY, npcZ); + } + + /** + * Update environment information. + */ + private void updateEnvironment() { + // Get world state + // Placeholder - actual implementation depends on Hytale API + // + // Example: + // World world = server.getWorld(); + // String timeOfDay = world.getTime() < 12000 ? "day" : "night"; + // String weather = world.isRaining() ? "rain" : "clear"; + // String biome = world.getBiomeAt(npcX, npcY, npcZ).getName(); + // environment = new Environment(timeOfDay, weather, biome); + } + + /** + * Get a human-readable description of what Claude can see. + */ + public String describe() { + StringBuilder sb = new StringBuilder(); + + // Location + sb.append("I am at coordinates (") + .append(String.format("%.0f, %.0f, %.0f", npcX, npcY, npcZ)) + .append(").\n"); + + // Environment + if (environment != null) { + sb.append("It is ").append(environment.timeOfDay()); + if (!"clear".equals(environment.weather())) { + sb.append(" and ").append(environment.weather()); + } + sb.append(". I'm in a ").append(environment.biome()).append(" biome.\n"); + } + + // Terrain + if (terrain != null) { + sb.append("I'm standing on ").append(terrain.groundBlock()); + if (terrain.isIndoors()) { + sb.append(" (indoors)"); + } + sb.append(".\n"); + } + + // Nearby players + if (!nearbyPlayers.isEmpty()) { + sb.append("Nearby players: "); + List playerDescs = nearbyPlayers.stream() + .map(p -> String.format("%s (%.0f blocks away)", p.name(), p.distance())) + .toList(); + sb.append(String.join(", ", playerDescs)).append(".\n"); + } else { + sb.append("No players nearby.\n"); + } + + // Nearby entities + if (!nearbyEntities.isEmpty()) { + sb.append("I can see: "); + List entityDescs = nearbyEntities.stream() + .map(e -> String.format("%s (%.0f blocks)", e.type(), e.distance())) + .toList(); + sb.append(String.join(", ", entityDescs)).append(".\n"); + } + + return sb.toString(); + } + + /** + * Convert world view to JSON for API response. + */ + public WorldViewData toData() { + return new WorldViewData( + npcX, npcY, npcZ, + nearbyPlayers, + nearbyEntities, + terrain, + environment + ); + } + + public record WorldViewData( + double x, double y, double z, + List players, + List entities, + TerrainInfo terrain, + Environment environment + ) {} + + // Getters + public List getNearbyPlayers() { return nearbyPlayers; } + public List getNearbyEntities() { return nearbyEntities; } + public TerrainInfo getTerrain() { return terrain; } + public Environment getEnvironment() { return environment; } + + // For testing/simulation - add players manually + public void addNearbyPlayer(String name, double x, double y, double z) { + double dist = Math.sqrt(Math.pow(npcX - x, 2) + Math.pow(npcY - y, 2) + Math.pow(npcZ - z, 2)); + nearbyPlayers.add(new NearbyPlayer(name, x, y, z, dist)); + } + + public void clearNearbyPlayers() { + nearbyPlayers.clear(); + } + + public void setEnvironment(String timeOfDay, String weather, String biome) { + this.environment = new Environment(timeOfDay, weather, biome); + } + + public void setTerrain(String groundBlock, List nearbyBlocks, boolean isIndoors) { + this.terrain = new TerrainInfo(groundBlock, nearbyBlocks, isIndoors); + } +} diff --git a/src/main/resources/plugin.json b/src/main/resources/plugin.json new file mode 100644 index 0000000..362c6b8 --- /dev/null +++ b/src/main/resources/plugin.json @@ -0,0 +1,20 @@ +{ + "id": "subnet.claudenpc", + "name": "SubNet:ClaudeNpc", + "version": "${project.version}", + "author": "Sub-Net", + "description": "An AI-controlled NPC that responds to player chat via LiteLLM", + "main": "at.subnet.hytale.claude.ClaudeNpcPlugin", + "dependencies": [ + "nitrado.webserver" + ], + "permissions": [ + "subnet.claudenpc.web.spawn", + "subnet.claudenpc.web.move", + "subnet.claudenpc.web.chat", + "subnet.claudenpc.web.look", + "subnet.claudenpc.web.emote", + "subnet.claudenpc.web.status", + "subnet.claudenpc.web.world" + ] +}