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:
+37
@@ -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/
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user