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