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.
This commit is contained in:
2026-01-24 17:42:00 +01:00
commit 814e2ca73a
11 changed files with 1856 additions and 0 deletions
+37
View File
@@ -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/
+134
View File
@@ -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:<password>' \
-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:<password>' \
-H 'Content-Type: application/json' \
-d '{"message": "Hello, adventurers!"}'
```
**Get status:**
```bash
curl https://hytale01:5523/SubNet/ClaudeNpc/status \
-u 'serviceaccount.claude:<password>'
```
**Follow a player:**
```bash
curl -X POST https://hytale01:5523/SubNet/ClaudeNpc/move \
-u 'serviceaccount.claude:<password>' \
-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)
+137
View File
@@ -0,0 +1,137 @@
<?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>at.subnet.hytale</groupId>
<artifactId>claude-npc</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Claude NPC Plugin</name>
<description>An AI-controlled NPC for Hytale that responds to player chat via LiteLLM</description>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<!-- Hytale plugin API repo (placeholder - use local JAR if not available) -->
<repository>
<id>hytale-plugins</id>
<url>https://maven.hytale.com/plugins</url>
</repository>
</repositories>
<dependencies>
<!-- Hytale Server API - provided at runtime -->
<dependency>
<groupId>com.hytale</groupId>
<artifactId>server-api</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
<!-- Nitrado WebServer Plugin API - for registering HTTP endpoints -->
<dependency>
<groupId>com.nitrado.hytale</groupId>
<artifactId>webserver-api</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
<!-- JSON processing -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>
<!-- HTTP client for LiteLLM -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- Annotations -->
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>26.0.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>SubNet_ClaudeNpc</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>25</source>
<target>25</target>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<relocations>
<relocation>
<pattern>com.google.gson</pattern>
<shadedPattern>at.subnet.hytale.claude.shaded.gson</shadedPattern>
</relocation>
<relocation>
<pattern>okhttp3</pattern>
<shadedPattern>at.subnet.hytale.claude.shaded.okhttp3</shadedPattern>
</relocation>
<relocation>
<pattern>okio</pattern>
<shadedPattern>at.subnet.hytale.claude.shaded.okio</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifestEntries>
<Plugin-Name>SubNet:ClaudeNpc</Plugin-Name>
<Plugin-Version>${project.version}</Plugin-Version>
<Plugin-Author>Sub-Net</Plugin-Author>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
</project>
@@ -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<ChatMessage> chatHistory = new ArrayList<>();
private static final int MAX_CHAT_HISTORY = 20;
// Pending chat responses
private final BlockingQueue<ChatRequest> 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<WorldView.NearbyPlayer> 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<LiteLlmClient.Message> 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<WorldView.NearbyPlayer> 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; }
}
@@ -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<Boolean> 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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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);
}
}
}
@@ -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<Message> 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();
}
}
@@ -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<NearbyPlayer> nearbyPlayers = new ArrayList<>();
private List<NearbyEntity> 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<String> 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<String> 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<String> 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<NearbyPlayer> players,
List<NearbyEntity> entities,
TerrainInfo terrain,
Environment environment
) {}
// Getters
public List<NearbyPlayer> getNearbyPlayers() { return nearbyPlayers; }
public List<NearbyEntity> 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<String> nearbyBlocks, boolean isIndoors) {
this.terrain = new TerrainInfo(groundBlock, nearbyBlocks, isIndoors);
}
}
+20
View File
@@ -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"
]
}