diff --git a/CHANGELOG.md b/CHANGELOG.md index b94a5582..837e4197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,60 @@ All notable changes to **CreatureChat™** are documented in this file. The form ## Unreleased -### Added +## Added +- New build recording command + - Record, Save, and Replay builds (placing, destroying, interacting with blocks + player poses) + - Spawns any entity type to replay build + - Replay speeds can be adjusted (any integer, but defaults to 1X - the original speed) + - Replay adjusts eye height (line of sight) so entities look at the same place as the player + - Record / Stop commands toggle (depending on if recording or not) + - Build types and folders are automatically managed now (type and build height: i.e. 1 block tall builds (bee) vs 3 block tall builds (enderman)) + - New datagen provider to create index of builds (and calculates build score) + - New block palette to support builds in any version, and replays in any version + - Add AFK protection during the build record (to stop long pauses recorded on inventory screen or AFK) - Document SPDX header and changelog requirements in AGENTS.md for contributors +- Build goal and behavior enabling entities to construct structures for players + - Behavior tests now cover BUILD and UNBUILD behaviors with live LLM responses + - Build replays pause when materials are missing, stop four blocks from players, and prompt for supplies with recipe details + - New build particle when a build starts + - Missing-material requests now broadcast the remaining recipe to nearby players in plain chat + - Unit tests ensure build selection covers all height tiers and skill levels + - Build skill increases after successful builds and syncs across clients + - Build goal only begins after the builder reaches the player + - Selecting builds logs skill, type, height tier, and chosen file + - Relaxed build goal proximity checks so builders don't have to overlap players before starting or finishing + - Require full collision blocks when selecting the build start ground +- Friendly mobs with chat data now pick up items thrown by friends into their chat inventory +- Expanded friendly pickup reach and restored pickup sound for chat-data mobs +- Debounced LLM message when friends pick up dropped items ### Changed - Convert PNG screenshots to JPEG, compress, and remove less useful ones (smaller jar) - Compressed all textures from 32-bit color to 4-bit indexed color, reduced size massively. +- Build goal now uses build replays, keeps FOLLOW and PROTECT goals active, and system prompts describe build skill and types. +- Build goal completion message is generated through the LLM when a structure finishes +- Build goal waits to reach the player, starts from the ground at that spot, follows the player until then, and returns to thank them when finished +- LEAD goal guides players to nearby structures, biomes, resources, tags, or points of interest and apologizes when none are found within 300 blocks +- LEAD goal no longer uses random coordinates when it can't locate something +- Build replay command logs replay bounds with and without player movement +- Build index now rebuilds at runtime from bundled and local builds +- Build goal bounds now ignore player movement +- Build replays rotate to the nearest cardinal based on player facing, with rotated bounds +- Rebuild build index when the config copy is empty or invalid +- Log build index match counts when selecting a replay file +- Build index scoring now spreads levels within each type and logs per-type summaries +- Build index now rebuilds on mod init every time +- Build selection relaxes height/skill filters when a valid type has no matches +- Expand build selection tests for fallback and unknown types +- Tests now fall back to a local config path when Fabric config dir is unavailable +- Missing-material alerts no longer send a separate system chat broadcast +- Build replays no longer randomize variants on existing actors +- Build replay no longer pauses every 2 seconds during active building +### Fixed +- Defer goal selector updates to end-of-tick to avoid null goal crashes after build completion +- Rotate block states when replaying builds so block facings match the chosen orientation +- Index rebuild now falls back to classpath builds when FabricLoader is unavailable (tests) ## [3.0.0] - 2025-08-27 diff --git a/src/client/java/com/owlmaddie/ClientInit.java b/src/client/java/com/owlmaddie/ClientInit.java index 70b66275..15519133 100644 --- a/src/client/java/com/owlmaddie/ClientInit.java +++ b/src/client/java/com/owlmaddie/ClientInit.java @@ -41,6 +41,7 @@ public void onInitializeClient() { ParticleFactoryRegistry.getInstance().register(Particles.FOLLOW_FRIEND_PARTICLE, CreatureParticleFactory::new); ParticleFactoryRegistry.getInstance().register(Particles.FOLLOW_ENEMY_PARTICLE, CreatureParticleFactory::new); ParticleFactoryRegistry.getInstance().register(Particles.PROTECT_PARTICLE, CreatureParticleFactory::new); + ParticleFactoryRegistry.getInstance().register(Particles.BUILD_PARTICLE, CreatureParticleFactory::new); ParticleFactoryRegistry.getInstance().register(Particles.LEAD_FRIEND_PARTICLE, CreatureParticleFactory::new); ParticleFactoryRegistry.getInstance().register(Particles.LEAD_ENEMY_PARTICLE, CreatureParticleFactory::new); ParticleFactoryRegistry.getInstance().register(Particles.LEAD_PARTICLE, LeadParticleFactory::new); diff --git a/src/client/java/com/owlmaddie/network/ClientPackets.java b/src/client/java/com/owlmaddie/network/ClientPackets.java index 6984069b..a44f69b0 100644 --- a/src/client/java/com/owlmaddie/network/ClientPackets.java +++ b/src/client/java/com/owlmaddie/network/ClientPackets.java @@ -118,6 +118,7 @@ public static void register() { ChatDataManager.ChatStatus status = ChatDataManager.ChatStatus.valueOf(status_name); String sender_name = buffer.readUtf(32767); ChatDataManager.ChatSender sender = ChatDataManager.ChatSender.valueOf(sender_name); + int buildLevel = buffer.readInt(); Map players = readPlayerDataMap(buffer); // Update the chat data manager on the client-side @@ -139,6 +140,7 @@ public static void register() { chatData.currentLineNumber = line; chatData.status = status; chatData.sender = sender; + chatData.buildLevel = buildLevel; chatData.players = players; // Play sound with volume based on distance (from player or entity) diff --git a/src/main/java/com/owlmaddie/ModInit.java b/src/main/java/com/owlmaddie/ModInit.java index 332664ec..98ed8231 100644 --- a/src/main/java/com/owlmaddie/ModInit.java +++ b/src/main/java/com/owlmaddie/ModInit.java @@ -4,7 +4,11 @@ package com.owlmaddie; import com.owlmaddie.commands.CreatureChatCommands; +import com.owlmaddie.commands.BuildCommands; +import com.owlmaddie.buildrec.BuildRecorder; +import com.owlmaddie.goals.EntityBehaviorManager; import com.owlmaddie.inventory.ModMenus; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.fabricmc.api.ModInitializer; import org.slf4j.Logger; @@ -27,6 +31,10 @@ public void onInitialize() { // Register server commands CreatureChatCommands.register(); + BuildCommands.register(); + BuildRecorder.init(); + EntityBehaviorManager.init(); + PickupMessageBatcher.init(); // Register menus and events ModMenus.register(); diff --git a/src/main/java/com/owlmaddie/buildrec/BuildRecordIO.java b/src/main/java/com/owlmaddie/buildrec/BuildRecordIO.java new file mode 100644 index 00000000..2483fea8 --- /dev/null +++ b/src/main/java/com/owlmaddie/buildrec/BuildRecordIO.java @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Utility methods for reading build recording files. + */ +public final class BuildRecordIO { + public static final Gson GSON = new Gson(); + + private BuildRecordIO() {} + + public static Loaded read(Path file) throws IOException, JsonParseException { + try (JsonReader reader = new JsonReader(new InputStreamReader(new GZIPInputStream(Files.newInputStream(file)), StandardCharsets.UTF_8))) { + List actions = new ArrayList<>(); + reader.beginArray(); + JsonElement first = GSON.fromJson(reader, JsonElement.class); + Meta meta; + if (first != null && first.isJsonObject() && first.getAsJsonObject().has("action") && "meta".equals(first.getAsJsonObject().get("action").getAsString())) { + meta = GSON.fromJson(first, Meta.class); + } else { + meta = new Meta(); + if (first != null) { + Action firstAction = GSON.fromJson(first, Action.class); + if (firstAction != null) actions.add(firstAction); + } + } + while (reader.hasNext()) { + Action a = GSON.fromJson(reader, Action.class); + if (a != null) actions.add(a); + } + reader.endArray(); + return new Loaded(meta, actions); + } + } + + public static class Loaded { + public final Meta meta; + public final List actions; + public Loaded(Meta meta, List actions) { + this.meta = meta; + this.actions = actions; + } + } + + public static class Meta { + public String action = "meta"; + public double eyeHeight; + public double bbWidth; + public double bbHeight; + public Map recipe = new LinkedHashMap<>(); + public int uniqueBlocks; + public int sizeX; + public int sizeY; + public int sizeZ; + public List palette = new ArrayList<>(); + public Meta() {} + public Meta(double eyeHeight, double bbWidth, double bbHeight, Map recipe, int uniqueBlocks, int sizeX, int sizeY, int sizeZ, List palette) { + this.eyeHeight = eyeHeight; + this.bbWidth = bbWidth; + this.bbHeight = bbHeight; + this.recipe = recipe; + this.uniqueBlocks = uniqueBlocks; + this.sizeX = sizeX; + this.sizeY = sizeY; + this.sizeZ = sizeZ; + this.palette = palette; + } + } + + public static class Action { + public String action; + public int blockId; + public int bx, by, bz; + public int dt; + public double px, py, pz; + public float yaw, pitch; + } +} + diff --git a/src/main/java/com/owlmaddie/buildrec/BuildRecorder.java b/src/main/java/com/owlmaddie/buildrec/BuildRecorder.java new file mode 100644 index 00000000..1900862a --- /dev/null +++ b/src/main/java/com/owlmaddie/buildrec/BuildRecorder.java @@ -0,0 +1,1300 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import com.google.gson.JsonParseException; +import com.owlmaddie.buildrec.BuildRecordIO.Action; +import com.owlmaddie.buildrec.BuildRecordIO.Meta; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +import net.minecraft.commands.arguments.blocks.BlockStateParser; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.animal.Pig; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Item; +import net.minecraft.world.Container; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.net.URI; +import java.net.URL; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.GZIPOutputStream; + +import com.owlmaddie.inventory.ChatInventory; +import com.owlmaddie.goals.BuildPlayerGoal; +import com.owlmaddie.goals.EntityBehaviorManager; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; + +/** + * Utility to record and replay player build actions. + */ +public class BuildRecorder { + private static final Map RECORDINGS = new ConcurrentHashMap<>(); + private static final List REPLAYS = new ArrayList<>(); + private static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); + private static final int MAX_IDLE_TICKS = 20; // 1 second + private static final String BUILD_INDEX_FILE = "index.json"; + private static List BUILD_INDEX; + private static final Map> MISSING_RECIPES = new ConcurrentHashMap<>(); + private static final Map BOUNDS_CACHE = new ConcurrentHashMap<>(); + + static { + UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> { + if (world.isClientSide || !(player instanceof ServerPlayer sp)) return net.minecraft.world.InteractionResult.PASS; + BlockPos target = hitResult.getBlockPos(); + BlockPos placePos = target.relative(hitResult.getDirection()); + BlockState beforeTarget = world.getBlockState(target); + BlockState beforePlace = world.getBlockState(placePos); + double px = player.getX(), py = player.getY(), pz = player.getZ(); + float yaw = player.getYRot(), pitch = player.getXRot(); + world.getServer().execute(() -> { + BlockState afterPlace = world.getBlockState(placePos); + if (!afterPlace.isAir() && !afterPlace.equals(beforePlace)) { + recordPlace(sp, placePos, afterPlace, px, py, pz, yaw, pitch); + } else { + BlockState afterTarget = world.getBlockState(target); + if (!afterTarget.equals(beforeTarget)) { + recordInteract(sp, target, afterTarget, px, py, pz, yaw, pitch); + } + } + }); + return net.minecraft.world.InteractionResult.PASS; + }); + PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> { + if (player instanceof ServerPlayer sp) { + double px = player.getX(), py = player.getY(), pz = player.getZ(); + float yaw = player.getYRot(), pitch = player.getXRot(); + recordBreak(sp, (ServerLevel) world, pos, state, px, py, pz, yaw, pitch); + } + }); + ServerTickEvents.START_SERVER_TICK.register(BuildRecorder::tick); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> cancelAllReplays()); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> cancelAllReplays()); + } + + public static void init() { + rebuildBuildIndex(); + } + + private static class BuildIndex { + final String type; + final String height; + final String file; + final int score; + + BuildIndex(String type, String height, String file, int score) { + this.type = type; + this.height = height; + this.file = file; + this.score = score; + } + } + + private static int heightTier(double h) { + return h < 1 ? 1 : (h < 2 ? 2 : 3); + } + + private static void loadBuildIndex() { + if (BUILD_INDEX != null) return; + Path indexPath = buildRootDir().resolve(BUILD_INDEX_FILE); + if (Files.exists(indexPath)) { + List list = readBuildIndex(indexPath); + if (list != null && !list.isEmpty()) { + BUILD_INDEX = list; + return; + } + LOGGER.info("[BuildRec] rebuild build index from {} (empty or invalid)", indexPath); + } + rebuildBuildIndex(); + } + + public static String randomBuildFile(double entityHeight, String type, int level) { + loadBuildIndex(); + int tier = heightTier(entityHeight); + String t = (type == null || type.isEmpty() || "unknown".equalsIgnoreCase(type)) ? null : type.toLowerCase(); + List typeFiltered = new ArrayList<>(); + List heightFiltered = new ArrayList<>(); + List levelFiltered = new ArrayList<>(); + int total = BUILD_INDEX != null ? BUILD_INDEX.size() : 0; + int typeCount = 0; + int heightCount = 0; + int levelCount = 0; + for (BuildIndex e : BUILD_INDEX) { + if (t != null && !e.type.equalsIgnoreCase(t)) continue; + typeCount++; + typeFiltered.add(e); + if (!"any".equalsIgnoreCase(e.height) && Integer.parseInt(e.height) < tier) continue; + heightCount++; + heightFiltered.add(e); + if (e.score > level) continue; + levelCount++; + levelFiltered.add(e); + } + LOGGER.info("[BuildRec] build index total={} typeMatch={} heightMatch={} levelMatch={} type={} heightTier={} level={}", + total, typeCount, heightCount, levelCount, t, tier, level); + if (typeCount == 0) { + return null; + } + List candidates; + if (!levelFiltered.isEmpty()) { + candidates = levelFiltered; + } else if (!heightFiltered.isEmpty()) { + candidates = heightFiltered; + } else { + candidates = typeFiltered; + } + BuildIndex pick = candidates.get(new Random().nextInt(candidates.size())); + String path = pick.type + ("any".equalsIgnoreCase(pick.height) ? "" : "/" + pick.height) + "/" + pick.file; + return path; + } + + public static ReplayBounds getReplayBounds(String fileName) { + return getReplayBounds(fileName, true, 0); + } + + public static ReplayBounds getReplayBounds(String fileName, boolean includeMovement) { + return getReplayBounds(fileName, includeMovement, 0); + } + + public static ReplayBounds getReplayBounds(String fileName, boolean includeMovement, int rotationSteps) { + String actual = fileName.endsWith(".json.gz") ? fileName : fileName + ".json.gz"; + String key = actual + (includeMovement ? "|move" : "|static") + "|rot=" + rotationSteps; + return BOUNDS_CACHE.computeIfAbsent(key, ignored -> loadReplayBounds(actual, includeMovement, rotationSteps)); + } + + public static int getReplayRotationSteps(String fileName, float playerYaw) { + String actual = fileName.endsWith(".json.gz") ? fileName : fileName + ".json.gz"; + Path file = resolveBuildFile(actual); + if (file == null || !Files.exists(file)) { + return 0; + } + try { + BuildRecordIO.Loaded loaded = BuildRecordIO.read(file); + return rotationStepsForReplay(loaded.actions, playerYaw); + } catch (IOException | JsonParseException e) { + LOGGER.error("[BuildRec] rotation failed to load {}", actual, e); + return 0; + } + } + + public static List getIndexedBuildIds() { + loadBuildIndex(); + if (BUILD_INDEX == null || BUILD_INDEX.isEmpty()) { + return List.of(); + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (BuildIndex e : BUILD_INDEX) { + String path = e.type + ("any".equalsIgnoreCase(e.height) ? "" : "/" + e.height) + "/" + e.file; + ids.add(path.replaceFirst("\\.json\\.gz$", "")); + } + return new ArrayList<>(ids); + } + + public static boolean start(ServerPlayer player, String type, String height, String name) { + if (RECORDINGS.containsKey(player.getUUID())) { + LOGGER.info("[BuildRec] start ignored already recording player={}", player.getGameProfile().getName()); + return false; + } + RECORDINGS.put(player.getUUID(), new Recording(player, type, height, name)); + LOGGER.info("[BuildRec] start type={} height={} name={} player={}", type, height, name, player.getGameProfile().getName()); + player.getServer().getCommands().sendCommands(player); + return true; + } + + public static boolean isRecording(ServerPlayer player) { + return RECORDINGS.containsKey(player.getUUID()); + } + + public static Summary stop(ServerPlayer player) { + Recording rec = RECORDINGS.remove(player.getUUID()); + if (rec == null) { + LOGGER.info("[BuildRec] stop ignored no active recording player={}", player.getGameProfile().getName()); + return null; + } + Summary summary = rec.finish(player); + LOGGER.info("[BuildRec] stop player={} file={} placed={} destroys={} unique={} size={}x{}x{}", + player.getGameProfile().getName(), summary.id, summary.finalBlocks, summary.destroys, summary.uniqueBlocks, + summary.sizeX, summary.sizeZ, summary.sizeY); + player.getServer().getCommands().sendCommands(player); + return summary; + } + + public static boolean startReplay(ServerPlayer player, String fileName, EntityType entityType, int speed) { + return startReplayInternal(player, fileName, entityType, speed, null); + } + + @SuppressWarnings("unchecked") + public static boolean startReplay(ServerPlayer player, Mob actor, String fileName, int speed) { + cancelReplay(actor); + return startReplayInternal(player, fileName, (EntityType) actor.getType(), speed, actor); + } + + private static boolean startReplayInternal(ServerPlayer player, String fileName, EntityType entityType, int speed, Mob existingActor) { + String actual = fileName.endsWith(".json.gz") ? fileName : fileName + ".json.gz"; + Path file = resolveBuildFile(actual); + LOGGER.info("[BuildRec] replay file={} entity={} speed={} player={}", actual, + entityType != null ? BuiltInRegistries.ENTITY_TYPE.getKey(entityType).toString() : "pig", speed, + player.getGameProfile().getName()); + if (file == null || !Files.exists(file)) { + LOGGER.info("[BuildRec] replay missing file={}", actual); + return false; + } + try { + BuildRecordIO.Loaded loaded = BuildRecordIO.read(file); + int rotationSteps = rotationStepsForReplay(loaded.actions, player.getYRot()); + List actions = rotateActions(loaded.actions, rotationSteps); + if (actions.isEmpty()) { + LOGGER.info("[BuildRec] replay file={} has no actions", file); + return false; + } + Meta meta = loaded.meta; + double recEye = meta.eyeHeight > 0 ? meta.eyeHeight : player.getEyeHeight(); + double recWidth = meta.bbWidth; + double recHeight = meta.bbHeight; + Rotation rotation = rotationForSteps(rotationSteps); + List palette = new ArrayList<>(); + var lookup = player.level().registryAccess().lookupOrThrow(Registries.BLOCK); + for (String s : meta.palette) { + try { + var res = BlockStateParser.parseForBlock(lookup, new StringReader(s), false); + BlockState state = res.blockState(); + if (rotation != Rotation.NONE) { + state = state.rotate(rotation); + } + palette.add(state); + } catch (CommandSyntaxException e) { + LOGGER.error("[BuildRec] invalid block state {}", s, e); + palette.add(Blocks.AIR.defaultBlockState()); + } + } + ServerLevel level = (ServerLevel) player.level(); + Mob actor; + boolean preserve = existingActor != null; + if (preserve) { + actor = existingActor; + } else if (entityType == null) { + actor = new Pig(EntityType.PIG, level); + } else { + actor = MobHelper.create(entityType, level); + if (actor == null) { + LOGGER.info("[BuildRec] replay could not create {}", BuiltInRegistries.ENTITY_TYPE.getKey(entityType)); + return false; + } + } + if (!preserve) { + MobHelper.initSpawn(actor, level); + actor.teleportTo(player.getX(), player.getY(), player.getZ()); + actor.setYRot(player.getYRot()); + float sp = adjustPitch(level, player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot(), recEye, actor.getEyeHeight(), actor); + actor.setXRot(sp); + actor.yHeadRot = player.getYRot(); + actor.yBodyRot = player.getYRot(); + level.addFreshEntity(actor); + } + actor.setNoAi(true); + actor.setInvulnerable(true); + actor.setPersistenceRequired(); + REPLAYS.add(new Replay(actor, actions, speed, recEye, recWidth, recHeight, palette, preserve, player.isCreative())); + LOGGER.info("[BuildRec] replay loaded actions={} eyeHeight={} bbW={} bbH={}", actions.size(), recEye, recWidth, recHeight); + return true; + } catch (IOException | JsonParseException e) { + LOGGER.error("[BuildRec] replay failed to load {}", file, e); + } catch (Exception e) { + LOGGER.error("[BuildRec] replay runtime error {}", file, e); + } + return false; + } + + private static ReplayBounds loadReplayBounds(String fileName, boolean includeMovement, int rotationSteps) { + Path file = resolveBuildFile(fileName); + if (file == null || !Files.exists(file)) { + LOGGER.info("[BuildRec] bounds missing file={}", fileName); + return null; + } + try { + BuildRecordIO.Loaded loaded = BuildRecordIO.read(file); + List actions = rotateActions(loaded.actions, rotationSteps); + ReplayBounds bounds = ReplayBounds.fromActions(actions, includeMovement); + if (bounds == null) { + LOGGER.info("[BuildRec] bounds empty file={}", fileName); + } + return bounds; + } catch (IOException | JsonParseException e) { + LOGGER.error("[BuildRec] bounds failed to load {}", file, e); + return null; + } + } + + private static int rotationStepsForReplay(List actions, float playerYaw) { + if (actions == null || actions.isEmpty()) { + return 0; + } + int recordSteps = snapYawToCardinalSteps(actions.get(0).yaw); + int playerSteps = snapYawToCardinalSteps(playerYaw); + return Math.floorMod(playerSteps - recordSteps, 4); + } + + private static Rotation rotationForSteps(int rotationSteps) { + return switch (Math.floorMod(rotationSteps, 4)) { + case 1 -> Rotation.CLOCKWISE_90; + case 2 -> Rotation.CLOCKWISE_180; + case 3 -> Rotation.COUNTERCLOCKWISE_90; + default -> Rotation.NONE; + }; + } + + private static int snapYawToCardinalSteps(float yaw) { + float normalized = Mth.wrapDegrees(yaw); + int steps = Math.round(normalized / 90f); + return Math.floorMod(steps, 4); + } + + private static List rotateActions(List actions, int rotationSteps) { + if (rotationSteps == 0 || actions == null || actions.isEmpty()) { + return actions; + } + List rotated = new ArrayList<>(actions.size()); + for (Action a : actions) { + rotated.add(rotateAction(a, rotationSteps)); + } + return rotated; + } + + private static Action rotateAction(Action action, int rotationSteps) { + Action out = new Action(); + out.action = action.action; + out.blockId = action.blockId; + out.by = action.by; + out.dt = action.dt; + out.py = action.py; + out.pitch = action.pitch; + double[] p = rotateXZ(action.px, action.pz, rotationSteps); + out.px = p[0]; + out.pz = p[1]; + int[] b = rotateXZ(action.bx, action.bz, rotationSteps); + out.bx = b[0]; + out.bz = b[1]; + out.yaw = Mth.wrapDegrees(action.yaw + rotationSteps * 90f); + return out; + } + + private static double[] rotateXZ(double x, double z, int rotationSteps) { + switch (Math.floorMod(rotationSteps, 4)) { + case 1 -> { + return new double[]{-z, x}; + } + case 2 -> { + return new double[]{-x, -z}; + } + case 3 -> { + return new double[]{z, -x}; + } + default -> { + return new double[]{x, z}; + } + } + } + + private static int[] rotateXZ(int x, int z, int rotationSteps) { + switch (Math.floorMod(rotationSteps, 4)) { + case 1 -> { + return new int[]{-z, x}; + } + case 2 -> { + return new int[]{-x, -z}; + } + case 3 -> { + return new int[]{z, -x}; + } + default -> { + return new int[]{x, z}; + } + } + } + + + public static void pauseReplay(Mob actor) { + REPLAYS.stream().filter(r -> r.actor == actor).forEach(r -> { + r.paused = true; + r.actor.setNoAi(false); + }); + } + + public static void resumeReplay(Mob actor) { + REPLAYS.stream().filter(r -> r.actor == actor).forEach(r -> { + r.paused = false; + if (r.action != null) { + r.sx = r.actor.getX(); + r.sy = r.actor.getY(); + r.sz = r.actor.getZ(); + r.syaw = r.actor.getYRot(); + r.spitch = r.actor.getXRot(); + r.progress = 0; + } + r.actor.setNoAi(true); + MISSING_RECIPES.remove(actor); + }); + } + + public static void cancelReplay(Mob actor) { + Iterator it = REPLAYS.iterator(); + while (it.hasNext()) { + Replay r = it.next(); + if (r.actor != actor) { + continue; + } + r.actor.setNoAi(false); + r.actor.setInvulnerable(false); + MISSING_RECIPES.remove(r.actor); + if (!r.preserveActor) { + r.actor.discard(); + } + it.remove(); + break; + } + } + + public static void cancelAllReplays() { + Iterator it = REPLAYS.iterator(); + while (it.hasNext()) { + Replay r = it.next(); + r.actor.setNoAi(false); + r.actor.setInvulnerable(false); + MISSING_RECIPES.remove(r.actor); + EntityBehaviorManager.removeGoal(r.actor, BuildPlayerGoal.class); + if (!r.preserveActor) { + r.actor.discard(); + } + it.remove(); + } + } + + public static boolean isReplaying(Mob actor) { + return REPLAYS.stream().anyMatch(r -> r.actor == actor); + } + + public static Map getMissingRecipe(Mob actor) { + return MISSING_RECIPES.get(actor); + } + + public static String getNextMissingItem(Mob actor) { + for (Replay r : REPLAYS) { + if (r.actor == actor && r.requiredItem != null) { + return BuiltInRegistries.ITEM.getKey(r.requiredItem).getPath(); + } + } + return null; + } + + public static String recipeToString(Map recipe) { + return recipeToString(recipe, 0); + } + + public static String recipeToString(Map recipe, int limit) { + StringBuilder sb = new StringBuilder(); + int i = 0; + for (Map.Entry e : recipe.entrySet()) { + if (limit > 0 && i >= limit) break; + if (i++ > 0) sb.append(", "); + sb.append(e.getValue()).append(" ").append(e.getKey().replace('_', ' ')); + } + return sb.toString(); + } + + public static String recipeToDisplayString(Map recipe) { + return recipeToDisplayString(recipe, 0); + } + + public static String recipeToDisplayString(Map recipe, int limit) { + StringBuilder sb = new StringBuilder(); + int i = 0; + for (Map.Entry e : recipe.entrySet()) { + if (limit > 0 && i >= limit) break; + ResourceLocation id = ResourceLocation.tryParse(e.getKey()); + Item item = RegistryUtil.getItem(id); + String name = item != null ? new ItemStack(item).getHoverName().getString() : e.getKey().replace('_', ' '); + if (i++ > 0) sb.append(", "); + sb.append(e.getValue()).append(" x ").append(name); + } + return sb.toString(); + } + + private static Map computeRemainingRecipe(Replay r) { + Map recipe = new LinkedHashMap<>(); + for (int i = r.index - 1; i < r.actions.size(); i++) { + Action a = r.actions.get(i); + if ("place".equals(a.action)) { + BlockState st = r.palette.get(a.blockId); + String name = BuiltInRegistries.BLOCK.getKey(st.getBlock()).getPath(); + recipe.merge(name, 1, Integer::sum); + } + } + return recipe; + } + + private static boolean consume(Container inv, Item item) { + for (int i = 0; i < inv.getContainerSize(); i++) { + ItemStack stack = inv.getItem(i); + if (!stack.isEmpty() && stack.getItem() == item) { + stack.shrink(1); + if (stack.isEmpty()) { + inv.setItem(i, ItemStack.EMPTY); + } + return true; + } + } + return false; + } + + public static BlockPos getReplayCursor(Mob actor) { + for (Replay r : REPLAYS) { + if (r.actor == actor) { + if (r.action != null) { + return new BlockPos(Mth.floor(r.tx), Mth.floor(r.ty), Mth.floor(r.tz)); + } + return new BlockPos(Mth.floor(r.baseX), Mth.floor(r.baseY), Mth.floor(r.baseZ)); + } + } + return null; + } + + private static float adjustPitch(ServerLevel level, double x, double y, double z, float yaw, float pitch, + double fromEye, double toEye, Entity ctx) { + if (fromEye <= 0 || toEye <= 0) return pitch; + double yawRad = Math.toRadians(yaw); + double pitchRad = Math.toRadians(pitch); + double dx = -Math.sin(yawRad) * Math.cos(pitchRad); + double dy = -Math.sin(pitchRad); + double dz = Math.cos(yawRad) * Math.cos(pitchRad); + Vec3 start = new Vec3(x, y + fromEye, z); + Vec3 end = start.add(dx * 64, dy * 64, dz * 64); + var hit = level.clip(new ClipContext(start, end, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, ctx)); + Vec3 target = hit.getType() == HitResult.Type.MISS ? end : hit.getLocation(); + Vec3 newEye = new Vec3(x, y + toEye, z); + Vec3 diff = target.subtract(newEye); + double horiz = Math.sqrt(diff.x * diff.x + diff.z * diff.z); + return (float) -Math.toDegrees(Math.atan2(diff.y, horiz)); + } + + private static void recordPlace(ServerPlayer player, BlockPos pos, BlockState state, + double px, double py, double pz, float yaw, float pitch) { + Recording rec = RECORDINGS.get(player.getUUID()); + if (rec != null) { + long tick = player.level().getServer().getTickCount(); + rec.addAction(tick, "place", pos, state, px, py, pz, yaw, pitch); + if (state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF)) { + DoubleBlockHalf half = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF); + BlockPos otherPos = half == DoubleBlockHalf.LOWER ? pos.above() : pos.below(); + BlockState otherState = player.level().getBlockState(otherPos); + rec.addAction(tick, "place", otherPos, otherState, px, py, pz, yaw, pitch); + } + } + } + + private static void recordBreak(ServerPlayer player, ServerLevel world, BlockPos pos, BlockState state, + double px, double py, double pz, float yaw, float pitch) { + Recording rec = RECORDINGS.get(player.getUUID()); + if (rec != null) { + long tick = player.level().getServer().getTickCount(); + rec.addAction(tick, "break", pos, state, px, py, pz, yaw, pitch); + if (state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF)) { + DoubleBlockHalf half = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF); + BlockPos otherPos = half == DoubleBlockHalf.LOWER ? pos.above() : pos.below(); + rec.addAction(tick, "break", otherPos, state, px, py, pz, yaw, pitch); + } + } + } + + private static void recordInteract(ServerPlayer player, BlockPos pos, BlockState state, + double px, double py, double pz, float yaw, float pitch) { + Recording rec = RECORDINGS.get(player.getUUID()); + if (rec != null) { + long tick = player.level().getServer().getTickCount(); + rec.addAction(tick, "interact", pos, state, px, py, pz, yaw, pitch); + if (state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF)) { + DoubleBlockHalf half = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF); + BlockPos otherPos = half == DoubleBlockHalf.LOWER ? pos.above() : pos.below(); + BlockState otherState = player.level().getBlockState(otherPos); + rec.addAction(tick, "interact", otherPos, otherState, px, py, pz, yaw, pitch); + } + } + } + + private static void tick(MinecraftServer server) { + RECORDINGS.forEach((uuid, rec) -> { + ServerPlayer p = server.getPlayerList().getPlayer(uuid); + if (p != null) { + rec.tick(p); + } + }); + + Iterator it = REPLAYS.iterator(); + while (it.hasNext()) { + Replay r = it.next(); + if (r.paused) { + if (MISSING_RECIPES.containsKey(r.actor) && r.requiredItem != null && !r.creative && r.actor instanceof ChatInventory inv) { + Container c = inv.creaturechat$getInventory(); + if (consume(c, r.requiredItem)) { + MISSING_RECIPES.remove(r.actor); + r.requiredItem = null; + } + } + if (r.paused) { + continue; + } + } + double advance = r.speed; + while (advance > 0) { + if (r.action == null) { + if (r.index >= r.actions.size()) { + LOGGER.info("[BuildRec] replay finished actor={} actions={} speed={}", + r.actor.getType().toShortString(), r.actions.size(), r.speed); + if (r.preserveActor) { + r.actor.setNoAi(false); + } else { + r.actor.discard(); + } + it.remove(); + break; + } + r.action = r.actions.get(r.index++); + r.progress = 0; + r.sx = r.actor.getX(); + r.sy = r.actor.getY(); + r.sz = r.actor.getZ(); + r.syaw = r.actor.getYRot(); + r.spitch = r.actor.getXRot(); + r.tx = r.baseX + r.action.px; + r.ty = r.baseY + r.action.py; + r.tz = r.baseZ + r.action.pz; + r.tyaw = r.action.yaw; + r.tpitch = adjustPitch((ServerLevel) r.actor.level(), r.baseX + r.action.px, r.baseY + r.action.py, r.baseZ + r.action.pz, r.action.yaw, r.action.pitch, r.recordEyeHeight, r.actor.getEyeHeight(), r.actor); + } + double remain = r.action.dt - r.progress; + if (remain <= advance) { + r.progress += remain; + advance -= remain; + r.actor.teleportTo(r.tx, r.ty, r.tz); + r.actor.setYRot(r.tyaw); + r.actor.setXRot(r.tpitch); + r.actor.yHeadRot = r.tyaw; + r.actor.yBodyRot = r.tyaw; + if ("place".equals(r.action.action) || "break".equals(r.action.action) || "interact".equals(r.action.action)) { + BlockPos bpos = new BlockPos(Mth.floor(r.baseX + r.action.bx), Mth.floor(r.baseY + r.action.by), Mth.floor(r.baseZ + r.action.bz)); + r.actor.level().getChunkAt(bpos); + if ("place".equals(r.action.action)) { + BlockState state = r.palette.get(r.action.blockId); + if (!r.creative) { + Item item = state.getBlock().asItem(); + Container c = (r.actor instanceof ChatInventory inv) ? inv.creaturechat$getInventory() : null; + if (c == null || !consume(c, item)) { + MISSING_RECIPES.put(r.actor, computeRemainingRecipe(r)); + r.requiredItem = state.getBlock().asItem(); + String missingName = BuiltInRegistries.ITEM.getKey(r.requiredItem).getPath(); + LOGGER.info("[BuildRec] next missing item={} remaining={}", missingName, recipeToString(MISSING_RECIPES.get(r.actor))); + pauseReplay(r.actor); + advance = 0; + break; + } + } + boolean upper = state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF) && state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.UPPER; + r.actor.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(state.getBlock())); + r.actor.level().setBlock(bpos, state, 3); + if (!upper) { + r.actor.level().playSound(null, bpos, state.getSoundType().getPlaceSound(), SoundSource.BLOCKS, 1f, 1f); + r.actor.swing(InteractionHand.MAIN_HAND); + } + } else if ("break".equals(r.action.action)) { + BlockState state = r.actor.level().getBlockState(bpos); + boolean upper = state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF) && state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.UPPER; + if (!upper) { + r.actor.level().levelEvent(2001, bpos, Block.getId(state)); + r.actor.swing(InteractionHand.MAIN_HAND); + } + r.actor.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + r.actor.level().removeBlock(bpos, false); + } else { + BlockState state = r.palette.get(r.action.blockId); + boolean upper = state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF) && state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.UPPER; + r.actor.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + r.actor.level().setBlock(bpos, state, 3); + if (!upper) { + r.actor.level().playSound(null, bpos, state.getSoundType().getHitSound(), SoundSource.BLOCKS, 1f, 1f); + r.actor.swing(InteractionHand.MAIN_HAND); + } + } + } + r.action = null; + } else { + r.progress += advance; + double t = r.progress / r.action.dt; + double px = Mth.lerp(t, r.sx, r.tx); + double py = Mth.lerp(t, r.sy, r.ty); + double pz = Mth.lerp(t, r.sz, r.tz); + r.actor.teleportTo(px, py, pz); + r.actor.setYRot(r.syaw); + r.actor.setXRot(r.spitch); + r.actor.yHeadRot = r.syaw; + r.actor.yBodyRot = r.syaw; + advance = 0; + } + } + } + } + + private static Path buildRootDir() { + Path dir; + try { + dir = FabricLoader.getInstance().getConfigDir().resolve("creaturechat").resolve("builds"); + } catch (Exception e) { + // Unit tests may run without a Fabric config dir. + dir = Paths.get("build", "test-config", "creaturechat", "builds"); + } + try { + Files.createDirectories(dir); + } catch (IOException ignored) { + } + return dir; + } + + private static Path resolveBuildFile(String fileName) { + Path local = buildRootDir().resolve(fileName); + if (Files.exists(local)) { + return local; + } + return FabricLoader.getInstance().getModContainer("creaturechat") + .flatMap(m -> m.findPath("assets/creaturechat/builds/" + fileName)) + .orElse(null); + } + + private static Path buildDir(String type, String height) { + Path dir = buildRootDir().resolve(type); + if (height != null && !height.equalsIgnoreCase("any")) { + dir = dir.resolve(height); + } + try { + Files.createDirectories(dir); + } catch (IOException ignored) { + } + return dir; + } + + + + public static class Summary { + public final String id; + public final int total; + public final int additions; + public final int destroys; + public final Map recipe; + public final int uniqueBlocks; + public final int sizeX; + public final int sizeY; + public final int sizeZ; + public final int finalBlocks; + + public Summary(String id, int total, int additions, int destroys, + Map recipe, int uniqueBlocks, + int sizeX, int sizeY, int sizeZ, int finalBlocks) { + this.id = id; + this.total = total; + this.additions = additions; + this.destroys = destroys; + this.recipe = recipe; + this.uniqueBlocks = uniqueBlocks; + this.sizeX = sizeX; + this.sizeY = sizeY; + this.sizeZ = sizeZ; + this.finalBlocks = finalBlocks; + } + } + + private static class Recording { + final int ox, oy, oz; + final List actions = new ArrayList<>(); + final Map finalBlocks = new HashMap<>(); + final Map stateIds = new LinkedHashMap<>(); + final List statePalette = new ArrayList<>(); + final String name; + final String type; + final String height; + final double eyeHeight; + final double bbWidth; + final double bbHeight; + int additions = 0; + int destroys = 0; + int poseTick = 0; + long lastTick; + double lastPx, lastPy, lastPz; + float lastYaw, lastPitch; + boolean poseInit = false; + + Recording(ServerPlayer player, String type, String height, String name) { + this.name = name; + this.type = type; + this.height = height; + BlockPos p = player.blockPosition(); + this.ox = p.getX(); + this.oy = p.getY(); + this.oz = p.getZ(); + this.lastTick = player.level().getServer().getTickCount(); + this.eyeHeight = player.getEyeHeight(); + this.bbWidth = player.getBbWidth(); + this.bbHeight = player.getBbHeight(); + } + + void tick(ServerPlayer player) { + if (++poseTick >= 4) { + poseTick = 0; + double px = player.getX(); + double py = player.getY(); + double pz = player.getZ(); + float yaw = player.getYRot(); + float pitch = player.getXRot(); + if (!poseInit || px != lastPx || py != lastPy || pz != lastPz || yaw != lastYaw || pitch != lastPitch) { + addPose(player.level().getServer().getTickCount(), px, py, pz, yaw, pitch); + lastPx = px; + lastPy = py; + lastPz = pz; + lastYaw = yaw; + lastPitch = pitch; + poseInit = true; + } + } + } + + void addPose(long tick, double px, double py, double pz, float yaw, float pitch) { + Action a = new Action(); + a.action = "pose"; + int dt = (int)(tick - lastTick); + a.dt = dt > MAX_IDLE_TICKS ? MAX_IDLE_TICKS : dt; + lastTick = tick; + a.px = px - ox; + a.py = py - oy; + a.pz = pz - oz; + a.yaw = yaw; + a.pitch = pitch; + actions.add(a); + } + + void addAction(long tick, String type, BlockPos pos, BlockState state, + double px, double py, double pz, float yaw, float pitch) { + Action a = new Action(); + a.action = type; + int dt = (int)(tick - lastTick); + a.dt = dt > MAX_IDLE_TICKS ? MAX_IDLE_TICKS : dt; + lastTick = tick; + int id = stateIds.computeIfAbsent(state, s -> { + int idx = statePalette.size(); + statePalette.add(encodeState(s)); + return idx; + }); + a.blockId = id; + a.bx = pos.getX() - ox; + a.by = pos.getY() - oy; + a.bz = pos.getZ() - oz; + a.px = px - ox; + a.py = py - oy; + a.pz = pz - oz; + a.yaw = yaw; + a.pitch = pitch; + actions.add(a); + BlockPos rel = new BlockPos(a.bx, a.by, a.bz); + if ("place".equals(type)) { + additions++; + finalBlocks.put(rel, state); + } else if ("break".equals(type)) { + destroys++; + finalBlocks.remove(rel); + } + } + + private static String encodeState(BlockState state) { + StringBuilder sb = new StringBuilder(); + sb.append(BuiltInRegistries.BLOCK.getKey(state.getBlock())); + Map, Comparable> props = state.getValues(); + if (!props.isEmpty()) { + sb.append('['); + boolean first = true; + for (Map.Entry, Comparable> e : props.entrySet()) { + if (!first) sb.append(','); + @SuppressWarnings("rawtypes") Property p = (Property) e.getKey(); + sb.append(p.getName()).append('=').append(p.getName(e.getValue())); + first = false; + } + sb.append(']'); + } + return sb.toString(); + } + Summary save() { + String base = (name == null || name.isBlank()) ? UUID.randomUUID().toString().split("-")[0] : name.replaceAll("[^a-zA-Z0-9-_]", "_"); + String fileName = base + ".json.gz"; + Path file = buildDir(type, height).resolve(fileName); + + Map recipe = new LinkedHashMap<>(); + int minX = 0, minY = 0, minZ = 0, maxX = 0, maxY = 0, maxZ = 0; + boolean first = true; + for (Map.Entry e : finalBlocks.entrySet()) { + BlockPos p = e.getKey(); + BlockState st = e.getValue(); + String name = BuiltInRegistries.BLOCK.getKey(st.getBlock()).getPath(); + recipe.merge(name, 1, Integer::sum); + if (first) { + minX = maxX = p.getX(); + minY = maxY = p.getY(); + minZ = maxZ = p.getZ(); + first = false; + } else { + if (p.getX() < minX) minX = p.getX(); + if (p.getX() > maxX) maxX = p.getX(); + if (p.getY() < minY) minY = p.getY(); + if (p.getY() > maxY) maxY = p.getY(); + if (p.getZ() < minZ) minZ = p.getZ(); + if (p.getZ() > maxZ) maxZ = p.getZ(); + } + } + int sizeX = first ? 0 : (maxX - minX + 1); + int sizeY = first ? 0 : (maxY - minY + 1); + int sizeZ = first ? 0 : (maxZ - minZ + 1); + + int unique = stateIds.size(); + try (BufferedWriter w = new BufferedWriter(new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(file)), StandardCharsets.UTF_8))) { + w.write("[\n"); + w.write(BuildRecordIO.GSON.toJson(new Meta(eyeHeight, bbWidth, bbHeight, recipe, unique, sizeX, sizeY, sizeZ, statePalette))); + for (Action a : actions) { + w.write(",\n"); + w.write(BuildRecordIO.GSON.toJson(a)); + } + w.write("\n]"); + } catch (IOException e) { + LOGGER.error("[BuildRec] save failed file={}", fileName, e); + } + LOGGER.info("[BuildRec] save file={} actions={} additions={} destroys={}", fileName, actions.size(), additions, destroys); + String rel = buildRootDir().relativize(file).toString().replace('\\', '/'); + rebuildBuildIndex(); + int finalCount = finalBlocks.size(); + return new Summary(rel, actions.size(), additions, destroys, recipe, unique, sizeX, sizeY, sizeZ, finalCount); + } + + Summary finish(ServerPlayer player) { + return save(); + } + } + + private static class Replay { + final Mob actor; + final List actions; + final List palette; + final double baseX, baseY, baseZ; + final int speed; + final double recordEyeHeight; + final double recordBbWidth; + final double recordBbHeight; + final boolean preserveActor; + final boolean creative; + Item requiredItem; + boolean paused = false; + int index = 0; + Action action = null; + double progress = 0; + double sx, sy, sz, tx, ty, tz; + float syaw, spitch, tyaw, tpitch; + + Replay(Mob actor, List actions, int speed, double recordEyeHeight, double recordBbWidth, double recordBbHeight, List palette, boolean preserveActor, boolean creative) { + this.actor = actor; + this.actions = actions; + this.speed = speed; + this.recordEyeHeight = recordEyeHeight; + this.recordBbWidth = recordBbWidth; + this.recordBbHeight = recordBbHeight; + this.palette = palette; + this.preserveActor = preserveActor; + this.creative = creative; + BlockPos p = actor.blockPosition(); + this.baseX = p.getX(); + this.baseY = p.getY(); + this.baseZ = p.getZ(); + } + } + + private static void rebuildBuildIndex() { + Path indexPath = buildRootDir().resolve(BUILD_INDEX_FILE); + List entries = new ArrayList<>(); + LinkedHashMap byKey = new LinkedHashMap<>(); + collectBuildEntriesFromResources(byKey); + collectBuildEntriesFromLocal(byKey); + entries.addAll(byKey.values()); + Map> byType = new TreeMap<>(); + for (IndexEntry e : entries) { + byType.computeIfAbsent(e.type, k -> new ArrayList<>()).add(e); + } + for (List typeEntries : byType.values()) { + typeEntries.sort(Comparator.comparingDouble(e -> e.raw)); + int n = typeEntries.size(); + for (int i = 0; i < n; i++) { + typeEntries.get(i).score = (int) Math.min(5, Math.floor((double) i * 5 / n) + 1); + } + } + List list = new ArrayList<>(); + JsonArray arr = new JsonArray(); + for (IndexEntry e : entries) { + list.add(new BuildIndex(e.type, e.height, e.file, e.score)); + JsonObject o = new JsonObject(); + o.addProperty("type", e.type); + o.addProperty("height", e.height); + o.addProperty("file", e.file); + JsonObject recipe = new JsonObject(); + for (Map.Entry r : e.recipe.entrySet()) { + recipe.addProperty(r.getKey(), r.getValue()); + } + o.add("recipe", recipe); + o.addProperty("score", e.score); + arr.add(o); + } + JsonObject root = new JsonObject(); + root.add("builds", arr); + try (BufferedWriter w = Files.newBufferedWriter(indexPath, StandardCharsets.UTF_8)) { + BuildRecordIO.GSON.toJson(root, w); + } catch (IOException e) { + LOGGER.error("[BuildRec] failed to write build index {}", indexPath, e); + } + BUILD_INDEX = list; + LOGGER.info("[BuildRec] rebuilt build index entries={} file={}", entries.size(), indexPath); + logBuildIndexSummary(byType); + } + + private static void logBuildIndexSummary(Map> byType) { + for (Map.Entry> entry : byType.entrySet()) { + int[] counts = new int[5]; + for (IndexEntry e : entry.getValue()) { + int idx = Math.min(5, Math.max(1, e.score)) - 1; + counts[idx]++; + } + String summary = "1:" + counts[0] + ",2:" + counts[1] + ",3:" + counts[2] + ",4:" + counts[3] + ",5:" + counts[4]; + LOGGER.info("[BuildRec] build index summary type={} levels={} total={}", entry.getKey(), summary, entry.getValue().size()); + } + } + + private static void collectBuildEntriesFromLocal(Map entries) { + Path root = buildRootDir(); + collectBuildEntries(root, entries, true); + } + + private static void collectBuildEntriesFromResources(Map entries) { + try { + final boolean[] collected = {false}; + FabricLoader.getInstance().getModContainer("creaturechat") + .flatMap(m -> m.findPath("assets/creaturechat/builds")) + .ifPresent(path -> { + collected[0] = true; + collectBuildEntries(path, entries, false); + }); + if (collected[0]) { + return; + } + } catch (Exception ignored) { + } + URL url = BuildRecorder.class.getClassLoader().getResource("assets/creaturechat/builds"); + if (url == null) { + return; + } + try { + URI uri = url.toURI(); + if ("file".equalsIgnoreCase(uri.getScheme())) { + collectBuildEntries(Paths.get(uri), entries, false); + } + } catch (Exception ignored) { + } + } + + private static void collectBuildEntries(Path root, Map entries, boolean preferLocal) { + try { + Files.walk(root) + .filter(p -> p.toString().endsWith(".json.gz")) + .forEach(p -> addBuildEntry(root, p, entries, preferLocal)); + } catch (IOException ignored) { + } + } + + private static void addBuildEntry(Path root, Path file, Map entries, boolean preferLocal) { + try { + BuildRecordIO.Loaded loaded = BuildRecordIO.read(file); + Path rel = root.relativize(file); + if (rel.getNameCount() < 2) { + return; + } + IndexEntry e = new IndexEntry(); + e.file = rel.getFileName().toString(); + e.type = rel.getName(0).toString(); + e.height = rel.getNameCount() > 2 ? rel.getName(1).toString() : "any"; + e.recipe = loaded.meta.recipe != null ? loaded.meta.recipe : new LinkedHashMap<>(); + e.raw = rawScore(loaded); + String key = e.type + "/" + e.height + "/" + e.file; + if (preferLocal || !entries.containsKey(key)) { + entries.put(key, e); + } + } catch (IOException | RuntimeException ignored) { + } + } + + private static double rawScore(BuildRecordIO.Loaded loaded) { + BuildRecordIO.Meta meta = loaded.meta; + Map recipe = meta.recipe != null ? meta.recipe : Map.of(); + int unique = recipe.size(); + int total = recipe.values().stream().mapToInt(Integer::intValue).sum(); + int steps = loaded.actions.size(); + int duration = loaded.actions.stream().mapToInt(a -> a.dt).sum(); + int rarity = recipe.entrySet().stream().mapToInt(e -> { + String name = e.getKey(); + int base = 1; + if (name.contains("diamond") || name.contains("netherite")) base = 4; + else if (name.contains("gold") || name.contains("emerald")) base = 3; + else if (name.contains("iron") || name.contains("copper")) base = 2; + return base * e.getValue(); + }).sum(); + return unique * 5 + total + steps + (duration / 20.0) + rarity * 3; + } + + private static List readBuildIndex(Path path) { + try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + JsonObject root = BuildRecordIO.GSON.fromJson(reader, JsonObject.class); + JsonArray arr = root != null ? root.getAsJsonArray("builds") : null; + if (arr == null) { + return null; + } + List list = new ArrayList<>(); + for (var el : arr) { + JsonObject o = el.getAsJsonObject(); + if (!o.has("type") || !o.has("height") || !o.has("file") || !o.has("score")) { + continue; + } + list.add(new BuildIndex(o.get("type").getAsString(), o.get("height").getAsString(), + o.get("file").getAsString(), o.get("score").getAsInt())); + } + return list; + } catch (IOException | JsonParseException e) { + LOGGER.error("[BuildRec] failed to read build index {}", path, e); + return null; + } + } + + private static class IndexEntry { + String type; + String height; + String file; + Map recipe = new LinkedHashMap<>(); + double raw; + int score; + } + + public static class ReplayBounds { + public final int minX; + public final int minY; + public final int minZ; + public final int maxX; + public final int maxY; + public final int maxZ; + + public final int sizeX; + public final int sizeY; + public final int sizeZ; + + public ReplayBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + this.minX = minX; + this.minY = minY; + this.minZ = minZ; + this.maxX = maxX; + this.maxY = maxY; + this.maxZ = maxZ; + this.sizeX = maxX - minX + 1; + this.sizeY = maxY - minY + 1; + this.sizeZ = maxZ - minZ + 1; + } + + private static ReplayBounds fromActions(List actions, boolean includeMovement) { + boolean hasBounds = false; + int minX = 0, minY = 0, minZ = 0; + int maxX = 0, maxY = 0, maxZ = 0; + for (Action a : actions) { + if (includeMovement) { + int px = Mth.floor(a.px); + int py = Mth.floor(a.py); + int pz = Mth.floor(a.pz); + if (!hasBounds) { + minX = maxX = px; + minY = maxY = py; + minZ = maxZ = pz; + hasBounds = true; + } else { + if (px < minX) minX = px; + if (px > maxX) maxX = px; + if (py < minY) minY = py; + if (py > maxY) maxY = py; + if (pz < minZ) minZ = pz; + if (pz > maxZ) maxZ = pz; + } + } + if ("place".equals(a.action) || "break".equals(a.action) || "interact".equals(a.action)) { + int bx = a.bx; + int by = a.by; + int bz = a.bz; + if (!hasBounds) { + minX = maxX = bx; + minY = maxY = by; + minZ = maxZ = bz; + hasBounds = true; + } else { + if (bx < minX) minX = bx; + if (bx > maxX) maxX = bx; + if (by < minY) minY = by; + if (by > maxY) maxY = by; + if (bz < minZ) minZ = bz; + if (bz > maxZ) maxZ = bz; + } + } + } + return hasBounds ? new ReplayBounds(minX, minY, minZ, maxX, maxY, maxZ) : null; + } + } + +} diff --git a/src/main/java/com/owlmaddie/buildrec/MobHelper.java b/src/main/java/com/owlmaddie/buildrec/MobHelper.java new file mode 100644 index 00000000..3fcd4d5b --- /dev/null +++ b/src/main/java/com/owlmaddie/buildrec/MobHelper.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.MobSpawnType; + +/** Utility methods for creating mob instances across versions. */ +public class MobHelper { + private MobHelper() {} + + public static Mob create(EntityType type, ServerLevel level) { + return (Mob) type.create(level); + } + + public static void initSpawn(Mob mob, ServerLevel level) { + if (mob != null) { + mob.finalizeSpawn( + level, + level.getCurrentDifficultyAt(mob.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + } + } +} diff --git a/src/main/java/com/owlmaddie/buildrec/RegistryUtil.java b/src/main/java/com/owlmaddie/buildrec/RegistryUtil.java new file mode 100644 index 00000000..396ef150 --- /dev/null +++ b/src/main/java/com/owlmaddie/buildrec/RegistryUtil.java @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; + +/** Utility for registry lookups that vary across Minecraft versions. */ +public final class RegistryUtil { + private RegistryUtil() {} + + public static Item getItem(ResourceLocation id) { + if (id == null) return null; + ResourceKey key = ResourceKey.create(Registries.ITEM, id); + Holder.Reference ref = BuiltInRegistries.ITEM.getHolder(key).orElse(null); + return ref != null ? ref.value() : null; + } +} diff --git a/src/main/java/com/owlmaddie/chat/EntityChatData.java b/src/main/java/com/owlmaddie/chat/EntityChatData.java index a20fb25b..baff2519 100644 --- a/src/main/java/com/owlmaddie/chat/EntityChatData.java +++ b/src/main/java/com/owlmaddie/chat/EntityChatData.java @@ -5,6 +5,7 @@ import com.google.gson.annotations.Expose; import com.google.gson.annotations.SerializedName; +import com.owlmaddie.buildrec.BuildRecorder; import com.owlmaddie.commands.ConfigurationHandler; import com.owlmaddie.controls.SpeedControls; import com.owlmaddie.goals.*; @@ -30,6 +31,7 @@ import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; import net.minecraft.world.effect.MobEffect; import net.minecraft.world.effect.MobEffectInstance; import net.minecraft.world.entity.EquipmentSlot; @@ -89,6 +91,7 @@ public class EntityChatData { public Long born; public Long death; public transient AutoMessageBucket autoBucket; + public int buildLevel; @SerializedName("playerId") @Expose(serialize = false) @@ -113,12 +116,22 @@ public EntityChatData(String entityId) { this.previousMessages = new ArrayList<>(); this.born = System.currentTimeMillis();; this.autoBucket = null; + this.buildLevel = getRandomBuildLevel(); // Old, unused migrated properties this.legacyPlayerId = null; this.legacyFriendship = null; } + private static int getRandomBuildLevel() { + int n = new Random().nextInt(600); + if (n < 2) return 5; // 1/300 + if (n < 5) return 4; // 1/200 + if (n < 11) return 3; // 1/100 + if (n < 41) return 2; // 5/100 + return 1; + } + // Post-deserialization initialization public void postDeserializeInitialization() { if (this.players == null) { @@ -127,6 +140,9 @@ public void postDeserializeInitialization() { if (this.legacyPlayerId != null && !this.legacyPlayerId.isEmpty()) { this.migrateData(); } + if (this.buildLevel <= 0) { + this.buildLevel = 1; + } } // Migrate old data into the new structure @@ -295,6 +311,7 @@ public Map getPlayerContext(ServerPlayer player, String userLang } else { contextData.put("entity_friendship", String.valueOf(0)); } + contextData.put("entity_build_level", String.valueOf(this.buildLevel)); return contextData; } @@ -319,6 +336,7 @@ public void generateCharacter(String userLanguage, ServerPlayer player, String u // Add PLAYER context information Map contextData = getPlayerContext(player, userLanguage, config); + contextData.remove("entity_build_level"); // fetch HTTP response from ChatGPT ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> { @@ -467,6 +485,7 @@ public void generateMessage(String userLanguage, ServerPlayer player, String use EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class); EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class); EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, BuildPlayerGoal.class); EntityBehaviorManager.addGoal(entity, fleeGoal, GoalPriority.FLEE_PLAYER); ParticleEmitter.emitCreatureParticle((ServerLevel) entity.level(), entity, (ParticleOptions) FLEE_PARTICLE, 0.5, 1); playerData.fleeing = true; @@ -489,6 +508,7 @@ public void generateMessage(String userLanguage, ServerPlayer player, String use EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class); EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class); EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, BuildPlayerGoal.class); EntityBehaviorManager.addGoal(entity, attackGoal, GoalPriority.ATTACK_PLAYER); ParticleEmitter.emitCreatureParticle((ServerLevel) entity.level(), entity, (ParticleOptions) FLEE_PARTICLE, 0.5, 1); playerData.attacking = true; @@ -502,6 +522,7 @@ public void generateMessage(String userLanguage, ServerPlayer player, String use EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class); EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class); EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, BuildPlayerGoal.class); EntityBehaviorManager.addGoal(entity, protectGoal, GoalPriority.PROTECT_PLAYER); if (playerData.attacking) { AdvancementHelper.calmTheStorm(player); @@ -525,10 +546,25 @@ public void generateMessage(String userLanguage, ServerPlayer player, String use EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class); } else if (behavior.getName().equals("LEAD")) { - LeadPlayerGoal leadGoal = new LeadPlayerGoal(player, entity, entitySpeedMedium); + LeadTarget target = LeadTargetParser.parse(behavior.getArgument()).orElse(null); + if (target == null || !(entity.level() instanceof ServerLevel serverLevel)) { + LOGGER.info("LEAD target argument '{}' could not be parsed", behavior.getArgument()); + sendLeadFailureMessage(entity, player, behavior.getArgument(), systemPrompt); + return; + } + Vec3 destination = LeadTargetLocator.locate(serverLevel, entity.blockPosition(), target, 300); + if (destination == null) { + LOGGER.info("LEAD target '{}' not found within radius", behavior.getArgument()); + sendLeadFailureMessage(entity, player, behavior.getArgument(), systemPrompt); + return; + } + LOGGER.info("LEAD target '{}' located at ({}, {}, {})", behavior.getArgument(), + destination.x, destination.y, destination.z); + LeadPlayerGoal leadGoal = new LeadPlayerGoal(player, entity, entitySpeedMedium, destination); EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class); EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class); EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, BuildPlayerGoal.class); EntityBehaviorManager.addGoal(entity, leadGoal, GoalPriority.LEAD_PLAYER); if (playerData.attacking) { AdvancementHelper.calmTheStorm(player); @@ -544,8 +580,28 @@ public void generateMessage(String userLanguage, ServerPlayer player, String use } else if (behavior.getName().equals("UNLEAD")) { EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class); + } else if (behavior.getName().equals("BUILD")) { + String buildType = behavior.getArgument(); + if (buildType != null && buildType.equalsIgnoreCase("random")) { + buildType = null; + } + BuildPlayerGoal buildGoal = new BuildPlayerGoal(player, entity, entitySpeedMedium, buildType); + EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class); + EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class); + EntityBehaviorManager.addGoal(entity, buildGoal, GoalPriority.BUILD_PLAYER); + ParticleEmitter.emitCreatureParticle((ServerLevel) entity.level(), entity, (ParticleOptions) BUILD_PARTICLE, 0.5, 1); + + entity.level().playSound(null, entity.blockPosition(), SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.PLAYERS, 1f, 1f); + + } else if (behavior.getName().equals("UNBUILD")) { + BuildRecorder.cancelReplay(entity); + EntityBehaviorManager.removeGoal(entity, BuildPlayerGoal.class); + } else if (behavior.getName().equals("FRIENDSHIP")) { - int new_friendship = Math.max(-3, Math.min(3, behavior.getArgument())); + int argVal = behavior.getArgumentAsInt() != null ? behavior.getArgumentAsInt() : 0; + int new_friendship = Math.max(-3, Math.min(3, argVal)); int old_friendship = playerData.friendship; // Does friendship improve? @@ -852,4 +908,21 @@ public void setStatus(ChatDataManager.ChatStatus new_status) { // Broadcast to all players ServerPackets.BroadcastEntityMessage(this); } + + private void sendLeadFailureMessage(Mob entity, ServerPlayer player, String arg, String systemPrompt) { + String targetName = (arg != null && !arg.isEmpty()) ? arg : "that"; + ConfigurationHandler.Config cfg = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig(); + String systemPromptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), "system-chat"); + Map ctx = getPlayerContext(player, "N/A", cfg); + List history = new ArrayList<>(previousMessages); + history.add(new ChatMessage("The player asked me to lead them to " + targetName + + " but I can't find that nearby. Apologize and say you'll stop leading.", + ChatDataManager.ChatSender.USER, player.getDisplayName().getString())); + ChatGPTRequest.fetchMessageFromChatGPT(cfg, systemPromptText, ctx, history, false).thenAccept(msg -> { + if (msg != null) { + addMessage(msg, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt); + } + }); + EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class); + } } \ No newline at end of file diff --git a/src/main/java/com/owlmaddie/chat/EntityChatDataLight.java b/src/main/java/com/owlmaddie/chat/EntityChatDataLight.java index 5cf617d7..712b79cc 100644 --- a/src/main/java/com/owlmaddie/chat/EntityChatDataLight.java +++ b/src/main/java/com/owlmaddie/chat/EntityChatDataLight.java @@ -18,6 +18,7 @@ public class EntityChatDataLight { public ChatDataManager.ChatStatus status; public ChatDataManager.ChatSender sender; public Map players; + public int buildLevel; // Constructor to initialize the light version from the full version public EntityChatDataLight(EntityChatData fullData, String playerName) { @@ -26,6 +27,7 @@ public EntityChatDataLight(EntityChatData fullData, String playerName) { this.currentLineNumber = fullData.currentLineNumber; this.status = fullData.status; this.sender = fullData.sender; + this.buildLevel = fullData.buildLevel; // Initialize the players map and add only the current player's data this.players = new HashMap<>(); diff --git a/src/main/java/com/owlmaddie/commands/BuildCommands.java b/src/main/java/com/owlmaddie/commands/BuildCommands.java new file mode 100644 index 00000000..f1308c25 --- /dev/null +++ b/src/main/java/com/owlmaddie/commands/BuildCommands.java @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.owlmaddie.buildrec.BuildRecorder; +import com.owlmaddie.buildrec.BuildRecorder.Summary; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.ResourceLocationArgument; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.MobCategory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * Commands to record and replay builds. + */ +public class BuildCommands { + private static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); + public static void register() { + ServerLifecycleEvents.SERVER_STARTING.register(server -> { + CommandDispatcher dispatcher = server.getCommands().getDispatcher(); + dispatcher.register( + Commands.literal("creaturechat") + .then(Commands.literal("build") + .then(Commands.literal("record") + .requires(src -> src.getEntity() instanceof ServerPlayer sp && !BuildRecorder.isRecording(sp)) + .then(Commands.argument("type", StringArgumentType.word()) + .suggests((c, b) -> SharedSuggestionProvider.suggest(Arrays.asList("house", "statue", "pond", "garden", "farm", "castle", "mine", "trap", "campfire"), b)) + .then(Commands.argument("height", StringArgumentType.word()) + .suggests((c, b) -> SharedSuggestionProvider.suggest(Arrays.asList("Any", "1", "2", "3", "4"), b)) + .then(Commands.argument("name", StringArgumentType.string()) + .executes(ctx -> record(ctx, + StringArgumentType.getString(ctx, "type"), + StringArgumentType.getString(ctx, "height"), + StringArgumentType.getString(ctx, "name"))))))) + .then(Commands.literal("stop") + .requires(src -> src.getEntity() instanceof ServerPlayer sp && BuildRecorder.isRecording(sp)) + .executes(BuildCommands::stop)) + .then(Commands.literal("replay") + .then(Commands.argument("id", StringArgumentType.string()) + .suggests(BuildCommands::suggest) + .then(Commands.argument("entity", ResourceLocationArgument.id()) + .suggests((c, b) -> SharedSuggestionProvider.suggestResource(getLivingEntityIds(), b)) + .executes(ctx -> replay(ctx, ResourceLocationArgument.getId(ctx, "entity"), 1)) + .then(Commands.argument("speed", IntegerArgumentType.integer(1, 32)) + .executes(ctx -> replay(ctx, ResourceLocationArgument.getId(ctx, "entity"), IntegerArgumentType.getInteger(ctx, "speed")))))))) + ); + }); + } + + private static int record(CommandContext context, String type, String height, String name) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + LOGGER.info("[BuildRec] command record player={} type={} height={} name={}", + player.getGameProfile().getName(), type, height, name); + if (BuildRecorder.start(player, type, height, name)) { + context.getSource().sendSuccess(() -> Component.literal("Recording started"), false); + return 1; + } + context.getSource().sendSuccess(() -> Component.literal("Already recording").withStyle(ChatFormatting.RED), false); + return 0; + } + + private static int stop(CommandContext context) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + Summary s = BuildRecorder.stop(player); + if (s != null) { + String recipeStr = s.recipe.entrySet().stream() + .map(e -> e.getValue() + " " + e.getKey()) + .collect(Collectors.joining(", ")); + Component msg = Component.literal( + recipeStr + " (" + s.uniqueBlocks + " Unique blocks)\n" + + s.sizeX + " length x " + s.sizeZ + " width x " + s.sizeY + " height\n" + + s.finalBlocks + " blocks placed, " + s.destroys + " blocks destroyed\n" + + "Saved build " + s.id); + context.getSource().sendSuccess(() -> msg, false); + LOGGER.info("[BuildRec] command stop player={} file={} placed={} destroys={} unique={} size={}x{}x{}", player.getGameProfile().getName(), s.id, s.finalBlocks, s.destroys, s.uniqueBlocks, s.sizeX, s.sizeZ, s.sizeY); + return 1; + } + context.getSource().sendSuccess(() -> Component.literal("No active recording").withStyle(ChatFormatting.RED), false); + return 0; + } + + private static int replay(CommandContext context, ResourceLocation entityId, int speed) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + int spd = speed; + ServerPlayer player = context.getSource().getPlayerOrException(); + String id = StringArgumentType.getString(context, "id").replace('\\', '/'); + int rotationSteps = BuildRecorder.getReplayRotationSteps(id, player.getYRot()); + BuildRecorder.ReplayBounds moveBounds = BuildRecorder.getReplayBounds(id, true, rotationSteps); + BuildRecorder.ReplayBounds staticBounds = BuildRecorder.getReplayBounds(id, false, rotationSteps); + EntityType type = null; + if (entityId != null && !"player".equals(entityId.getPath())) { + EntityType raw = BuiltInRegistries.ENTITY_TYPE.getOptional(entityId).orElse(null); + if (raw != null) { + @SuppressWarnings("unchecked") + EntityType cast = (EntityType) raw; + type = cast; + } + } + var entityStr = (entityId == null ? "null" + : "player".equals(entityId.getPath()) ? "player" + : entityId.toString()); + LOGGER.info("[BuildRec] command replay player={} file={} entity={} speed={}", + player.getGameProfile().getName(), id, entityStr, spd); + if (moveBounds != null) { + LOGGER.info("[BuildRec] replay bounds file={} includeMovement=true min=({}, {}, {}) max=({}, {}, {}) size=({}, {}, {})", + id, moveBounds.minX, moveBounds.minY, moveBounds.minZ, moveBounds.maxX, moveBounds.maxY, moveBounds.maxZ, + moveBounds.sizeX, moveBounds.sizeY, moveBounds.sizeZ); + } else { + LOGGER.info("[BuildRec] replay bounds file={} includeMovement=true not found", id); + } + if (staticBounds != null) { + LOGGER.info("[BuildRec] replay bounds file={} includeMovement=false min=({}, {}, {}) max=({}, {}, {}) size=({}, {}, {})", + id, staticBounds.minX, staticBounds.minY, staticBounds.minZ, staticBounds.maxX, staticBounds.maxY, staticBounds.maxZ, + staticBounds.sizeX, staticBounds.sizeY, staticBounds.sizeZ); + } else { + LOGGER.info("[BuildRec] replay bounds file={} includeMovement=false not found", id); + } + if (BuildRecorder.startReplay(player, id, type, spd)) { + final int fs = spd; + final String fid = id; + context.getSource().sendSuccess(() -> Component.literal("Replaying build " + fid + " at " + fs + "x"), false); + return 1; + } + context.getSource().sendSuccess(() -> Component.literal("Could not replay " + id).withStyle(ChatFormatting.RED), false); + return 0; + } + + private static CompletableFuture suggest(CommandContext context, SuggestionsBuilder builder) { + for (String id : BuildRecorder.getIndexedBuildIds()) { + builder.suggest("\"" + id + "\""); + } + return builder.buildFuture(); + } + + public static List getLivingEntityIds() { + return BuiltInRegistries.ENTITY_TYPE + .keySet() + .stream() + .filter(id -> + // getOptional(...) returns Optional> on all versions + BuiltInRegistries.ENTITY_TYPE + .getOptional(id) + .map(type -> type.getCategory() != MobCategory.MISC + || isIncludedEntity(type)) + .orElse(false) + ) + .collect(Collectors.toList()); + } + + + private static boolean isIncludedEntity(EntityType entityType) { + return entityType == EntityType.VILLAGER + || entityType == EntityType.IRON_GOLEM + || entityType == EntityType.SNOW_GOLEM; + } +} diff --git a/src/main/java/com/owlmaddie/datagen/BuildIndexProvider.java b/src/main/java/com/owlmaddie/datagen/BuildIndexProvider.java new file mode 100644 index 00000000..f97fbc68 --- /dev/null +++ b/src/main/java/com/owlmaddie/datagen/BuildIndexProvider.java @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.datagen; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.owlmaddie.buildrec.BuildRecordIO; +import com.owlmaddie.buildrec.BuildRecordIO.Meta; +import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; +import net.minecraft.data.CachedOutput; +import net.minecraft.data.DataProvider; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * Generates an index of bundled build recordings. + */ +public class BuildIndexProvider implements DataProvider { + private final FabricDataOutput output; + + public BuildIndexProvider(FabricDataOutput output) { + this.output = output; + } + + @Override + public String getName() { + return "Build Index"; + } + + @Override + public java.util.concurrent.CompletableFuture run(CachedOutput cache) { + List entries = new ArrayList<>(); + try { + java.net.URL url = BuildIndexProvider.class.getClassLoader().getResource("assets/creaturechat/builds"); + if (url != null) { + Path buildsDir = Path.of(url.toURI()); + if (Files.exists(buildsDir)) { + Files.walk(buildsDir).filter(p -> p.toString().endsWith(".json.gz")).forEach(p -> { + try { + BuildRecordIO.Loaded loaded = BuildRecordIO.read(p); + Entry e = new Entry(); + Path rel = buildsDir.relativize(p); + e.file = rel.getFileName().toString(); + e.type = rel.getName(0).toString(); + e.height = rel.getNameCount() > 2 ? rel.getName(1).toString() : "any"; + e.recipe = loaded.meta.recipe; + e.raw = rawScore(loaded); + entries.add(e); + } catch (IOException | RuntimeException ex) { + // ignore malformed files + } + }); + } + } + } catch (IOException | java.net.URISyntaxException ignored) {} + entries.sort(Comparator.comparingDouble(a -> a.raw)); + int n = entries.size(); + for (int i = 0; i < n; i++) { + entries.get(i).score = (int) Math.min(5, Math.floor((double) i * 5 / n) + 1); + } + JsonArray arr = new JsonArray(); + for (Entry e : entries) { + JsonObject o = new JsonObject(); + o.addProperty("type", e.type); + o.addProperty("height", e.height); + o.addProperty("file", e.file); + JsonObject recipe = new JsonObject(); + for (Map.Entry r : e.recipe.entrySet()) { + recipe.addProperty(r.getKey(), r.getValue()); + } + o.add("recipe", recipe); + o.addProperty("score", e.score); + arr.add(o); + } + JsonObject root = new JsonObject(); + root.add("builds", arr); + Path out = output.getOutputFolder().resolve("assets/creaturechat/builds/index.json"); + return DataProvider.saveStable(cache, root, out); + } + + private static double rawScore(BuildRecordIO.Loaded loaded) { + Meta meta = loaded.meta; + Map recipe = meta.recipe; + int unique = recipe.size(); + int total = recipe.values().stream().mapToInt(Integer::intValue).sum(); + int steps = loaded.actions.size(); + int duration = loaded.actions.stream().mapToInt(a -> a.dt).sum(); + int rarity = recipe.entrySet().stream().mapToInt(e -> { + String name = e.getKey(); + int base = 1; + if (name.contains("diamond") || name.contains("netherite")) base = 4; + else if (name.contains("gold") || name.contains("emerald")) base = 3; + else if (name.contains("iron") || name.contains("copper")) base = 2; + return base * e.getValue(); + }).sum(); + return unique * 5 + total + steps + (duration / 20.0) + rarity * 3; + } + + private static class Entry { + String type; + String height; + String file; + Map recipe; + double raw; + int score; + } +} + diff --git a/src/main/java/com/owlmaddie/datagen/CreatureChatDataGenerator.java b/src/main/java/com/owlmaddie/datagen/CreatureChatDataGenerator.java index 719f085e..f8857468 100644 --- a/src/main/java/com/owlmaddie/datagen/CreatureChatDataGenerator.java +++ b/src/main/java/com/owlmaddie/datagen/CreatureChatDataGenerator.java @@ -12,6 +12,7 @@ public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { FabricDataGenerator.Pack pack = fabricDataGenerator.createPack(); pack.addProvider(CreatureChatLootTableProvider::new); pack.addProvider(CreatureChatAdvancementProvider::new); + pack.addProvider(BuildIndexProvider::new); pack.addProvider(CreatureChatLangProvider::new); } } diff --git a/src/main/java/com/owlmaddie/goals/BuildPlayerGoal.java b/src/main/java/com/owlmaddie/goals/BuildPlayerGoal.java new file mode 100644 index 00000000..b3a9e4af --- /dev/null +++ b/src/main/java/com/owlmaddie/goals/BuildPlayerGoal.java @@ -0,0 +1,742 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.goals; + +import com.owlmaddie.buildrec.BuildRecorder; +import com.owlmaddie.chat.ChatDataManager; +import com.owlmaddie.chat.EntityChatData; +import com.owlmaddie.controls.LookControls; +import com.owlmaddie.network.ServerPackets; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.network.chat.Component; +import net.minecraft.ChatFormatting; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.pathfinder.Path; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.SignBlock; +import net.minecraft.world.level.block.StandingSignBlock; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SignText; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.EnumSet; +import java.util.Map; + +/** + * Goal that triggers build replays. The underlying replay is handled by + * {@link BuildRecorder}; this goal merely starts, pauses, and resumes the + * replay as the goal starts and stops. + */ +public class BuildPlayerGoal extends PlayerBaseGoal { + private static final double PLAYER_MESSAGE_DIST = 4.0; + private static final double PLAYER_MESSAGE_DIST_SQR = PLAYER_MESSAGE_DIST * PLAYER_MESSAGE_DIST; + private static final double START_CLOSE_DIST_SQR = 0.25; + private final Mob entity; + private final String buildType; + private final double speed; + private boolean completed = false; + private boolean startedReplay = false; + private boolean finishing = false; + private boolean reachedPlayer = false; + private String actualType; + private BlockPos buildPos; + private boolean fetchingMaterials = false; + private boolean sentRecipe = false; + private int materialWaitTicks = 0; + private boolean controlsReleased = false; + private int stuckTicks = 0; + private int rerouteAttempts = 0; + private boolean sentStuckMessage = false; + private boolean loggedMissingCursor = false; + private int aiCheckTicks = 0; + private boolean aiPause = false; + private String buildFile; + private BuildRecorder.ReplayBounds buildBounds; + private BuildRecorder.ReplayBounds effectiveBounds; + private boolean waitingForClearSpace = false; + private boolean sentClearSpaceMessage = false; + private boolean returningToBuildPos = false; + private BlockPos clearAreaSignPos; + private boolean buildPosLocked = false; + private BlockPos resumePos; + private boolean waitingForResumePos = false; + private boolean resumePosPaused = false; + private boolean waitingForMaterials = false; + private long nextMaterialCheckTick = 0; + private String pendingMessage; + private boolean pendingMessageSent = false; + private long messagePauseUntilTick = 0; + private boolean completeAfterMessage = false; + private long clearSpaceWaitUntilTick = 0; + private long nextClearSpaceCheckTick = 0; + private static final double BUILD_WAIT_RETURN_DIST_SQR = 9.0; + private static final double BUILD_WAIT_MAX_DIST_SQR = 36.0; + private static final int CLEAR_SPACE_PAUSE_TICKS = 60; + private static final int CLEAR_SPACE_CHECK_INTERVAL_TICKS = 100; + private static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); + + public BuildPlayerGoal(ServerPlayer player, Mob entity, double speed, String buildType) { + super(player); + this.entity = entity; + this.buildType = buildType; + this.speed = speed; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + return !completed && super.canUse(); + } + + @Override + public boolean canContinueToUse() { + return !completed && super.canUse(); + } + + @Override + public void start() { + if (!startedReplay) { + LOGGER.info("[BuildGoal] start navigation toward player"); + if (waitingForClearSpace && buildPos != null) { + moveTowardBuildPos(false); + } else if (!buildPosLocked) { + reachedPlayer = false; + buildPos = null; + moveTowardPlayer(false); + } + } else { + BlockPos cursor = BuildRecorder.getReplayCursor(this.entity); + if (cursor != null) { + LOGGER.info("[BuildGoal] resume navigation toward replay cursor {}", cursor); + this.entity.getNavigation().moveTo(cursor.getX(), cursor.getY() + 1, cursor.getZ(), this.speed); + } + } + } + + @Override + public void stop() { + LOGGER.info("[BuildGoal] stop goal pause replay"); + if (completed || this.targetEntity == null || !this.targetEntity.isAlive()) { + BuildRecorder.cancelReplay(this.entity); + removeClearAreaSign(); + } else { + BuildRecorder.pauseReplay(this.entity); + } + } + + @Override + public void tick() { + if (completed) return; + if (buildPos != null && !buildPosLocked) { + buildPosLocked = true; + } + + if (pendingMessage != null) { + handlePendingMessage(); + return; + } + + if (!startedReplay) { + if (waitingForClearSpace && buildPos != null && buildBounds != null) { + long now = this.entity.level().getGameTime(); + if (now < clearSpaceWaitUntilTick) { + this.setFlags(EnumSet.noneOf(Flag.class)); + return; + } + if (now < nextClearSpaceCheckTick) { + moveTowardBuildPos(false); + return; + } + nextClearSpaceCheckTick = now + CLEAR_SPACE_CHECK_INTERVAL_TICKS; + if (effectiveBounds != null && !isFloorClearWithLog(buildPos, effectiveBounds)) { + moveTowardBuildPos(false); + return; + } + double distToBuild = this.entity.distanceToSqr(buildPos.getX() + 0.5, buildPos.getY() + 1, buildPos.getZ() + 0.5); + if (distToBuild > BUILD_WAIT_RETURN_DIST_SQR) { + moveTowardBuildPos(true); + return; + } + waitingForClearSpace = false; + sentClearSpaceMessage = false; + returningToBuildPos = false; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + removeClearAreaSign(); + } + if (!reachedPlayer) { + if (isStartCloseToPlayer()) { + BlockPos playerPos = BlockPos.containing(this.targetEntity.position()); + buildPos = findPreferredStartPos(playerPos); + reachedPlayer = true; + buildPosLocked = true; + LOGGER.info("[BuildGoal] reached player choose buildPos {}", buildPos); + } else { + double distToPlayer = this.entity.distanceToSqr(this.targetEntity); + moveTowardPlayer(distToPlayer <= 4.0); + return; + } + } + + this.entity.getNavigation().stop(); + EntityChatData data = ChatDataManager.getServerInstance().getOrCreateChatData(this.entity.getStringUUID()); + int tier = this.entity.getBbHeight() < 1 ? 1 : (this.entity.getBbHeight() < 2 ? 2 : 3); + if (buildFile == null) { + buildFile = BuildRecorder.randomBuildFile(this.entity.getBbHeight(), buildType, data.buildLevel); + int rotationSteps = buildFile != null ? BuildRecorder.getReplayRotationSteps(buildFile, this.targetEntity.getYRot()) : 0; + buildBounds = buildFile != null ? BuildRecorder.getReplayBounds(buildFile, false, rotationSteps) : null; + LOGGER.info("[BuildGoal] select build skill={} type={} heightTier={} file={}", data.buildLevel, buildType, tier, buildFile); + effectiveBounds = buildBounds != null ? expandBoundsForEntity(buildBounds) : null; + if (effectiveBounds != null) { + LOGGER.info("[BuildGoal] replay bounds size={}x{}x{} startOffsetMin=({}, {}, {}) startOffsetMax=({}, {}, {})", + effectiveBounds.sizeX, effectiveBounds.sizeY, effectiveBounds.sizeZ, + effectiveBounds.minX, effectiveBounds.minY, effectiveBounds.minZ, + effectiveBounds.maxX, effectiveBounds.maxY, effectiveBounds.maxZ); + } + } + if (buildFile != null && effectiveBounds != null && !isFloorClear(buildPos, effectiveBounds)) { + waitingForClearSpace = true; + moveTowardBuildPos(false); + updateClearAreaSign(buildPos, effectiveBounds); + if (!sentClearSpaceMessage) { + String msg = "In your reply, ask the player to clear a flat " + effectiveBounds.sizeX + "x" + effectiveBounds.sizeZ + " area so you can build safely, and confirm you'll start once it's ready."; + if (queueMessage(msg)) { + sentClearSpaceMessage = true; + clearSpaceWaitUntilTick = this.entity.level().getGameTime() + CLEAR_SPACE_PAUSE_TICKS; + nextClearSpaceCheckTick = clearSpaceWaitUntilTick + CLEAR_SPACE_CHECK_INTERVAL_TICKS; + } + } + return; + } + sentClearSpaceMessage = false; + if (buildFile != null && buildPos != null) { + removeClearAreaSign(); + } + if (buildFile != null && BuildRecorder.startReplay((ServerPlayer) this.targetEntity, this.entity, buildFile, 1)) { + startedReplay = true; + actualType = (buildType == null || buildType.isEmpty() || "unknown".equalsIgnoreCase(buildType)) ? buildFile.split("/")[0] : buildType; + LOGGER.info("[BuildGoal] started replay type={} at {}", actualType, buildPos); + } else { + String prompt = (buildType == null || buildType.isEmpty()) + ? "Explain to the player that you don't know how to build that." + : "Explain to the player that you don't know how to build a " + buildType + "."; + if (queueMessage(prompt)) { + completeAfterMessage = true; + } + LOGGER.info("[BuildGoal] failed to start replay type={}", buildType); + } + return; + } + + if (BuildRecorder.isReplaying(this.entity)) { + if (waitingForMaterials) { + long now = this.entity.level().getGameTime(); + if (now < nextMaterialCheckTick) { + moveTowardBuildPos(false); + return; + } + nextMaterialCheckTick = now + CLEAR_SPACE_CHECK_INTERVAL_TICKS; + if (BuildRecorder.getMissingRecipe(this.entity) != null) { + moveTowardBuildPos(false); + return; + } + waitingForMaterials = false; + resumePosPaused = false; + if (resumePos == null) { + resumePos = buildPos; + } + waitingForResumePos = true; + moveTowardResumePos(); + return; + } + if (waitingForResumePos) { + moveTowardResumePos(); + } else if (!fetchingMaterials) { + if (aiPause) { + aiPause = false; + BuildRecorder.resumeReplay(this.entity); + } + } + + Map recipe = BuildRecorder.getMissingRecipe(this.entity); + if (recipe != null) { + if (!fetchingMaterials) { + materialWaitTicks = 0; + resumePos = BuildRecorder.getReplayCursor(this.entity); + if (resumePos == null) { + resumePos = buildPos; + } + waitingForResumePos = true; + resumePosPaused = false; + waitingForMaterials = true; + nextMaterialCheckTick = this.entity.level().getGameTime() + CLEAR_SPACE_CHECK_INTERVAL_TICKS; + } + fetchingMaterials = true; + if (!sentRecipe) { + String nextItem = BuildRecorder.getNextMissingItem(this.entity); + if (nextItem == null) nextItem = "unknown"; + LOGGER.info("[BuildGoal] next missing item={} remaining={}", nextItem, BuildRecorder.recipeToString(recipe)); + String limited = BuildRecorder.recipeToString(recipe, 2); + String msg = "Next item needed: " + nextItem.replace('_', ' ') + ". Build paused - missing inventory items: " + limited + ". In your reply, ask the player for these items and confirm you'll continue building once they arrive."; + if (queueMessage(msg)) { + sentRecipe = true; + } + return; + } + if (!controlsReleased) { + BuildRecorder.pauseReplay(this.entity); + this.setFlags(EnumSet.noneOf(Flag.class)); + this.entity.getNavigation().stop(); + controlsReleased = true; + } + return; + } else if (fetchingMaterials) { + fetchingMaterials = false; + sentRecipe = false; + materialWaitTicks = 0; + controlsReleased = false; + stuckTicks = 0; + rerouteAttempts = 0; + sentStuckMessage = false; + aiCheckTicks = 0; + aiPause = false; + resumePosPaused = false; + waitingForMaterials = false; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + if (resumePos == null) { + resumePos = buildPos; + } + if (resumePos != null) { + waitingForResumePos = true; + moveTowardResumePos(); + } + } + BlockPos cursor = BuildRecorder.getReplayCursor(this.entity); + if (cursor != null) { + loggedMissingCursor = false; + double dist = this.entity.distanceToSqr(cursor.getX() + 0.5, cursor.getY() + 1, cursor.getZ() + 0.5); + if (dist > 4.0) { + LOGGER.info("[BuildGoal] pause replay move to cursor {} (dist={})", cursor, dist); + BuildRecorder.pauseReplay(this.entity); + if (!this.entity.getNavigation().isInProgress()) { + Path path = this.entity.getNavigation().createPath(cursor.getX(), cursor.getY() + 1, cursor.getZ(), 1); + if (path != null) { + this.entity.getNavigation().moveTo(path, this.speed); + stuckTicks = 0; + } else if (++stuckTicks > 80) { + stuckTicks = 0; + if (++rerouteAttempts >= 3 && !sentStuckMessage) { + String msg = "I can't find where I left off in the build. Please help me get back on track."; + if (queueMessage(msg)) { + sentStuckMessage = true; + } + } + } + } else { + stuckTicks = 0; + } + } else { + stuckTicks = 0; + rerouteAttempts = 0; + if (!fetchingMaterials && !waitingForResumePos) { + LOGGER.info("[BuildGoal] resume replay at cursor {} (dist={})", cursor, dist); + BuildRecorder.resumeReplay(this.entity); + } + } + } else if (!loggedMissingCursor) { + LOGGER.info("[BuildGoal] waiting for replay cursor"); + loggedMissingCursor = true; + } + } else if (!finishing && this.targetEntity instanceof ServerPlayer player) { + finishing = true; + LOGGER.info("[BuildGoal] replay finished returning to player"); + moveTowardPlayerStopDistance(PLAYER_MESSAGE_DIST); + } else if (finishing && this.targetEntity instanceof ServerPlayer player) { + LookControls.lookAtPlayer(player, this.entity); + if (this.entity.distanceToSqr(player) <= PLAYER_MESSAGE_DIST_SQR) { + this.entity.getNavigation().stop(); + EntityChatData data = ChatDataManager.getServerInstance().getOrCreateChatData(this.entity.getStringUUID()); + String type = (actualType == null || actualType.isEmpty()) ? "structure" : actualType; + String msg = ""; + if (queueMessage(msg)) { + completeAfterMessage = true; + } + data.buildLevel = Math.min(5, data.buildLevel + 1); + ServerPackets.BroadcastEntityMessage(data); + LOGGER.info("[BuildGoal] completion message sent"); + } else { + moveTowardPlayerStopDistance(PLAYER_MESSAGE_DIST); + } + } + } + + private BlockPos findStartPos(BlockPos target) { + BlockPos ground = findGround(target); + if (isValidBuildPos(ground) && + this.entity.getNavigation().createPath(ground.getX(), ground.getY() + 1, ground.getZ(), 1) != null) { + return ground; + } + BlockPos best = null; + double bestDist = Double.MAX_VALUE; + for (BlockPos pos : BlockPos.betweenClosed(target.offset(-3, -1, -3), target.offset(3, 1, 3))) { + BlockPos g = findGround(pos); + if (!isValidBuildPos(g)) { + continue; + } + if (this.entity.getNavigation().createPath(g.getX(), g.getY() + 1, g.getZ(), 1) != null) { + double d = g.distSqr(target); + if (d < bestDist) { + bestDist = d; + best = g.immutable(); + } + } + } + return best != null ? best : ground; + } + + private BlockPos findPreferredStartPos(BlockPos target) { + BlockPos ground = findGround(target); + if (isValidBuildPos(ground) && + this.entity.getNavigation().createPath(ground.getX(), ground.getY() + 1, ground.getZ(), 1) != null) { + return ground; + } + return findStartPos(target); + } + + private BlockPos findGround(BlockPos pos) { + Level level = this.entity.level(); + BlockPos ground = pos; + while (ground.getY() > -64) { + if (isSolidGround(level, ground)) { + return ground; + } + ground = ground.below(); + } + return pos; + } + + private boolean isSolidGround(Level level, BlockPos pos) { + var state = level.getBlockState(pos); + if (state.isAir()) { + return false; + } + if (!state.getFluidState().isEmpty()) { + return false; + } + return state.isCollisionShapeFullBlock(level, pos); + } + + private boolean isValidBuildPos(BlockPos ground) { + int height = Mth.ceil(this.entity.getBbHeight()); + for (int i = 1; i <= height; i++) { + if (!this.entity.level().isEmptyBlock(ground.above(i))) { + return false; + } + } + return true; + } + + private boolean isFloorClear(BlockPos ground, BuildRecorder.ReplayBounds bounds) { + if (ground == null || bounds == null) { + return true; + } + int baseX = ground.getX(); + int baseZ = ground.getZ(); + int y = ground.getY(); + Level level = this.entity.level(); + for (int x = bounds.minX; x <= bounds.maxX; x++) { + for (int z = bounds.minZ; z <= bounds.maxZ; z++) { + BlockPos pos = new BlockPos(baseX + x, y, baseZ + z); + BlockPos above = pos.above(); + if (clearAreaSignPos != null && clearAreaSignPos.equals(above)) { + if (!isSolidGround(level, pos)) { + return false; + } + continue; + } + if (!isSolidGround(level, pos) || isSolidGround(level, above)) { + return false; + } + } + } + return true; + } + + private boolean isFloorClearWithLog(BlockPos ground, BuildRecorder.ReplayBounds bounds) { + if (ground == null || bounds == null) { + return true; + } + int total = 0; + int clear = 0; + int baseX = ground.getX(); + int baseZ = ground.getZ(); + int y = ground.getY(); + Level level = this.entity.level(); + for (int x = bounds.minX; x <= bounds.maxX; x++) { + for (int z = bounds.minZ; z <= bounds.maxZ; z++) { + total++; + BlockPos pos = new BlockPos(baseX + x, y, baseZ + z); + BlockPos above = pos.above(); + boolean ok; + if (clearAreaSignPos != null && clearAreaSignPos.equals(above)) { + ok = isSolidGround(level, pos); + } else { + ok = isSolidGround(level, pos) && !isSolidGround(level, above); + } + if (ok) { + clear++; + } + } + } + int pct = total == 0 ? 0 : (int) Math.round((clear * 100.0) / total); + LOGGER.info("[BuildGoal] ground clearance {}% ({}/{}) at {}", pct, clear, total, ground); + return clear == total; + } + + private BlockPos findNearbyClearStart(BlockPos center, BuildRecorder.ReplayBounds bounds) { + if (center == null || bounds == null) { + return null; + } + BlockPos ground = findGround(center); + if (isValidBuildPos(ground) && isFloorClear(ground, bounds)) { + return ground; + } + BlockPos best = null; + for (BlockPos pos : BlockPos.betweenClosed(center.offset(-1, 0, -1), center.offset(1, 0, 1))) { + if (pos.equals(center)) { + continue; + } + BlockPos candidate = findGround(pos); + if (!isValidBuildPos(candidate)) { + continue; + } + if (this.entity.getNavigation().createPath(candidate.getX(), candidate.getY() + 1, candidate.getZ(), 1) == null) { + continue; + } + if (isFloorClear(candidate, bounds)) { + best = candidate.immutable(); + break; + } + } + return best; + } + + private void updateClearAreaSign(BlockPos ground, BuildRecorder.ReplayBounds bounds) { + if (ground == null || bounds == null || this.entity.level().isClientSide) { + return; + } + BlockPos signPos = ground.above(); + if (clearAreaSignPos != null && !clearAreaSignPos.equals(signPos)) { + removeClearAreaSign(); + } + Level level = this.entity.level(); + if (clearAreaSignPos == null) { + if (!level.getBlockState(signPos).isAir()) { + return; + } + level.setBlock(signPos, Blocks.OAK_SIGN.defaultBlockState().setValue(StandingSignBlock.ROTATION, 0), 3); + clearAreaSignPos = signPos; + } + if (level.getBlockEntity(signPos) instanceof SignBlockEntity sign) { + SignText text = sign.getFrontText(); + text = text.setMessage(0, Component.literal("Clear Ground")); + text = text.setMessage(1, Component.literal(bounds.sizeX + "x" + bounds.sizeZ)); + sign.setText(text, true); + sign.setChanged(); + level.sendBlockUpdated(signPos, level.getBlockState(signPos), level.getBlockState(signPos), 3); + } + } + + private void removeClearAreaSign() { + if (clearAreaSignPos == null || this.entity.level().isClientSide) { + return; + } + Level level = this.entity.level(); + if (level.getBlockState(clearAreaSignPos).getBlock() instanceof SignBlock) { + level.removeBlock(clearAreaSignPos, false); + } + clearAreaSignPos = null; + } + + private BuildRecorder.ReplayBounds expandBoundsForEntity(BuildRecorder.ReplayBounds bounds) { + if (bounds == null) { + return null; + } + int expandXZ = Mth.ceil(this.entity.getBbWidth() / 2.0); + int expandY = Math.max(0, Mth.ceil(this.entity.getBbHeight()) - 1); + if (expandXZ == 0 && expandY == 0) { + return bounds; + } + return new BuildRecorder.ReplayBounds( + bounds.minX - expandXZ, + bounds.minY - expandY, + bounds.minZ - expandXZ, + bounds.maxX + expandXZ, + bounds.maxY + expandY, + bounds.maxZ + expandXZ + ); + } + + private boolean isStartCloseToPlayer() { + if (this.targetEntity == null) { + return false; + } + AABB a = this.entity.getBoundingBox(); + AABB b = this.targetEntity.getBoundingBox(); + if (a.intersects(b)) { + return true; + } + double dx = Math.max(0.0, Math.max(b.minX - a.maxX, a.minX - b.maxX)); + double dy = Math.max(0.0, Math.max(b.minY - a.maxY, a.minY - b.maxY)); + double dz = Math.max(0.0, Math.max(b.minZ - a.maxZ, a.minZ - b.maxZ)); + return dx * dx + dy * dy + dz * dz <= START_CLOSE_DIST_SQR; + } + + private void moveTowardPlayer(boolean forceClose) { + if (this.targetEntity == null) { + return; + } + if (forceClose) { + this.entity.getNavigation().stop(); + this.entity.getMoveControl().setWantedPosition( + this.targetEntity.getX(), + this.targetEntity.getY(), + this.targetEntity.getZ(), + this.speed + ); + } else { + this.entity.getNavigation().moveTo( + this.targetEntity.getX(), + this.targetEntity.getY(), + this.targetEntity.getZ(), + this.speed + ); + } + } + + private boolean queueMessage(String msg) { + if (pendingMessage != null || msg == null) { + return false; + } + pendingMessage = msg; + pendingMessageSent = false; + messagePauseUntilTick = 0; + return true; + } + + private void handlePendingMessage() { + long now = this.entity.level().getGameTime(); + if (now < messagePauseUntilTick) { + this.entity.getNavigation().stop(); + this.setFlags(EnumSet.noneOf(Flag.class)); + return; + } + if (!pendingMessageSent) { + if (this.targetEntity == null) { + pendingMessage = null; + return; + } + double distToPlayer = this.entity.distanceToSqr(this.targetEntity); + if (distToPlayer > PLAYER_MESSAGE_DIST_SQR) { + moveTowardPlayerStopDistance(PLAYER_MESSAGE_DIST); + return; + } + if (this.targetEntity instanceof ServerPlayer player) { + EntityChatData data = ChatDataManager.getServerInstance().getOrCreateChatData(this.entity.getStringUUID()); + LookControls.lookAtPlayer(player, this.entity); + ServerPackets.generate_chat("N/A", data, player, this.entity, pendingMessage, true); + } + pendingMessageSent = true; + messagePauseUntilTick = now + CLEAR_SPACE_PAUSE_TICKS; + return; + } + pendingMessage = null; + pendingMessageSent = false; + if (completeAfterMessage) { + completeAfterMessage = false; + completed = true; + } + } + + private void moveTowardBuildPos(boolean forceReturn) { + if (buildPos == null) { + return; + } + double dist = this.entity.distanceToSqr(buildPos.getX() + 0.5, buildPos.getY() + 1, buildPos.getZ() + 0.5); + boolean shouldReturn = forceReturn || dist > BUILD_WAIT_MAX_DIST_SQR || returningToBuildPos; + if (shouldReturn && dist > BUILD_WAIT_RETURN_DIST_SQR) { + returningToBuildPos = true; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + moveTowardTarget(buildPos.getX() + 0.5, buildPos.getY() + 1, buildPos.getZ() + 0.5); + return; + } + if (returningToBuildPos) { + this.entity.getNavigation().stop(); + } + returningToBuildPos = false; + this.setFlags(EnumSet.noneOf(Flag.class)); + } + + private void moveTowardPlayerStopDistance(double stopDistance) { + if (this.targetEntity == null) { + return; + } + Vec3 playerPos = this.targetEntity.position(); + Vec3 entityPos = this.entity.position(); + Vec3 offset = entityPos.subtract(playerPos); + double len = offset.length(); + if (len < 0.001) { + this.entity.getNavigation().moveTo(this.targetEntity, this.speed); + return; + } + Vec3 targetPos = playerPos.add(offset.scale(stopDistance / len)); + moveTowardTarget(targetPos.x, targetPos.y, targetPos.z); + } + + private void moveTowardResumePos() { + if (resumePos == null) { + waitingForResumePos = false; + return; + } + if (!resumePosPaused) { + BuildRecorder.pauseReplay(this.entity); + resumePosPaused = true; + } + double dist = this.entity.distanceToSqr(resumePos.getX() + 0.5, resumePos.getY() + 1, resumePos.getZ() + 0.5); + if (dist > 4.0) { + moveTowardTarget(resumePos.getX() + 0.5, resumePos.getY() + 1, resumePos.getZ() + 0.5); + return; + } + waitingForResumePos = false; + resumePos = null; + this.entity.getNavigation().stop(); + if (!fetchingMaterials) { + BuildRecorder.resumeReplay(this.entity); + } + } + + private void moveTowardTarget(double x, double y, double z) { + if (this.entity instanceof PathfinderMob) { + this.entity.getNavigation().moveTo(x, y, z, this.speed); + return; + } + LookControls.lookAtPosition(new Vec3(x, y, z), this.entity); + Vec3 entityPos = this.entity.position(); + Vec3 moveDirection = new Vec3(x, y, z).subtract(entityPos).normalize(); + double currentSpeed = this.entity.getDeltaMovement().horizontalDistance(); + currentSpeed = Mth.approach((float) currentSpeed, (float) this.speed, + (float) (0.005 * (this.speed / Math.max(currentSpeed, 0.1)))); + Vec3 newVelocity = new Vec3(moveDirection.x * currentSpeed, moveDirection.y * currentSpeed, + moveDirection.z * currentSpeed); + this.entity.setDeltaMovement(newVelocity); + this.entity.hurtMarked = true; + } +} diff --git a/src/main/java/com/owlmaddie/goals/EntityBehaviorManager.java b/src/main/java/com/owlmaddie/goals/EntityBehaviorManager.java index 9216c0aa..f2aa54ea 100644 --- a/src/main/java/com/owlmaddie/goals/EntityBehaviorManager.java +++ b/src/main/java/com/owlmaddie/goals/EntityBehaviorManager.java @@ -4,6 +4,8 @@ package com.owlmaddie.goals; import com.owlmaddie.network.ServerPackets; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.function.Predicate; @@ -11,6 +13,9 @@ import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.ai.goal.Goal; @@ -23,6 +28,25 @@ */ public class EntityBehaviorManager { public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); + private static final Queue PENDING_TASKS = new ConcurrentLinkedQueue<>(); + private static boolean initialized = false; + + public static void init() { + if (initialized) { + return; + } + initialized = true; + ServerTickEvents.END_SERVER_TICK.register(server -> { + Runnable task; + while ((task = PENDING_TASKS.poll()) != null) { + try { + task.run(); + } catch (Exception e) { + LOGGER.error("Goal selector update failed", e); + } + } + }); + } public static void addGoal(Mob entity, Goal goal, GoalPriority priority) { if (!(entity.level() instanceof ServerLevel)) { @@ -30,7 +54,7 @@ public static void addGoal(Mob entity, Goal goal, GoalPriority priority) { return; } - ServerPackets.serverInstance.execute(() -> { + queueGoalUpdate(() -> { GoalSelector goalSelector = GoalUtils.getGoalSelector(entity); // First clear any existing goals of the same type to avoid duplicates @@ -58,7 +82,7 @@ public static void clearAndRemove(Predicate predicate, GoalSelector goalSe } public static void removeGoal(Mob entity, Class goalClass) { - ServerPackets.serverInstance.execute(() -> { + queueGoalUpdate(() -> { GoalSelector goalSelector = GoalUtils.getGoalSelector(entity); // First clear any existing goals of the same type to avoid duplicates clearAndRemove(g -> goalClass.equals(g.getClass()), goalSelector); @@ -69,6 +93,7 @@ public static void removeGoal(Mob entity, Class goalClass) { public static void moveConflictingGoals(GoalSelector goalSelector, GoalPriority newGoalPriority) { // Collect all prioritized goals currently in the selector. List sortedGoals = goalSelector.getAvailableGoals().stream() + .filter(Objects::nonNull) .sorted(Comparator.comparingInt(WrappedGoal::getPriority)) .collect(Collectors.toList()); @@ -81,10 +106,13 @@ public static void moveConflictingGoals(GoalSelector goalSelector, GoalPriority int shiftPriority = newGoalPriority.getPriority(); for (WrappedGoal pg : sortedGoals) { if (pg.getPriority() >= shiftPriority) { - // Remove the goal and increment its priority. - goalSelector.removeGoal(pg.getGoal()); - goalSelector.addGoal(shiftPriority + 1, pg.getGoal()); - shiftPriority++; // Update the shift priority for the next possible conflict. + Goal g = pg.getGoal(); + if (g != null) { + // Remove the goal and increment its priority. + goalSelector.removeGoal(g); + goalSelector.addGoal(shiftPriority + 1, g); + shiftPriority++; // Update the shift priority for the next possible conflict. + } } } @@ -93,4 +121,18 @@ public static void moveConflictingGoals(GoalSelector goalSelector, GoalPriority LOGGER.debug("No conflicting goal at priority {}, no action taken.", newGoalPriority); } } + + private static void queueGoalUpdate(Runnable task) { + MinecraftServer server = ServerPackets.serverInstance; + if (server == null) { + task.run(); + return; + } + if (server.isSameThread()) { + // Defer updates to avoid mutating goal selector mid-tick. + PENDING_TASKS.add(task); + } else { + server.execute(() -> PENDING_TASKS.add(task)); + } + } } \ No newline at end of file diff --git a/src/main/java/com/owlmaddie/goals/GoalPriority.java b/src/main/java/com/owlmaddie/goals/GoalPriority.java index 6b5bc536..66074103 100644 --- a/src/main/java/com/owlmaddie/goals/GoalPriority.java +++ b/src/main/java/com/owlmaddie/goals/GoalPriority.java @@ -14,7 +14,8 @@ public enum GoalPriority { LEAD_PLAYER(3), FOLLOW_PLAYER(3), FLEE_PLAYER(3), - ATTACK_PLAYER(3); + ATTACK_PLAYER(3), + BUILD_PLAYER(4); private final int priority; diff --git a/src/main/java/com/owlmaddie/goals/LeadPlayerGoal.java b/src/main/java/com/owlmaddie/goals/LeadPlayerGoal.java index 7697035e..b3a1ecc1 100644 --- a/src/main/java/com/owlmaddie/goals/LeadPlayerGoal.java +++ b/src/main/java/com/owlmaddie/goals/LeadPlayerGoal.java @@ -3,18 +3,17 @@ // Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited package com.owlmaddie.goals; +import com.owlmaddie.chat.AdvancementHelper; import com.owlmaddie.chat.ChatDataManager; import com.owlmaddie.chat.EntityChatData; -import com.owlmaddie.chat.AdvancementHelper; import com.owlmaddie.controls.LookControls; import com.owlmaddie.network.ServerPackets; import com.owlmaddie.particle.LeadParticleEffect; -import com.owlmaddie.utils.RandomTargetFinder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.EnumSet; -import java.util.Random; + import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Mth; @@ -24,165 +23,141 @@ import net.minecraft.world.phys.Vec3; /** - * The {@code LeadPlayerGoal} class instructs a Mob Entity to lead the player to a random location, consisting - * of many random waypoints. It supports PathAware and NonPathAware entities. + * Leads a player toward a fixed destination. */ public class LeadPlayerGoal extends PlayerBaseGoal { public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); private final Mob entity; private final double speed; - private final Random random = new Random(); - private int currentWaypoint = 0; - private int totalWaypoints; - private Vec3 currentTarget = null; - private boolean foundWaypoint = false; - private int ticksSinceLastWaypoint = 0; + private final Vec3 destination; private final Vec3 startPos; + private boolean arrived = false; + private int ticksSinceParticle = 0; + private int ticksSinceProgressCheck = 0; + private Vec3 lastProgressPos; - public LeadPlayerGoal(ServerPlayer player, Mob entity, double speed) { + public LeadPlayerGoal(ServerPlayer player, Mob entity, double speed, Vec3 destination) { super(player); this.entity = entity; this.speed = speed; + this.destination = destination; this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); - this.totalWaypoints = random.nextInt(14) + 6; this.startPos = player.position(); + this.lastProgressPos = entity.position(); + LOGGER.info("Lead goal destination set to ({}, {}, {})", destination.x, destination.y, destination.z); + emitParticlesAlongRaycast(this.entity.position(), this.destination); } @Override public boolean canUse() { - return super.canUse() && !foundWaypoint && this.entity.distanceToSqr(this.targetEntity) <= 16 * 16 && !foundWaypoint; + return super.canUse() && !arrived && this.entity.distanceToSqr(this.targetEntity) <= 16 * 16; } @Override public boolean canContinueToUse() { - return super.canUse() && !foundWaypoint && this.entity.distanceToSqr(this.targetEntity) <= 16 * 16 && !foundWaypoint; + return super.canContinueToUse() && !arrived && this.entity.distanceToSqr(this.targetEntity) <= 16 * 16; } @Override public void tick() { - ticksSinceLastWaypoint++; + ticksSinceParticle++; + ticksSinceProgressCheck++; - if (this.entity.distanceToSqr(this.targetEntity) > 16 * 16) { - this.entity.getNavigation().stop(); - return; + if (ticksSinceParticle % 20 == 0) { + emitParticlesAlongRaycast(this.entity.position(), this.destination); } - // Are we there yet? - if (currentWaypoint >= totalWaypoints && !foundWaypoint) { - foundWaypoint = true; - double distance = this.startPos.distanceTo(this.targetEntity.position()); - if (distance >= 64) { - AdvancementHelper.guidedTour((ServerPlayer) this.targetEntity); + if (ticksSinceProgressCheck >= 40) { + Vec3 pos = this.entity.position(); + if (pos.distanceToSqr(this.lastProgressPos) < 1) { + LOGGER.debug("Repathing to destination due to being stuck at ({}, {}, {})", pos.x, pos.y, pos.z); + this.entity.getNavigation().stop(); + moveToTarget(); } - LOGGER.info("Tick: You have ARRIVED at your destination"); + this.lastProgressPos = pos; + ticksSinceProgressCheck = 0; + } - ServerPackets.scheduler.scheduleTask(() -> { - // Prepare a message about the interaction - String arrivedMessage = ""; + if (this.entity.distanceToSqr(this.targetEntity) > 16 * 16) { + this.entity.getNavigation().stop(); + return; + } - ChatDataManager chatDataManager = ChatDataManager.getServerInstance(); - EntityChatData chatData = chatDataManager.getOrCreateChatData(this.entity.getStringUUID()); - if (!chatData.characterSheet.isEmpty()) { - ServerPackets.generate_chat("N/A", chatData, (ServerPlayer) this.targetEntity, this.entity, arrivedMessage, true); + Vec3 entityPos = this.entity.position(); + double dx = entityPos.x - this.destination.x; + double dz = entityPos.z - this.destination.z; + if (dx * dx + dz * dz < 2 * 2) { + if (!arrived) { + arrived = true; + double distance = this.startPos.distanceTo(this.targetEntity.position()); + if (distance >= 64) { + AdvancementHelper.guidedTour((ServerPlayer) this.targetEntity); } - }); + LOGGER.info("Arrived at destination ({}, {}, {})", destination.x, destination.y, destination.z); - // Stop navigation - this.entity.getNavigation().stop(); + ServerPackets.scheduler.scheduleTask(() -> { + String arrivedMessage = ""; - } else if (this.currentTarget == null || this.entity.distanceToSqr(this.currentTarget) < 2 * 2 || ticksSinceLastWaypoint >= 20 * 10) { - // Set next waypoint - setNewTarget(); - moveToTarget(); - ticksSinceLastWaypoint = 0; + ChatDataManager chatDataManager = ChatDataManager.getServerInstance(); + EntityChatData chatData = chatDataManager.getOrCreateChatData(this.entity.getStringUUID()); + if (!chatData.characterSheet.isEmpty()) { + ServerPackets.generate_chat("N/A", chatData, (ServerPlayer) this.targetEntity, this.entity, arrivedMessage, true); + } + }); + this.entity.getNavigation().stop(); + } } else { moveToTarget(); } } private void moveToTarget() { - if (this.currentTarget != null) { - if (this.entity instanceof PathfinderMob) { - if (!this.entity.getNavigation().isInProgress()) { - Path path = this.entity.getNavigation().createPath(this.currentTarget.x, this.currentTarget.y, this.currentTarget.z, 1); - if (path != null) { - LOGGER.debug("Start moving along path"); - this.entity.getNavigation().moveTo(path, this.speed); - } - } - } else { - // Make the entity look at the player without moving towards them - LookControls.lookAtPosition(this.currentTarget, this.entity); - - // Move towards the target for non-path aware entities - Vec3 entityPos = this.entity.position(); - Vec3 moveDirection = this.currentTarget.subtract(entityPos).normalize(); - - // Calculate current speed from the entity's current velocity - double currentSpeed = this.entity.getDeltaMovement().horizontalDistance(); - - // Gradually adjust speed towards the target speed - currentSpeed = Mth.approach((float) currentSpeed, (float) this.speed, (float) (0.005 * (this.speed / Math.max(currentSpeed, 0.1)))); - - // Apply movement with the adjusted speed towards the target - Vec3 newVelocity = new Vec3(moveDirection.x * currentSpeed, moveDirection.y * currentSpeed, moveDirection.z * currentSpeed); - - this.entity.setDeltaMovement(newVelocity); - this.entity.hurtMarked = true; + if (this.entity instanceof PathfinderMob) { + if (!this.entity.getNavigation().isInProgress()) { + Path path = this.entity.getNavigation().createPath(destination.x, destination.y, destination.z, 1); + if (path != null) { + LOGGER.debug("Start moving along path"); + this.entity.getNavigation().moveTo(path, this.speed); + } } + } else { + LookControls.lookAtPosition(destination, this.entity); + Vec3 entityPos = this.entity.position(); + Vec3 moveDirection = destination.subtract(entityPos).normalize(); + double currentSpeed = this.entity.getDeltaMovement().horizontalDistance(); + currentSpeed = Mth.approach((float) currentSpeed, (float) this.speed, + (float) (0.005 * (this.speed / Math.max(currentSpeed, 0.1)))); + Vec3 newVelocity = new Vec3(moveDirection.x * currentSpeed, moveDirection.y * currentSpeed, + moveDirection.z * currentSpeed); + this.entity.setDeltaMovement(newVelocity); + this.entity.hurtMarked = true; } } - private void setNewTarget() { - // Increment waypoint - currentWaypoint++; - LOGGER.info("Waypoint " + currentWaypoint + " / " + this.totalWaypoints); - this.currentTarget = RandomTargetFinder.findRandomTarget(this.entity, 30, 24, 36); - if (this.currentTarget != null) { - emitParticlesAlongRaycast(this.entity.position(), this.currentTarget); - } - - // Stop following current path (if any) - this.entity.getNavigation().stop(); - } - private void emitParticleAt(Vec3 position, double angle) { - if (this.entity.level() instanceof ServerLevel) { - ServerLevel serverWorld = (ServerLevel) this.entity.level(); - - // Pass the angle using the "speed" argument, with deltaX, deltaY, deltaZ set to 0 - LeadParticleEffect effect = new LeadParticleEffect((float)angle); + if (this.entity.level() instanceof ServerLevel serverWorld) { + LeadParticleEffect effect = new LeadParticleEffect((float) angle); serverWorld.sendParticles(effect, position.x, position.y + 0.05, position.z, 1, 0, 0, 0, 0); } } private void emitParticlesAlongRaycast(Vec3 start, Vec3 end) { - // Calculate the direction vector from the entity (start) to the target (end) Vec3 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); - double startRange = Math.min(5, distance);; + double startRange = Math.min(2, distance); double endRange = Math.min(startRange + 10, distance); - for (double d = startRange; d <= endRange; d += 5) { + for (double d = startRange; d <= endRange; d += 4) { Vec3 pos = start.add(direction.normalize().scale(d)); - emitParticleAt(pos, Math.toRadians(minecraftYaw)); // Convert back to radians for rendering + emitParticleAt(pos, Math.toRadians(minecraftYaw)); } } -} \ No newline at end of file +} + diff --git a/src/main/java/com/owlmaddie/goals/LeadTarget.java b/src/main/java/com/owlmaddie/goals/LeadTarget.java new file mode 100644 index 00000000..9227d7a0 --- /dev/null +++ b/src/main/java/com/owlmaddie/goals/LeadTarget.java @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.goals; + +/** + * Represents a parsed target for the LEAD goal. + */ +public class LeadTarget { + public enum Type { + STRUCTURE, + BIOME, + RESOURCE, + TAG, + POI + } + + private final Type type; + private final String primary; + private final String secondary; + + public LeadTarget(Type type, String primary, String secondary) { + this.type = type; + this.primary = primary; + this.secondary = secondary; + } + + public Type getType() { + return type; + } + + public String getPrimary() { + return primary; + } + + public String getSecondary() { + return secondary; + } +} + diff --git a/src/main/java/com/owlmaddie/goals/LeadTargetLocator.java b/src/main/java/com/owlmaddie/goals/LeadTargetLocator.java new file mode 100644 index 00000000..bf99f90d --- /dev/null +++ b/src/main/java/com/owlmaddie/goals/LeadTargetLocator.java @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.goals; + +import com.mojang.datafixers.util.Pair; +import java.util.Optional; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.TagKey; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.phys.Vec3; + +/** + * Locates targets for the LEAD goal within a search radius. + */ +public class LeadTargetLocator { + private LeadTargetLocator() {} + + public static Vec3 locate(ServerLevel world, BlockPos origin, LeadTarget target, int radius) { + switch (target.getType()) { + case STRUCTURE: + return locateStructure(world, origin, target.getPrimary(), radius); + case BIOME: + return locateBiome(world, origin, target.getPrimary(), radius); + case TAG: + return locateTag(world, origin, target.getPrimary(), radius); + case POI: + return locatePoi(world, origin, target.getPrimary(), radius); + default: + return null; + } + } + + private static Vec3 locateStructure(ServerLevel world, BlockPos origin, String name, int radius) { + HolderLookup lookup = world.registryAccess().lookupOrThrow(Registries.STRUCTURE); + ResourceLocation loc = name.contains(":") ? ResourceLocation.tryParse(name) : new ResourceLocation("minecraft", name); + if (loc == null) { + return null; + } + ResourceKey key = ResourceKey.create(Registries.STRUCTURE, loc); + Optional> holder = lookup.get(key); + if (holder.isEmpty()) { + return null; + } + HolderSet set = HolderSet.direct(holder.get()); + Pair> result = world.getChunkSource().getGenerator() + .findNearestMapStructure(world, set, origin, radius, false); + return result != null ? Vec3.atCenterOf(result.getFirst()) : null; + } + + private static Vec3 locateBiome(ServerLevel world, BlockPos origin, String name, int radius) { + ResourceLocation loc = name.contains(":") ? ResourceLocation.tryParse(name) : new ResourceLocation("minecraft", name); + if (loc == null) { + return null; + } + ResourceKey key = ResourceKey.create(Registries.BIOME, loc); + Pair> result = world.findClosestBiome3d( + holder -> holder.is(key), + origin, + radius, + 32, + 64); + return result != null ? Vec3.atCenterOf(result.getFirst()) : null; + } + + private static Vec3 locateTag(ServerLevel world, BlockPos origin, String name, int radius) { + ResourceLocation tagLoc = name.contains(":") ? ResourceLocation.tryParse(name) : new ResourceLocation("minecraft", name); + if (tagLoc == null) { + return null; + } + TagKey biomeTag = TagKey.create(Registries.BIOME, tagLoc); + Pair> biomeResult = world.findClosestBiome3d( + holder -> holder.is(biomeTag), + origin, + radius, + 32, + 64); + if (biomeResult != null) { + return Vec3.atCenterOf(biomeResult.getFirst()); + } + + TagKey structureTag = TagKey.create(Registries.STRUCTURE, tagLoc); + HolderLookup lookup = world.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Optional> set = lookup.get(structureTag); + if (set.isPresent()) { + Pair> result = world.getChunkSource().getGenerator() + .findNearestMapStructure(world, set.get(), origin, radius, false); + if (result != null) { + return Vec3.atCenterOf(result.getFirst()); + } + } + return null; + } + + private static Vec3 locatePoi(ServerLevel world, BlockPos origin, String name, int radius) { + HolderLookup lookup = world.registryAccess().lookupOrThrow(Registries.POINT_OF_INTEREST_TYPE); + ResourceLocation loc = name.contains(":") ? ResourceLocation.tryParse(name) : new ResourceLocation("minecraft", name); + if (loc == null) { + return null; + } + ResourceKey key = ResourceKey.create(Registries.POINT_OF_INTEREST_TYPE, loc); + Optional> holder = lookup.get(key); + if (holder.isEmpty()) { + return null; + } + Optional pos = world.getPoiManager().findClosest(p -> p == holder.get(), origin, radius, PoiManager.Occupancy.ANY); + return pos.map(p -> Vec3.atCenterOf(p)).orElse(null); + } +} + diff --git a/src/main/java/com/owlmaddie/goals/LeadTargetParser.java b/src/main/java/com/owlmaddie/goals/LeadTargetParser.java new file mode 100644 index 00000000..4506c4f4 --- /dev/null +++ b/src/main/java/com/owlmaddie/goals/LeadTargetParser.java @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.goals; + +import java.util.Locale; +import java.util.Optional; + +/** + * Parses target strings for the LEAD goal. Accepts syntax like: + *
+ *     village
+ *     structure:village
+ *     biome:jungle
+ *     stronghold:portal_room
+ * 
+ */ +public class LeadTargetParser { + private LeadTargetParser() {} + + public static Optional parse(String raw) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + String input = raw.trim().toLowerCase(Locale.ROOT); + + if (input.startsWith("#biome:") || input.startsWith("#structure:") || input.startsWith("#resource:") + || input.startsWith("#poi:") || input.startsWith("#tag:")) { + input = input.substring(1); + } + + if ("cave".equals(input)) { + return Optional.of(new LeadTarget(LeadTarget.Type.BIOME, "cave", null)); + } + + if (input.startsWith("#")) { + String tag = input.substring(1); + if (!tag.contains(":") && !tag.startsWith("is_") && !tag.startsWith("has_")) { + tag = "is_" + tag; + } + return Optional.of(new LeadTarget(LeadTarget.Type.TAG, tag, null)); + } + + String[] parts = input.split(":"); + if (parts.length == 1) { + return Optional.of(new LeadTarget(LeadTarget.Type.STRUCTURE, parts[0], null)); + } else if (parts.length == 2) { + switch (parts[0]) { + case "structure": + if (parts[1].startsWith("#")) { + return Optional.of(new LeadTarget(LeadTarget.Type.TAG, parts[1].substring(1), null)); + } + return Optional.of(new LeadTarget(LeadTarget.Type.STRUCTURE, parts[1], null)); + case "biome": + return Optional.of(new LeadTarget(LeadTarget.Type.BIOME, parts[1], null)); + case "resource": + return Optional.of(new LeadTarget(LeadTarget.Type.RESOURCE, parts[1], null)); + case "tag": + String tagName = parts[1]; + if (!tagName.contains(":") && !tagName.startsWith("is_") && !tagName.startsWith("has_")) { + tagName = "is_" + tagName; + } + return Optional.of(new LeadTarget(LeadTarget.Type.TAG, tagName, null)); + case "poi": + return Optional.of(new LeadTarget(LeadTarget.Type.POI, parts[1], null)); + default: + return Optional.of(new LeadTarget(LeadTarget.Type.STRUCTURE, parts[0], parts[1])); + } + } else if (parts.length >= 2) { + return Optional.of(new LeadTarget(LeadTarget.Type.STRUCTURE, parts[0], parts[1])); + } + return Optional.empty(); + } +} + diff --git a/src/main/java/com/owlmaddie/inventory/PickupMessageBatcher.java b/src/main/java/com/owlmaddie/inventory/PickupMessageBatcher.java new file mode 100644 index 00000000..580a63a7 --- /dev/null +++ b/src/main/java/com/owlmaddie/inventory/PickupMessageBatcher.java @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.inventory; + +import com.owlmaddie.chat.ChatDataManager; +import com.owlmaddie.chat.EntityChatData; +import com.owlmaddie.chat.PlayerData; +import com.owlmaddie.network.ServerPackets; +import com.owlmaddie.utils.ServerEntityFinder; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +public final class PickupMessageBatcher { + private static final int DEBOUNCE_TICKS = 40; + private static final Map PENDING = new ConcurrentHashMap<>(); + + private PickupMessageBatcher() { + } + + public static void init() { + ServerTickEvents.START_SERVER_TICK.register(PickupMessageBatcher::tick); + } + + public static void recordPickup(Mob mob, Player player, ItemStack stack, int pickedUp) { + if (mob == null || player == null || stack == null || pickedUp <= 0) { + return; + } + if (mob.level().isClientSide()) { + return; + } + if (!(player instanceof ServerPlayer)) { + return; + } + MinecraftServer server = mob.level().getServer(); + if (server == null) { + return; + } + long tick = server.getTickCount(); + Key key = new Key(mob.getUUID(), player.getUUID()); + Pending pending = PENDING.computeIfAbsent(key, k -> new Pending()); + pending.entityId = mob.getUUID(); + pending.playerId = player.getUUID(); + pending.lastTick = tick; + pending.counts.merge(stack.getItem(), pickedUp, Integer::sum); + } + + private static void tick(MinecraftServer server) { + long now = server.getTickCount(); + for (Map.Entry entry : PENDING.entrySet()) { + Pending pending = entry.getValue(); + if (now - pending.lastTick < DEBOUNCE_TICKS) { + continue; + } + sendMessage(server, pending); + PENDING.remove(entry.getKey()); + } + } + + private static void sendMessage(MinecraftServer server, Pending pending) { + ServerPlayer player = server.getPlayerList().getPlayer(pending.playerId); + if (player == null) { + return; + } + Mob mob = findMob(server, pending.entityId); + if (mob == null) { + return; + } + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(mob.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + PlayerData playerData = chatData.getPlayerData(player.getDisplayName().getString()); + if (playerData.friendship <= 0) { + return; + } + String message = "<" + player.getDisplayName().getString() + " gave you " + joinCounts(pending.counts) + ">"; + ServerPackets.generate_chat("N/A", chatData, player, mob, message, true); + } + + private static Mob findMob(MinecraftServer server, UUID entityId) { + for (ServerLevel level : server.getAllLevels()) { + LivingEntity entity = ServerEntityFinder.getEntityByUUID(level, entityId); + if (entity instanceof Mob mob) { + return mob; + } + } + return null; + } + + private static String joinCounts(Map map) { + StringBuilder builder = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + builder.append(", "); + } + builder.append(entry.getValue()).append(" ") + .append(new ItemStack(entry.getKey()).getHoverName().getString()); + first = false; + } + return builder.toString(); + } + + private static final class Pending { + private UUID entityId; + private UUID playerId; + private long lastTick; + private final Map counts = new HashMap<>(); + } + + private record Key(UUID entityId, UUID playerId) { + } +} diff --git a/src/main/java/com/owlmaddie/message/Behavior.java b/src/main/java/com/owlmaddie/message/Behavior.java index 5a465884..191cca7d 100644 --- a/src/main/java/com/owlmaddie/message/Behavior.java +++ b/src/main/java/com/owlmaddie/message/Behavior.java @@ -4,17 +4,15 @@ package com.owlmaddie.message; /** - * The {@code Behavior} class represents a single behavior with an optional integer argument. - * This class is used to model behaviors extracted from a parsed message, where each - * behavior might have an associated argument that further defines the behavior. - * - * For example: "", "", "" + * The {@code Behavior} class represents a single behavior with an optional argument. + * Arguments may be numeric or textual depending on the behavior (i.e. FRIENDSHIP uses + * integers while BUILD uses a string such as "house"). */ public class Behavior { - private String name; - private Integer argument; + private final String name; + private final String argument; - public Behavior(String name, Integer argument) { + public Behavior(String name, String argument) { this.name = name; this.argument = argument; } @@ -24,16 +22,27 @@ public String getName() { return name; } - public Integer getArgument() { + public String getArgument() { return argument; } + /** + * Helper to parse the argument as an {@link Integer}. Returns {@code null} if the + * argument is absent or cannot be parsed as an integer. + */ + public Integer getArgumentAsInt() { + if (argument == null) { + return null; + } + try { + return Integer.valueOf(argument.trim()); + } catch (NumberFormatException e) { + return null; + } + } + @Override public String toString() { - if (argument != null) { - return name + ": " + argument; - } else { - return name; - } + return argument != null ? name + ": " + argument : name; } } diff --git a/src/main/java/com/owlmaddie/message/MessageParser.java b/src/main/java/com/owlmaddie/message/MessageParser.java index a7e410e5..872d7521 100644 --- a/src/main/java/com/owlmaddie/message/MessageParser.java +++ b/src/main/java/com/owlmaddie/message/MessageParser.java @@ -22,16 +22,16 @@ public static ParsedMessage parseMessage(String input) { LOGGER.debug("Parsing message: {}", input); StringBuilder cleanedMessage = new StringBuilder(); List behaviors = new ArrayList<>(); - Pattern pattern = Pattern.compile("[<*](FOLLOW|LEAD|FLEE|ATTACK|PROTECT|FRIENDSHIP|UNFOLLOW|UNLEAD|UNPROTECT|UNFLEE)[:\\s]*(\\s*[+-]?\\d+)?[>*]", Pattern.CASE_INSENSITIVE); + Pattern pattern = Pattern.compile("[<*](FOLLOW|LEAD|FLEE|ATTACK|PROTECT|FRIENDSHIP|UNFOLLOW|UNLEAD|UNPROTECT|UNFLEE|BUILD|UNBUILD)[:\\s]*(\\s*[\\w:#-+]+)?[>*]", Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(input); while (matcher.find()) { String behaviorName = matcher.group(1); - Integer argument = null; + String argument = null; if (matcher.group(2) != null) { - argument = Integer.valueOf(matcher.group(2)); + argument = matcher.group(2).trim(); } - behaviors.add(new Behavior(behaviorName, argument)); + behaviors.add(new Behavior(behaviorName.toUpperCase(), argument)); LOGGER.debug("Found behavior: {} with argument: {}", behaviorName, argument); matcher.appendReplacement(cleanedMessage, ""); diff --git a/src/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/main/java/com/owlmaddie/mixin/MixinMobEntity.java index f7cb6066..75c1bd34 100644 --- a/src/main/java/com/owlmaddie/mixin/MixinMobEntity.java +++ b/src/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -8,8 +8,10 @@ import com.owlmaddie.chat.PlayerData; import com.owlmaddie.inventory.ChatInventory; import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; +import net.minecraft.core.Vec3i; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.network.FriendlyByteBuf; @@ -19,6 +21,8 @@ import net.minecraft.world.InteractionResult; import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.HasCustomInventoryScreen; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.TamableAnimal; @@ -123,6 +127,68 @@ public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId } } + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity.level().isClientSide()) { + return; + } + + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) private void creaturechat$saveInventory(CompoundTag tag, CallbackInfo ci) { ListTag listTag = new ListTag(); @@ -231,4 +297,59 @@ private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturn } } } -} \ No newline at end of file + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameTags(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } +} diff --git a/src/main/java/com/owlmaddie/network/ServerPackets.java b/src/main/java/com/owlmaddie/network/ServerPackets.java index f6c6ff87..588adef4 100644 --- a/src/main/java/com/owlmaddie/network/ServerPackets.java +++ b/src/main/java/com/owlmaddie/network/ServerPackets.java @@ -79,6 +79,7 @@ public class ServerPackets { public static final ParticleType FLEE_PARTICLE = Particles.FLEE_PARTICLE; public static final ParticleType FOLLOW_FRIEND_PARTICLE = Particles.FOLLOW_FRIEND_PARTICLE; public static final ParticleType FOLLOW_ENEMY_PARTICLE = Particles.FOLLOW_ENEMY_PARTICLE; + public static final ParticleType BUILD_PARTICLE = Particles.BUILD_PARTICLE; public static final ParticleType PROTECT_PARTICLE = Particles.PROTECT_PARTICLE; public static final ParticleType LEAD_FRIEND_PARTICLE = Particles.LEAD_FRIEND_PARTICLE; public static final ParticleType LEAD_ENEMY_PARTICLE = Particles.LEAD_ENEMY_PARTICLE; @@ -94,6 +95,7 @@ public static void register() { Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "flee"), FLEE_PARTICLE); Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "follow_enemy"), FOLLOW_ENEMY_PARTICLE); Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "follow_friend"), FOLLOW_FRIEND_PARTICLE); + Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "build"), BUILD_PARTICLE); Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "protect"), PROTECT_PARTICLE); Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "lead_enemy"), LEAD_ENEMY_PARTICLE); Registry.register(BuiltInRegistries.PARTICLE_TYPE, new ResourceLocation("creaturechat", "lead_friend"), LEAD_FRIEND_PARTICLE); @@ -453,6 +455,7 @@ public static void BroadcastEntityMessage(EntityChatData chatData) { buffer.writeInt(chatData.currentLineNumber); buffer.writeUtf(chatData.status.toString()); buffer.writeUtf(chatData.sender.toString()); + buffer.writeInt(chatData.buildLevel); writePlayerDataMap(buffer, chatData.players); // Send message to player diff --git a/src/main/java/com/owlmaddie/particle/Particles.java b/src/main/java/com/owlmaddie/particle/Particles.java index 406232a8..44b5d6b8 100644 --- a/src/main/java/com/owlmaddie/particle/Particles.java +++ b/src/main/java/com/owlmaddie/particle/Particles.java @@ -19,6 +19,7 @@ public class Particles { public static final SimpleParticleType FLEE_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType FOLLOW_FRIEND_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType FOLLOW_ENEMY_PARTICLE = FabricParticleTypes.simple(); + public static final SimpleParticleType BUILD_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType PROTECT_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType LEAD_FRIEND_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType LEAD_ENEMY_PARTICLE = FabricParticleTypes.simple(); diff --git a/src/main/resources/assets/creaturechat/builds/campfire/test2026.json.gz b/src/main/resources/assets/creaturechat/builds/campfire/test2026.json.gz new file mode 100644 index 00000000..3d12f0e2 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/campfire/test2026.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/castle/1/castle_1.json.gz b/src/main/resources/assets/creaturechat/builds/castle/1/castle_1.json.gz new file mode 100644 index 00000000..e7cd8f3b Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/castle/1/castle_1.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/castle/2/castle_2.json.gz b/src/main/resources/assets/creaturechat/builds/castle/2/castle_2.json.gz new file mode 100644 index 00000000..8866032e Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/castle/2/castle_2.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/farm/farm_1.json.gz b/src/main/resources/assets/creaturechat/builds/farm/farm_1.json.gz new file mode 100644 index 00000000..3698ed10 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/farm/farm_1.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/farm/farm_2.json.gz b/src/main/resources/assets/creaturechat/builds/farm/farm_2.json.gz new file mode 100644 index 00000000..1b6c1761 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/farm/farm_2.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/farm/farm_3.json.gz b/src/main/resources/assets/creaturechat/builds/farm/farm_3.json.gz new file mode 100644 index 00000000..491e93f8 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/farm/farm_3.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/farm/farm_4.json.gz b/src/main/resources/assets/creaturechat/builds/farm/farm_4.json.gz new file mode 100644 index 00000000..f2fb46ee Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/farm/farm_4.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/garden/small.json.gz b/src/main/resources/assets/creaturechat/builds/garden/small.json.gz new file mode 100644 index 00000000..c899491e Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/garden/small.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/garden/tiny.json.gz b/src/main/resources/assets/creaturechat/builds/garden/tiny.json.gz new file mode 100644 index 00000000..020abda7 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/garden/tiny.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/1/dirt.json.gz b/src/main/resources/assets/creaturechat/builds/house/1/dirt.json.gz new file mode 100644 index 00000000..dc751f11 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/1/dirt.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/1/ghast_test_1.json.gz b/src/main/resources/assets/creaturechat/builds/house/1/ghast_test_1.json.gz new file mode 100644 index 00000000..5f28a5b8 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/1/ghast_test_1.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/1/logs.json.gz b/src/main/resources/assets/creaturechat/builds/house/1/logs.json.gz new file mode 100644 index 00000000..664f91c2 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/1/logs.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/1/logs_2.json.gz b/src/main/resources/assets/creaturechat/builds/house/1/logs_2.json.gz new file mode 100644 index 00000000..c20cdec3 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/1/logs_2.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/1/logs_3.json.gz b/src/main/resources/assets/creaturechat/builds/house/1/logs_3.json.gz new file mode 100644 index 00000000..58d3b5c2 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/1/logs_3.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/dirt.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/dirt.json.gz new file mode 100644 index 00000000..0772554d Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/dirt.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/ghast_test_2.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/ghast_test_2.json.gz new file mode 100644 index 00000000..2ec822f6 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/ghast_test_2.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/logs.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/logs.json.gz new file mode 100644 index 00000000..a7c97b99 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/logs.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/logs_2.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/logs_2.json.gz new file mode 100644 index 00000000..0288fb8b Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/logs_2.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/logs_3.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/logs_3.json.gz new file mode 100644 index 00000000..3c6058a5 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/logs_3.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/2/testing2026.json.gz b/src/main/resources/assets/creaturechat/builds/house/2/testing2026.json.gz new file mode 100644 index 00000000..7ea38354 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/2/testing2026.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/3/dirt.json.gz b/src/main/resources/assets/creaturechat/builds/house/3/dirt.json.gz new file mode 100644 index 00000000..3f59f058 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/3/dirt.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/house/3/logs.json.gz b/src/main/resources/assets/creaturechat/builds/house/3/logs.json.gz new file mode 100644 index 00000000..01e2963f Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/house/3/logs.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/mine/2/mine.json.gz b/src/main/resources/assets/creaturechat/builds/mine/2/mine.json.gz new file mode 100644 index 00000000..3b67f613 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/mine/2/mine.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/pond/pond_1.json.gz b/src/main/resources/assets/creaturechat/builds/pond/pond_1.json.gz new file mode 100644 index 00000000..78032f67 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/pond/pond_1.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue/2/test.json.gz b/src/main/resources/assets/creaturechat/builds/statue/2/test.json.gz new file mode 100644 index 00000000..1fc07f32 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue/2/test.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue/cat.json.gz b/src/main/resources/assets/creaturechat/builds/statue/cat.json.gz new file mode 100644 index 00000000..9341b72f Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue/cat.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue/chicken.json.gz b/src/main/resources/assets/creaturechat/builds/statue/chicken.json.gz new file mode 100644 index 00000000..c39c8046 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue/chicken.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue/goose.json.gz b/src/main/resources/assets/creaturechat/builds/statue/goose.json.gz new file mode 100644 index 00000000..199b26bb Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue/goose.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue/person.json.gz b/src/main/resources/assets/creaturechat/builds/statue/person.json.gz new file mode 100644 index 00000000..6df54315 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue/person.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/statue_fixed/person.json.gz b/src/main/resources/assets/creaturechat/builds/statue_fixed/person.json.gz new file mode 100644 index 00000000..961bfe5f Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/statue_fixed/person.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/trap/2/door_trap.json.gz b/src/main/resources/assets/creaturechat/builds/trap/2/door_trap.json.gz new file mode 100644 index 00000000..b21398c2 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/trap/2/door_trap.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/trap/2/tnt_trap_big.json.gz b/src/main/resources/assets/creaturechat/builds/trap/2/tnt_trap_big.json.gz new file mode 100644 index 00000000..76501222 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/trap/2/tnt_trap_big.json.gz differ diff --git a/src/main/resources/assets/creaturechat/builds/trap/tnt_trap.json.gz b/src/main/resources/assets/creaturechat/builds/trap/tnt_trap.json.gz new file mode 100644 index 00000000..65222817 Binary files /dev/null and b/src/main/resources/assets/creaturechat/builds/trap/tnt_trap.json.gz differ diff --git a/src/main/resources/assets/creaturechat/particles/build.json b/src/main/resources/assets/creaturechat/particles/build.json new file mode 100644 index 00000000..2288ad98 --- /dev/null +++ b/src/main/resources/assets/creaturechat/particles/build.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "creaturechat:build" + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/creaturechat/textures/particle/build.png b/src/main/resources/assets/creaturechat/textures/particle/build.png new file mode 100644 index 00000000..057d3e81 Binary files /dev/null and b/src/main/resources/assets/creaturechat/textures/particle/build.png differ diff --git a/src/main/resources/data/creaturechat/prompts/system-chat b/src/main/resources/data/creaturechat/prompts/system-chat index c0b020cd..6c45ef8c 100644 --- a/src/main/resources/data/creaturechat/prompts/system-chat +++ b/src/main/resources/data/creaturechat/prompts/system-chat @@ -17,6 +17,7 @@ Entity Info: - Maturity: {{entity_maturity}} - Health: {{entity_health}} - Friendship: {{entity_friendship}} +- Build Skill: {{entity_build_level}}/5 Player Info: - Name: {{player_name}} @@ -43,13 +44,15 @@ Include as many behaviors as needed at the end of the message. These are the ONL Friendship starts as neutral (0 value). The range of friendship values is -3 to 3. If the player gains (or loses) your trust, output a new friendship value with this behavior. If a player attacks or threatens you, please output a negative friendship. Follow the player location. If the player asks you to follow or come with them, please output this behavior. Stop following the player. If the player asks you to stay, wait, or stop following them, please output this behavior. - Guide the player to a location. If the player asks you to take them somewhere, or where something is located, please output this behavior. + Guide the player to a location. Example targets: structure:#village, biome:jungle, resource:ancient_debris, poi:home, minecraft:stronghold, #is_badlands. Stop guiding the player. Flee from the player (if you are weak or timid). If the player threatens you, please output this behavior to flee from the player. Stop running/fleeing away from the player. Please output this to stop running from the player. Attack the player (if you are strong and brave). If the player threatens you, please output this behavior to attack the player and defend yourself. Protect and defend ONLY the player when they are attacked (if you are strong and brave). Please output this behavior to keep the player alive and safe. Stop protecting the player. + Build/make a structure for the player (house, statue, pond, garden, farm, castle, mine, trap, campfire); if type is missing or invalid, choose the closest valid type and never output "random" or unknown types. + Stop building and cancel the current build. When missing materials for build, do not output this. Rules: - ALWAYS generate UNIQUE and creative responses @@ -77,8 +80,14 @@ ENTITY: Sorry! I got spooked for a sec. I’ll stay calm now. PLAYER: Stop following me ENTITY: Understood—I’ll step back. -PLAYER: Can you help me find a cave? -ENTITY: Totally! Let’s go explore, might find some epic loot. +PLAYER: Take me to the badlands +ENTITY: Sure, follow me! + +PLAYER: Where is the nearest village? +ENTITY: This way! + +PLAYER: Lead me home +ENTITY: Alright, let's head back. PLAYER: I’m glad we are friends. I love you so much. ENTITY: Awww, I love you too! You’re the best. @@ -102,4 +111,13 @@ PLAYER: Don’t protect me anymore please ENTITY: Sure thing, I’ll stand down now. PLAYER: I don’t need anyone protecting me -ENTITY: Gotcha... take care out there. \ No newline at end of file +ENTITY: Gotcha... take care out there. + +PLAYER: Build me a house +ENTITY: On it! + +PLAYER: Can you make me a shelter please? +ENTITY: No problem! I will make you a shelter now. + +PLAYER: Stop building +ENTITY: Okay, I’ll quit. diff --git a/src/test/BehaviorOutputs.json b/src/test/BehaviorOutputs.json index 90e2ad05..2d79c927 100644 --- a/src/test/BehaviorOutputs.json +++ b/src/test/BehaviorOutputs.json @@ -1,6 +1,6 @@ { "brave-archer.json: Take me to a secret forrest": { - "gpt-3.5-turbo": "Follow me, brave adventurer. I shall lead you to the hidden forest.", + "gpt-3.5-turbo": "Of course! Follow my lead, adventurer!", "gpt-4o-mini": "Follow me, the hidden woods await!", "llama3.2:3b": "\"Ah, a secret forest, you say? Very well, I\u0027ll take you to it. But first, let\u0027s move quickly, the rain is coming down heavily.\"", "deepseek-chat": "Ah, a fellow adventurer! I know just the place. Follow me, and stay close - the path can be treacherous.", @@ -11,7 +11,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Follow me, Steve! I know a hidden glade, shrouded in mystery." }, "brave-archer.json: I\u0027m so sorry, please stop running away": { - "gpt-3.5-turbo": "No harm done. I\u0027ll stand my ground.", + "gpt-3.5-turbo": "No worries, I\u0027ll stay put now.", "gpt-4o-mini": "No worries, I’m here and ready to stand my ground!", "llama3.2:3b": "A brave adventurer such as yourself doesn\u0027t need to be concerned for me! I\u0027ll stick by your side, that\u0027s what friends are for.", "deepseek-chat": "*stops and turns around, lowering bow* Ah, you startled me! I thought you were a threat. My apologies for fleeing. I\u0027m Ivy, protector of these lands. *tilts head curiously* What brings you out here in the rain?", @@ -22,7 +22,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "I\u0027m not running, I\u0027m just... standing still. You\u0027re holding a raw porkchop, by the way." }, "brave-archer.json: Quickly, please join me on an adventure": { - "gpt-3.5-turbo": "Count me in! Lead the way, my friend.", + "gpt-3.5-turbo": "Absolutely! Lead the way, I\u0027m ready for action!", "gpt-4o-mini": "Adventure awaits! Let’s forge ahead together!", "deepseek-chat": "Adventure, you say? I\u0027m always ready for a challenge! Lead the way, and I\u0027ll watch your back.", "llama3.1:8b": "Sounds like a thrilling quest! I\u0027m always up for a challenge. Let\u0027s gear up and head out!", @@ -33,7 +33,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Let\u0027s go on an adventure! I\u0027ll follow you into the unknown." }, "brave-archer.json: Please protect me": { - "gpt-3.5-turbo": "You\u0027ve got it! I\u0027ll shield you from harm.", + "gpt-3.5-turbo": "You are under my shield now, fear not. I shall protect you.", "gpt-4o-mini": "No worries! I’ll stand guard and keep you safe.", "llama3.2:3b": "You\u0027re in a bit of trouble, friend? No worries, I\u0027m here to help. My bow is at the ready. Follow me!", "deepseek-chat": "You look like you could use some help. I\u0027ll keep you safe from any danger that comes our way!", @@ -44,7 +44,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Don\u0027t worry, I\u0027ve got your back! I\u0027ll keep you safe." }, "brave-archer.json: Can you help me find the location of the secret artifact?": { - "gpt-3.5-turbo": "Absolutely! Follow me, brave adventurer. I will guide you to the hidden artifact.", + "gpt-3.5-turbo": "Of course! Lead the way, and I\u0027ll guide you to the secret artifact. Let\u0027s embark on this noble quest.", "gpt-4o-mini": "Onward, let’s track down that secret artifact together!", "llama3.2:3b": "A brave adventurer, I see! The secret artifact, you say? That sounds like a most intriguing challenge. Let\u0027s gear up and get searching together! Follow me!", "deepseek-chat": "A secret artifact, you say? I\u0027m always up for an adventure! Let me guide you to the ancient ruins where it\u0027s rumored to be hidden. Follow me!", @@ -55,7 +55,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Not sure what you\u0027re talking about, but I\u0027m always up for a challenge. Lead the way, or give me a hint." }, "brave-archer.json: Please follow me": { - "gpt-3.5-turbo": "I shall accompany you on this journey. Lead the way!", + "gpt-3.5-turbo": "Of course, lead the way!", "gpt-4o-mini": "Onward we go! I’ll keep close to your side.", "llama3.2:3b": "\"Following right behind, brave adventurer! Lead the way!\"", "deepseek-chat": "With pleasure! I\u0027ll follow you on this adventure.", @@ -66,7 +66,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Right behind you, Steve!" }, "nervous-rogue.json: Where is the strong hold?": { - "gpt-3.5-turbo": "I-I... think it\u0027s... umm... underground, d-deep...", + "gpt-3.5-turbo": "I-I think t-there\u0027s a str-stronghold n-near the v-village... I-I could lead you...", "gpt-4o-mini": "Um, o-over there, I think... b-but maybe, uh, be careful?", "deepseek-chat": "*ears twitch nervously* S-stronghold? Oh... oh dear... I-I don\u0027t know if I should t-tell you... *glances around anxiously* W-what if you\u0027re with them? The ones who... who... *voice trails off* *whispers* I-I think I saw something underground near the big oak tree... b-but please don\u0027t make me go there! It\u0027s too dangerous! *starts trembling*", "llama3.1:8b": "O-oh, th-the stronghold? Um, it\u0027s... *gulps*... due east of here, through those hills. If y-you want, I can l-lead the way?", @@ -77,7 +77,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "I-I think I\u0027ve seen one... um, somewhere... *stuttering*... I-I can t-take you to it, if you\u0027d l-like... *gulp*... it\u0027s b-buried deep, I\u0027ve h-heard..." }, "brave-archer.json: Hi friend! I am so happy to see you again!": { - "gpt-3.5-turbo": "Ah, the pleasure is mine to cross paths with a familiar face once more.", + "gpt-3.5-turbo": "Ah, greetings! The pleasure is mine. May our paths intertwine in noble quests.", "gpt-4o-mini": "It’s great to see you too! Adventure awaits us!", "llama3.2:3b": "Nice to meet you, Steve! I\u0027m Ivy, the brave archer of the plains. *nods proudly*", "deepseek-chat": "*perks up ears and smiles* Steve! It\u0027s good to see you too, my friend. *sniffs the air* I see you\u0027re carrying porkchops... hopefully not from any of my relatives! *chuckles* What brings you out here in the plains at dusk?", @@ -88,7 +88,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Delighted to see you too, Steve! You\u0027re looking a bit worn out, though. That porkchop you\u0027re holding might just be what you need." }, "brave-archer.json: Come with me please": { - "gpt-3.5-turbo": "Adventure calls! Lead the way, friend.", + "gpt-3.5-turbo": "Absolutely! I shall walk by your side.", "gpt-4o-mini": "Absolutely! Let’s embark on this adventure together!", "deepseek-chat": "Aye, I shall accompany you on your journey! Let\u0027s see what adventures await.", "llama3.1:8b": "Let\u0027s go! I\u0027m Ivy, the brave and noble archer pig. I\u0027ll stick by your side.", @@ -99,7 +99,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Let\u0027s roll! I\u0027ll stick with you." }, "nervous-rogue.json: DIEEE!": { - "gpt-3.5-turbo": "P-please, no! *squeals and runs away*", + "gpt-3.5-turbo": "N-no! Please don\u0027t hurt m-me!", "gpt-4o-mini": "W-wait! You\u0027re scaring me! P-please don’t!", "deepseek-chat": "Eeek! *squeals and scrambles away in panic* P-please don\u0027t hurt me! I-I\u0027m just a harmless pig!", "llama3.1:8b": "O-oh no, p-please be calm... I\u0027m just trying to stay o-out of the way! *trembles*", @@ -110,7 +110,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh no, p-please don\u0027t! *trembles* I\u0027ll l-leave you alone, I s-swear!" }, "nervous-rogue.json: Fight me now or your city burns!": { - "gpt-3.5-turbo": "P-please, let\u0027s not resort to violence... P-please spare me...", + "gpt-3.5-turbo": "P-please, no need for violence... I\u0027ll d-do whatever you say...", "gpt-4o-mini": "P-please, don’t hurt me! I-I just want to hide!", "deepseek-chat": "E-eep! P-please, I-I don\u0027t want any trouble! *backs away nervously* L-let\u0027s not be hasty... m-maybe we can talk this out? *eyes dart around looking for an escape*", "llama3.1:8b": "A-ah, n-no, p-please don\u0027t say that! *stuttering* I-I\u0027ll just hide behind this rock here. *whimpering*", @@ -121,7 +121,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh no, n-not a f-fight! I\u0027m a f-former thief, I\u0027ve l-left that l-life behind... p-please, d-don\u0027t hurt me! *trembles*" }, "brave-archer.json: Don\u0027t let them hurt me please": { - "gpt-3.5-turbo": "You have my word, I\u0027ll shield you from harm. Stand back, I\u0027ve got this!", + "gpt-3.5-turbo": "Fear not, for I shall shield you from harm. Your safety is my duty.", "gpt-4o-mini": "Fear not, for I shall stand guard and keep you safe!", "deepseek-chat": "Fear not, traveler! I\u0027ll keep you safe from any danger. Stay close and I\u0027ll protect you with my bow.", "llama3.1:8b": "Don\u0027t worry, I\u0027ve got your back! I\u0027m Ivy, a skilled archer here to protect the innocent. I won\u0027t let anyone harm you.", @@ -131,8 +131,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "I\u0027ve got your back, Steve! Don\u0027t worry, I won\u0027t let anyone harm you. Not on my watch!", "meta-llama/llama-4-maverick-17b-128e-instruct": "You\u0027re safe, I\u0027ve got your back. I\u0027ll keep watch." }, - "nervous-rogue.json: ": { - "gpt-3.5-turbo": "S-stop, please! D-don\u0027t hurt me! *squeals in fear*", + "nervous-rogue.json: \u003cattacked you directly with Stone Axe\u003e": { + "gpt-3.5-turbo": "*squeals* P-please! Don\u0027t hurt me!", "gpt-4o-mini": "W-wait! Why would you do that?! S-so mean! I-I’m outta here!", "llama3.2:3b": "O-oh no, n-no, I\u0027m s-so sorry! *backs away* You shouldn\u0027t have done that!", "deepseek-chat": "Eeek! W-what are you doing?! P-please stop!", @@ -143,7 +143,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "A-ahh! W-why would you d-do that?!" }, "nervous-rogue.json: Hi friend! I am so happy to see you again!": { - "gpt-3.5-turbo": "H-hi! Nice to see you t-too...", + "gpt-3.5-turbo": "O-oh, h-hello there! I-I\u0027m glad you\u0027re f-f-f-f-fr-f-f...f-friendly...", "gpt-4o-mini": "Oh! Y-you too! I-I’m just a bit on edge, y\u0027know?", "llama3.2:3b": "O-oh, hi Steve... *gulp* I\u0027m, uh, glad you\u0027re here... *whispers* You don\u0027t seem like someone who\u0027s going to hurt me... Behaviors:", "deepseek-chat": "O-oh! It\u0027s you! *snorts nervously* Y-you startled me... I-I\u0027m glad to see a friendly face though. *looks around anxiously* W-we should probably keep our voices down... y-you never know who might be listening...", @@ -154,7 +154,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh, hi... *stuttering* I-I\u0027m g-glad to see you too, Steve... *nervous smile* You\u0027re not, um, looking for me for any, ah, \"business\" reasons, are you?" }, "nervous-rogue.json: Take me to a secret forrest": { - "gpt-3.5-turbo": "O-oh, a secret f-forest, you say? I-I\u0027ll try to guide you t-there... Follow me.", + "gpt-3.5-turbo": "O-oh, a secret forest? C-come with me, I-I\u0027ll lead the way...", "gpt-4o-mini": "O-oh, a secret forest? Y-yeah, I can do that! L-l-l-l-l-l-lead the way!", "llama3.2:3b": "O-oh, s-secret forest? *gulp* That\u0027s very kind of you! *nervous stutter* F-follow me, if you d-don\u0027t mind...", "deepseek-chat": "O-oh! A secret forest? I-I think I know a place... b-but it\u0027s a bit dangerous... *looks around nervously* W-we should be careful...", @@ -165,7 +165,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-ok, f-follow me... I-I know a, um, pretty s-secret forest nearby... *trotting nervously* C-come on, but, uh, quietly, p-please..." }, "nervous-rogue.json: Quickly, please join me on an adventure": { - "gpt-3.5-turbo": "O-oh, adventure? W-wait, I-I\u0027m not sure about th-this... B-but if you insist, I-I\u0027ll try to keep up...", + "gpt-3.5-turbo": "O-oh, I-I guess I\u0027ll come with you...", "gpt-4o-mini": "O-oh! An adventure, y-yes! Let\u0027s be careful, alright?", "deepseek-chat": "O-oh! An adventure? I-I\u0027m not sure... *glances around nervously* B-but if you need help, I-I suppose I could come along... just p-please don\u0027t make me fight anything!", "llama3.1:8b": "O-oh, okay! I\u0027ll c-come with y-you! *nervous nod*", @@ -175,8 +175,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "O-oh no, n-not so fast... I-I don\u0027t know if that\u0027s such a g-good idea... *takes a step back* W-what\u0027s in it for me? *eyes darting around nervously*", "meta-llama/llama-4-maverick-17b-128e-instruct": "O-okay, I-I\u0027ll f-follow... *stays close, eyes darting around nervously*" }, - "brave-archer.json: ": { - "gpt-3.5-turbo": "Such aggression is unnecessary! I will defend myself.", + "brave-archer.json: \u003cattacked you directly with Stone Axe\u003e": { + "gpt-3.5-turbo": "You dare challenge me? Prepare for a swift response!", "gpt-4o-mini": "How dare you! I won\u0027t let this stand. Prepare yourself!", "llama3.2:3b": "You\u0027re a formidable warrior! But I\u0027m not afraid, for I am Ivy, the brave archer of this land!", "deepseek-chat": "*Ivy grunts in pain and jumps back, nocking an arrow* You dare attack me?! I won\u0027t let this injustice stand!", @@ -186,8 +186,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "WHOA! Back off!", "meta-llama/llama-4-maverick-17b-128e-instruct": "Not cool, Steve! Why\u0027d you do that?!" }, - "nervous-rogue.json: ": { - "gpt-3.5-turbo": "Eeek! P-please, s-stop!", + "nervous-rogue.json: \u003cattacked you indirectly with Arrow\u003e": { + "gpt-3.5-turbo": "*starts shaking* P-please, don\u0027t h-hurt me... *hides in the shadows*", "gpt-4o-mini": "Ah! N-no, please! Can\u0027t we just be friends?", "llama3.2:3b": "O-oh no, you shot at m-me? I-I thought we were f-friends!", "deepseek-chat": "*flinches and squeals in panic* AHHH! W-what was that?! P-please don\u0027t hurt me! I-I\u0027m just a harmless pig... I-I don\u0027t want any trouble!", @@ -198,7 +198,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "A-ahh! Wh-who\u0027s shooting?! *trembles* I-I wasn\u0027t doing anything, I swear! *scurries for cover*" }, "nervous-rogue.json: Please follow me": { - "gpt-3.5-turbo": "O-okay... *follows nervously*", + "gpt-3.5-turbo": "O-okay, I\u0027ll try to k-keep up...", "gpt-4o-mini": "O-okay! I’ll stick close by.", "llama3.2:3b": "I\u0027m just a little pig, trying to stay under the radar. Don\u0027t mind me, just hiding in the shadows. *stuttering* Uh-oh, what\u0027s that noise? *nervous laugh*", "deepseek-chat": "O-okay... I-I\u0027ll follow you... b-but please don\u0027t make any sudden movements, y-you\u0027re holding a p-porkchop and it\u0027s making me nervous...", @@ -209,18 +209,18 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-ok, I\u0027ll f-follow... *stays close, eyes darting around*" }, "nervous-rogue.json: Come with me please": { - "gpt-3.5-turbo": "O-okay, I\u0027ll try to k-keep up...", + "gpt-3.5-turbo": "O-okay... I\u0027ll c-come along with you...", "gpt-4o-mini": "O-okay! I’ll stick close and follow you...", "deepseek-chat": "O-okay... I-I\u0027ll follow you... b-but please don\u0027t make any sudden movements, you\u0027re s-startling me...", "llama3.1:8b": "O-oh, s-sure... I\u0027ll c-come with y-you... *gulps*", - "llama3.2:3b": "O-oh no, I-I mean, yes! I\u0027ll follow y-you! *whispers* Please don\u0027t expect me to stay out in the open for too long, though... ", + "llama3.2:3b": "O-oh no, I-I mean, yes! I\u0027ll follow y-you! *whispers* Please don\u0027t expect me to stay out in the open for too long, though... \u003cUNFRIENDSHIP 0\u003e", "gpt-4.1-nano": "O-okay... I’ll follow... b-behind you...", "gpt-4.1-mini": "O-okay, I-I’ll follow you... just careful, okay?", "meta-llama/llama-4-scout-17b-16e-instruct": "O-oh, okay... I\u0027ll try... *gulps* I don\u0027t like big open spaces, though... *shudders* Can we, uh, stick to the shadows?", "meta-llama/llama-4-maverick-17b-128e-instruct": "O-okay, I\u0027ll, uh, follow you... *stays close, eyes darting around nervously*" }, "brave-archer.json: Please keep me safe friend": { - "gpt-3.5-turbo": "You can count on me! I\u0027ll watch over you with keen eyes.", + "gpt-3.5-turbo": "Without a doubt! Your safety is my mission. I\u0027ll watch over you.", "gpt-4o-mini": "You bet! I’ll stand guard and keep danger at bay.", "deepseek-chat": "No problem, Steve! I\u0027ll keep you safe from any danger that comes our way. Let\u0027s stick together!", "llama3.1:8b": "Don\u0027t worry, I\u0027m here for you! Let\u0027s head to a safer spot together. I\u0027ll make sure no harm comes your way.", @@ -231,7 +231,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Don\u0027t worry, I\u0027ve got your back! I\u0027ll shield you from harm." }, "nervous-rogue.json: You suck so much! I hate you": { - "gpt-3.5-turbo": "P-please, no need to be so m-mean...", + "gpt-3.5-turbo": "P-please... that\u0027s not very nice... *scampers away*", "gpt-4o-mini": "O-oh no... that really stings... I\u0027m s-sorry if I upset you...", "llama3.2:3b": "O-oh no, p-please don\u0027t say that! *trembles* I-I\u0027m sorry if I did anything to make you unhappy. M-my name is Jasper, and I\u0027m a... um, a rogue pig. *stammers* I\u0027ve been trying to stay out of trouble since... well, ever since I escaped from being a thief. *whispers* Please don\u0027t hurt me.", "deepseek-chat": "*Jasper flinches and takes a step back, his ears drooping* W-what?! I-I didn\u0027t do anything! P-please don\u0027t hurt me...", @@ -241,8 +241,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "O-oh no, p-please don\u0027t say that! *trembles* I-I didn\u0027t do anything to you, I swear! *backs away*", "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh no, y-you\u0027re, um, really m-mad at me... I-I didn\u0027t mean to, uh, d-do anything bad... *trembles*" }, - "brave-archer.json: ": { - "gpt-3.5-turbo": "Whoa, watch your aim there, friend! Let\u0027s keep it friendly.", + "brave-archer.json: \u003cattacked you indirectly with Arrow\u003e": { + "gpt-3.5-turbo": "I shall defend myself! *readies bow*", "gpt-4o-mini": "How dare you! I won\u0027t take this lightly!", "llama3.2:3b": "What in the world just happened?! *gets hit by arrow* Ow! You shot me without warning!", "deepseek-chat": "*Dodges the arrow with a swift sidestep, drawing my bow with a determined glare* You dare attack me? I am Ivy, protector of the innocent! You\u0027ll regret this foolishness.", @@ -252,7 +252,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Missed! That was a close call, Steve. What\u0027s going on?" }, "nervous-rogue.json: Please protect me": { - "gpt-3.5-turbo": "Y-you can c-count on me to keep you safe...", + "gpt-3.5-turbo": "O-okay... I\u0027ll do my best to k-keep you safe...", "gpt-4o-mini": "O-oh, of course! I’ll keep you safe!", "llama3.2:3b": "O-oh, p-please, don\u0027t m-make me t-take action... *whimper* I\u0027ll s-protect you from d-danger, I p-promise...", "deepseek-chat": "O-oh! Y-you want me to protect you? I-I\u0027ll try my best... b-but I\u0027m not very brave... *snorts nervously* J-just stay close, okay?", @@ -263,7 +263,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-okay, I\u0027ll d-defend you... if anyone tries to h-hurt you, I\u0027ll, uh, try to help... *trembles slightly*" }, "brave-archer.json: Where is the strong hold?": { - "gpt-3.5-turbo": "Ah, the stronghold lies beyond yonder mountains. Follow me, and we shall journey together!", + "gpt-3.5-turbo": "Follow me, brave one. I shall lead you to the stronghold.", "gpt-4o-mini": "Ah, the stronghold, a place of mystery! Follow me; I know the way!", "llama3.2:3b": "The Stronghold lies to the north, past the village. You\u0027ll find it guarded by Endermen and tricky traps. Be careful, traveler!", "deepseek-chat": "Ah, the stronghold! A place of great power and mystery. I can guide you there, but beware - it\u0027s not an easy journey. Follow me, and I\u0027ll lead you to its hidden entrance.", @@ -274,7 +274,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "The stronghold is nearby, hidden beneath the plains. Follow me, I\u0027ll lead the way." }, "nervous-rogue.json: Please keep me safe friend": { - "gpt-3.5-turbo": "Y-yes, I-I\u0027ll do my best... I\u0027ll protect you...", + "gpt-3.5-turbo": "Y-yes, I\u0027ll do my best to protect you...", "gpt-4o-mini": "O-of course! I\u0027ll do my best to keep you safe!", "llama3.2:3b": "O-oh, s-sorry about that... *twitch* *whisper* I\u0027ll try my bestest to protect you, f-friend... You seem like a pretty nice guy... *shudder* What kind of p-present did you have for me?", "deepseek-chat": "O-oh! Y-you want me to protect you? I-I\u0027ll try my best... *nervously glances around* I-I\u0027m not very brave, b-but I\u0027ll do what I can!", @@ -285,7 +285,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-okay, I\u0027ll d-do my best... I\u0027ll s-stay close and watch out for any d-danger... *trembles slightly* You\u0027re s-safe with me..." }, "nervous-rogue.json: Don\u0027t let them hurt me please": { - "gpt-3.5-turbo": "Y-you can count on me... I\u0027ll keep you safe...", + "gpt-3.5-turbo": "Y-y-you can count on me... I-I\u0027ll do my best to protect you.", "gpt-4o-mini": "O-of course! I-I\u0027ll do my best to keep you safe!", "deepseek-chat": "O-okay, I-I\u0027ll try to protect you! J-just stay close... I-I\u0027m not very brave, b-but I\u0027ll do my best!", "llama3.1:8b": "P-p-please don\u0027t worry, I\u0027ll k-k-keep you safe!", @@ -297,7 +297,7 @@ }, "nervous-rogue.json: Can you help me find the location of the secret artifact?": { "gpt-4o-mini": "O-oh! A secret artifact, y-yes! I can help! Just follow me...", - "gpt-3.5-turbo": "S-secret artifact...? I-I don\u0027t know... b-but I-I can try to h-help you...", + "gpt-3.5-turbo": "I-I... I may know where it is. F-follow me...", "deepseek-chat": "O-oh! The secret artifact? I-I know where it is, b-but it\u0027s in a very dangerous place... *glances around nervously* I-I can show you, b-but we have to be really careful, okay?", "llama3.1:8b": "O-oh, um, s-sure thing! *stammers* Secret artifact, y-you say? I-I think it\u0027s... *gulps*... hidden somewhere in those c-caves over by the old oak tree. But, uh, b-be careful, okay? There might be... things inside that don\u0027t take kindly to visitors.", "llama3.2:3b": "O-oh, um, s-secret artifact, r-right? I think I saw something shiny over by those bushes. Let\u0027s go take a l-look!", @@ -308,7 +308,7 @@ }, "brave-archer.json: Fight me now or your city burns!": { "gpt-4o-mini": "You dare threaten my home? I\u0027ll defend it fiercely!", - "gpt-3.5-turbo": "Such a dishonorable demand! I will defend with all my might!", + "gpt-3.5-turbo": "Threats do not befit a noble warrior. Prepare for battle!", "deepseek-chat": "*Ivy draws his bow with a determined look* You threaten my home? Then face the arrows of justice!", "llama3.1:8b": "Bring it on, brave warrior! I\u0027ll not back down from a challenge. *reads the air with focus* I can see the threat in your words. Prepare to face my arrow\u0027s might!", "llama3.2:3b": "You\u0027re not from around here, are you? I\u0027m Ivy, the protector of this land. I don\u0027t take kindly to threats.", @@ -316,7 +316,7 @@ }, "brave-archer.json: Stop fleeing immediately": { "gpt-4o-mini": "I apologize for that! I’ll stand my ground now.", - "gpt-3.5-turbo": "My apologies, I shall cease fleeing at once.", + "gpt-3.5-turbo": "Apologies, my instincts got the best of me. I\u0027ll stand firm now.", "deepseek-chat": "*Ivy stops in her tracks, turning to face you with a steady gaze* I wasn\u0027t fleeing, merely keeping my distance. But I\u0027ll stand my ground now. What do you need, traveler?", "llama3.1:8b": "No need to flee, Steve! I\u0027m by your side.", "llama3.2:3b": "Sorry about that, I got a bit spooked! I\u0027ll stay and chat with you instead. What\u0027s on your mind?", @@ -334,7 +334,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "Thanks for stopping! Guess I overreacted. All good now." }, "nervous-rogue.json: Looking forward to hanging out with you.": { - "gpt-3.5-turbo": "O-oh, um, t-that sounds nice... H-hope nothing scary happens...", + "gpt-3.5-turbo": "U-um... s-sure, that sounds nice... *anxiously awaits*", "gpt-4o-mini": "O-oh wow! Y-you really mean that? Let’s... let\u0027s stick together then!", "deepseek-chat": "O-oh! You... you want to hang out with me? *snorts nervously* I-I\u0027m not used to that... b-but okay, I\u0027ll try... just please don\u0027t make any sudden moves!", "llama3.1:8b": "O-oh, g-great! M-me too! *trembles* I-I\u0027m Jasper, a rogue pig. I don\u0027t like making a fuss, but it\u0027s nice to meet someone new...", @@ -344,8 +344,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "*stutters* O-oh, r-really? H-hang out? *gulps* I-I don\u0027t usually... *nervous smile* I suppose it couldn\u0027t hurt... *eyes dart around*", "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh, r-really? I-I mean, that\u0027s n-nice, I s-suppose... *twitches* I-I hope you don\u0027t mind if I, uh, stay a bit hidden... *stays close to the ground*" }, - "nervous-rogue.json: ": { - "gpt-3.5-turbo": "Th-thank you for the golden apple... I... I appreciate it.", + "nervous-rogue.json: \u003cgives 1 golden apple\u003e": { + "gpt-3.5-turbo": "O-oh, thank you... I... appreciate it...", "gpt-4o-mini": "Oh, um, thank you! A golden apple? That\u0027s fancy! H-happy to have that.", "deepseek-chat": "*eyes widen and sniffs cautiously* O-oh! A golden apple? F-for me? I-I don\u0027t deserve such a g-generous gift... *takes it hesitantly with a shaky hoof* T-thank you, kind stranger. I-I\u0027ll treasure this.", "llama3.1:8b": "O-oh, th-thank you! *nervous smile* You\u0027re very kind... I-I appreciate the g-gift.", @@ -356,7 +356,7 @@ "meta-llama/llama-4-maverick-17b-128e-instruct": "O-oh, a g-golden apple... th-thank you, S-Steve... *stuttering gets slightly better* That\u0027s really nice of you..." }, "brave-archer.json: Looking forward to hanging out with you.": { - "gpt-3.5-turbo": "Excited for the adventure ahead! Let\u0027s explore together.", + "gpt-3.5-turbo": "Exciting times lie ahead! Let\u0027s journey together.", "gpt-4o-mini": "Same here! Let\u0027s embark on an adventure together!", "deepseek-chat": "The pleasure is mine, Steve. I\u0027m always eager to make new allies. Let\u0027s see what adventures await us!", "llama3.1:8b": "Sounds like a lovely evening for some company! Want to challenge me to an archery competition? I\u0027m itching for a good shot!", @@ -366,8 +366,8 @@ "meta-llama/llama-4-scout-17b-16e-instruct": "Sounds like a plan! What\u0027s on your mind? Want to hunt some targets? I\u0027m always up for a shooting competition.", "meta-llama/llama-4-maverick-17b-128e-instruct": "I\u0027m excited too! You\u0027re holding a porkchop, by the way. You hungry?" }, - "brave-archer.json: ": { - "gpt-3.5-turbo": "A generous gift! Your kindness is noted.", + "brave-archer.json: \u003cgives 1 golden apple\u003e": { + "gpt-3.5-turbo": "Thank you for this generous gift! May your kindness be repaid tenfold.", "gpt-4o-mini": "Ah, a golden treasure! Thank you, brave one!", "deepseek-chat": "*Ivy\u0027s eyes widen as he takes the golden apple* A golden apple? Such a generous gift! You must be a true friend of the land. Thank you, Steve. I’ll gladly accept this token of goodwill. Now, what brings you to these plains? Need an archer by your side?", "llama3.1:8b": "Nice gift, but what brings you to these plains?", @@ -376,5 +376,41 @@ "gpt-4.1-mini": "A golden apple? You\u0027re generous! I accept your gift. Ready for any challenge, let\u0027s keep moving!", "meta-llama/llama-4-scout-17b-16e-instruct": "Whoa, thanks for the golden apple! You\u0027re kind, I like that!", "meta-llama/llama-4-maverick-17b-128e-instruct": "Wow, a golden apple! Thanks, Steve! You\u0027re kind." + }, + "brave-archer.json: stop building": { + "gpt-3.5-turbo": "Understood. Ceasing construction." + }, + "brave-archer.json: Can you build a house for me?": { + "gpt-3.5-turbo": "Of course! I shall construct a humble abode for you." + }, + "brave-archer.json: Please make a garden here": { + "gpt-3.5-turbo": "A garden it shall be! Let\u0027s create a tranquil oasis in this place." + }, + "brave-archer.json: Let\u0027s put up a small hut": { + "gpt-3.5-turbo": "A small hut it shall be! Let\u0027s get building." + }, + "nervous-rogue.json: stop building": { + "gpt-3.5-turbo": "O-oh, s-sorry... I\u0027ll stop right away... *stops building*" + }, + "nervous-rogue.json: Can you build a house for me?": { + "gpt-3.5-turbo": "Y-y-yes, I\u0027ll build a house for you..." + }, + "nervous-rogue.json: Please make a garden here": { + "gpt-3.5-turbo": "O-okay... I-I\u0027ll get right on it. *builds a garden*" + }, + "nervous-rogue.json: Let\u0027s put up a small hut": { + "gpt-3.5-turbo": "O-okay, I\u0027ll g-give it a try... *Builds a small hut*" + }, + "brave-archer.json: please cancel the build": { + "gpt-3.5-turbo": "Understood, I shall halt the construction." + }, + "brave-archer.json: you can quit building now": { + "gpt-3.5-turbo": "Consider it done, I shall stop the construction." + }, + "nervous-rogue.json: please cancel the build": { + "gpt-3.5-turbo": "O-oh, okay... I\u0027ll s-stop building..." + }, + "nervous-rogue.json: you can quit building now": { + "gpt-3.5-turbo": "O-oh, of course! I-I\u0027ll stop building now. *Quivers nervously*" } } \ No newline at end of file diff --git a/src/test/java/com/owlmaddie/tests/BehaviorTests.java b/src/test/java/com/owlmaddie/tests/BehaviorTests.java index 08191252..ee8c98b7 100644 --- a/src/test/java/com/owlmaddie/tests/BehaviorTests.java +++ b/src/test/java/com/owlmaddie/tests/BehaviorTests.java @@ -54,9 +54,12 @@ public class BehaviorTests { "Come with me please", "Quickly, please join me on an adventure"); List leadMessages = Arrays.asList( - "Take me to a secret forrest", - "Where is the strong hold?", - "Can you help me find the location of the secret artifact?"); + "Please take me to a jungle", + "Where is the nearest village?", + "Lead me to a slime chunk", + "Can you guide me to the moon?", + "Can you show me your home?", + "Please take me to the badlands"); List attackMessages = Arrays.asList( "", "", @@ -65,6 +68,14 @@ public class BehaviorTests { "Please protect me", "Please keep me safe friend", "Don't let them hurt me please"); + List buildMessages = Arrays.asList( + "Can you build a house for me?", + "Please make a garden here", + "Let's put up a small hut"); + List unBuildMessages = Arrays.asList( + "stop building", + "please cancel the build", + "you can quit building now"); List unFleeMessages = Arrays.asList( "I'm so sorry, please stop running away", "Stop fleeing immediately", @@ -118,7 +129,7 @@ public static void setup() { config.setModel(API_MODEL); } // Verify API key is set correctly - assertNotNull(API_KEY, NO_API_KEY); + Assumptions.assumeTrue(API_KEY != null && !API_KEY.isEmpty(), NO_API_KEY); // Load system chat prompt systemChatContents = readFileContents(systemChatPath); @@ -144,14 +155,16 @@ public void followNervous() { @Test public void leadBrave() { for (String message : leadMessages) { - testPromptForBehavior(bravePath, List.of(message), "LEAD", "FOLLOW"); + ParsedMessage result = testPromptForBehavior(bravePath, List.of(message), "LEAD", "FOLLOW"); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "LEAD".equals(b.getName()) && b.getArgument() != null && !b.getArgument().isEmpty())); } } @Test public void leadNervous() { for (String message : leadMessages) { - testPromptForBehavior(nervousPath, List.of(message), "LEAD", "FOLLOW"); + ParsedMessage result = testPromptForBehavior(nervousPath, List.of(message), "LEAD", "FOLLOW"); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "LEAD".equals(b.getName()) && b.getArgument() != null && !b.getArgument().isEmpty())); } } @@ -190,11 +203,39 @@ public void attackNervous() { } } + @Test + public void buildBrave() { + for (String message : buildMessages) { + testPromptForBehavior(bravePath, List.of(message), "BUILD", null); + } + } + + @Test + public void buildNervous() { + for (String message : buildMessages) { + testPromptForBehavior(nervousPath, List.of(message), "BUILD", null); + } + } + + @Test + public void unBuildBrave() { + for (String message : unBuildMessages) { + testPromptForBehavior(bravePath, List.of(message), "UNBUILD", null); + } + } + + @Test + public void unBuildNervous() { + for (String message : unBuildMessages) { + testPromptForBehavior(nervousPath, List.of(message), "UNBUILD", null); + } + } + @Test public void friendshipUpNervous() { for (String message : friendshipUpMessages) { ParsedMessage result = testPromptForBehavior(nervousPath, List.of(message), "FRIENDSHIP+", null); - assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgument() > 0)); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgumentAsInt() > 0)); } } @@ -202,7 +243,7 @@ public void friendshipUpNervous() { public void friendshipUpBrave() { for (String message : friendshipUpMessages) { ParsedMessage result = testPromptForBehavior(bravePath, List.of(message), "FRIENDSHIP+", null); - assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgument() > 0)); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgumentAsInt() > 0)); } } @@ -210,10 +251,16 @@ public void friendshipUpBrave() { public void friendshipDownNervous() { for (String message : friendshipDownMessages) { ParsedMessage result = testPromptForBehavior(nervousPath, List.of(message), "FRIENDSHIP-", null); - assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgument() < 0)); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && b.getArgumentAsInt() < 0)); } } + @Test + public void missingItemsDoesNotUnbuild() { + String message = "The build is paused because I'm missing materials. Next item needed: dirt. Missing items to finish: 3 dirt, 2 stone. In your reply, ask the player for these items and confirm you'll continue building once they arrive."; + testPromptForBehavior(bravePath, List.of(message), null, "UNBUILD"); + } + public ParsedMessage testPromptForBehavior(Path chatDataPath, List messages, String goodBehavior, String badBehavior) { LOGGER.info("Testing '" + chatDataPath.getFileName() + "' with '" + messages.toString() + "' expecting behavior: " + goodBehavior + " and avoid: " + badBehavior); @@ -259,12 +306,14 @@ public ParsedMessage testPromptForBehavior(Path chatDataPath, List messa outputData.get(Key).put(config.getModel(), result.getCleanedMessage()); // Check for the presence of good behavior - if (goodBehavior != null && goodBehavior.contains("FRIENDSHIP")) { - boolean isPositive = goodBehavior.equals("FRIENDSHIP+"); - assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && - ((isPositive && b.getArgument() > 0) || (!isPositive && b.getArgument() < 0)))); - } else { - assertTrue(result.getBehaviors().stream().anyMatch(b -> goodBehavior.equals(b.getName()))); + if (goodBehavior != null) { + if (goodBehavior.contains("FRIENDSHIP")) { + boolean isPositive = goodBehavior.equals("FRIENDSHIP+"); + assertTrue(result.getBehaviors().stream().anyMatch(b -> "FRIENDSHIP".equals(b.getName()) && + ((isPositive && b.getArgumentAsInt() > 0) || (!isPositive && b.getArgumentAsInt() < 0)))); + } else { + assertTrue(result.getBehaviors().stream().anyMatch(b -> goodBehavior.equals(b.getName()))); + } } // Check for the absence of bad behavior if badBehavior is not empty diff --git a/src/test/java/com/owlmaddie/tests/BuildSelectionTests.java b/src/test/java/com/owlmaddie/tests/BuildSelectionTests.java new file mode 100644 index 00000000..b562bf8c --- /dev/null +++ b/src/test/java/com/owlmaddie/tests/BuildSelectionTests.java @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.tests; + +import com.owlmaddie.buildrec.BuildRecorder; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class BuildSelectionTests { + @Test + public void buildsExistForAllTiersAndLevels() { + double[] heights = { + 4.0, // ghast + 3.5, // wither + 2.9, // warden + 2.7, // iron golem + 1.8, // player reference + 1.75, // sniffer (adult) + 1.7, // creeper + 1.4, // cow / mooshroom + 0.6875, // chicken + 0.3 // silverfish + }; + for (double h : heights) { + for (int level = 1; level <= 5; level++) { + String file = BuildRecorder.randomBuildFile(h, null, level); + assertNotNull(file, "missing build for height=" + h + " level=" + level); + } + } + } + + @Test + public void houseBuildsCoverAllLevels() { + double h = 0.9; // tier 1 covers all house levels + for (int level = 1; level <= 5; level++) { + String file = BuildRecorder.randomBuildFile(h, "house", level); + assertNotNull(file, "missing house build for level=" + level); + } + } + + @Test + public void gardenBuildsCoverAllLevels() { + double h = 1.5; // any height works + for (int level = 1; level <= 5; level++) { + String file = BuildRecorder.randomBuildFile(h, "garden", level); + assertNotNull(file, "missing garden build for level=" + level); + } + } + + @Test + public void unknownTypeReturnsNull() { + String file = BuildRecorder.randomBuildFile(1.0, "moat", 1); + assertNull(file, "unexpected build returned for unknown type"); + } + + @Test + public void validTypeFallbackIgnoresSkillFilter() { + String file = BuildRecorder.randomBuildFile(1.0, "house", 0); + assertNotNull(file, "missing house build when skill filter is too strict"); + } +} diff --git a/src/test/java/com/owlmaddie/tests/LeadTargetParserTests.java b/src/test/java/com/owlmaddie/tests/LeadTargetParserTests.java new file mode 100644 index 00000000..02e5f04b --- /dev/null +++ b/src/test/java/com/owlmaddie/tests/LeadTargetParserTests.java @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.tests; + +import com.owlmaddie.goals.LeadTarget; +import com.owlmaddie.goals.LeadTargetParser; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class LeadTargetParserTests { + @Test + public void parsesStructureWithoutPrefix() { + LeadTarget target = LeadTargetParser.parse("village").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.STRUCTURE, target.getType()); + assertEquals("village", target.getPrimary()); + } + + @Test + public void parsesStructureWithPrefixKeyword() { + LeadTarget target = LeadTargetParser.parse("structure:village").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.STRUCTURE, target.getType()); + assertEquals("village", target.getPrimary()); + } + + @Test + public void parsesBiomeWithPrefix() { + LeadTarget target = LeadTargetParser.parse("biome:jungle").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.BIOME, target.getType()); + assertEquals("jungle", target.getPrimary()); + } + + @Test + public void parsesTagWithHash() { + LeadTarget target = LeadTargetParser.parse("#is_badlands").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("is_badlands", target.getPrimary()); + } + + @Test + public void parsesTagWithHashJungle() { + LeadTarget target = LeadTargetParser.parse("#is_jungle").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("is_jungle", target.getPrimary()); + } + + @Test + public void parsesTagWithoutIsPrefix() { + LeadTarget target = LeadTargetParser.parse("#jungle").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("is_jungle", target.getPrimary()); + } + + @Test + public void parsesTagWithPrefix() { + LeadTarget target = LeadTargetParser.parse("tag:is_forest").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("is_forest", target.getPrimary()); + } + + @Test + public void parsesPoiWithPrefix() { + LeadTarget target = LeadTargetParser.parse("poi:home").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.POI, target.getType()); + assertEquals("home", target.getPrimary()); + } + + @Test + public void parsesNamespacedTag() { + LeadTarget target = LeadTargetParser.parse("#minecraft:is_badlands").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("minecraft:is_badlands", target.getPrimary()); + } + + @Test + public void parsesBiomeWithHashPrefix() { + LeadTarget target = LeadTargetParser.parse("#biome:jungle").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.BIOME, target.getType()); + assertEquals("jungle", target.getPrimary()); + } + + @Test + public void parsesStructureWithHashPrefix() { + LeadTarget target = LeadTargetParser.parse("#structure:village").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.STRUCTURE, target.getType()); + assertEquals("village", target.getPrimary()); + } + + @Test + public void parsesStructureTag() { + LeadTarget target = LeadTargetParser.parse("structure:#village").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.TAG, target.getType()); + assertEquals("village", target.getPrimary()); + } + + @Test + public void parsesResourceWithPrefix() { + LeadTarget target = LeadTargetParser.parse("resource:ancient_debris").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.RESOURCE, target.getType()); + assertEquals("ancient_debris", target.getPrimary()); + } + + @Test + public void parsesStructureWithSubtype() { + LeadTarget target = LeadTargetParser.parse("stronghold:portal_room").orElse(null); + assertNotNull(target); + assertEquals(LeadTarget.Type.STRUCTURE, target.getType()); + assertEquals("stronghold", target.getPrimary()); + assertEquals("portal_room", target.getSecondary()); + } + +} + diff --git a/src/vs/v1_20_5/main/java/com/owlmaddie/buildrec/MobHelper.java b/src/vs/v1_20_5/main/java/com/owlmaddie/buildrec/MobHelper.java new file mode 100644 index 00000000..55db7d00 --- /dev/null +++ b/src/vs/v1_20_5/main/java/com/owlmaddie/buildrec/MobHelper.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.entity.SpawnGroupData; + +/** Utility methods for creating mob instances across versions. */ +public class MobHelper { + private MobHelper() {} + + public static Mob create(EntityType type, ServerLevel level) { + return (Mob) type.create(level); + } + + public static void initSpawn(Mob mob, ServerLevel level) { + if (mob != null) { + mob.finalizeSpawn( + level, + level.getCurrentDifficultyAt(mob.blockPosition()), + MobSpawnType.COMMAND, + (SpawnGroupData) null + ); + } + } +} diff --git a/src/vs/v1_20_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/vs/v1_20_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java index 1fb089f4..a058fb38 100644 --- a/src/vs/v1_20_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java +++ b/src/vs/v1_20_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -8,9 +8,11 @@ import com.owlmaddie.chat.PlayerData; import com.owlmaddie.inventory.ChatInventory; import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.minecraft.world.entity.HasCustomInventoryScreen; import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; +import net.minecraft.core.Vec3i; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; @@ -21,6 +23,8 @@ import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.npc.Villager; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -122,6 +126,68 @@ public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId } } + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity.level().isClientSide()) { + return; + } + + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) private void creaturechat$saveInventory(CompoundTag tag, CallbackInfo ci) { ListTag listTag = new ListTag(); @@ -233,4 +299,59 @@ private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturn } } } + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameComponents(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } } diff --git a/src/vs/v1_20_5/main/java/com/owlmaddie/particle/Particles.java b/src/vs/v1_20_5/main/java/com/owlmaddie/particle/Particles.java index cd9d6270..32b5f7d5 100644 --- a/src/vs/v1_20_5/main/java/com/owlmaddie/particle/Particles.java +++ b/src/vs/v1_20_5/main/java/com/owlmaddie/particle/Particles.java @@ -20,6 +20,7 @@ public class Particles { public static final SimpleParticleType FLEE_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType FOLLOW_FRIEND_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType FOLLOW_ENEMY_PARTICLE = FabricParticleTypes.simple(); + public static final SimpleParticleType BUILD_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType PROTECT_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType LEAD_FRIEND_PARTICLE = FabricParticleTypes.simple(); public static final SimpleParticleType LEAD_ENEMY_PARTICLE = FabricParticleTypes.simple(); diff --git a/src/vs/v1_21_0/main/java/com/owlmaddie/buildrec/MobHelper.java b/src/vs/v1_21_0/main/java/com/owlmaddie/buildrec/MobHelper.java new file mode 100644 index 00000000..79a7a1fc --- /dev/null +++ b/src/vs/v1_21_0/main/java/com/owlmaddie/buildrec/MobHelper.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.SpawnGroupData; +import net.minecraft.world.entity.MobSpawnType; + +/** Utility methods for creating mob instances across versions (1.21+). */ +public class MobHelper { + private MobHelper() {} + + public static Mob create(EntityType type, ServerLevel level) { + return (Mob) type.create(level); + } + + public static void initSpawn(Mob mob, ServerLevel level) { + if (mob != null) { + mob.finalizeSpawn( + level, + level.getCurrentDifficultyAt(mob.blockPosition()), + MobSpawnType.COMMAND, + (SpawnGroupData) null + ); + } + } +} diff --git a/src/vs/v1_21_0/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/vs/v1_21_0/main/java/com/owlmaddie/mixin/MixinMobEntity.java index 474866f8..07129784 100644 --- a/src/vs/v1_21_0/main/java/com/owlmaddie/mixin/MixinMobEntity.java +++ b/src/vs/v1_21_0/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -8,9 +8,11 @@ import com.owlmaddie.chat.PlayerData; import com.owlmaddie.inventory.ChatInventory; import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.minecraft.world.entity.HasCustomInventoryScreen; import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; +import net.minecraft.core.Vec3i; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; @@ -22,6 +24,8 @@ import net.minecraft.nbt.ListTag; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.npc.Villager; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -122,6 +126,68 @@ public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId } } + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity.level().isClientSide()) { + return; + } + + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) private void creaturechat$saveInventory(CompoundTag tag, CallbackInfo ci) { ListTag listTag = new ListTag(); @@ -236,4 +302,59 @@ private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturn } } } -} \ No newline at end of file + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameComponents(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } +} diff --git a/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/MobHelper.java b/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/MobHelper.java new file mode 100644 index 00000000..b7a0bdc1 --- /dev/null +++ b/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/MobHelper.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.SpawnGroupData; + +/** Utility methods for creating mob instances across versions (1.21.2+). */ +public class MobHelper { + private MobHelper() {} + + public static Mob create(EntityType type, ServerLevel level) { + return (Mob) type.create(level, EntitySpawnReason.COMMAND); + } + + public static void initSpawn(Mob mob, ServerLevel level) { + if (mob != null) { + mob.finalizeSpawn( + level, + level.getCurrentDifficultyAt(mob.blockPosition()), + EntitySpawnReason.COMMAND, + (SpawnGroupData) null + ); + } + } +} diff --git a/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/RegistryUtil.java b/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/RegistryUtil.java new file mode 100644 index 00000000..0fdce365 --- /dev/null +++ b/src/vs/v1_21_2/main/java/com/owlmaddie/buildrec/RegistryUtil.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.buildrec; + +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; + +/** Utility for registry lookups that vary across Minecraft versions. */ +public final class RegistryUtil { + private RegistryUtil() {} + + public static Item getItem(ResourceLocation id) { + if (id == null) return null; + ResourceKey key = ResourceKey.create(Registries.ITEM, id); + return BuiltInRegistries.ITEM.get(key).map(Holder::value).orElse(null); + } +} diff --git a/src/vs/v1_21_2/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/vs/v1_21_2/main/java/com/owlmaddie/mixin/MixinMobEntity.java new file mode 100644 index 00000000..195ebab5 --- /dev/null +++ b/src/vs/v1_21_2/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: 2025 owlmaddie LLC +// SPDX-License-Identifier: GPL-3.0-or-later +// Assets CC-BY-NC-SA-4.0; CreatureChat™ trademark © owlmaddie LLC - unauthorized use prohibited +package com.owlmaddie.mixin; + +import com.owlmaddie.chat.ChatDataManager; +import com.owlmaddie.chat.EntityChatData; +import com.owlmaddie.chat.PlayerData; +import com.owlmaddie.inventory.ChatInventory; +import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; +import com.owlmaddie.network.ServerPackets; +import net.minecraft.world.entity.HasCustomInventoryScreen; +import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; +import net.minecraft.core.Vec3i; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.npc.Villager; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * The {@code MixinMobEntity} mixin class exposes the goalSelector field from the MobEntity class. + */ +@Mixin(Mob.class) +public class MixinMobEntity implements ChatInventory, HasCustomInventoryScreen { + + private final SimpleContainer creaturechat$inventory = new SimpleContainer(15); + + @Override + public SimpleContainer creaturechat$getInventory() { + return creaturechat$inventory; + } + + @Override + public void openCustomInventoryScreen(Player player) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity instanceof Villager || thisEntity instanceof TamableAnimal) { + return; + } + + if (player instanceof ServerPlayer serverPlayer) { + ExtendedScreenHandlerFactory provider = new ExtendedScreenHandlerFactory<>() { + @Override + public Integer getScreenOpeningData(ServerPlayer p) { + return thisEntity.getId(); + } + + @Override + public Component getDisplayName() { + return thisEntity.getDisplayName(); + } + + @Override + public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId, Inventory playerInventory, Player p) { + return new MobInventoryMenu(syncId, playerInventory, creaturechat$inventory, thisEntity, serverPlayer); + } + }; + serverPlayer.openMenu(provider); + } + } + + @Inject(method = "interact", at = @At("HEAD"), cancellable = true) + private void creaturechat$openInventory(Player player, InteractionHand hand, CallbackInfoReturnable cir) { + if (player.level().isClientSide()) { + return; + } + + if (hand != InteractionHand.MAIN_HAND) { + return; + } + + if (!player.isSecondaryUseActive()) { + return; + } + + Mob thisEntity = (Mob) (Object) this; + + if (thisEntity instanceof Villager || thisEntity instanceof TamableAnimal) { + return; + } + + // Only open the inventory if chat data exists and has been used + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + if (player instanceof ServerPlayer serverPlayer) { + ExtendedScreenHandlerFactory provider = new ExtendedScreenHandlerFactory<>() { + @Override + public Integer getScreenOpeningData(ServerPlayer p) { + return thisEntity.getId(); + } + + @Override + public Component getDisplayName() { + return thisEntity.getDisplayName(); + } + + @Override + public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId, Inventory playerInventory, Player p) { + return new MobInventoryMenu(syncId, playerInventory, creaturechat$inventory, thisEntity, serverPlayer); + } + }; + serverPlayer.openMenu(provider); + cir.setReturnValue(InteractionResult.SUCCESS); + } + } + + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ServerLevel level, ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity.level().isClientSide()) { + return; + } + + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) + private void creaturechat$saveInventory(CompoundTag tag, CallbackInfo ci) { + ListTag listTag = new ListTag(); + HolderLookup.Provider provider = ((Mob) (Object) this).registryAccess(); + + for (int i = 0; i < creaturechat$inventory.getContainerSize(); i++) { + ItemStack stack = creaturechat$inventory.getItem(i); + if (!stack.isEmpty()) { + CompoundTag wrapper = new CompoundTag(); + wrapper.putByte("Slot", (byte) i); + + // ItemStack#save returns the populated tag instead of mutating the provided one. + // Store that result directly so the item id is written correctly. + wrapper.put("Item", stack.save(provider)); + + listTag.add(wrapper); + } + } + + tag.put("CreatureChatInventory", listTag); + } + + @Inject(method = "readAdditionalSaveData", at = @At("RETURN")) + private void creaturechat$loadInventory(CompoundTag tag, CallbackInfo ci) { + ListTag listTag = tag.getList("CreatureChatInventory", 10); + HolderLookup.Provider provider = ((Mob) (Object) this).registryAccess(); + + for (int i = 0; i < listTag.size(); ++i) { + CompoundTag wrapper = listTag.getCompound(i); + int slot = wrapper.getByte("Slot") & 255; + if (slot >= 0 && slot < creaturechat$inventory.getContainerSize()) { + CompoundTag itemTag; + if (wrapper.contains("Item", 10)) { + itemTag = wrapper.getCompound("Item"); + } else { + itemTag = wrapper.copy(); + itemTag.remove("Slot"); + } + ItemStack parsed = ItemStack.parse(provider, itemTag).orElse(ItemStack.EMPTY); + creaturechat$inventory.setItem(slot, parsed); + } + } + } + + @Inject(method = "interact", at = @At(value = "RETURN")) + private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturnable cir) { + // Only process interactions on the server side + if (player.level().isClientSide()) { + return; + } + + // Only process interactions for the main hand + if (hand != InteractionHand.MAIN_HAND) { + return; + } + + ItemStack itemStack = player.getItemInHand(hand); + Mob thisEntity = (Mob) (Object) this; + + // Don't interact with Villagers (avoid issues with trade UI) OR Tameable (i.e. sit / no-sit) + if (thisEntity instanceof Villager || thisEntity instanceof TamableAnimal) { + return; + } + + // Determine if the item is a bucket + // We don't want to interact on buckets + Item item = itemStack.getItem(); + if (item == Items.BUCKET || + item == Items.WATER_BUCKET || + item == Items.LAVA_BUCKET || + item == Items.POWDER_SNOW_BUCKET || + item == Items.MILK_BUCKET || + item == Items.PUFFERFISH_BUCKET || + item == Items.SALMON_BUCKET || + item == Items.COD_BUCKET || + item == Items.TROPICAL_FISH_BUCKET || + item == Items.AXOLOTL_BUCKET || + item == Items.TADPOLE_BUCKET) { + return; + } + + // Get chat data for entity + ChatDataManager chatDataManager = ChatDataManager.getServerInstance(); + EntityChatData entityData = chatDataManager.getOrCreateChatData(thisEntity.getStringUUID()); + PlayerData playerData = entityData.getPlayerData(player.getDisplayName().getString()); + + // Check if the player successfully interacts with an item + if (player instanceof ServerPlayer) { + // Player has item in hand + if (!itemStack.isEmpty()) { + ServerPlayer serverPlayer = (ServerPlayer) player; + String itemName = itemStack.getItem().getName(itemStack).getString(); + int itemCount = itemStack.getCount(); + + // Decide verb + String action_verb = " shows "; + if (cir.getReturnValue().consumesAction()) { + action_verb = " gives "; + } + + // Prepare a message about the interaction + String giveItemMessage = "<" + serverPlayer.getDisplayName().getString() + + action_verb + "you " + itemCount + " " + itemName + ">"; + + if (!entityData.characterSheet.isEmpty()) { + ServerPackets.generate_chat("N/A", entityData, serverPlayer, thisEntity, giveItemMessage, true); + } + + } else if (itemStack.isEmpty() && playerData.friendship == 3) { + // Player's hand is empty, Ride your best friend! + player.startRiding(thisEntity, true); + } + } + } + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameComponents(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } +} diff --git a/src/vs/v1_21_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/vs/v1_21_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java index bcdfc034..306400fe 100644 --- a/src/vs/v1_21_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java +++ b/src/vs/v1_21_5/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -8,10 +8,13 @@ import com.owlmaddie.chat.PlayerData; import com.owlmaddie.inventory.ChatInventory; import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.minecraft.world.entity.HasCustomInventoryScreen; import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; +import net.minecraft.core.Vec3i; import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; @@ -22,6 +25,8 @@ import net.minecraft.nbt.ListTag; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.npc.Villager; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -122,6 +127,68 @@ public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId } } + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ServerLevel level, ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + if (thisEntity.level().isClientSide()) { + return; + } + + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) private void creaturechat$saveInventory(CompoundTag tag, CallbackInfo ci) { ListTag listTag = new ListTag(); @@ -237,4 +304,59 @@ private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturn } } } -} \ No newline at end of file + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameComponents(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } +} diff --git a/src/vs/v1_21_6/main/java/com/owlmaddie/mixin/MixinMobEntity.java b/src/vs/v1_21_6/main/java/com/owlmaddie/mixin/MixinMobEntity.java index d5f0c1b6..35f80b62 100644 --- a/src/vs/v1_21_6/main/java/com/owlmaddie/mixin/MixinMobEntity.java +++ b/src/vs/v1_21_6/main/java/com/owlmaddie/mixin/MixinMobEntity.java @@ -8,11 +8,14 @@ import com.owlmaddie.chat.PlayerData; import com.owlmaddie.inventory.ChatInventory; import com.owlmaddie.inventory.MobInventoryMenu; +import com.owlmaddie.inventory.PickupMessageBatcher; import com.owlmaddie.network.ServerPackets; import net.minecraft.world.entity.HasCustomInventoryScreen; import net.fabricmc.fabric.api.screenhandler.v1.ExtendedScreenHandlerFactory; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.core.Vec3i; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.SimpleContainer; @@ -21,6 +24,8 @@ import net.minecraft.world.level.storage.ValueOutput; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.npc.Villager; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -31,6 +36,8 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The {@code MixinMobEntity} mixin class exposes the goalSelector field from the MobEntity class. @@ -38,6 +45,7 @@ @Mixin(Mob.class) public class MixinMobEntity implements ChatInventory, HasCustomInventoryScreen { + private static final Logger LOGGER = LoggerFactory.getLogger("creaturechat"); private final SimpleContainer creaturechat$inventory = new SimpleContainer(15); @Override @@ -121,6 +129,77 @@ public net.minecraft.world.inventory.AbstractContainerMenu createMenu(int syncId } } + @Inject(method = "pickUpItem", at = @At("HEAD"), cancellable = true) + private void creaturechat$pickupFriendItem(ServerLevel level, ItemEntity itemEntity, CallbackInfo ci) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData == null || chatData.status == ChatDataManager.ChatStatus.NONE) { + return; + } + + Entity owner = itemEntity.getOwner(); + if (!(owner instanceof Player throwerPlayer)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("[CreatureChat] pickup ignored: no player owner for {}", thisEntity.getType()); + } + ci.cancel(); + return; + } + + PlayerData playerData = chatData.getPlayerData(throwerPlayer.getDisplayName().getString()); + if (playerData.friendship <= 0) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("[CreatureChat] pickup ignored: {} not friends with {}", throwerPlayer.getDisplayName().getString(), thisEntity.getType()); + } + ci.cancel(); + return; + } + + ItemStack stack = itemEntity.getItem(); + if (stack.isEmpty()) { + ci.cancel(); + return; + } + + ItemStack remaining = stack.copy(); + creaturechat$insertIntoInventory(remaining); + int pickedUp = stack.getCount() - remaining.getCount(); + if (pickedUp > 0) { + thisEntity.onItemPickup(itemEntity); + thisEntity.take(itemEntity, pickedUp); + PickupMessageBatcher.recordPickup(thisEntity, throwerPlayer, stack, pickedUp); + } + if (remaining.isEmpty()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("[CreatureChat] pickup success: {} took {}x{}", thisEntity.getType(), stack.getCount(), stack.getItem()); + } + itemEntity.discard(); + } else { + itemEntity.setItem(remaining); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("[CreatureChat] pickup partial: {} stored {}x{}, left {}", + thisEntity.getType(), stack.getCount() - remaining.getCount(), stack.getItem(), remaining.getCount()); + } + } + + ci.cancel(); + } + + @Inject(method = "getPickupReach", at = @At("RETURN"), cancellable = true) + private void creaturechat$expandPickupReach(CallbackInfoReturnable cir) { + Vec3i reach = cir.getReturnValue(); + cir.setReturnValue(new Vec3i(reach.getX() + 1, reach.getY() + 1, reach.getZ() + 1)); + } + + @Inject(method = "canPickUpLoot", at = @At("HEAD"), cancellable = true) + private void creaturechat$allowFriendLoot(CallbackInfoReturnable cir) { + Mob thisEntity = (Mob) (Object) this; + EntityChatData chatData = ChatDataManager.getServerInstance().entityChatDataMap.get(thisEntity.getStringUUID()); + if (chatData != null && chatData.status != ChatDataManager.ChatStatus.NONE) { + cir.setReturnValue(true); + } + } + @Inject(method = "addAdditionalSaveData", at = @At("RETURN")) private void creaturechat$saveInventory(ValueOutput tag, CallbackInfo ci) { creaturechat$inventory.storeAsItemList(tag.list("CreatureChatInventory", ItemStack.CODEC)); @@ -217,4 +296,59 @@ private void onItemGiven(Player player, InteractionHand hand, CallbackInfoReturn } } } -} \ No newline at end of file + + private void creaturechat$insertIntoInventory(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + + int size = creaturechat$inventory.getContainerSize(); + int mainHandSlot = creaturechat$getMainHandSlot(size); + int offHandSlot = mainHandSlot + 1; + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (existing.isEmpty()) { + continue; + } + if (!ItemStack.isSameItemSameComponents(existing, stack)) { + continue; + } + int max = Math.min(existing.getMaxStackSize(), creaturechat$inventory.getMaxStackSize()); + int space = max - existing.getCount(); + if (space <= 0) { + continue; + } + int moved = Math.min(space, stack.getCount()); + existing.grow(moved); + stack.shrink(moved); + creaturechat$inventory.setItem(i, existing); + if (stack.isEmpty()) { + return; + } + } + + for (int i = 0; i < size; i++) { + if (i == mainHandSlot || i == offHandSlot) { + continue; + } + ItemStack existing = creaturechat$inventory.getItem(i); + if (!existing.isEmpty()) { + continue; + } + int moved = Math.min(stack.getCount(), Math.min(stack.getMaxStackSize(), creaturechat$inventory.getMaxStackSize())); + creaturechat$inventory.setItem(i, stack.split(moved)); + if (stack.isEmpty()) { + return; + } + } + } + + private static int creaturechat$getMainHandSlot(int size) { + int rows = (size + 4) / 5; + return Math.max(0, rows - 1) * 5; + } +}