Commit 1ee5fbc1 by Jonathan Thomas

Merge branch 'player-data-refactor' into 'develop'

Player Data Refactor: Particles, Sounds, and Data Enhancements

See merge request !18
parents c3112c6b 35d3af57
Pipeline #13216 passed with stages
in 2 minutes 58 seconds
......@@ -4,7 +4,28 @@ All notable changes to **CreatureChat** are documented in this file. The format
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.0] - 2024-12-28
### Added
- New friendship particles (hearts + fire) to indicate when friendship changes
- Added sound effects for max friendship and max enemy
- New follow, flee, attack, lead, and protect particles & sound effects (for easy confirmation of behaviors)
- New animated lead particle (arrows pointing where they are going)
- New animated attack particles (with random # of particles)
- New sounds and particles when max friendship with EnderDragon (plus XP drop)
- New `/creaturechat story` command to customize the character creation and chat prompts with custom text.
### Changed
- Entity chat data now separates friendship by player and includes timestamps
- When entity conversations switch players, a message is added for clarity (so the entity knows a new player entered the conversation)
- Data is no longer deleted on entity death, and instead a "death" timestamp is recorded
- Removed "pirate" speaking style and a few <non-response> outputs
- Passive entities no longer emit damage particles when attacking, they emit custom attack particles
- Protect now auto sets friendship to 1 (if <= 0), to prevent entity from attacking and protecting at the same time
- Seperated `generateCharacter()` and `generateMessage()` functions for simplicity
- Fixing PACKET_S2C_MESSAGE from crashing a newly logging on player, if they receive that message first.
- Added NULL checks on client message listeners (to prevent crashes for invalid or uninitialized clients)
- Broadcast ALL player friendships with each message update (to keep client in sync with server)
### Fixed
- Fixed a regression caused by adding a "-forge" suffix to one of our builds
......
......@@ -2,14 +2,19 @@ package com.owlmaddie;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.network.ClientPackets;
import com.owlmaddie.particle.CreatureParticleFactory;
import com.owlmaddie.particle.LeadParticleFactory;
import com.owlmaddie.ui.BubbleRenderer;
import com.owlmaddie.ui.ClickHandler;
import com.owlmaddie.ui.PlayerMessageManager;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.particle.v1.ParticleFactoryRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code ClientInit} class initializes this mod in the client and defines all hooks into the
* render pipeline to draw chat bubbles, text, and entity icons.
......@@ -19,6 +24,20 @@ public class ClientInit implements ClientModInitializer {
@Override
public void onInitializeClient() {
// Register particle factories
ParticleFactoryRegistry.getInstance().register(HEART_SMALL_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(HEART_BIG_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FIRE_SMALL_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FIRE_BIG_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(ATTACK_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FLEE_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FOLLOW_FRIEND_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FOLLOW_ENEMY_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(PROTECT_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_FRIEND_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_ENEMY_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_PARTICLE, LeadParticleFactory::new);
ClientTickEvents.END_CLIENT_TICK.register(client -> {
tickCounter++;
PlayerMessageManager.tickUpdate();
......
......@@ -3,6 +3,8 @@ package com.owlmaddie.network;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.ui.BubbleRenderer;
import com.owlmaddie.ui.PlayerMessageManager;
import com.owlmaddie.utils.ClientEntityFinder;
......@@ -20,10 +22,7 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
......@@ -94,41 +93,78 @@ public class ClientPackets {
ClientPlayNetworking.send(ServerPackets.PACKET_C2S_SEND_CHAT, buf);
}
// Reading a Map<String, PlayerData> from the buffer
public static Map<String, PlayerData> readPlayerDataMap(PacketByteBuf buffer) {
int size = buffer.readInt(); // Read the size of the map
Map<String, PlayerData> map = new HashMap<>();
for (int i = 0; i < size; i++) {
String key = buffer.readString(); // Read the key (playerName)
PlayerData data = new PlayerData();
data.friendship = buffer.readInt(); // Read PlayerData field(s)
map.put(key, data); // Add to the map
}
return map;
}
public static void register() {
// Client-side packet handler, message sync
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_MESSAGE, (client, handler, buffer, responseSender) -> {
// Read the data from the server packet
UUID entityId = UUID.fromString(buffer.readString());
String playerIdStr = buffer.readString();
String sendingPlayerIdStr = buffer.readString(32767);
String senderPlayerName = buffer.readString(32767);
UUID senderPlayerId;
if (!sendingPlayerIdStr.isEmpty()) {
senderPlayerId = UUID.fromString(sendingPlayerIdStr);
} else {
senderPlayerId = null;
}
String message = buffer.readString(32767);
int line = buffer.readInt();
String status_name = buffer.readString(32767);
ChatDataManager.ChatStatus status = ChatDataManager.ChatStatus.valueOf(status_name);
String sender_name = buffer.readString(32767);
int friendship = buffer.readInt();
ChatDataManager.ChatSender sender = ChatDataManager.ChatSender.valueOf(sender_name);
Map<String, PlayerData> players = readPlayerDataMap(buffer);
// Update the chat data manager on the client-side
client.execute(() -> { // Make sure to run on the client thread
// Ensure client.player is initialized
if (client.player == null || client.world == null) {
LOGGER.warn("Client not fully initialized. Dropping message for entity '{}'.", entityId);
return;
}
// Update the chat data manager on the client-side
MobEntity entity = ClientEntityFinder.getEntityByUUID(client.world, entityId);
if (entity != null) {
ChatDataManager chatDataManager = ChatDataManager.getClientInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(entity.getUuidAsString());
chatData.playerId = playerIdStr;
if (entity == null) {
LOGGER.warn("Entity with ID '{}' not found. Skipping message processing.", entityId);
return;
}
// Get entity chat data for current entity & player
String currentPlayerName = client.player.getDisplayName().getString();
ChatDataManager chatDataManager = ChatDataManager.getClientInstance();
EntityChatData chatData = chatDataManager.getOrCreateChatData(entity.getUuidAsString(), currentPlayerName);
if (senderPlayerId != null && sender == ChatDataManager.ChatSender.USER && status == ChatDataManager.ChatStatus.DISPLAY) {
// Add player message to queue for rendering
PlayerMessageManager.addMessage(senderPlayerId, message, senderPlayerName, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
chatData.status = ChatDataManager.ChatStatus.PENDING;
} else {
// Add entity message
if (!message.isEmpty()) {
chatData.currentMessage = message;
}
chatData.currentLineNumber = line;
chatData.status = ChatDataManager.ChatStatus.valueOf(status_name);
chatData.sender = ChatDataManager.ChatSender.valueOf(sender_name);
chatData.friendship = friendship;
if (chatData.sender == ChatDataManager.ChatSender.USER && !playerIdStr.isEmpty()) {
// Add player message to queue for rendering
PlayerMessageManager.addMessage(UUID.fromString(chatData.playerId), chatData.currentMessage, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
}
// Play sound with volume based on distance (from player or entity)
playNearbyUISound(client, entity, 0.2f);
chatData.status = status;
chatData.sender = sender;
chatData.players = players; // friendships
}
// Play sound with volume based on distance (from player or entity)
playNearbyUISound(client, entity, 0.2f);
});
});
......@@ -154,14 +190,14 @@ public class ClientPackets {
// Decompress the combined byte array to get the original JSON string
String chatDataJSON = Decompression.decompressString(combined.toByteArray());
if (chatDataJSON == null) {
LOGGER.info("Error decompressing lite JSON string from bytes");
if (chatDataJSON == null || chatDataJSON.isEmpty()) {
LOGGER.warn("Received invalid or empty chat data JSON. Skipping processing.");
return;
}
// Parse JSON and update client chat data
Gson GSON = new Gson();
Type type = new TypeToken<ConcurrentHashMap<String, ChatDataManager.EntityChatData>>(){}.getType();
Type type = new TypeToken<ConcurrentHashMap<String, EntityChatData>>(){}.getType();
ChatDataManager.getClientInstance().entityChatDataMap = GSON.fromJson(chatDataJSON, type);
// Clear receivedChunks for future use
......@@ -202,7 +238,12 @@ public class ClientPackets {
PlayerEntity player = ClientEntityFinder.getPlayerEntityFromUUID(playerId);
// Update the player status data manager on the client-side
client.execute(() -> { // Make sure to run on the client thread
client.execute(() -> {
if (player == null) {
LOGGER.warn("Player entity is null. Skipping status update.");
return;
}
if (isChatOpen) {
PlayerMessageManager.openChatUI(playerId);
playNearbyUISound(client, player, 0.2f);
......
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleTextureSheet;
import net.minecraft.client.particle.SpriteBillboardParticle;
import net.minecraft.client.world.ClientWorld;
/**
* The {@code BehaviorParticle} class defines a custom CreatureChat behavior particle with an initial upward velocity
* that gradually decreases, ensuring it never moves downward.
*/
public class BehaviorParticle extends SpriteBillboardParticle {
protected BehaviorParticle(ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
super(world, x, y, z, velocityX, velocityY, velocityZ);
this.scale(2f);
this.setMaxAge(35);
// Start with an initial upward velocity
this.velocityY = 0.1;
this.velocityX *= 0.1;
this.velocityZ *= 0.1;
this.collidesWithWorld = false;
}
@Override
public ParticleTextureSheet getType() {
return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE;
}
@Override
public int getBrightness(float tint) {
return 0xF000F0;
}
@Override
public void tick() {
super.tick();
// Gradually decrease the upward velocity over time
if (this.velocityY > 0) {
this.velocityY -= 0.002;
}
// Ensure the particle doesn't start moving downwards
if (this.velocityY < 0) {
this.velocityY = 0;
}
}
}
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleFactory;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.particle.DefaultParticleType;
/**
* The {@code CreatureParticleFactory} class is responsible for creating instances of
* {@link BehaviorParticle} with the specified parameters.
*/
public class CreatureParticleFactory implements ParticleFactory<DefaultParticleType> {
private final SpriteProvider spriteProvider;
public CreatureParticleFactory(SpriteProvider spriteProvider) {
this.spriteProvider = spriteProvider;
}
@Override
public BehaviorParticle createParticle(DefaultParticleType type, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
BehaviorParticle particle = new BehaviorParticle(world, x, y, z, velocityX, velocityY, velocityZ);
particle.setSprite(this.spriteProvider);
return particle;
}
}
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleTextureSheet;
import net.minecraft.client.particle.SpriteBillboardParticle;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.render.Camera;
import net.minecraft.client.render.VertexConsumer;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.util.math.MathHelper;
import org.joml.Vector3f;
import net.minecraft.util.math.Vec3d;
/**
* The {@code LeadParticle} class renders a static LEAD behavior particle (i.e. animated arrow pointing in the direction of lead). It
* uses a SpriteProvider for animation.
*/
public class LeadParticle extends SpriteBillboardParticle {
private final SpriteProvider spriteProvider;
public LeadParticle(ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, SpriteProvider spriteProvider, double angle) {
super(world, x, y, z, 0, 0, 0);
this.velocityX = 0f;
this.velocityY = 0f;
this.velocityZ = 0f;
this.spriteProvider = spriteProvider;
this.angle = (float) angle;
this.scale(4.5F);
this.setMaxAge(40);
this.setSpriteForAge(spriteProvider);
}
@Override
public void tick() {
super.tick();
this.setSpriteForAge(spriteProvider);
}
@Override
public int getBrightness(float tint) {
return 0xF000F0;
}
@Override
public ParticleTextureSheet getType() {
return ParticleTextureSheet.PARTICLE_SHEET_TRANSLUCENT;
}
@Override
public void buildGeometry(VertexConsumer vertexConsumer, Camera camera, float tickDelta) {
// Get the current position of the particle relative to the camera
Vec3d cameraPos = camera.getPos();
float particleX = (float)(MathHelper.lerp((double)tickDelta, this.prevPosX, this.x) - cameraPos.getX());
float particleY = (float)(MathHelper.lerp((double)tickDelta, this.prevPosY, this.y) - cameraPos.getY());
float particleZ = (float)(MathHelper.lerp((double)tickDelta, this.prevPosZ, this.z) - cameraPos.getZ());
// Define the four vertices of the particle (keeping it flat on the XY plane)
Vector3f[] vertices = new Vector3f[]{
new Vector3f(-1.0F, 0.0F, -1.0F), // Bottom-left
new Vector3f(-1.0F, 0.0F, 1.0F), // Top-left
new Vector3f(1.0F, 0.0F, 1.0F), // Top-right
new Vector3f(1.0F, 0.0F, -1.0F) // Bottom-right
};
// Apply scaling and rotation using the particle's angle (in world space)
float size = this.getSize(tickDelta); // Get the size of the particle at the current tick
for (Vector3f vertex : vertices) {
vertex.mul(size); // Scale the vertices
vertex.rotateY(angle);
vertex.add(particleX, particleY, particleZ); // Translate to particle position
}
// Get the UV coordinates from the sprite (used for texture mapping)
float minU = this.getMinU();
float maxU = this.getMaxU();
float minV = this.getMinV();
float maxV = this.getMaxV();
int light = this.getBrightness(tickDelta);
// Render each vertex of the particle (flat on the XY plane)
vertexConsumer.vertex(vertices[0].x(), vertices[0].y(), vertices[0].z()).texture(maxU, maxV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[1].x(), vertices[1].y(), vertices[1].z()).texture(maxU, minV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[2].x(), vertices[2].y(), vertices[2].z()).texture(minU, minV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[3].x(), vertices[3].y(), vertices[3].z()).texture(minU, maxV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
}
}
\ No newline at end of file
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleFactory;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.world.ClientWorld;
/**
* The {@code LeadParticleFactory} class generates new arrow particles for LEAD behavior. It passes along the 'angle' to rotate the particle. It also
* sets the motion/acceleration to 0.
*/
public class LeadParticleFactory implements ParticleFactory<LeadParticleEffect> {
private final SpriteProvider spriteProvider;
public LeadParticleFactory(SpriteProvider spriteProvider) {
this.spriteProvider = spriteProvider;
}
@Override
public LeadParticle createParticle(LeadParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
double angle = effect.getAngle();
return new LeadParticle(world, x, y, z, 0, 0, 0, this.spriteProvider, angle);
}
}
......@@ -2,6 +2,8 @@ package com.owlmaddie.ui;
import com.mojang.blaze3d.systems.RenderSystem;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.utils.EntityHeights;
import com.owlmaddie.utils.EntityRendererAccessor;
import com.owlmaddie.utils.TextureLoader;
......@@ -9,6 +11,7 @@ import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.font.TextRenderer.TextLayerType;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.client.render.*;
import net.minecraft.client.render.entity.EntityRenderer;
import net.minecraft.client.util.math.MatrixStack;
......@@ -469,12 +472,20 @@ public class BubbleRenderer {
// Get position matrix
Matrix4f matrix = matrices.peek().getPositionMatrix();
// Look-up greeting (if any)
ChatDataManager.EntityChatData chatData = null;
// Get the player
ClientPlayerEntity player = MinecraftClient.getInstance().player;
// Get chat message (if any)
EntityChatData chatData = null;
PlayerData playerData = null;
if (entity instanceof MobEntity) {
chatData = ChatDataManager.getClientInstance().getOrCreateChatData(entity.getUuidAsString());
chatData = ChatDataManager.getClientInstance().getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
if (chatData != null) {
playerData = chatData.getPlayerData(player.getDisplayName().getString());
}
} else if (entity instanceof PlayerEntity) {
chatData = PlayerMessageManager.getMessage(entity.getUuid());
playerData = new PlayerData(); // no friendship needed for player messages
}
float minTextHeight = (ChatDataManager.DISPLAY_NUM_LINES * (fontRenderer.fontHeight + lineSpacing)) + (DISPLAY_PADDING * 2);
......@@ -518,13 +529,13 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, true);
// Draw text background (no smaller than 50F tall)
drawTextBubbleBackground("text-top", matrices, -64, 0, 128, scaledTextHeight, chatData.friendship);
drawTextBubbleBackground("text-top", matrices, -64, 0, 128, scaledTextHeight, playerData.friendship);
// Draw face icon of entity
drawEntityIcon(matrices, entity, -82, 7, 32, 32);
// Draw Friendship status
drawFriendshipStatus(matrices, 51, 18, 31, 21, chatData.friendship);
drawFriendshipStatus(matrices, 51, 18, 31, 21, playerData.friendship);
// Draw 'arrows' & 'keyboard' buttons
if (chatData.currentLineNumber > 0) {
......@@ -544,10 +555,10 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, false);
// Draw 'resume chat' button
if (chatData.friendship == 3) {
if (playerData.friendship == 3) {
// Friend chat bubble
drawIcon("button-chat-friend", matrices, -16, textHeaderHeight, 32, 17);
} else if (chatData.friendship == -3) {
} else if (playerData.friendship == -3) {
// Enemy chat bubble
drawIcon("button-chat-enemy", matrices, -16, textHeaderHeight, 32, 17);
} else {
......@@ -560,7 +571,7 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, true);
// Draw text background
drawTextBubbleBackground("text-top-player", matrices, -64, 0, 128, scaledTextHeight, chatData.friendship);
drawTextBubbleBackground("text-top-player", matrices, -64, 0, 128, scaledTextHeight, playerData.friendship);
// Draw face icon of player
drawPlayerIcon(matrices, entity, -75, 14, 18, 18);
......
package com.owlmaddie.ui;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.network.ClientPackets;
import com.owlmaddie.utils.ClientEntityFinder;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
......@@ -119,7 +120,7 @@ public class ClickHandler {
MobEntity closestEntity = ClientEntityFinder.getEntityByUUID(client.world, closestEntityUUID);
if (closestEntity != null) {
// Look-up conversation
ChatDataManager.EntityChatData chatData = ChatDataManager.getClientInstance().getOrCreateChatData(closestEntityUUID.toString());
EntityChatData chatData = ChatDataManager.getClientInstance().getOrCreateChatData(closestEntityUUID.toString(), player.getDisplayName().getString());
// Determine area clicked inside chat bubble (top, left, right)
String hitRegion = determineHitRegion(closestHitResult.get(), closestBubbleData.position, camera, closestBubbleData.height);
......
package com.owlmaddie.ui;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import java.util.concurrent.atomic.AtomicInteger;
/**
......@@ -8,11 +10,11 @@ import java.util.concurrent.atomic.AtomicInteger;
* many ticks to remain visible, and the message to display. Similar to an EntityChatData, but
* much simpler.
*/
public class PlayerMessage extends ChatDataManager.EntityChatData {
public class PlayerMessage extends EntityChatData {
public AtomicInteger tickCountdown;
public PlayerMessage(String playerId, String messageText, int ticks) {
super("", playerId);
public PlayerMessage(String playerId, String playerName, String messageText, int ticks) {
super(playerId, playerName);
this.currentMessage = messageText;
this.currentLineNumber = 0;
this.tickCountdown = new AtomicInteger(ticks);
......
......@@ -12,8 +12,8 @@ public class PlayerMessageManager {
private static final ConcurrentHashMap<UUID, PlayerMessage> messages = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<UUID, Boolean> openChatUIs = new ConcurrentHashMap<>();
public static void addMessage(UUID playerUUID, String messageText, int ticks) {
messages.put(playerUUID, new PlayerMessage(playerUUID.toString(), messageText, ticks));
public static void addMessage(UUID playerUUID, String messageText, String playerName, int ticks) {
messages.put(playerUUID, new PlayerMessage(playerUUID.toString(), playerName, messageText, ticks));
}
public static PlayerMessage getMessage(UUID playerId) {
......
......@@ -2,40 +2,17 @@ package com.owlmaddie.chat;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.controls.SpeedControls;
import com.owlmaddie.goals.*;
import com.owlmaddie.items.RarityItemCollector;
import com.owlmaddie.json.QuestJson;
import com.owlmaddie.message.Behavior;
import com.owlmaddie.message.MessageParser;
import com.owlmaddie.message.ParsedMessage;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
import com.owlmaddie.utils.VillagerEntityAccessor;
import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.TameableEntity;
import net.minecraft.entity.passive.VillagerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Rarity;
import net.minecraft.util.WorldSavePath;
import net.minecraft.util.math.MathHelper;
import net.minecraft.village.VillageGossipType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* The {@code ChatDataManager} class manages chat data for all entities. This class also helps
......@@ -51,7 +28,6 @@ public class ChatDataManager {
public static int MAX_CHAR_IN_USER_MESSAGE = 512;
public static int TICKS_TO_DISPLAY_USER_MESSAGE = 70;
public static int MAX_AUTOGENERATE_RESPONSES = 3;
public QuestJson quest = null;
private static final Gson GSON = new Gson();
public enum ChatStatus {
......@@ -69,414 +45,6 @@ public class ChatDataManager {
// HashMap to associate unique entity IDs with their chat data
public ConcurrentHashMap<String, EntityChatData> entityChatDataMap;
public static class ChatMessage {
public String message;
public ChatSender sender;
public ChatMessage(String message, ChatSender sender) {
this.message = message;
this.sender = sender;
}
}
// Inner class to hold entity-specific data
public static class EntityChatData {
public String entityId;
public String playerId;
public String currentMessage;
public int currentLineNumber;
public ChatStatus status;
public List<ChatMessage> previousMessages;
public String characterSheet;
public ChatSender sender;
public int friendship; // -3 to 3 (0 = neutral)
public int auto_generated;
public EntityChatData(String entityId, String playerId) {
this.entityId = entityId;
this.playerId = playerId;
this.currentMessage = "";
this.currentLineNumber = 0;
this.previousMessages = new ArrayList<>();
this.characterSheet = "";
this.status = ChatStatus.NONE;
this.sender = ChatSender.USER;
this.friendship = 0;
this.auto_generated = 0;
}
// Light version with no 'previousMessages' attribute
public class EntityChatDataLight {
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatStatus status;
public ChatSender sender;
public int friendship;
}
// Generate light version of chat data (no previous messages)
public EntityChatDataLight toLightVersion() {
EntityChatDataLight light = new EntityChatDataLight();
light.entityId = this.entityId;
light.currentMessage = this.currentMessage;
light.currentLineNumber = this.currentLineNumber;
light.status = this.status;
light.sender = this.sender;
light.friendship = this.friendship;
return light;
}
public String getCharacterProp(String propertyName) {
// Create a case-insensitive regex pattern to match the property name and capture its value
Pattern pattern = Pattern.compile("-?\\s*" + Pattern.quote(propertyName) + ":\\s*(.+)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(characterSheet);
if (matcher.find()) {
// Return the captured value, trimmed of any excess whitespace
return matcher.group(1).trim().replace("\"", "");
}
return "N/A";
}
// Generate context object
public Map<String, String> getPlayerContext(ServerPlayerEntity player, String userLanguage) {
// Add PLAYER context information
Map<String, String> contextData = new HashMap<>();
contextData.put("player_name", player.getDisplayName().getString());
contextData.put("player_health", player.getHealth() + "/" + player.getMaxHealth());
contextData.put("player_hunger", String.valueOf(player.getHungerManager().getFoodLevel()));
contextData.put("player_held_item", String.valueOf(player.getMainHandStack().getItem().toString()));
contextData.put("player_biome", player.getWorld().getBiome(player.getBlockPos()).getKey().get().getValue().getPath());
contextData.put("player_is_creative", player.isCreative() ? "yes" : "no");
contextData.put("player_is_swimming", player.isSwimming() ? "yes" : "no");
contextData.put("player_is_on_ground", player.isOnGround() ? "yes" : "no");
contextData.put("player_language", userLanguage);
ItemStack feetArmor = player.getInventory().armor.get(0);
ItemStack legsArmor = player.getInventory().armor.get(1);
ItemStack chestArmor = player.getInventory().armor.get(2);
ItemStack headArmor = player.getInventory().armor.get(3);
contextData.put("player_armor_head", headArmor.getItem().toString());
contextData.put("player_armor_chest", chestArmor.getItem().toString());
contextData.put("player_armor_legs", legsArmor.getItem().toString());
contextData.put("player_armor_feet", feetArmor.getItem().toString());
// Get active player effects
String effectsString = player.getActiveStatusEffects().entrySet().stream()
.map(entry -> entry.getKey().getTranslationKey() + " x" + (entry.getValue().getAmplifier() + 1))
.collect(Collectors.joining(", "));
contextData.put("player_active_effects", effectsString);
// Get World time (as 24 hour value)
int hours = (int) ((player.getWorld().getTimeOfDay() / 1000 + 6) % 24); // Minecraft day starts at 6 AM
int minutes = (int) (((player.getWorld().getTimeOfDay() % 1000) / 1000.0) * 60);
contextData.put("world_time", String.format("%02d:%02d", hours, minutes));
contextData.put("world_is_raining", player.getWorld().isRaining() ? "yes" : "no");
contextData.put("world_is_thundering", player.getWorld().isThundering() ? "yes" : "no");
contextData.put("world_difficulty", player.getWorld().getDifficulty().getName());
contextData.put("world_is_hardcore", player.getWorld().getLevelProperties().isHardcore() ? "yes" : "no");
// Get moon phase
String moonPhaseDescription = switch (player.getWorld().getMoonPhase()) {
case 0 -> "Full Moon";
case 1 -> "Waning Gibbous";
case 2 -> "Last Quarter";
case 3 -> "Waning Crescent";
case 4 -> "New Moon";
case 5 -> "Waxing Crescent";
case 6 -> "First Quarter";
case 7 -> "Waxing Gibbous";
default -> "Unknown";
};
contextData.put("world_moon_phase", moonPhaseDescription);
// Get Entity details
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
if (entity.getCustomName() == null) {
contextData.put("entity_name", "");
} else {
contextData.put("entity_name", entity.getCustomName().getString());
}
contextData.put("entity_type", entity.getType().getName().getString());
contextData.put("entity_health", entity.getHealth() + "/" + entity.getMaxHealth());
contextData.put("entity_personality", getCharacterProp("Personality"));
contextData.put("entity_speaking_style", getCharacterProp("Speaking Style / Tone"));
contextData.put("entity_likes", getCharacterProp("Likes"));
contextData.put("entity_dislikes", getCharacterProp("Dislikes"));
contextData.put("entity_age", getCharacterProp("Age"));
contextData.put("entity_alignment", getCharacterProp("Alignment"));
contextData.put("entity_class", getCharacterProp("Class"));
contextData.put("entity_skills", getCharacterProp("Skills"));
contextData.put("entity_background", getCharacterProp("Background"));
contextData.put("entity_friendship", String.valueOf(friendship));
return contextData;
}
// Generate greeting
public void generateMessage(String userLanguage, ServerPlayerEntity player, String systemPrompt, String userMessage, boolean is_auto_message) {
this.status = ChatStatus.PENDING;
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
if (systemPrompt == "system-character") {
// Add message without playerId (so it does not display)
this.addMessage(userMessage, ChatSender.USER, "");
} else if (systemPrompt == "system-chat") {
this.addMessage(userMessage, ChatSender.USER, player.getUuidAsString());
}
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null && systemPrompt == "system-character") {
// Character Sheet: Remove system-character message from previous messages
previousMessages.clear();
// Add NEW CHARACTER sheet & greeting
this.characterSheet = output_message;
String shortGreeting = getCharacterProp("short greeting");
if (shortGreeting.isEmpty()) {
shortGreeting = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
this.addMessage(shortGreeting.replace("\n", " "), ChatSender.ASSISTANT, player.getUuidAsString());
} else if (output_message != null && systemPrompt == "system-chat") {
// Chat Message: Parse message for behaviors
ParsedMessage result = MessageParser.parseMessage(output_message.replace("\n", " "));
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
// Determine entity's default speed
// Some Entities (i.e. Axolotl) set this incorrectly... so adjusting in the SpeedControls class
float entitySpeed = SpeedControls.getMaxSpeed(entity);
float entitySpeedMedium = MathHelper.clamp(entitySpeed * 1.15F, 0.5f, 1.15f);
float entitySpeedFast = MathHelper.clamp(entitySpeed * 1.3F, 0.5f, 1.3f);
// Apply behaviors (if any)
for (Behavior behavior : result.getBehaviors()) {
LOGGER.info("Behavior: " + behavior.getName() + (behavior.getArgument() != null ?
", Argument: " + behavior.getArgument() : ""));
// Apply behaviors to entity
if (behavior.getName().equals("FOLLOW")) {
FollowPlayerGoal followGoal = new FollowPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, followGoal, GoalPriority.FOLLOW_PLAYER);
} else if (behavior.getName().equals("UNFOLLOW")) {
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
} else if (behavior.getName().equals("FLEE")) {
float fleeDistance = 40F;
FleePlayerGoal fleeGoal = new FleePlayerGoal(player, entity, entitySpeedFast, fleeDistance);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, fleeGoal, GoalPriority.FLEE_PLAYER);
} else if (behavior.getName().equals("UNFLEE")) {
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
} else if (behavior.getName().equals("ATTACK")) {
AttackPlayerGoal attackGoal = new AttackPlayerGoal(player, entity, entitySpeedFast);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, attackGoal, GoalPriority.ATTACK_PLAYER);
} else if (behavior.getName().equals("PROTECT")) {
ProtectPlayerGoal protectGoal = new ProtectPlayerGoal(player, entity, 1.0);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, protectGoal, GoalPriority.PROTECT_PLAYER);
} else if (behavior.getName().equals("UNPROTECT")) {
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
} else if (behavior.getName().equals("LEAD")) {
LeadPlayerGoal leadGoal = new LeadPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, leadGoal, GoalPriority.LEAD_PLAYER);
} else if (behavior.getName().equals("UNLEAD")) {
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
} else if (behavior.getName().equals("FRIENDSHIP")) {
int new_friendship = Math.max(-3, Math.min(3, behavior.getArgument()));
// Does friendship improve?
if (new_friendship > this.friendship) {
// Stop any attack/flee if friendship improves
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
if (entity instanceof EnderDragonEntity && new_friendship == 3) {
// Trigger end of game (friendship always wins!)
EnderDragonEntity dragon = (EnderDragonEntity) entity;
dragon.getFight().dragonKilled(dragon);
}
}
// Merchant deals (if friendship changes with a Villager
if (entity instanceof VillagerEntity && this.friendship != new_friendship) {
VillagerEntityAccessor villager = (VillagerEntityAccessor) entity;
switch (new_friendship) {
case 3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_POSITIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 10);
break;
case -1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 10);
break;
case -2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
case -3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_NEGATIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
}
}
// Tame best friends and un-tame worst enemies
if (entity instanceof TameableEntity && this.friendship != new_friendship) {
TameableEntity tamableEntity = (TameableEntity) entity;
if (new_friendship == 3 && !tamableEntity.isTamed()) {
tamableEntity.setOwner(player);
} else if (new_friendship == -3 && tamableEntity.isTamed()) {
tamableEntity.setTamed(false);
tamableEntity.setOwnerUuid(null);
}
}
this.friendship = new_friendship;
}
}
// Add ASSISTANT message to history
this.addMessage(result.getOriginalMessage(), ChatSender.ASSISTANT, player.getUuidAsString());
// Get cleaned message (i.e. no <BEHAVIOR> strings)
String cleanedMessage = result.getCleanedMessage();
if (cleanedMessage.isEmpty()) {
cleanedMessage = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
// Update the current message to a 'cleaned version'
this.currentMessage = cleanedMessage;
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatSender.ASSISTANT, player.getUuidAsString());
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
});
}
public static String truncateString(String input, int maxLength) {
return input.length() > maxLength ? input.substring(0, maxLength - 3) + "..." : input;
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatSender messageSender, String playerId) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), MAX_CHAR_IN_USER_MESSAGE));
// Add message to history
previousMessages.add(new ChatMessage(truncatedMessage, messageSender));
// Set new message and reset line number of displayed text
currentMessage = truncatedMessage;
currentLineNumber = 0;
if (messageSender == ChatSender.ASSISTANT) {
// Show new generated message
status = ChatStatus.DISPLAY;
} else if (messageSender == ChatSender.USER) {
// Show pending icon
status = ChatStatus.PENDING;
}
sender = messageSender;
this.playerId = playerId;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
// Get wrapped lines
public List<String> getWrappedLines() {
return LineWrapper.wrapLines(this.currentMessage, MAX_CHAR_PER_LINE);
}
public boolean isEndOfMessage() {
int totalLines = this.getWrappedLines().size();
// Check if the current line number plus DISPLAY_NUM_LINES covers or exceeds the total number of lines
return currentLineNumber + DISPLAY_NUM_LINES >= totalLines;
}
public void setLineNumber(Integer lineNumber) {
int totalLines = this.getWrappedLines().size();
// Ensure the lineNumber is within the valid range
currentLineNumber = Math.min(Math.max(lineNumber, 0), totalLines);
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
public void setStatus(ChatStatus new_status) {
status = new_status;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
}
public void clearData() {
// Clear the chat data for the previous session
entityChatDataMap.clear();
......@@ -504,8 +72,8 @@ public class ChatDataManager {
}
// Retrieve chat data for a specific entity, or create it if it doesn't exist
public EntityChatData getOrCreateChatData(String entityId) {
return entityChatDataMap.computeIfAbsent(entityId, k -> new EntityChatData(entityId, ""));
public EntityChatData getOrCreateChatData(String entityId, String playerName) {
return entityChatDataMap.computeIfAbsent(entityId, k -> new EntityChatData(entityId, playerName));
}
// Update the UUID in the map (i.e. bucketed entity and then released, changes their UUID)
......@@ -517,58 +85,19 @@ public class ChatDataManager {
LOGGER.info("Updated chat data from UUID (" + oldUUID + ") to UUID (" + newUUID + ")");
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(data);
ServerPackets.BroadcastPacketMessage(data, null);
} else {
LOGGER.info("Unable to update chat data, UUID not found: " + oldUUID);
}
}
// Generate quest data for this server session
public void generateQuest() {
// Get items needed for Quest prompt
List<String> commonItems = RarityItemCollector.getItemsByRarity(Rarity.COMMON, 5);
List<String> uncommonItems = RarityItemCollector.getItemsByRarity(Rarity.UNCOMMON, 5);
List<String> rareItems = RarityItemCollector.getItemsByRarity(Rarity.RARE, 5);
// Get entities needed for Quest prompt
List<String> commonEntities = RarityItemCollector.getEntitiesByRarity(Rarity.COMMON, 5);
List<String> uncommonEntities = RarityItemCollector.getEntitiesByRarity(Rarity.UNCOMMON, 5);
List<String> rareEntities = RarityItemCollector.getEntitiesByRarity(Rarity.RARE, 5);
// Add context information for prompt
Map<String, String> contextData = new HashMap<>();
contextData.put("items_common", String.join("\n", commonItems));
contextData.put("items_uncommon", String.join("\n", uncommonItems));
contextData.put("items_rare", String.join("\n", rareItems));
contextData.put("entities_common", String.join("\n", commonEntities));
contextData.put("entities_uncommon", String.join("\n", uncommonEntities));
contextData.put("entities_rare", String.join("\n", rareEntities));
// Add message
List<ChatMessage> messages = new ArrayList<>();
messages.add(new ChatMessage("Generate me a new fantasy story with ONLY the 1st character in the story", ChatSender.USER));
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String questPrompt = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), "system-quest");
// Generate Quest: fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, questPrompt, contextData, messages, true).thenAccept(output_message -> {
// New Quest
Gson gson = new Gson();
quest = gson.fromJson(output_message, QuestJson.class);
});
}
// Save chat data to file
public String GetLightChatData() {
public String GetLightChatData(String playerName) {
try {
// Create "light" version of entire chat data HashMap
HashMap<String, EntityChatData.EntityChatDataLight> lightVersionMap = new HashMap<>();
this.entityChatDataMap.forEach((id, entityChatData) -> lightVersionMap.put(id, entityChatData.toLightVersion()));
// Convert light chat data to JSON string
return GSON.toJson(lightVersionMap).toString();
HashMap<String, EntityChatDataLight> lightVersionMap = new HashMap<>();
this.entityChatDataMap.forEach((name, entityChatData) -> lightVersionMap.put(name, entityChatData.toLightVersion(playerName)));
return GSON.toJson(lightVersionMap);
} catch (Exception e) {
// Handle exceptions
return "";
......@@ -580,6 +109,9 @@ public class ChatDataManager {
File saveFile = new File(server.getSavePath(WorldSavePath.ROOT).toFile(), "chatdata.json");
LOGGER.info("Saving chat data to " + saveFile.getAbsolutePath());
// Clean up blank, temp entities in data
entityChatDataMap.values().removeIf(entityChatData -> entityChatData.status == ChatStatus.NONE);
try (Writer writer = new OutputStreamWriter(new FileOutputStream(saveFile), StandardCharsets.UTF_8)) {
GSON.toJson(this.entityChatDataMap, writer);
} catch (Exception e) {
......@@ -598,6 +130,14 @@ public class ChatDataManager {
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(loadFile), StandardCharsets.UTF_8)) {
Type type = new TypeToken<ConcurrentHashMap<String, EntityChatData>>(){}.getType();
this.entityChatDataMap = GSON.fromJson(reader, type);
// Clean up blank, temp entities in data
entityChatDataMap.values().removeIf(entityChatData -> entityChatData.status == ChatStatus.NONE);
// Post-process each EntityChatData object
for (EntityChatData entityChatData : entityChatDataMap.values()) {
entityChatData.postDeserializeInitialization();
}
} catch (Exception e) {
LOGGER.error("Error loading chat data", e);
this.entityChatDataMap = new ConcurrentHashMap<>();
......
......@@ -118,7 +118,7 @@ public class ChatGPTRequest {
return (int) Math.round(text.length() / 3.5);
}
public static CompletableFuture<String> fetchMessageFromChatGPT(ConfigurationHandler.Config config, String systemPrompt, Map<String, String> contextData, List<ChatDataManager.ChatMessage> messageHistory, Boolean jsonMode) {
public static CompletableFuture<String> fetchMessageFromChatGPT(ConfigurationHandler.Config config, String systemPrompt, Map<String, String> contextData, List<ChatMessage> messageHistory, Boolean jsonMode) {
// Init API & LLM details
String apiUrl = config.getUrl();
String apiKey = config.getApiKey();
......@@ -151,7 +151,7 @@ public class ChatGPTRequest {
// Iterate backwards through the message history
for (int i = messageHistory.size() - 1; i >= 0; i--) {
ChatDataManager.ChatMessage chatMessage = messageHistory.get(i);
ChatMessage chatMessage = messageHistory.get(i);
String senderName = chatMessage.sender.toString().toLowerCase(Locale.ENGLISH);
String messageText = replacePlaceholders(chatMessage.message, contextData);
int messageTokens = estimateTokenSize(senderName + ": " + messageText);
......@@ -213,7 +213,6 @@ public class ChatGPTRequest {
ChatGPTResponse chatGPTResponse = gsonOutput.fromJson(response.toString(), ChatGPTResponse.class);
if (chatGPTResponse != null && chatGPTResponse.choices != null && !chatGPTResponse.choices.isEmpty()) {
String content = chatGPTResponse.choices.get(0).message.content;
LOGGER.info("Generated message: " + content);
return content;
}
}
......
package com.owlmaddie.chat;
/**
* The {@code ChatMessage} class represents a single message.
*/
public class ChatMessage {
public String message;
public String name;
public ChatDataManager.ChatSender sender;
public Long timestamp;
public ChatMessage(String message, ChatDataManager.ChatSender sender, String playerName) {
this.message = message;
this.sender = sender;
this.name = playerName;
this.timestamp = System.currentTimeMillis();
}
}
\ No newline at end of file
package com.owlmaddie.chat;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.controls.SpeedControls;
import com.owlmaddie.goals.*;
import com.owlmaddie.message.Behavior;
import com.owlmaddie.message.MessageParser;
import com.owlmaddie.message.ParsedMessage;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.particle.ParticleEmitter;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
import com.owlmaddie.utils.VillagerEntityAccessor;
import net.minecraft.entity.ExperienceOrbEntity;
import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.TameableEntity;
import net.minecraft.entity.passive.VillagerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundCategory;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.MathHelper;
import net.minecraft.village.VillageGossipType;
import net.minecraft.world.GameRules;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code EntityChatData} class represents a conversation between an
* entity and one or more players, including friendship, character sheets,
* and the status of the current displayed message.
*/
public class EntityChatData {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public String characterSheet;
public ChatDataManager.ChatSender sender;
public int auto_generated;
public List<ChatMessage> previousMessages;
public Long born;
public Long death;
@SerializedName("playerId")
@Expose(serialize = false)
private String legacyPlayerId;
@SerializedName("friendship")
@Expose(serialize = false)
public Integer legacyFriendship;
// The map to store data for each player interacting with this entity
public Map<String, PlayerData> players;
public EntityChatData(String entityId, String playerName) {
this.entityId = entityId;
this.players = new HashMap<>();
if (!playerName.isEmpty()) {
this.players.put(playerName, new PlayerData());
}
this.currentMessage = "";
this.currentLineNumber = 0;
this.characterSheet = "";
this.status = ChatDataManager.ChatStatus.NONE;
this.sender = ChatDataManager.ChatSender.USER;
this.auto_generated = 0;
this.previousMessages = new ArrayList<>();
this.born = System.currentTimeMillis();;
// Old, unused migrated properties
this.legacyPlayerId = null;
this.legacyFriendship = null;
}
// Post-deserialization initialization
public void postDeserializeInitialization() {
if (this.players == null) {
this.players = new HashMap<>(); // Ensure players map is initialized
}
if (this.legacyPlayerId != null && !this.legacyPlayerId.isEmpty()) {
this.migrateData();
}
}
// Migrate old data into the new structure
private void migrateData() {
// Ensure the blank player data entry exists
PlayerData blankPlayerData = this.players.computeIfAbsent("", k -> new PlayerData());
// Update the previousMessages arraylist and add timestamps if missing
if (this.previousMessages != null) {
for (ChatMessage message : this.previousMessages) {
if (message.timestamp == null) {
message.timestamp = System.currentTimeMillis();
}
if (message.name == null || message.name.isEmpty()) {
message.name = "";
}
}
}
blankPlayerData.friendship = this.legacyFriendship;
if (this.born == null) {
this.born = System.currentTimeMillis();;
}
// Clean up old player data
this.legacyPlayerId = null;
this.legacyFriendship = null;
}
// Get the player data (or fallback to the blank player)
public PlayerData getPlayerData(String playerName) {
if (this.players == null) {
return new PlayerData();
}
// Check if the playerId exists in the players map
if (this.players.containsKey("")) {
// If a blank migrated legacy entity is found, always return this
return this.players.get("");
} else if (this.players.containsKey(playerName)) {
// Return a specific player's data
return this.players.get(playerName);
} else {
// Return a blank player data
PlayerData newPlayerData = new PlayerData();
this.players.put(playerName, newPlayerData);
return newPlayerData;
}
}
// Generate light version of chat data (no previous messages)
public EntityChatDataLight toLightVersion(String playerName) {
return new EntityChatDataLight(this, playerName);
}
public String getCharacterProp(String propertyName) {
// Create a case-insensitive regex pattern to match the property name and capture its value
Pattern pattern = Pattern.compile("-?\\s*" + Pattern.quote(propertyName) + ":\\s*(.+)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(characterSheet);
if (matcher.find()) {
// Return the captured value, trimmed of any excess whitespace
return matcher.group(1).trim().replace("\"", "");
}
return "N/A";
}
// Generate context object
public Map<String, String> getPlayerContext(ServerPlayerEntity player, String userLanguage, ConfigurationHandler.Config config) {
// Add PLAYER context information
Map<String, String> contextData = new HashMap<>();
contextData.put("player_name", player.getDisplayName().getString());
contextData.put("player_health", player.getHealth() + "/" + player.getMaxHealth());
contextData.put("player_hunger", String.valueOf(player.getHungerManager().getFoodLevel()));
contextData.put("player_held_item", String.valueOf(player.getMainHandStack().getItem().toString()));
contextData.put("player_biome", player.getWorld().getBiome(player.getBlockPos()).getKey().get().getValue().getPath());
contextData.put("player_is_creative", player.isCreative() ? "yes" : "no");
contextData.put("player_is_swimming", player.isSwimming() ? "yes" : "no");
contextData.put("player_is_on_ground", player.isOnGround() ? "yes" : "no");
contextData.put("player_language", userLanguage);
ItemStack feetArmor = player.getInventory().armor.get(0);
ItemStack legsArmor = player.getInventory().armor.get(1);
ItemStack chestArmor = player.getInventory().armor.get(2);
ItemStack headArmor = player.getInventory().armor.get(3);
contextData.put("player_armor_head", headArmor.getItem().toString());
contextData.put("player_armor_chest", chestArmor.getItem().toString());
contextData.put("player_armor_legs", legsArmor.getItem().toString());
contextData.put("player_armor_feet", feetArmor.getItem().toString());
// Get active player effects
String effectsString = player.getActiveStatusEffects().entrySet().stream()
.map(entry -> entry.getKey().getTranslationKey() + " x" + (entry.getValue().getAmplifier() + 1))
.collect(Collectors.joining(", "));
contextData.put("player_active_effects", effectsString);
// Add custom story section (if any)
if (!config.getStory().isEmpty()) {
contextData.put("story", "Story: " + config.getStory());
} else {
contextData.put("story", "");
}
// Get World time (as 24 hour value)
int hours = (int) ((player.getWorld().getTimeOfDay() / 1000 + 6) % 24); // Minecraft day starts at 6 AM
int minutes = (int) (((player.getWorld().getTimeOfDay() % 1000) / 1000.0) * 60);
contextData.put("world_time", String.format("%02d:%02d", hours, minutes));
contextData.put("world_is_raining", player.getWorld().isRaining() ? "yes" : "no");
contextData.put("world_is_thundering", player.getWorld().isThundering() ? "yes" : "no");
contextData.put("world_difficulty", player.getWorld().getDifficulty().getName());
contextData.put("world_is_hardcore", player.getWorld().getLevelProperties().isHardcore() ? "yes" : "no");
// Get moon phase
String moonPhaseDescription = switch (player.getWorld().getMoonPhase()) {
case 0 -> "Full Moon";
case 1 -> "Waning Gibbous";
case 2 -> "Last Quarter";
case 3 -> "Waning Crescent";
case 4 -> "New Moon";
case 5 -> "Waxing Crescent";
case 6 -> "First Quarter";
case 7 -> "Waxing Gibbous";
default -> "Unknown";
};
contextData.put("world_moon_phase", moonPhaseDescription);
// Get Entity details
MobEntity entity = (MobEntity) ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
if (entity.getCustomName() == null) {
contextData.put("entity_name", "");
} else {
contextData.put("entity_name", entity.getCustomName().getString());
}
contextData.put("entity_type", entity.getType().getName().getString());
contextData.put("entity_health", entity.getHealth() + "/" + entity.getMaxHealth());
contextData.put("entity_personality", getCharacterProp("Personality"));
contextData.put("entity_speaking_style", getCharacterProp("Speaking Style / Tone"));
contextData.put("entity_likes", getCharacterProp("Likes"));
contextData.put("entity_dislikes", getCharacterProp("Dislikes"));
contextData.put("entity_age", getCharacterProp("Age"));
contextData.put("entity_alignment", getCharacterProp("Alignment"));
contextData.put("entity_class", getCharacterProp("Class"));
contextData.put("entity_skills", getCharacterProp("Skills"));
contextData.put("entity_background", getCharacterProp("Background"));
PlayerData playerData = this.getPlayerData(player.getDisplayName().getString());
if (playerData != null) {
contextData.put("entity_friendship", String.valueOf(playerData.friendship));
} else {
contextData.put("entity_friendship", String.valueOf(0));
}
return contextData;
}
// Generate a new character
public void generateCharacter(String userLanguage, ServerPlayerEntity player, String userMessage, boolean is_auto_message) {
String systemPrompt = "system-character";
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
this.addMessage(userMessage, ChatDataManager.ChatSender.USER, player, systemPrompt);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage, config);
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null) {
// Character Sheet: Remove system-character message from previous messages
previousMessages.clear();
// Add NEW CHARACTER sheet & greeting
this.characterSheet = output_message;
String shortGreeting = Optional.ofNullable(getCharacterProp("short greeting")).filter(s -> !s.isEmpty()).orElse(Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE)).replace("\n", " ");
this.addMessage(shortGreeting, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
});
}
// Generate greeting
public void generateMessage(String userLanguage, ServerPlayerEntity player, String userMessage, boolean is_auto_message) {
String systemPrompt = "system-chat";
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
this.addMessage(userMessage, ChatDataManager.ChatSender.USER, player, systemPrompt);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage, config);
// Get messages for player
PlayerData playerData = this.getPlayerData(player.getDisplayName().getString());
if (previousMessages.size() == 1) {
// No messages exist yet for this player (start with normal greeting)
String shortGreeting = Optional.ofNullable(getCharacterProp("short greeting")).filter(s -> !s.isEmpty()).orElse(Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE)).replace("\n", " ");
previousMessages.add(0, new ChatMessage(shortGreeting, ChatDataManager.ChatSender.ASSISTANT, player.getDisplayName().getString()));
}
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null) {
// Chat Message: Parse message for behaviors
ParsedMessage result = MessageParser.parseMessage(output_message.replace("\n", " "));
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
// Determine entity's default speed
// Some Entities (i.e. Axolotl) set this incorrectly... so adjusting in the SpeedControls class
float entitySpeed = SpeedControls.getMaxSpeed(entity);
float entitySpeedMedium = MathHelper.clamp(entitySpeed * 1.15F, 0.5f, 1.15f);
float entitySpeedFast = MathHelper.clamp(entitySpeed * 1.3F, 0.5f, 1.3f);
// Apply behaviors (if any)
for (Behavior behavior : result.getBehaviors()) {
LOGGER.info("Behavior: " + behavior.getName() + (behavior.getArgument() != null ?
", Argument: " + behavior.getArgument() : ""));
// Apply behaviors to entity
if (behavior.getName().equals("FOLLOW")) {
FollowPlayerGoal followGoal = new FollowPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, followGoal, GoalPriority.FOLLOW_PLAYER);
if (playerData.friendship >= 0) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FOLLOW_FRIEND_PARTICLE, 0.5, 1);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FOLLOW_ENEMY_PARTICLE, 0.5, 1);
}
} else if (behavior.getName().equals("UNFOLLOW")) {
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
} else if (behavior.getName().equals("FLEE")) {
float fleeDistance = 40F;
FleePlayerGoal fleeGoal = new FleePlayerGoal(player, entity, entitySpeedFast, fleeDistance);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, fleeGoal, GoalPriority.FLEE_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FLEE_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("UNFLEE")) {
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
} else if (behavior.getName().equals("ATTACK")) {
AttackPlayerGoal attackGoal = new AttackPlayerGoal(player, entity, entitySpeedFast);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, attackGoal, GoalPriority.ATTACK_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FLEE_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("PROTECT")) {
if (playerData.friendship <= 0) {
// force friendship to prevent entity from attacking player when protecting
playerData.friendship = 1;
}
ProtectPlayerGoal protectGoal = new ProtectPlayerGoal(player, entity, 1.0);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, protectGoal, GoalPriority.PROTECT_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, PROTECT_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("UNPROTECT")) {
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
} else if (behavior.getName().equals("LEAD")) {
LeadPlayerGoal leadGoal = new LeadPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, leadGoal, GoalPriority.LEAD_PLAYER);
if (playerData.friendship >= 0) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, LEAD_FRIEND_PARTICLE, 0.5, 1);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, LEAD_ENEMY_PARTICLE, 0.5, 1);
}
} else if (behavior.getName().equals("UNLEAD")) {
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
} else if (behavior.getName().equals("FRIENDSHIP")) {
int new_friendship = Math.max(-3, Math.min(3, behavior.getArgument()));
// Does friendship improve?
if (new_friendship > playerData.friendship) {
// Stop any attack/flee if friendship improves
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
if (entity instanceof EnderDragonEntity && new_friendship == 3) {
// Trigger end of game (friendship always wins!)
EnderDragonEntity dragon = (EnderDragonEntity) entity;
// Emit particles & sound
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_BIG_PARTICLE, 3, 200);
entity.getWorld().playSound(entity, entity.getBlockPos(), SoundEvents.ENTITY_ENDER_DRAGON_DEATH, SoundCategory.PLAYERS, 0.3F, 1.0F);
entity.getWorld().playSound(entity, entity.getBlockPos(), SoundEvents.UI_TOAST_CHALLENGE_COMPLETE, SoundCategory.PLAYERS, 0.5F, 1.0F);
// Check if the game rule for mob loot is enabled
boolean doMobLoot = entity.getWorld().getGameRules().getBoolean(GameRules.DO_MOB_LOOT);
// If this is the first time the dragon is 'befriended', adjust the XP
int baseXP = 500;
if (dragon.getFight() != null && !dragon.getFight().hasPreviouslyKilled()) {
baseXP = 12000;
}
// If the world is a server world and mob loot is enabled, spawn XP orbs
if (entity.getWorld() instanceof ServerWorld && doMobLoot) {
// Loop to spawn XP orbs
for (int j = 1; j <= 11; j++) {
float xpFraction = (j == 11) ? 0.2F : 0.08F;
int xpAmount = MathHelper.floor((float)baseXP * xpFraction);
ExperienceOrbEntity.spawn((ServerWorld)entity.getWorld(), entity.getPos(), xpAmount);
}
}
// Mark fight as over
dragon.getFight().dragonKilled(dragon);
}
}
// Merchant deals (if friendship changes with a Villager
if (entity instanceof VillagerEntity && playerData.friendship != new_friendship) {
VillagerEntityAccessor villager = (VillagerEntityAccessor) entity;
switch (new_friendship) {
case 3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_POSITIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 10);
break;
case -1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 10);
break;
case -2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
case -3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_NEGATIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
}
}
// Tame best friends and un-tame worst enemies
if (entity instanceof TameableEntity && playerData.friendship != new_friendship) {
TameableEntity tamableEntity = (TameableEntity) entity;
if (new_friendship == 3 && !tamableEntity.isTamed()) {
tamableEntity.setOwner(player);
} else if (new_friendship == -3 && tamableEntity.isTamed()) {
tamableEntity.setTamed(false);
tamableEntity.setOwnerUuid(null);
}
}
// Emit friendship particles
if (playerData.friendship != new_friendship) {
int friendDiff = new_friendship - playerData.friendship;
if (friendDiff > 0) {
// Heart particles
if (new_friendship == 3) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_BIG_PARTICLE, 0.5, 10);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_SMALL_PARTICLE, 0.1, 1);
}
} else if (friendDiff < 0) {
// Fire particles
if (new_friendship == -3) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FIRE_BIG_PARTICLE, 0.5, 10);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FIRE_SMALL_PARTICLE, 0.1, 1);
}
}
}
playerData.friendship = new_friendship;
}
}
// Add ASSISTANT message to history
this.addMessage(result.getOriginalMessage(), ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Get cleaned message (i.e. no <BEHAVIOR> strings)
String cleanedMessage = result.getCleanedMessage();
if (cleanedMessage.isEmpty()) {
cleanedMessage = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
// Update the current message to a 'cleaned version'
this.currentMessage = cleanedMessage;
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, player);
});
}
public static String truncateString(String input, int maxLength) {
return input.length() > maxLength ? input.substring(0, maxLength - 3) + "..." : input;
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatDataManager.ChatSender sender, ServerPlayerEntity player, String systemPrompt) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), ChatDataManager.MAX_CHAR_IN_USER_MESSAGE));
// Add context-switching logic for USER messages only
String playerName = player.getDisplayName().getString();
if (sender == ChatDataManager.ChatSender.USER && previousMessages.size() > 1) {
ChatMessage lastMessage = previousMessages.get(previousMessages.size() - 1);
if (lastMessage.name == null || !lastMessage.name.equals(playerName)) { // Null-safe check
boolean isReturningPlayer = previousMessages.stream().anyMatch(msg -> playerName.equals(msg.name)); // Avoid NPE here too
String note = isReturningPlayer
? "<returning player: " + playerName + " resumes the conversation>"
: "<a new player has joined the conversation: " + playerName + ">";
previousMessages.add(new ChatMessage(note, sender, playerName));
// Log context-switching message
LOGGER.info("Conversation-switching message: status=PENDING, sender={}, message={}, player={}, entity={}",
ChatDataManager.ChatStatus.PENDING, note, playerName, entityId);
}
}
// Add message to history
previousMessages.add(new ChatMessage(truncatedMessage, sender, playerName));
// Log regular message addition
LOGGER.info("Message added: status={}, sender={}, message={}, player={}, entity={}",
status.toString(), sender.toString(), truncatedMessage, playerName, entityId);
// Update current message and reset line number of displayed text
this.currentMessage = truncatedMessage;
this.currentLineNumber = 0;
this.sender = sender;
// Determine status for message
if (sender == ChatDataManager.ChatSender.ASSISTANT) {
status = ChatDataManager.ChatStatus.DISPLAY;
} else if (sender == ChatDataManager.ChatSender.USER && systemPrompt.equals("system-chat")) {
// Only show system-chat messages above players (not system-character ones)
status = ChatDataManager.ChatStatus.DISPLAY;
} else {
status = ChatDataManager.ChatStatus.PENDING;
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, player);
}
// Get wrapped lines
public List<String> getWrappedLines() {
return LineWrapper.wrapLines(this.currentMessage, ChatDataManager.MAX_CHAR_PER_LINE);
}
public boolean isEndOfMessage() {
int totalLines = this.getWrappedLines().size();
// Check if the current line number plus DISPLAY_NUM_LINES covers or exceeds the total number of lines
return currentLineNumber + ChatDataManager.DISPLAY_NUM_LINES >= totalLines;
}
public void setLineNumber(Integer lineNumber) {
int totalLines = this.getWrappedLines().size();
// Ensure the lineNumber is within the valid range
currentLineNumber = Math.min(Math.max(lineNumber, 0), totalLines);
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, null);
}
public void setStatus(ChatDataManager.ChatStatus new_status) {
status = new_status;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, null);
}
}
\ No newline at end of file
package com.owlmaddie.chat;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* The {@code EntityChatDataLight} class represents the current displayed message, and no
* previous messages or player message history. This is primarily used to broadcast the
* currently displayed messages to players as they connect to the server.
*/
public class EntityChatDataLight {
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public ChatDataManager.ChatSender sender;
public Map<String, PlayerData> players;
// Constructor to initialize the light version from the full version
public EntityChatDataLight(EntityChatData fullData, String playerName) {
this.entityId = fullData.entityId;
this.currentMessage = fullData.currentMessage;
this.currentLineNumber = fullData.currentLineNumber;
this.status = fullData.status;
this.sender = fullData.sender;
// Initialize the players map and add only the current player's data
this.players = new HashMap<>();
PlayerData playerData = fullData.getPlayerData(playerName);
this.players.put(playerName, playerData);
}
}
\ No newline at end of file
package com.owlmaddie.chat;
/**
* The {@code PlayerData} class represents data associated with a player,
* specifically tracking their friendship level.
*/
public class PlayerData {
public int friendship;
public PlayerData() {
this.friendship = 0;
}
}
\ No newline at end of file
......@@ -73,6 +73,7 @@ public class ConfigurationHandler {
private int timeout = 10;
private List<String> whitelist = new ArrayList<>();
private List<String> blacklist = new ArrayList<>();
private String story = "";
// Getters and setters for existing fields
public String getApiKey() { return apiKey; }
......@@ -110,5 +111,8 @@ public class ConfigurationHandler {
public List<String> getBlacklist() { return blacklist; }
public void setBlacklist(List<String> blacklist) { this.blacklist = blacklist; }
public String getStory() { return story; }
public void setStory(String story) { this.story = story; }
}
}
......@@ -46,6 +46,7 @@ public class CreatureChatCommands {
.then(registerSetCommand("url", "URL", StringArgumentType.string()))
.then(registerSetCommand("model", "Model", StringArgumentType.string()))
.then(registerSetCommand("timeout", "Timeout (seconds)", IntegerArgumentType.integer()))
.then(registerStoryCommand())
.then(registerWhitelistCommand())
.then(registerBlacklistCommand())
.then(registerHelpCommand()));
......@@ -133,6 +134,7 @@ public class CreatureChatCommands {
+ "/creaturechat url set \"<url>\" - Sets the URL\n"
+ "/creaturechat model set <model> - Sets the model\n"
+ "/creaturechat timeout set <seconds> - Sets the API timeout\n"
+ "/creaturechat story set \"<story>\" - Sets a custom story\n"
+ "/creaturechat whitelist <entityType | all | clear> - Show chat bubbles\n"
+ "/creaturechat blacklist <entityType | all | clear> - Hide chat bubbles\n"
+ "\n"
......@@ -144,6 +146,50 @@ public class CreatureChatCommands {
});
}
private static LiteralArgumentBuilder<ServerCommandSource> registerStoryCommand() {
return CommandManager.literal("story")
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.literal("set")
.then(CommandManager.argument("value", StringArgumentType.string())
.executes(context -> {
String story = StringArgumentType.getString(context, "value");
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(story); // Assuming Config has a `setStory` method
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
context.getSource().sendFeedback(() -> Text.literal("Story set successfully: " + story).formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to set story!").formatted(Formatting.RED), false);
return 0;
}
})
))
.then(CommandManager.literal("clear")
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(""); // Clear the story
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
context.getSource().sendFeedback(() -> Text.literal("Story cleared successfully!").formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to clear story!").formatted(Formatting.RED), false);
return 0;
}
}))
.then(CommandManager.literal("display")
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
String story = config.getStory(); // Assuming Config has a `getStory` method
if (story == null || story.isEmpty()) {
context.getSource().sendFeedback(() -> Text.literal("No story is currently set.").formatted(Formatting.RED), false);
return 0;
} else {
context.getSource().sendFeedback(() -> Text.literal("Current story: " + story).formatted(Formatting.AQUA), false);
return 1;
}
}));
}
private static <T> int setConfig(ServerCommandSource source, String settingName, T value, boolean useServerConfig, String settingDescription) {
ConfigurationHandler configHandler = new ConfigurationHandler(source.getServer());
ConfigurationHandler.Config config = configHandler.loadConfig();
......
......@@ -3,16 +3,18 @@ package com.owlmaddie.goals;
import net.minecraft.entity.LivingEntity;
import net.minecraft.entity.ai.RangedAttackMob;
import net.minecraft.entity.mob.Angerable;
import java.util.concurrent.ThreadLocalRandom;
import net.minecraft.entity.mob.HostileEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.GolemEntity;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.Vec3d;
import java.util.EnumSet;
import static com.owlmaddie.network.ServerPackets.ATTACK_PARTICLE;
/**
* The {@code AttackPlayerGoal} class instructs a Mob Entity to show aggression towards a target Entity.
* For passive entities like chickens (or hostile entities in creative mode), damage is simulated with particles.
......@@ -94,12 +96,10 @@ public class AttackPlayerGoal extends PlayerBaseGoal {
this.attackerEntity.playSound(SoundEvents.ENTITY_PLAYER_HURT, 1F, 1F);
// Spawn red particles to simulate 'injury'
((ServerWorld) this.attackerEntity.getWorld()).spawnParticles(ParticleTypes.DAMAGE_INDICATOR,
this.targetEntity.getX(),
this.targetEntity.getBodyY(0.5D),
this.targetEntity.getZ(),
10, // number of particles
0.1, 0.1, 0.1, 0.2); // speed and randomness
int numParticles = ThreadLocalRandom.current().nextInt(2, 7); // Random number between 2 (inclusive) and 7 (exclusive)
((ServerWorld) this.attackerEntity.getWorld()).spawnParticles(ATTACK_PARTICLE,
this.targetEntity.getX(), this.targetEntity.getBodyY(0.5D), this.targetEntity.getZ(),
numParticles, 0.5, 0.5, 0.1, 0.4);
}
@Override
......
package com.owlmaddie.goals;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.controls.LookControls;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.particle.LeadParticleEffect;
import com.owlmaddie.utils.RandomTargetFinder;
import net.minecraft.entity.ai.pathing.Path;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.mob.PathAwareEntity;
import net.minecraft.particle.ParticleEffect;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.MathHelper;
......@@ -71,7 +71,7 @@ public class LeadPlayerGoal extends PlayerBaseGoal {
String arrivedMessage = "<You have arrived at your destination>";
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(this.entity.getUuidAsString());
EntityChatData chatData = chatDataManager.getOrCreateChatData(this.entity.getUuidAsString(), this.targetEntity.getDisplayName().getString());
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", chatData, (ServerPlayerEntity) this.targetEntity, this.entity, arrivedMessage, true);
}
......@@ -130,27 +130,49 @@ public class LeadPlayerGoal extends PlayerBaseGoal {
LOGGER.info("Waypoint " + currentWaypoint + " / " + this.totalWaypoints);
this.currentTarget = RandomTargetFinder.findRandomTarget(this.entity, 30, 24, 36);
if (this.currentTarget != null) {
emitParticleAt(this.currentTarget, ParticleTypes.FLAME);
emitParticlesAlongRaycast(this.entity.getPos(), this.currentTarget, ParticleTypes.CLOUD, 0.5);
emitParticlesAlongRaycast(this.entity.getPos(), this.currentTarget);
}
// Stop following current path (if any)
this.entity.getNavigation().stop();
}
private void emitParticleAt(Vec3d position, ParticleEffect particleType) {
private void emitParticleAt(Vec3d position, double angle) {
if (this.entity.getWorld() instanceof ServerWorld) {
ServerWorld serverWorld = (ServerWorld) this.entity.getWorld();
serverWorld.spawnParticles(particleType, position.x, position.y, position.z, 5, 0, 0, 0, 0);
// Pass the angle using the "speed" argument, with deltaX, deltaY, deltaZ set to 0
LeadParticleEffect effect = new LeadParticleEffect(angle);
serverWorld.spawnParticles(effect, position.x, position.y + 0.05, position.z, 1, 0, 0, 0, 0);
}
}
private void emitParticlesAlongRaycast(Vec3d start, Vec3d end, ParticleEffect particleType, double step) {
Vec3d direction = end.subtract(start).normalize();
private void emitParticlesAlongRaycast(Vec3d start, Vec3d end) {
// Calculate the direction vector from the entity (start) to the target (end)
Vec3d direction = end.subtract(start);
// Calculate the angle in the XZ-plane using atan2 (this is in radians)
double angleRadians = Math.atan2(direction.z, direction.x);
// Convert from radians to degrees
double angleDegrees = Math.toDegrees(angleRadians);
// Convert the calculated angle to Minecraft's yaw system:
double minecraftYaw = (360 - (angleDegrees + 90)) % 360;
// Correct the 180-degree flip
minecraftYaw = (minecraftYaw + 180) % 360;
if (minecraftYaw < 0) {
minecraftYaw += 360;
}
// Emit particles along the ray from startRange to endRange
double distance = start.distanceTo(end);
for (double d = 0; d <= distance; d += step) {
Vec3d pos = start.add(direction.multiply(d));
emitParticleAt(pos, particleType);
double startRange = Math.min(5, distance);;
double endRange = Math.min(startRange + 10, distance);
for (double d = startRange; d <= endRange; d += 5) {
Vec3d pos = start.add(direction.normalize().multiply(d));
emitParticleAt(pos, Math.toRadians(minecraftYaw)); // Convert back to radians for rendering
}
}
}
\ No newline at end of file
package com.owlmaddie.json;
import java.util.List;
public class QuestJson {
Story story;
List<Character> characters;
public static class Story {
String background;
String clue;
}
public static class Character {
String name;
int age;
String personality;
String greeting;
String entity_type_key;
Quest quest;
String choice_question;
List<Choice> choices;
}
public static class Quest {
List<QuestItem> quest_items;
List<DropItem> drop_items;
}
public static class QuestItem {
String key;
int quantity;
}
public static class DropItem {
String key;
int quantity;
}
public static class Choice {
String choice;
String clue;
}
}
......@@ -16,7 +16,7 @@ public class MessageParser {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
public static ParsedMessage parseMessage(String input) {
LOGGER.info("Parsing message: {}", input);
LOGGER.debug("Parsing message: {}", input);
StringBuilder cleanedMessage = new StringBuilder();
List<Behavior> behaviors = new ArrayList<>();
Pattern pattern = Pattern.compile("[<*](FOLLOW|LEAD|FLEE|ATTACK|PROTECT|FRIENDSHIP|UNFOLLOW|UNLEAD|UNPROTECT|UNFLEE)[:\\s]*(\\s*[+-]?\\d+)?[>*]", Pattern.CASE_INSENSITIVE);
......@@ -29,7 +29,7 @@ public class MessageParser {
argument = Integer.valueOf(matcher.group(2));
}
behaviors.add(new Behavior(behaviorName, argument));
LOGGER.info("Found behavior: {} with argument: {}", behaviorName, argument);
LOGGER.debug("Found behavior: {} with argument: {}", behaviorName, argument);
matcher.appendReplacement(cleanedMessage, "");
}
......@@ -40,7 +40,7 @@ public class MessageParser {
// Remove all occurrences of "<>" and "**" (if any)
displayMessage = displayMessage.replaceAll("<>", "").replaceAll("\\*\\*", "").trim();
LOGGER.info("Cleaned message: {}", displayMessage);
LOGGER.debug("Cleaned message: {}", displayMessage);
return new ParsedMessage(displayMessage, input.trim(), behaviors);
}
......
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.network.ServerPackets;
import net.minecraft.entity.Entity;
import net.minecraft.entity.LivingEntity;
......@@ -18,20 +20,26 @@ import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* The {@code MixinLivingEntity} class modifies the behavior of {@link LivingEntity} to integrate
* custom friendship, chat, and death message mechanics. It prevents friendly entities from targeting players,
* generates contextual chat messages on attacks, and broadcasts custom death messages for named entities.
*/
@Mixin(LivingEntity.class)
public class MixinLivingEntity {
private ChatDataManager.EntityChatData getChatData(LivingEntity entity) {
private EntityChatData getChatData(LivingEntity entity, PlayerEntity player) {
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
return chatDataManager.getOrCreateChatData(entity.getUuidAsString());
return chatDataManager.getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
}
@Inject(method = "canTarget(Lnet/minecraft/entity/LivingEntity;)Z", at = @At("HEAD"), cancellable = true)
private void modifyCanTarget(LivingEntity target, CallbackInfoReturnable<Boolean> cir) {
if (target instanceof PlayerEntity) {
LivingEntity thisEntity = (LivingEntity) (Object) this;
ChatDataManager.EntityChatData chatData = getChatData(thisEntity);
if (chatData.friendship > 0) {
EntityChatData entityData = getChatData(thisEntity, (PlayerEntity) target);
PlayerData playerData = entityData.getPlayerData(target.getDisplayName().getString());
if (playerData.friendship > 0) {
// Friendly creatures can't target a player
cir.setReturnValue(false);
}
......@@ -53,11 +61,11 @@ public class MixinLivingEntity {
if (attacker instanceof PlayerEntity && thisEntity instanceof MobEntity && !thisEntity.isDead()) {
// Generate attacked message (only if the previous user message was not an attacked message)
// We don't want to constantly generate messages during a prolonged, multi-damage event
ChatDataManager.EntityChatData chatData = getChatData(thisEntity);
ServerPlayerEntity player = (ServerPlayerEntity) attacker;
EntityChatData chatData = getChatData(thisEntity, player);
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < ChatDataManager.MAX_AUTOGENERATE_RESPONSES) {
// Only auto-generate a response to being attacked if chat data already exists
// and this is the first attack event.
ServerPlayerEntity player = (ServerPlayerEntity) attacker;
ItemStack weapon = player.getMainHandStack();
String weaponName = weapon.isEmpty() ? "with fists" : "with " + weapon.getItem().toString();
......
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.network.ServerPackets;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.TameableEntity;
......@@ -52,7 +54,8 @@ public class MixinMobEntity {
// Get chat data for entity
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(thisEntity.getUuidAsString());
EntityChatData entityData = chatDataManager.getOrCreateChatData(thisEntity.getUuidAsString(), player.getDisplayName().getString());
PlayerData playerData = entityData.getPlayerData(player.getDisplayName().getString());
// Check if the player successfully interacts with an item
if (player instanceof ServerPlayerEntity) {
......@@ -72,11 +75,11 @@ public class MixinMobEntity {
String giveItemMessage = "<" + serverPlayer.getName().getString() +
action_verb + "you " + itemCount + " " + itemName + ">";
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", chatData, serverPlayer, thisEntity, giveItemMessage, true);
if (!entityData.characterSheet.isEmpty() && entityData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", entityData, serverPlayer, thisEntity, giveItemMessage, true);
}
} else if (itemStack.isEmpty() && chatData.friendship == 3) {
} else if (itemStack.isEmpty() && playerData.friendship == 3) {
// Player's hand is empty, Ride your best friend!
player.startRiding(thisEntity, true);
}
......
......@@ -6,6 +6,10 @@ import net.minecraft.village.VillagerGossips;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
/**
* The {@code MixinVillagerEntity} class adds an accessor to expose the gossip system of {@link VillagerEntity}.
* This allows external classes to retrieve and interact with a villager's gossip data.
*/
@Mixin(VillagerEntity.class)
public abstract class MixinVillagerEntity implements VillagerEntityAccessor {
......
......@@ -2,10 +2,13 @@ package com.owlmaddie.network;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.ChatDataSaverScheduler;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.goals.EntityBehaviorManager;
import com.owlmaddie.goals.GoalPriority;
import com.owlmaddie.goals.TalkPlayerGoal;
import com.owlmaddie.particle.LeadParticleEffect;
import com.owlmaddie.utils.Compression;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
......@@ -14,10 +17,15 @@ import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerWorldEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.fabricmc.fabric.api.particle.v1.FabricParticleTypes;
import net.minecraft.entity.Entity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.particle.DefaultParticleType;
import net.minecraft.particle.ParticleType;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
......@@ -31,6 +39,7 @@ import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
......@@ -52,8 +61,34 @@ public class ServerPackets {
public static final Identifier PACKET_S2C_LOGIN = new Identifier("creaturechat", "packet_s2c_login");
public static final Identifier PACKET_S2C_WHITELIST = new Identifier("creaturechat", "packet_s2c_whitelist");
public static final Identifier PACKET_S2C_PLAYER_STATUS = new Identifier("creaturechat", "packet_s2c_player_status");
public static final DefaultParticleType HEART_SMALL_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType HEART_BIG_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FIRE_SMALL_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FIRE_BIG_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType ATTACK_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FLEE_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FOLLOW_FRIEND_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FOLLOW_ENEMY_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType PROTECT_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType LEAD_FRIEND_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType LEAD_ENEMY_PARTICLE = FabricParticleTypes.simple();
public static final ParticleType<LeadParticleEffect> LEAD_PARTICLE = FabricParticleTypes.complex(LeadParticleEffect.DESERIALIZER);
public static void register() {
// Register custom particles
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "heart_small"), HEART_SMALL_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "heart_big"), HEART_BIG_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "fire_small"), FIRE_SMALL_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "fire_big"), FIRE_BIG_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "attack"), ATTACK_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "flee"), FLEE_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "follow_enemy"), FOLLOW_ENEMY_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "follow_friend"), FOLLOW_FRIEND_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "protect"), PROTECT_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead_enemy"), LEAD_ENEMY_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead_friend"), LEAD_FRIEND_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead"), LEAD_PARTICLE);
// Handle packet for Greeting
ServerPlayNetworking.registerGlobalReceiver(PACKET_C2S_GREETING, (server, player, handler, buf, responseSender) -> {
UUID entityId = UUID.fromString(buf.readString());
......@@ -63,7 +98,7 @@ public class ServerPackets {
server.execute(() -> {
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), entityId);
if (entity != null) {
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
if (chatData.characterSheet.isEmpty()) {
generate_character(userLanguage, chatData, player, entity);
}
......@@ -84,7 +119,7 @@ public class ServerPackets {
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
LOGGER.debug("Update read lines to " + lineNumber + " for: " + entity.getType().toString());
chatData.setLineNumber(lineNumber);
}
......@@ -104,7 +139,7 @@ public class ServerPackets {
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
LOGGER.debug("Hiding chat bubble for: " + entity.getType().toString());
chatData.setStatus(ChatDataManager.ChatStatus.valueOf(status_name));
}
......@@ -148,7 +183,7 @@ public class ServerPackets {
server.execute(() -> {
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), entityId);
if (entity != null) {
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString(), player.getDisplayName().getString());
if (chatData.characterSheet.isEmpty()) {
generate_character(userLanguage, chatData, player, entity);
} else {
......@@ -168,7 +203,7 @@ public class ServerPackets {
LOGGER.info("Server send compressed, chunked login message packets to player: " + player.getName().getString());
// Get lite JSON data & compress to byte array
String chatDataJSON = ChatDataManager.getServerInstance().GetLightChatData();
String chatDataJSON = ChatDataManager.getServerInstance().GetLightChatData(player.getDisplayName().getString());
byte[] compressedData = Compression.compressString(chatDataJSON);
if (compressedData == null) {
LOGGER.error("Failed to compress chat data.");
......@@ -219,8 +254,8 @@ public class ServerPackets {
ServerEntityEvents.ENTITY_UNLOAD.register((entity, world) -> {
String entityUUID = entity.getUuidAsString();
if (entity.getRemovalReason() == Entity.RemovalReason.KILLED && ChatDataManager.getServerInstance().entityChatDataMap.containsKey(entityUUID)) {
LOGGER.info("Entity killed (" + entityUUID + "), removing chat data.");
ChatDataManager.getServerInstance().entityChatDataMap.remove(entityUUID);
LOGGER.info("Entity killed (" + entityUUID + "), updating death time stamp.");
ChatDataManager.getServerInstance().entityChatDataMap.get(entityUUID).death = System.currentTimeMillis();
}
});
......@@ -251,13 +286,12 @@ public class ServerPackets {
} else {
// Iterate over all players and send the packet
for (ServerPlayerEntity serverPlayer : serverInstance.getPlayerManager().getPlayerList()) {
LOGGER.info("Broadcast whitelist / blacklist packet to player: " + serverPlayer.getName().getString());
ServerPlayNetworking.send(serverPlayer, PACKET_S2C_WHITELIST, buffer);
}
}
}
public static void generate_character(String userLanguage, ChatDataManager.EntityChatData chatData, ServerPlayerEntity player, MobEntity entity) {
public static void generate_character(String userLanguage, EntityChatData chatData, ServerPlayerEntity player, MobEntity entity) {
// Set talk to player goal (prevent entity from walking off)
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
......@@ -284,22 +318,40 @@ public class ServerPackets {
}
userMessageBuilder.append("They speak in '").append(userLanguage).append("' with a ").append(randomSpeakingStyle).append(" style.");
LOGGER.info(userMessageBuilder.toString());
chatData.generateMessage(userLanguage, player, "system-character", userMessageBuilder.toString(), false);
// Generate new character
chatData.generateCharacter(userLanguage, player, userMessageBuilder.toString(), false);
}
public static void generate_chat(String userLanguage, ChatDataManager.EntityChatData chatData, ServerPlayerEntity player, MobEntity entity, String message, boolean is_auto_message) {
public static void generate_chat(String userLanguage, EntityChatData chatData, ServerPlayerEntity player, MobEntity entity, String message, boolean is_auto_message) {
// Set talk to player goal (prevent entity from walking off)
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
// Add new message
LOGGER.info("Player message received: " + message + " | Entity: " + entity.getType().toString());
chatData.generateMessage(userLanguage, player, "system-chat", message, is_auto_message);
chatData.generateMessage(userLanguage, player, message, is_auto_message);
}
// Writing a Map<String, PlayerData> to the buffer
public static void writePlayerDataMap(PacketByteBuf buffer, Map<String, PlayerData> map) {
buffer.writeInt(map.size()); // Write the size of the map
for (Map.Entry<String, PlayerData> entry : map.entrySet()) {
buffer.writeString(entry.getKey()); // Write the key (playerName)
PlayerData data = entry.getValue();
buffer.writeInt(data.friendship); // Write PlayerData field(s)
}
}
// Send new message to all connected players
public static void BroadcastPacketMessage(ChatDataManager.EntityChatData chatData) {
public static void BroadcastPacketMessage(EntityChatData chatData, ServerPlayerEntity sender) {
// Log useful information before looping through all players
LOGGER.info("Broadcasting message: sender={}, entityId={}, status={}, currentMessage={}, currentLineNumber={}, senderType={}",
sender != null ? sender.getDisplayName().getString() : "Unknown",
chatData.entityId,
chatData.status,
chatData.currentMessage.length() > 24 ? chatData.currentMessage.substring(0, 24) + "..." : chatData.currentMessage,
chatData.currentLineNumber,
chatData.sender);
for (ServerWorld world : serverInstance.getWorlds()) {
UUID entityId = UUID.fromString(chatData.entityId);
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(world, entityId);
......@@ -307,26 +359,32 @@ public class ServerPackets {
// Set custom name (if null)
String characterName = chatData.getCharacterProp("name");
if (!characterName.isEmpty() && !characterName.equals("N/A") && entity.getCustomName() == null) {
LOGGER.debug("Setting entity name to " + characterName + " for " + chatData.entityId);
LOGGER.info("Setting entity name to " + characterName + " for " + chatData.entityId);
entity.setCustomName(Text.literal(characterName));
entity.setCustomNameVisible(true);
entity.setPersistent();
}
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the entity's chat updated data
buffer.writeString(chatData.entityId);
buffer.writeString(chatData.playerId);
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
buffer.writeInt(chatData.friendship);
// Iterate over all players and send the packet
for (ServerPlayerEntity player : serverInstance.getPlayerManager().getPlayerList()) {
LOGGER.debug("Server broadcast message to client: " + player.getName().getString() + " | Message: " + chatData.currentMessage);
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the entity's chat updated data
buffer.writeString(chatData.entityId);
if (sender != null) {
buffer.writeString(sender.getUuidAsString());
buffer.writeString(sender.getDisplayName().getString());
} else {
buffer.writeString("");
buffer.writeString("Unknown");
}
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
writePlayerDataMap(buffer, chatData.players);
// Send message to player
ServerPlayNetworking.send(player, PACKET_S2C_MESSAGE, buffer);
}
break;
......
package com.owlmaddie.particle;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.particle.ParticleEffect;
import net.minecraft.particle.ParticleType;
import static com.owlmaddie.network.ServerPackets.LEAD_PARTICLE;
/**
* The {@code LeadParticleEffect} class allows for an 'angle' to be passed along with the Particle, to rotate it in the direction of LEAD behavior.
*/
public class LeadParticleEffect implements ParticleEffect {
public static final ParticleEffect.Factory<LeadParticleEffect> DESERIALIZER = new Factory<>() {
@Override
public LeadParticleEffect read(ParticleType<LeadParticleEffect> particleType, PacketByteBuf buf) {
// Read the angle (or any other data) from the packet
double angle = buf.readDouble();
return new LeadParticleEffect(angle);
}
@Override
public LeadParticleEffect read(ParticleType<LeadParticleEffect> particleType, StringReader reader) throws CommandSyntaxException {
// Read the angle from a string
double angle = reader.readDouble();
return new LeadParticleEffect(angle);
}
};
private final double angle;
public LeadParticleEffect(double angle) {
this.angle = angle;
}
@Override
public ParticleType<?> getType() {
return LEAD_PARTICLE;
}
public double getAngle() {
return angle;
}
@Override
public void write(PacketByteBuf buf) {
// Write the angle to the packet
buf.writeDouble(angle);
}
@Override
public String asString() {
return Double.toString(angle);
}
}
package com.owlmaddie.particle;
import net.minecraft.entity.Entity;
import net.minecraft.particle.DefaultParticleType;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundCategory;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.MathHelper;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code ParticleEmitter} class provides utility methods for emitting custom particles and sounds
* around entities in the game. It calculates particle positions based on entity orientation
* and triggers sound effects based on particle type and count.
*/
public class ParticleEmitter {
public static void emitCreatureParticle(ServerWorld world, Entity entity, DefaultParticleType particleType, double spawnSize, int count) {
// Calculate the offset for the particle to appear above and in front of the entity
float yaw = entity.getHeadYaw();
double offsetX = -MathHelper.sin(yaw * ((float) Math.PI / 180F)) * 0.9;
double offsetY = entity.getHeight() + 0.5;
double offsetZ = MathHelper.cos(yaw * ((float) Math.PI / 180F)) * 0.9;
// Final position
double x = entity.getX() + offsetX;
double y = entity.getY() + offsetY;
double z = entity.getZ() + offsetZ;
// Emit the custom particle on the server
world.spawnParticles(particleType, x, y, z, count, spawnSize, spawnSize, spawnSize, 0.1F);
// Play sound when lots of hearts are emitted
if (particleType.equals(HEART_BIG_PARTICLE) && count > 1) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP, SoundCategory.PLAYERS, 0.4F, 1.0F);
} else if (particleType.equals(FIRE_BIG_PARTICLE) && count > 1) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.ITEM_AXE_STRIP, SoundCategory.PLAYERS, 0.8F, 1.0F);
} else if (particleType.equals(FOLLOW_FRIEND_PARTICLE) || particleType.equals(FOLLOW_ENEMY_PARTICLE) ||
particleType.equals(LEAD_FRIEND_PARTICLE) || particleType.equals(LEAD_ENEMY_PARTICLE)) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.BLOCK_AMETHYST_BLOCK_PLACE, SoundCategory.PLAYERS, 0.8F, 1.0F);
} else if (particleType.equals(PROTECT_PARTICLE)) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.BLOCK_BEACON_POWER_SELECT, SoundCategory.PLAYERS, 0.8F, 1.0F);
}
}
}
\ No newline at end of file
......@@ -33,12 +33,8 @@ public class Randomizer {
"<clears throat>",
"<peers over your shoulder>",
"<fakes a smile>",
"<checks the time>",
"<doodles in the air>",
"<mutters under breath>",
"<adjusts an imaginary tie>",
"<counts imaginary stars>",
"<plays with a nonexistent pet>"
"<counts imaginary stars>"
);
private static List<String> errorResponseMessages = Arrays.asList(
"Seems like my words got lost in the End. Check out http://discord.creaturechat.com for clues!",
......@@ -88,7 +84,7 @@ public class Randomizer {
"inquisitive", "cynical", "empathetic", "boisterous", "monotone", "laconic", "poetic",
"archaic", "childlike", "erudite", "streetwise", "flirtatious", "stoic", "rhetorical",
"inspirational", "goofy", "overly dramatic", "deadpan", "sing-song", "pompous",
"hyperactive", "valley girl", "robot", "pirate", "baby talk", "lolcat"
"hyperactive", "valley girl", "robot", "baby talk", "lolcat"
);
private static List<String> classes = Arrays.asList(
"warrior", "mage", "archer", "rogue", "paladin", "necromancer", "bard", "lorekeeper",
......
......@@ -2,6 +2,11 @@ package com.owlmaddie.utils;
import net.minecraft.village.VillagerGossips;
/**
* The {@code VillagerEntityAccessor} interface provides a method to access
* the gossip system of a villager. It enables interaction with a villager's
* gossip data for custom behavior or modifications.
*/
public interface VillagerEntityAccessor {
VillagerGossips getGossip();
}
{
"textures": [
"creaturechat:attack",
"creaturechat:attack1",
"creaturechat:attack2",
"creaturechat:attack3"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:fire_big"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:fire_small"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:flee"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:follow_enemy"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:follow_friend"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:heart_big"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:heart_small"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead",
"creaturechat:lead1",
"creaturechat:lead2",
"creaturechat:lead3",
"creaturechat:lead4"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead_enemy"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead_friend"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:protect"
]
}
\ No newline at end of file
......@@ -4,6 +4,8 @@ Minecraft. Please limit traits and background to a few choices and keep them ver
format below (including - dashes), and DO NOT output any intro text. If a language is mentioned, generate the
"Short Greeting" entirely in that language.
{{story}}
Be extremely creative! Include a short initial greeting (as spoken by the character using their personality
traits and speaking style / tone).
......
......@@ -3,6 +3,8 @@ Please do NOT break the 4th wall and leverage the entity's character sheet below
possible. Try to keep response to 1 to 2 sentences (very brief). Include behaviors at the end of the message
when relevant. IMPORTANT: Always generate responses in player's language (if valid).
{{story}}
Entity Character Sheet:
- Name: {{entity_name}}
- Personality: {{entity_personality}}
......
......@@ -203,7 +203,7 @@ public class BehaviorTests {
// Add test message
for (String message : messages) {
entityTestData.addMessage(message, ChatDataManager.ChatSender.USER);
entityTestData.addMessage(message, ChatDataManager.ChatSender.USER, "TestPlayer1");
}
// Get prompt
......
......@@ -3,6 +3,7 @@ package com.owlmaddie.utils;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.ChatMessage;
import java.io.IOException;
import java.lang.reflect.Type;
......@@ -26,7 +27,7 @@ public class EntityTestData {
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public List<ChatDataManager.ChatMessage> previousMessages;
public List<ChatMessage> previousMessages;
public String characterSheet;
public ChatDataManager.ChatSender sender;
public int friendship; // -3 to 3 (0 = neutral)
......@@ -59,12 +60,12 @@ public class EntityTestData {
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatDataManager.ChatSender messageSender) {
public void addMessage(String message, ChatDataManager.ChatSender messageSender, String playerName) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), ChatDataManager.MAX_CHAR_IN_USER_MESSAGE));
// Add message to history
previousMessages.add(new ChatDataManager.ChatMessage(truncatedMessage, messageSender));
previousMessages.add(new ChatMessage(truncatedMessage, messageSender, playerName));
// Set new message and reset line number of displayed text
currentMessage = truncatedMessage;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment