From e0c1bf8af72b64b6093182c9f9895c0a3144883a Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:00 -0300 Subject: [PATCH 01/10] feat(npcs): add domain model and architecture audit --- .../npcs/api/BigBangNpcs.java | 11 + .../bigbangessentials/npcs/api/NpcAction.java | 46 +++ .../npcs/api/NpcActionType.java | 7 + .../npcs/api/NpcDefinition.java | 105 +++++++ .../npcs/api/NpcHologramConfig.java | 46 +++ .../npcs/api/NpcInteractionConfig.java | 22 ++ .../npcs/api/NpcLocation.java | 47 +++ .../npcs/api/NpcLookSettings.java | 50 ++++ .../npcs/api/NpcService.java | 22 ++ .../bigbangessentials/npcs/api/NpcSkin.java | 47 +++ .../bigbangessentials/npcs/api/NpcStats.java | 80 ++++++ .../engineering/npcs/01-architecture-audit.md | 270 ++++++++++++++++++ 12 files changed, 753 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/BigBangNpcs.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcAction.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcActionType.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcDefinition.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcHologramConfig.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcInteractionConfig.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLocation.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLookSettings.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcService.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcSkin.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcStats.java create mode 100644 docs/engineering/npcs/01-architecture-audit.md diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/BigBangNpcs.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/BigBangNpcs.java new file mode 100644 index 00000000..0d58eb9c --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/BigBangNpcs.java @@ -0,0 +1,11 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import com.pedrodalben.bigbangessentials.npcs.service.NpcManager; + +public final class BigBangNpcs { + private BigBangNpcs() {} + + public static NpcService getApi() { + return NpcManager.getInstance(); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcAction.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcAction.java new file mode 100644 index 00000000..ffab09fd --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcAction.java @@ -0,0 +1,46 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import java.util.Objects; + +public final class NpcAction { + private final NpcActionType type; + private final String command; + + public NpcAction(NpcActionType type, String command) { + this.type = type != null ? type : NpcActionType.NONE; + this.command = command != null ? command.trim() : ""; + } + + public static NpcAction none() { + return new NpcAction(NpcActionType.NONE, ""); + } + + public static NpcAction playerCommand(String command) { + return new NpcAction(NpcActionType.PLAYER_COMMAND, stripSlash(command)); + } + + public static NpcAction consoleCommand(String command) { + return new NpcAction(NpcActionType.CONSOLE_COMMAND, stripSlash(command)); + } + + private static String stripSlash(String cmd) { + if (cmd == null) return ""; + String trimmed = cmd.trim(); + return trimmed.startsWith("/") ? trimmed.substring(1).trim() : trimmed; + } + + public NpcActionType type() { return type; } + public String command() { return command; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NpcAction that)) return false; + return type == that.type && command.equals(that.command); + } + + @Override + public int hashCode() { + return Objects.hash(type, command); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcActionType.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcActionType.java new file mode 100644 index 00000000..578a3b78 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcActionType.java @@ -0,0 +1,7 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +public enum NpcActionType { + NONE, + PLAYER_COMMAND, + CONSOLE_COMMAND +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcDefinition.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcDefinition.java new file mode 100644 index 00000000..efec69ca --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcDefinition.java @@ -0,0 +1,105 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import java.util.Objects; +import java.util.regex.Pattern; + +public final class NpcDefinition { + private static final Pattern VALID_ID = Pattern.compile("^[a-z0-9_-]{1,64}$"); + + private final String id; + private final boolean enabled; + private final String displayName; + private final NpcLocation location; + private final NpcSkin skin; + private final NpcAction action; + private final NpcHologramConfig hologram; + private final NpcLookSettings lookSettings; + private final double viewDistance; + private final double despawnDistance; + private final NpcInteractionConfig interaction; + + public NpcDefinition(String id, boolean enabled, String displayName, NpcLocation location, + NpcSkin skin, NpcAction action, NpcHologramConfig hologram, + NpcLookSettings lookSettings, double viewDistance, double despawnDistance, + NpcInteractionConfig interaction) { + this.id = normalizeId(id); + this.enabled = enabled; + this.displayName = displayName != null ? displayName : id; + this.location = Objects.requireNonNull(location, "location"); + this.skin = Objects.requireNonNull(skin, "skin"); + this.action = action != null ? action : NpcAction.none(); + this.hologram = hologram != null ? hologram : NpcHologramConfig.disabled(); + this.lookSettings = lookSettings != null ? lookSettings : NpcLookSettings.disabled(); + this.viewDistance = Math.max(1.0, viewDistance); + this.despawnDistance = Math.max(viewDistance, despawnDistance); + this.interaction = interaction != null ? interaction : NpcInteractionConfig.defaults(); + } + + public static String normalizeId(String id) { + if (id == null) throw new IllegalArgumentException("NPC id cannot be null"); + String normalized = id.trim().toLowerCase(); + if (!VALID_ID.matcher(normalized).matches()) { + throw new IllegalArgumentException("Invalid NPC id: '" + id + "'. Must match [a-z0-9_-]{1,64}"); + } + return normalized; + } + + public String id() { return id; } + public boolean enabled() { return enabled; } + public String displayName() { return displayName; } + public NpcLocation location() { return location; } + public NpcSkin skin() { return skin; } + public NpcAction action() { return action; } + public NpcHologramConfig hologram() { return hologram; } + public NpcLookSettings lookSettings() { return lookSettings; } + public double viewDistance() { return viewDistance; } + public double despawnDistance() { return despawnDistance; } + public NpcInteractionConfig interaction() { return interaction; } + + public NpcDefinition withEnabled(boolean enabled) { + return new NpcDefinition(id, enabled, displayName, location, skin, action, hologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withLocation(NpcLocation newLocation) { + return new NpcDefinition(id, enabled, displayName, newLocation, skin, action, hologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withSkin(NpcSkin newSkin) { + return new NpcDefinition(id, enabled, displayName, location, newSkin, action, hologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withAction(NpcAction newAction) { + return new NpcDefinition(id, enabled, displayName, location, skin, newAction, hologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withDisplayName(String newDisplayName) { + return new NpcDefinition(id, enabled, newDisplayName, location, skin, action, hologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withHologram(NpcHologramConfig newHologram) { + return new NpcDefinition(id, enabled, displayName, location, skin, action, newHologram, + lookSettings, viewDistance, despawnDistance, interaction); + } + + public NpcDefinition withLookSettings(NpcLookSettings newLook) { + return new NpcDefinition(id, enabled, displayName, location, skin, action, hologram, + newLook, viewDistance, despawnDistance, interaction); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NpcDefinition that)) return false; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcHologramConfig.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcHologramConfig.java new file mode 100644 index 00000000..6ae2c986 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcHologramConfig.java @@ -0,0 +1,46 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class NpcHologramConfig { + private final boolean enabled; + private final List lines; + private final double offsetY; + private final double viewDistance; + private final boolean shadow; + private final boolean seeThrough; + + public NpcHologramConfig(boolean enabled, List lines, double offsetY, double viewDistance, boolean shadow, boolean seeThrough) { + this.enabled = enabled; + this.lines = Collections.unmodifiableList(new ArrayList<>(lines != null ? lines : List.of())); + this.offsetY = Math.max(0.0, offsetY); + this.viewDistance = Math.max(1.0, viewDistance); + this.shadow = shadow; + this.seeThrough = seeThrough; + } + + public static NpcHologramConfig defaults(String displayName) { + return new NpcHologramConfig(true, List.of(displayName), 2.25, 32.0, true, false); + } + + public static NpcHologramConfig disabled() { + return new NpcHologramConfig(false, List.of(), 2.25, 32.0, true, false); + } + + public boolean enabled() { return enabled; } + public List lines() { return lines; } + public double offsetY() { return offsetY; } + public double viewDistance() { return viewDistance; } + public boolean shadow() { return shadow; } + public boolean seeThrough() { return seeThrough; } + + public NpcHologramConfig withEnabled(boolean enabled) { + return new NpcHologramConfig(enabled, lines, offsetY, viewDistance, shadow, seeThrough); + } + + public NpcHologramConfig withLines(List lines) { + return new NpcHologramConfig(enabled, lines, offsetY, viewDistance, shadow, seeThrough); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcInteractionConfig.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcInteractionConfig.java new file mode 100644 index 00000000..99f9aced --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcInteractionConfig.java @@ -0,0 +1,22 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +public final class NpcInteractionConfig { + private final double distance; + private final long cooldownMillis; + private final String permission; + + public NpcInteractionConfig(double distance, long cooldownMillis, String permission) { + this.distance = Math.max(0.5, distance); + this.cooldownMillis = Math.max(0, cooldownMillis); + this.permission = permission != null ? permission.trim() : ""; + } + + public static NpcInteractionConfig defaults() { + return new NpcInteractionConfig(4.5, 750, ""); + } + + public double distance() { return distance; } + public long cooldownMillis() { return cooldownMillis; } + public String permission() { return permission; } + public boolean hasPermission() { return !permission.isEmpty(); } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLocation.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLocation.java new file mode 100644 index 00000000..77b585e5 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLocation.java @@ -0,0 +1,47 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import net.minecraft.resources.ResourceLocation; +import java.util.Objects; + +public final class NpcLocation { + private final ResourceLocation dimension; + private final double x; + private final double y; + private final double z; + private final float yaw; + private final float pitch; + + public NpcLocation(ResourceLocation dimension, double x, double y, double z, float yaw, float pitch) { + this.dimension = Objects.requireNonNull(dimension, "dimension"); + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + } + + public ResourceLocation dimension() { return dimension; } + public double x() { return x; } + public double y() { return y; } + public double z() { return z; } + public float yaw() { return yaw; } + public float pitch() { return pitch; } + + public NpcLocation withYawPitch(float yaw, float pitch) { + return new NpcLocation(dimension, x, y, z, yaw, pitch); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NpcLocation that)) return false; + return Double.compare(that.x, x) == 0 && Double.compare(that.y, y) == 0 + && Double.compare(that.z, z) == 0 && Float.compare(that.yaw, yaw) == 0 + && Float.compare(that.pitch, pitch) == 0 && dimension.equals(that.dimension); + } + + @Override + public int hashCode() { + return Objects.hash(dimension, x, y, z, yaw, pitch); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLookSettings.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLookSettings.java new file mode 100644 index 00000000..0aa8cc82 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcLookSettings.java @@ -0,0 +1,50 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +public final class NpcLookSettings { + private final boolean enabled; + private final double range; + private final int updateIntervalTicks; + private final double minimumAngleChange; + private final double maxYawFromBase; + private final double maxPitchUp; + private final double maxPitchDown; + private final boolean rotateBody; + private final boolean resetWhenOutOfRange; + + public NpcLookSettings(boolean enabled, double range, int updateIntervalTicks, double minimumAngleChange, + double maxYawFromBase, double maxPitchUp, double maxPitchDown, + boolean rotateBody, boolean resetWhenOutOfRange) { + this.enabled = enabled; + this.range = Math.max(0.0, range); + this.updateIntervalTicks = Math.max(1, updateIntervalTicks); + this.minimumAngleChange = Math.max(0.0, minimumAngleChange); + this.maxYawFromBase = Math.max(0.0, maxYawFromBase); + this.maxPitchUp = Math.max(0.0, maxPitchUp); + this.maxPitchDown = Math.max(0.0, maxPitchDown); + this.rotateBody = rotateBody; + this.resetWhenOutOfRange = resetWhenOutOfRange; + } + + public static NpcLookSettings defaults() { + return new NpcLookSettings(true, 10.0, 4, 2.0, 100.0, 45.0, 35.0, true, true); + } + + public static NpcLookSettings disabled() { + return new NpcLookSettings(false, 10.0, 4, 2.0, 100.0, 45.0, 35.0, true, true); + } + + public boolean enabled() { return enabled; } + public double range() { return range; } + public int updateIntervalTicks() { return updateIntervalTicks; } + public double minimumAngleChange() { return minimumAngleChange; } + public double maxYawFromBase() { return maxYawFromBase; } + public double maxPitchUp() { return maxPitchUp; } + public double maxPitchDown() { return maxPitchDown; } + public boolean rotateBody() { return rotateBody; } + public boolean resetWhenOutOfRange() { return resetWhenOutOfRange; } + + public NpcLookSettings withEnabled(boolean enabled) { + return new NpcLookSettings(enabled, range, updateIntervalTicks, minimumAngleChange, + maxYawFromBase, maxPitchUp, maxPitchDown, rotateBody, resetWhenOutOfRange); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcService.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcService.java new file mode 100644 index 00000000..0ba3c447 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcService.java @@ -0,0 +1,22 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import java.util.Collection; +import java.util.Optional; + +public interface NpcService { + Optional find(String id); + + Collection list(); + + NpcDefinition create(NpcDefinition definition); + + NpcDefinition update(NpcDefinition definition); + + boolean delete(String id); + + void reload(); + + void save(); + + NpcStats stats(); +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcSkin.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcSkin.java new file mode 100644 index 00000000..b55a6373 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcSkin.java @@ -0,0 +1,47 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +import java.util.Objects; + +public final class NpcSkin { + private final String playerName; + private final String uuid; + private final String textureValue; + private final String textureSignature; + private final String model; // "default" or "slim" + private final long resolvedAt; + + public NpcSkin(String playerName, String uuid, String textureValue, String textureSignature, String model, long resolvedAt) { + this.playerName = Objects.requireNonNull(playerName, "playerName"); + this.uuid = uuid != null ? uuid : ""; + this.textureValue = textureValue != null ? textureValue : ""; + this.textureSignature = textureSignature != null ? textureSignature : ""; + this.model = model != null ? model : "default"; + this.resolvedAt = resolvedAt; + } + + public static NpcSkin unresolved(String playerName) { + return new NpcSkin(playerName, "", "", "", "default", 0L); + } + + public String playerName() { return playerName; } + public String uuid() { return uuid; } + public String textureValue() { return textureValue; } + public String textureSignature() { return textureSignature; } + public String model() { return model; } + public long resolvedAt() { return resolvedAt; } + public boolean isResolved() { return !textureValue.isEmpty(); } + public boolean isSlim() { return "slim".equalsIgnoreCase(model); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NpcSkin that)) return false; + return resolvedAt == that.resolvedAt && playerName.equals(that.playerName) + && textureValue.equals(that.textureValue) && model.equals(that.model); + } + + @Override + public int hashCode() { + return Objects.hash(playerName, textureValue, model, resolvedAt); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcStats.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcStats.java new file mode 100644 index 00000000..b641b794 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/api/NpcStats.java @@ -0,0 +1,80 @@ +package com.pedrodalben.bigbangessentials.npcs.api; + +public final class NpcStats { + private final int definitions; + private final int enabled; + private final int invalid; + private final int viewerSessions; + private final int visibleInstances; + private final int spatialIndexEntries; + private final int queuedSyncs; + private final int queuedSpawns; + private final int queuedDespawns; + private final int lookUpdatesLastTick; + private final int lookUpdatesDropped; + private final int skinMemCacheEntries; + private final int skinCacheHits; + private final int skinCacheMisses; + private final int skinStaleHits; + private final int skinNegativeHits; + private final int skinRequestsInFlight; + private final int skinRequestFailures; + private final int hologramsActive; + private final long lastReloadMillis; + private final long lastSaveMillis; + + public NpcStats(int definitions, int enabled, int invalid, int viewerSessions, int visibleInstances, + int spatialIndexEntries, int queuedSyncs, int queuedSpawns, int queuedDespawns, + int lookUpdatesLastTick, int lookUpdatesDropped, int skinMemCacheEntries, + int skinCacheHits, int skinCacheMisses, int skinStaleHits, int skinNegativeHits, + int skinRequestsInFlight, int skinRequestFailures, int hologramsActive, + long lastReloadMillis, long lastSaveMillis) { + this.definitions = definitions; + this.enabled = enabled; + this.invalid = invalid; + this.viewerSessions = viewerSessions; + this.visibleInstances = visibleInstances; + this.spatialIndexEntries = spatialIndexEntries; + this.queuedSyncs = queuedSyncs; + this.queuedSpawns = queuedSpawns; + this.queuedDespawns = queuedDespawns; + this.lookUpdatesLastTick = lookUpdatesLastTick; + this.lookUpdatesDropped = lookUpdatesDropped; + this.skinMemCacheEntries = skinMemCacheEntries; + this.skinCacheHits = skinCacheHits; + this.skinCacheMisses = skinCacheMisses; + this.skinStaleHits = skinStaleHits; + this.skinNegativeHits = skinNegativeHits; + this.skinRequestsInFlight = skinRequestsInFlight; + this.skinRequestFailures = skinRequestFailures; + this.hologramsActive = hologramsActive; + this.lastReloadMillis = lastReloadMillis; + this.lastSaveMillis = lastSaveMillis; + } + + public static NpcStats empty() { + return new NpcStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + public int definitions() { return definitions; } + public int enabled() { return enabled; } + public int invalid() { return invalid; } + public int viewerSessions() { return viewerSessions; } + public int visibleInstances() { return visibleInstances; } + public int spatialIndexEntries() { return spatialIndexEntries; } + public int queuedSyncs() { return queuedSyncs; } + public int queuedSpawns() { return queuedSpawns; } + public int queuedDespawns() { return queuedDespawns; } + public int lookUpdatesLastTick() { return lookUpdatesLastTick; } + public int lookUpdatesDropped() { return lookUpdatesDropped; } + public int skinMemCacheEntries() { return skinMemCacheEntries; } + public int skinCacheHits() { return skinCacheHits; } + public int skinCacheMisses() { return skinCacheMisses; } + public int skinStaleHits() { return skinStaleHits; } + public int skinNegativeHits() { return skinNegativeHits; } + public int skinRequestsInFlight() { return skinRequestsInFlight; } + public int skinRequestFailures() { return skinRequestFailures; } + public int hologramsActive() { return hologramsActive; } + public long lastReloadMillis() { return lastReloadMillis; } + public long lastSaveMillis() { return lastSaveMillis; } +} diff --git a/docs/engineering/npcs/01-architecture-audit.md b/docs/engineering/npcs/01-architecture-audit.md new file mode 100644 index 00000000..60518100 --- /dev/null +++ b/docs/engineering/npcs/01-architecture-audit.md @@ -0,0 +1,270 @@ +# NPC Module Architecture Audit + +**Date:** 2026-08-06 +**Minecraft:** 1.21.1 (Parchment 2024.11.17) +**NeoForge:** 21.1.179+ + +## 1. Existing Components to Reuse + +### 1.1 ModuleManager (`core/ModuleManager.java`) +- States: REGISTERED → DISABLED/BLOCKED → STARTING → RUNNING/FAILED +- `register(id, BooleanSupplier enabled, String... dependencies)` — used in BigBangEssentials.registerModules() +- `prepare(id)` — checks enabled + dependencies, transitions to STARTING +- `started(id, millis)` — transitions to RUNNING +- `failed(id, error)` — transitions to FAILED +- `isActive(id)` / `isRunning(id)` — query current state +- NPC module: `modules.register("npcs", () -> ConfigManager.isModuleEnabled("npcs"), "holograms")` with holograms as optional soft dependency + +### 1.2 ManagerRegistry (`core/ManagerRegistry.java`) +- `registerManager(name, category, class, supplier)` — tracks manager initialization +- `markInitialized(name)` / `markFailed(name, message)` — diagnostics +- NPC: register NpcManager with category "npcs" + +### 1.3 ConfigManager (`config/ConfigManager.java`) +- `isModuleEnabled(module)` → checks `modules.json` or `config.json` for `{module}Enabled` key +- `getConfig(name)` → returns JsonObject from world/serverconfig/bigbangessentials/{name}.json +- NPC toggle: Config file `modules.json` entry: `"npcsEnabled": true` + +### 1.4 ResourceUtil (`util/ResourceUtil.java`) +- `CONFIG_DIR = "world/serverconfig/bigbangessentials/"` — for configs +- `DATA_DIR = "bigbangessentials/"` — for runtime data +- NPC config path: `world/serverconfig/bigbangessentials/npcs/npcs.json` +- NPC data path: `bigbangessentials/npcs/skin-cache.json` + +### 1.5 Holograms Module +- `BigBangHolograms.getApi()` → `HologramService` interface +- Hologram id pattern: `bigbangessentials:/` → NPC holograms: `bigbangessentials:npc/` +- `HologramDefinition.builder(id).location(...).pages(...).persistent(true).build()` for creation +- `createOrUpdate()` for idempotent create/update +- `delete(id)` for removal +- `deleteByOwner(ownerId)` for orphan cleanup (on shutdown) +- Hologram lifecycle: `HologramDefinition.withEnabled(true/false)` to toggle + +### 1.6 Permission System (`api/permissions/PermissionAPI`) +- `PermissionAPI.hasPermission(uuid, permission)` — check perms +- Register permission nodes in permissions.md + +### 1.7 Platform Bridge (`util/Platform.java`) +- `Platform.getCurrentServer()` → `MinecraftServer` instance +- `Platform.init(provider)` — FabricPlatformProvider / NeoForgePlatformProvider +- `Platform.isModLoaded(modId)` — optional dependency checks + +### 1.8 Message System (`util/MessageUtil`) +- `MessageUtil.sendMessage(player, key, ...)` — localized messages +- `ChatComponentUtil.parseColorCodes(text)` — MiniMessage-esque formatting (``) + +### 1.9 Scheduler (`scheduler/TaskScheduler.java`) +- `TaskScheduler.onServerTick(server)` — periodic task execution +- Can use server's tick for budgeted operations + +### 1.10 Command Pattern +- Fabric: `CommandRegistrationCallback.EVENT.register((dispatcher, ...) -> ...)` +- NeoForge: `@SubscribeEvent RegisterCommandsEvent` +- Common: `BigBangEssentials.GameEvents.onRegisterCommands(dispatcher)` +- Brigadier commands registered in `CommandRegistry` + +## 2. New Components Required + +### 2.1 Domain Model (`api/`) +- `NpcDefinition` — immutable record/class with id, location, skin, action, hologram config, look settings +- `NpcLocation` — dimension, x, y, z, yaw, pitch +- `NpcSkin` — playerName (Mojang account), resolved texture value, signature, model +- `NpcAction` — type (PLAYER_COMMAND, CONSOLE_COMMAND, NONE), command string +- `NpcHologramConfig` — enabled, lines, offsetY, viewDistance, shadow, seeThrough +- `NpcLookSettings` — enabled, range, updateIntervalTicks, minimumAngleChange, rotateBody, etc. +- `NpcInteractionConfig` — distance, cooldownMillis, permission +- `NpcService` — API interface (find, list, create, update, delete, reload, save, stats) + +### 2.2 Persistence (`config/`, `persistence/`) +- `NpcConfig` — top-level config with defaults, performance settings, npcs map +- `NpcConfigStore` — reads/writes npcs.json atomically (temp + backup + ATOMIC_MOVE) +- `NpcConfigValidator` — validates individual NPC definitions, normalizes IDs + +### 2.3 Skin System (`skin/`) +- `SkinService` — orchestrator: check cache → resolve → cache +- `SkinCache` — in-memory ConcurrentHashMap with deduplication of inflight requests +- `SkinCacheEntry` — texture value, signature, uuid, model, fetchedAt, expiresAt +- `MojangSkinResolver` — HTTP calls to Mojang API (async, limited executor) +- Persistent skin cache: `bigbangessentials/npcs/skin-cache.json` + +### 2.4 Render System (`render/`) +- `NpcRenderService` — manages spawning/despawning per viewer +- `NpcRenderSnapshot` — entityId, UUID, position, skin data, rotation, metadata +- `NpcViewerService` — viewer sessions (matching hologram ViewerService pattern) +- `NpcViewerSession` — visible NPCs, entity IDs, fingerprints, last rotation +- `NpcEntityIdAllocator` — AtomicInteger starting at 2_000_000_000 (separate range from holograms 1_500_000_000) + +### 2.5 Spatial Index (`spatial/`) +- `NpcSpatialIndex` — adapted from `ChunkSpatialIndex`, queries NPCs near player chunk + +### 2.6 Look System (`look/`) +- `NpcLookService` — calculates yaw/pitch for each viewer, budgeted per tick +- Uses `Math.atan2` for yaw, clamped within maxYawFromBase/maxPitch +- Sends `ClientboundRotateHeadPacket` + `ClientboundTeleportEntityPacket` for rotation + +### 2.7 Interaction System (`interaction/`) +- `NpcInteractionService` — validates click, checks distance/permission/cooldown, dispatches +- `NpcActionExecutor` — executes PLAYER_COMMAND / CONSOLE_COMMAND +- Fabric bridge: `UseEntityCallback.EVENT` (already used by holograms/crates) +- NeoForge bridge: `PlayerInteractEvent.EntityInteractSpecific` (already used by holograms) + +### 2.8 Hologram Integration (`hologram/`) +- `NpcHologramService` — creates/updates/removes holograms via `BigBangHolograms.getApi()` +- Orphan cleanup on shutdown: `deleteByOwner("bigbangessentials:npc")` + +### 2.9 Service/Orchestrator (`service/`) +- `NpcManager` — singleton, implements `NpcService`, orchestrates all subsystems +- Lifecycle: initialize, tick, reload, shutdown + +## 3. Fabric vs NeoForge Differences + +| Concern | Fabric | NeoForge | +|---------|--------|----------| +| Entrypoint | `ModInitializer.onInitialize()` | `@Mod` constructor + `IEventBus` | +| Server events | `ServerLifecycleEvents` callbacks | `@SubscribeEvent` static methods | +| Tick | `ServerTickEvents.END_SERVER_TICK` | `ServerTickEvent.Post` | +| Player join | `ServerPlayConnectionEvents.JOIN` | `PlayerEvent.PlayerLoggedInEvent` | +| Player leave | `ServerPlayConnectionEvents.DISCONNECT` | `PlayerEvent.PlayerLoggedOutEvent` | +| Entity interact | `UseEntityCallback.EVENT` | `PlayerInteractEvent.EntityInteractSpecific` | +| Dimension change | `ServerEntityWorldChangeEvents.AFTER_PLAYER_CHANGE_WORLD` | `PlayerChangedDimensionEvent` | +| Command register | `CommandRegistrationCallback.EVENT` | `RegisterCommandsEvent` | +| Packet send | `player.connection.send(packet)` — same | `player.connection.send(packet)` — same | + +**Key insight: Packet sending is identical on both platforms.** The Minecraft protocol classes (`ClientboundAddPlayerPacket`, `ClientboundPlayerInfoUpdatePacket`, etc.) are on the Mojang-mapped classpath for both loaders. This means NPC rendering code can live entirely in `common/` — no separate Fabric/NeoForge renderers needed. + +Platform-specific code needed only for: +- Event registration (FabricEvents.java / NeoForgeEvents.java hooks) +- Entity interaction click detection (use existing bridge pattern) + +## 4. Rendering Strategy + +### 4.1 Packets Per Viewer (Spawn Sequence) +1. `ClientboundPlayerInfoUpdatePacket(ADD_PLAYER, [entry])` — add player info temporarily +2. `ClientboundAddPlayerPacket(...)` — spawn fake player entity (already existed since 1.20.5) +3. `ClientboundSetEntityDataPacket(id, metadata)` — skin layers, pose +4. Wait ~1 tick (optional, for skin load). Remove from tab: + `ClientboundPlayerInfoUpdatePacket(UPDATE_LISTED, [entry])` with listed=false — hide from tab without removing entity + +MC 1.21.1 uses `ClientboundPlayerInfoUpdatePacket` with Action enums. The `createPlayerInitializing()` static method creates ADD_PLAYER entries. After skin loads, `UPDATE_LISTED` action with `listed = false` hides from tab while keeping the entity visible. + +Alternative: Use `ClientboundPlayerInfoRemovePacket` after the entity spawn — but this might also remove the rendered entity on some client versions. Better to use the UPDATE_LISTED approach. + +### 4.2 Per-Viewer Rotation +- `ClientboundRotateHeadPacket(entityId, headYaw)` — head rotation +- `ClientboundTeleportEntityPacket(entityId, pos, bodyYaw, pitch, onGround)` — body + pitch + position + +### 4.3 Despawn +- `ClientboundRemoveEntitiesPacket(entityId)` — standard entity removal + +### 4.4 Fingerprint Strategy +- Track last sent state (position, rotation, skin signature) per viewer +- Skip packet when nothing changed +- Separate fingerprints for: position/rotation, metadata, skin + +## 5. Interaction Strategy + +### 5.1 Validation Chain +``` +entity ID received → lookup in session.entityIdToNpc → +check NPC exists → check NPC enabled → +check same dimension → check distance → +check permission → check cooldown → execute +``` + +### 5.2 Platform Registration +- Fabric: `UseEntityCallback.EVENT.register(...)` in FabricCrateEvents-style +- NeoForge: `PlayerInteractEvent.EntityInteractSpecific @SubscribeEvent` in NeoForgeEvents-style + +### 5.3 Duplicate Prevention +- `interactionCooldownMillis` minimum 250ms +- Track `lastClickTimestamp` per (player, npc) pair in ConcurrentHashMap + +## 6. Persistence Strategy + +### 6.1 NPC Configuration +- Path: `world/serverconfig/bigbangessentials/npcs/npcs.json` +- Format: JSON with schemaVersion +- Atomic write: serialize to .tmp → validate → backup old → ATOMIC_MOVE / rename +- Backup files: `npcs.json.bak`, `npcs.json.tmp` +- Debounced save on command changes (500-1000ms) + +### 6.2 Skin Cache +- Path: `bigbangessentials/npcs/skin-cache.json` +- Persisted on shutdown and periodically +- Each entry: normalizedName, originalName, uuid, textureValue, textureSignature, model, fetchedAt, expiresAt + +### 6.3 GSON Usage +- Project already uses GSON (`GsonBuilder().setPrettyPrinting().create()`) +- Same pattern for NPC config + +## 7. Skin Cache Strategy + +### 7.1 In-Memory +- `ConcurrentHashMap` keyed by normalized name +- `ConcurrentHashMap>` for inflight dedup +- Fresh TTL: 24h, Stale TTL: 30d, Negative: 10min + +### 7.2 Persistent +- JSON file loaded at startup, saved on shutdown +- Provides survival across restarts when Mojang API is down + +### 7.3 Executor +- Named, limited-thread pool: `Executors.newFixedThreadPool(2, factory)` +- Named "BigBangEssentials-NpcSkin" +- Shutdown in NpcManager.shutdown() +- All HTTP on background threads; results posted back via `server.execute(() -> ...)` or `server.tell(new TickTask(...))` + +## 8. Performance Impact Assessment + +### 8.1 Hot Path (every tick) +- Visibility sync: round-robin, `maxViewerSyncsPerTick = 50`, step interval 10 ticks +- Look updates: budgeted `maxLookUpdatesPerTick = 200` +- Spatial index queries: O(chunkRadius²) per synced player — very cheap (index is HashMap) +- No global N×M loops + +### 8.2 Cold Path (spawn/despawn) +- Spawn: `maxSpawnsPerTick = 20` (packets per tick cap) +- Despawn: `maxDespawnsPerTick = 50` +- Skin resolution: async, deduplicated, never on main thread + +### 8.3 Memory +- 100 NPCs: ~100 NpcDefinition objects + ~100 HologramDefinitions +- 50 players: 50 NpcViewerSessions (each ~5KB for visible NPC tracking) +- Skin cache: ~100 entries in memory (~1KB each) +- Total: < 1MB for NPC data + sessions + +### 8.4 Network +- Per NPC spawn: ~3 packets (player info + add entity + metadata) +- Per NPC despawn: 1 packet +- Per look update: 1-2 packets (head + body rotation) +- Spawn/despawn budgeted per tick to avoid spikes + +## 9. Risks + +### 9.1 High Risk +- **Packet version compatibility**: MC 1.21.1 packet classes may change across minor versions. Mitigation: use Mojang mappings, test on target version. +- **Skin removal from tab**: `UPDATE_LISTED` with `listed=false` behavior depends on client version. If broken, fallback to `ClientboundPlayerInfoRemovePacket` accepting possible visual artifact. +- **Entity ID collision with holograms**: Separate allocator ranges (NPCs: 2_000_000_000+, Holograms: 1_500_000_000-1_999_999_999). + +### 9.2 Medium Risk +- **Mojang API rate limiting**: Two concurrent requests max. If Moajng returns 429, all skin requests fail until rate limit resets. +- **Hologram module dependency**: NPC module depends on holograms being active. If holograms fail, NPCs degrade gracefully (no holograms, NPCs still work). + +### 9.3 Low Risk +- **Large skin cache file**: Thousands of entries → larger save times. Mitigation: limit cache entries, expire stale entries. +- **Entity metadata differences**: Slim vs default model metadata may differ across versions. Mitigation: test, use version-aware metadata. + +## 10. Integration Points Summary + +| Point | Where | How | +|-------|-------|-----| +| Module registration | BigBangEssentials.registerModules() | `modules.register("npcs", () -> ConfigManager.isModuleEnabled("npcs"), "holograms")` | +| Module startup | BigBangEssentials.GameEvents.onServerStarting() | `NpcManager.getInstance().initialize()` | +| Server tick | FabricEvents/NeoForgeEvents tick handler | `NpcManager.getInstance().tick()` | +| Player join | onPlayerLoggedIn | `NpcManager.getInstance().onPlayerJoin(player)` | +| Player leave | onPlayerLoggedOut | `NpcManager.getInstance().onPlayerLeave(player)` | +| Dimension change | FabricEvents/NeoForgeEvents | `NpcManager.getInstance().onPlayerDimensionChange(player)` | +| Entity interact | FabricEvents/NeoForgeEvents | `NpcManager.getInstance().getInteractionService().handleClick(...)` | +| Command registration | BigBangEssentials.GameEvents.onRegisterCommands() | Add NpcCommand registration | +| Server shutdown | onServerStopping | `NpcManager.getInstance().shutdown()` | +| Reload | NpcCommand /npc reload | `NpcManager.getInstance().reload()` | From 127a97ce806df5a969a0b6b3d0cbd32ad38b5c3a Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:05 -0300 Subject: [PATCH 02/10] feat(npcs): add atomic NPC configuration persistence --- .../npcs/config/NpcConfig.java | 87 +++++ .../npcs/config/NpcConfigStore.java | 366 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfig.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfigStore.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfig.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfig.java new file mode 100644 index 00000000..f8525e09 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfig.java @@ -0,0 +1,87 @@ +package com.pedrodalben.bigbangessentials.npcs.config; + +import com.pedrodalben.bigbangessentials.npcs.api.NpcDefinition; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class NpcConfig { + private final int schemaVersion; + private final double defaultViewDistance; + private final double defaultDespawnDistance; + private final double defaultInteractionDistance; + private final long defaultCooldownMillis; + private final int visibilityScanIntervalTicks; + private final int maxViewerSyncsPerTick; + private final int maxSpawnsPerTick; + private final int maxDespawnsPerTick; + private final int maxLookUpdatesPerTick; + private final int freshTtlHours; + private final int staleTtlDays; + private final int negativeCacheMinutes; + private final int maxConcurrentRequests; + private final int connectTimeoutMillis; + private final int requestTimeoutMillis; + private final Map npcs; + + public NpcConfig(int schemaVersion, double defaultViewDistance, double defaultDespawnDistance, + double defaultInteractionDistance, long defaultCooldownMillis, + int visibilityScanIntervalTicks, int maxViewerSyncsPerTick, + int maxSpawnsPerTick, int maxDespawnsPerTick, int maxLookUpdatesPerTick, + int freshTtlHours, int staleTtlDays, int negativeCacheMinutes, + int maxConcurrentRequests, int connectTimeoutMillis, int requestTimeoutMillis, + Map npcs) { + this.schemaVersion = schemaVersion; + this.defaultViewDistance = defaultViewDistance; + this.defaultDespawnDistance = defaultDespawnDistance; + this.defaultInteractionDistance = defaultInteractionDistance; + this.defaultCooldownMillis = defaultCooldownMillis; + this.visibilityScanIntervalTicks = visibilityScanIntervalTicks; + this.maxViewerSyncsPerTick = maxViewerSyncsPerTick; + this.maxSpawnsPerTick = maxSpawnsPerTick; + this.maxDespawnsPerTick = maxDespawnsPerTick; + this.maxLookUpdatesPerTick = maxLookUpdatesPerTick; + this.freshTtlHours = freshTtlHours; + this.staleTtlDays = staleTtlDays; + this.negativeCacheMinutes = negativeCacheMinutes; + this.maxConcurrentRequests = maxConcurrentRequests; + this.connectTimeoutMillis = connectTimeoutMillis; + this.requestTimeoutMillis = requestTimeoutMillis; + this.npcs = Collections.unmodifiableMap(new LinkedHashMap<>(npcs != null ? npcs : Map.of())); + } + + public static NpcConfig defaults() { + return new NpcConfig(1, 48.0, 56.0, 4.5, 750, + 10, 50, 20, 50, 200, + 24, 30, 10, 2, 3000, 5000, + new LinkedHashMap<>()); + } + + public int schemaVersion() { return schemaVersion; } + public double defaultViewDistance() { return defaultViewDistance; } + public double defaultDespawnDistance() { return defaultDespawnDistance; } + public double defaultInteractionDistance() { return defaultInteractionDistance; } + public long defaultCooldownMillis() { return defaultCooldownMillis; } + public int visibilityScanIntervalTicks() { return visibilityScanIntervalTicks; } + public int maxViewerSyncsPerTick() { return maxViewerSyncsPerTick; } + public int maxSpawnsPerTick() { return maxSpawnsPerTick; } + public int maxDespawnsPerTick() { return maxDespawnsPerTick; } + public int maxLookUpdatesPerTick() { return maxLookUpdatesPerTick; } + public int freshTtlHours() { return freshTtlHours; } + public int staleTtlDays() { return staleTtlDays; } + public int negativeCacheMinutes() { return negativeCacheMinutes; } + public int maxConcurrentRequests() { return maxConcurrentRequests; } + public int connectTimeoutMillis() { return connectTimeoutMillis; } + public int requestTimeoutMillis() { return requestTimeoutMillis; } + public Map npcs() { return npcs; } + + public NpcConfig withNpcs(Map npcs) { + return new NpcConfig(schemaVersion, defaultViewDistance, defaultDespawnDistance, + defaultInteractionDistance, defaultCooldownMillis, + visibilityScanIntervalTicks, maxViewerSyncsPerTick, maxSpawnsPerTick, + maxDespawnsPerTick, maxLookUpdatesPerTick, + freshTtlHours, staleTtlDays, negativeCacheMinutes, + maxConcurrentRequests, connectTimeoutMillis, requestTimeoutMillis, npcs); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfigStore.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfigStore.java new file mode 100644 index 00000000..b16d615b --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/config/NpcConfigStore.java @@ -0,0 +1,366 @@ +package com.pedrodalben.bigbangessentials.npcs.config; + +import com.google.gson.*; +import com.pedrodalben.bigbangessentials.npcs.api.*; +import com.pedrodalben.bigbangessentials.util.ResourceUtil; +import net.minecraft.resources.ResourceLocation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public class NpcConfigStore { + private static final Logger LOGGER = LoggerFactory.getLogger(NpcConfigStore.class); + private static final String DIR = "npcs/"; + private static final String FILE = "npcs.json"; + private static final String TMP = "npcs.json.tmp"; + private static final String BAK = "npcs.json.bak"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + private final Path configDir; + private final Path examplePath; + + public NpcConfigStore() { + this.configDir = ResourceUtil.getConfigDirectoryPath().resolve(DIR); + this.examplePath = configDir.resolve("npcs.example.json"); + ensureDirectory(); + } + + private void ensureDirectory() { + try { + Files.createDirectories(configDir); + } catch (IOException e) { + LOGGER.error("Failed to create NPC config directory: {}", configDir, e); + } + } + + public NpcConfig load() { + Path file = configDir.resolve(FILE); + if (!Files.exists(file)) { + createDefault(file); + } + + try (Reader reader = new FileReader(file.toFile())) { + JsonObject root = GSON.fromJson(reader, JsonObject.class); + if (root == null) { + LOGGER.error("NPC config is empty, loading defaults"); + return NpcConfig.defaults(); + } + return parseRoot(root); + } catch (Exception e) { + LOGGER.error("Failed to load NPC config from {}, trying backup", file, e); + Path bak = configDir.resolve(BAK); + if (Files.exists(bak)) { + try (Reader bakReader = new FileReader(bak.toFile())) { + JsonObject bakRoot = GSON.fromJson(bakReader, JsonObject.class); + if (bakRoot != null) return parseRoot(bakRoot); + } catch (Exception e2) { + LOGGER.error("Failed to load NPC config backup", e2); + } + } + return NpcConfig.defaults(); + } + } + + public void save(NpcConfig config) { + Path file = configDir.resolve(FILE); + Path tmp = configDir.resolve(TMP); + Path bak = configDir.resolve(BAK); + + try { + JsonObject root = serializeRoot(config); + String json = GSON.toJson(root); + + Files.writeString(tmp, json, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + + String verify = Files.readString(tmp); + GSON.fromJson(verify, JsonObject.class); + + if (Files.exists(file)) { + Files.move(file, bak, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + LOGGER.error("Failed to save NPC config atomically to {}", file, e); + } + } + + public void createExample() { + if (Files.exists(examplePath)) return; + NpcConfig defaults = NpcConfig.defaults(); + try { + Files.writeString(examplePath, GSON.toJson(serializeRoot(defaults)), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + LOGGER.warn("Failed to create NPC example config", e); + } + } + + private void createDefault(Path file) { + NpcConfig defaults = NpcConfig.defaults(); + try { + Files.writeString(file, GSON.toJson(serializeRoot(defaults)), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + LOGGER.error("Failed to create default NPC config", e); + } + } + + private NpcConfig parseRoot(JsonObject root) { + int schemaVersion = getInt(root, "schemaVersion", 1); + + JsonObject defaultsObj = root.has("defaults") ? root.getAsJsonObject("defaults") : new JsonObject(); + double defaultViewDistance = getDouble(defaultsObj, "viewDistance", 48.0); + double defaultDespawnDistance = getDouble(defaultsObj, "despawnDistance", 56.0); + double defaultInteractionDistance = getDouble(defaultsObj, "interactionDistance", 4.5); + long defaultCooldownMillis = getLong(defaultsObj, "interactionCooldownMillis", 750); + + JsonObject perfObj = root.has("performance") ? root.getAsJsonObject("performance") : new JsonObject(); + int visibilityScanIntervalTicks = getInt(perfObj, "visibilityScanIntervalTicks", 10); + int maxViewerSyncsPerTick = getInt(perfObj, "maxViewerSyncsPerTick", 50); + int maxSpawnsPerTick = getInt(perfObj, "maxSpawnsPerTick", 20); + int maxDespawnsPerTick = getInt(perfObj, "maxDespawnsPerTick", 50); + int maxLookUpdatesPerTick = getInt(perfObj, "maxLookUpdatesPerTick", 200); + + JsonObject skinCacheObj = root.has("skinCache") ? root.getAsJsonObject("skinCache") : new JsonObject(); + int freshTtlHours = getInt(skinCacheObj, "freshTtlHours", 24); + int staleTtlDays = getInt(skinCacheObj, "staleTtlDays", 30); + int negativeCacheMinutes = getInt(skinCacheObj, "negativeCacheMinutes", 10); + int maxConcurrentRequests = getInt(skinCacheObj, "maxConcurrentRequests", 2); + int connectTimeoutMillis = getInt(skinCacheObj, "connectTimeoutMillis", 3000); + int requestTimeoutMillis = getInt(skinCacheObj, "requestTimeoutMillis", 5000); + + Map npcs = new LinkedHashMap<>(); + if (root.has("npcs") && root.get("npcs").isJsonObject()) { + JsonObject npcsObj = root.getAsJsonObject("npcs"); + for (Map.Entry entry : npcsObj.entrySet()) { + try { + NpcDefinition def = parseNpc(entry.getKey(), entry.getValue().getAsJsonObject(), defaultViewDistance, defaultDespawnDistance, defaultInteractionDistance, defaultCooldownMillis); + npcs.put(def.id(), def); + } catch (Exception e) { + LOGGER.error("Invalid NPC entry '{}': {} — NPC disabled", entry.getKey(), e.getMessage()); + } + } + } + + return new NpcConfig(schemaVersion, defaultViewDistance, defaultDespawnDistance, + defaultInteractionDistance, defaultCooldownMillis, + visibilityScanIntervalTicks, maxViewerSyncsPerTick, maxSpawnsPerTick, + maxDespawnsPerTick, maxLookUpdatesPerTick, + freshTtlHours, staleTtlDays, negativeCacheMinutes, + maxConcurrentRequests, connectTimeoutMillis, requestTimeoutMillis, npcs); + } + + private NpcDefinition parseNpc(String id, JsonObject obj, double defaultVd, double defaultDd, double defaultId, long defaultCd) { + boolean enabled = getBool(obj, "enabled", true); + String displayName = obj.has("displayName") ? obj.get("displayName").getAsString() : id; + + JsonObject locObj = obj.has("location") ? obj.getAsJsonObject("location") : new JsonObject(); + ResourceLocation dimension = ResourceLocation.parse(locObj.has("dimension") ? locObj.get("dimension").getAsString() : "minecraft:overworld"); + double x = getDouble(locObj, "x", 0.0); + double y = getDouble(locObj, "y", 64.0); + double z = getDouble(locObj, "z", 0.0); + float yaw = getFloat(locObj, "yaw", 0.0f); + float pitch = getFloat(locObj, "pitch", 0.0f); + NpcLocation location = new NpcLocation(dimension, x, y, z, yaw, pitch); + + NpcSkin skin; + if (obj.has("skin") && obj.get("skin").isJsonObject()) { + JsonObject skinObj = obj.getAsJsonObject("skin"); + String playerName = skinObj.has("playerName") ? skinObj.get("playerName").getAsString() : "Steve"; + skin = NpcSkin.unresolved(playerName); + } else { + skin = NpcSkin.unresolved("Steve"); + } + + NpcAction action = NpcAction.none(); + if (obj.has("action") && obj.get("action").isJsonObject()) { + JsonObject actionObj = obj.getAsJsonObject("action"); + String typeStr = actionObj.has("type") ? actionObj.get("type").getAsString() : "NONE"; + NpcActionType type = NpcActionType.valueOf(typeStr.toUpperCase()); + String command = actionObj.has("command") ? actionObj.get("command").getAsString() : ""; + action = new NpcAction(type, command); + } + + NpcHologramConfig hologram = NpcHologramConfig.disabled(); + if (obj.has("hologram") && obj.get("hologram").isJsonObject()) { + JsonObject holObj = obj.getAsJsonObject("hologram"); + boolean holEnabled = getBool(holObj, "enabled", true); + List lines = new ArrayList<>(); + if (holObj.has("lines") && holObj.get("lines").isJsonArray()) { + for (JsonElement line : holObj.getAsJsonArray("lines")) { + lines.add(line.getAsString()); + } + } + double offsetY = getDouble(holObj, "offsetY", 2.25); + double viewDist = getDouble(holObj, "viewDistance", 32.0); + boolean shadow = getBool(holObj, "shadow", true); + boolean seeThrough = getBool(holObj, "seeThrough", false); + hologram = new NpcHologramConfig(holEnabled, lines, offsetY, viewDist, shadow, seeThrough); + } + + NpcLookSettings lookSettings = NpcLookSettings.defaults(); + if (obj.has("lookAtPlayers") && obj.get("lookAtPlayers").isJsonObject()) { + JsonObject lookObj = obj.getAsJsonObject("lookAtPlayers"); + boolean lookEnabled = getBool(lookObj, "enabled", true); + double range = getDouble(lookObj, "range", 10.0); + int interval = getInt(lookObj, "updateIntervalTicks", 4); + double minAngle = getDouble(lookObj, "minimumAngleChange", 2.0); + double maxYaw = getDouble(lookObj, "maxYawFromBase", 100.0); + double maxUp = getDouble(lookObj, "maxPitchUp", 45.0); + double maxDown = getDouble(lookObj, "maxPitchDown", 35.0); + boolean rotateBody = getBool(lookObj, "rotateBody", true); + boolean resetOut = getBool(lookObj, "resetWhenOutOfRange", true); + lookSettings = new NpcLookSettings(lookEnabled, range, interval, minAngle, maxYaw, maxUp, maxDown, rotateBody, resetOut); + } + + double viewDistance = getDouble(obj, "viewDistance", defaultVd); + double despawnDistance = getDouble(obj, "despawnDistance", defaultDd); + + NpcInteractionConfig interaction = NpcInteractionConfig.defaults(); + if (obj.has("interaction") && obj.get("interaction").isJsonObject()) { + JsonObject intObj = obj.getAsJsonObject("interaction"); + double dist = getDouble(intObj, "distance", defaultId); + long cd = getLong(intObj, "cooldownMillis", defaultCd); + String perm = intObj.has("permission") ? intObj.get("permission").getAsString() : ""; + interaction = new NpcInteractionConfig(dist, cd, perm); + } + + return new NpcDefinition(id, enabled, displayName, location, skin, action, hologram, lookSettings, + viewDistance, despawnDistance, interaction); + } + + private JsonObject serializeRoot(NpcConfig config) { + JsonObject root = new JsonObject(); + root.addProperty("schemaVersion", config.schemaVersion()); + + JsonObject defaults = new JsonObject(); + defaults.addProperty("viewDistance", config.defaultViewDistance()); + defaults.addProperty("despawnDistance", config.defaultDespawnDistance()); + defaults.addProperty("interactionDistance", config.defaultInteractionDistance()); + defaults.addProperty("interactionCooldownMillis", config.defaultCooldownMillis()); + root.add("defaults", defaults); + + JsonObject perf = new JsonObject(); + perf.addProperty("visibilityScanIntervalTicks", config.visibilityScanIntervalTicks()); + perf.addProperty("maxViewerSyncsPerTick", config.maxViewerSyncsPerTick()); + perf.addProperty("maxSpawnsPerTick", config.maxSpawnsPerTick()); + perf.addProperty("maxDespawnsPerTick", config.maxDespawnsPerTick()); + perf.addProperty("maxLookUpdatesPerTick", config.maxLookUpdatesPerTick()); + root.add("performance", perf); + + JsonObject skinCache = new JsonObject(); + skinCache.addProperty("freshTtlHours", config.freshTtlHours()); + skinCache.addProperty("staleTtlDays", config.staleTtlDays()); + skinCache.addProperty("negativeCacheMinutes", config.negativeCacheMinutes()); + skinCache.addProperty("maxConcurrentRequests", config.maxConcurrentRequests()); + skinCache.addProperty("connectTimeoutMillis", config.connectTimeoutMillis()); + skinCache.addProperty("requestTimeoutMillis", config.requestTimeoutMillis()); + root.add("skinCache", skinCache); + + JsonObject npcs = new JsonObject(); + for (NpcDefinition npc : config.npcs().values()) { + npcs.add(npc.id(), serializeNpc(npc)); + } + root.add("npcs", npcs); + + return root; + } + + private JsonObject serializeNpc(NpcDefinition npc) { + JsonObject obj = new JsonObject(); + obj.addProperty("enabled", npc.enabled()); + obj.addProperty("displayName", npc.displayName()); + + JsonObject loc = new JsonObject(); + loc.addProperty("dimension", npc.location().dimension().toString()); + loc.addProperty("x", npc.location().x()); + loc.addProperty("y", npc.location().y()); + loc.addProperty("z", npc.location().z()); + loc.addProperty("yaw", npc.location().yaw()); + loc.addProperty("pitch", npc.location().pitch()); + obj.add("location", loc); + + JsonObject skin = new JsonObject(); + skin.addProperty("playerName", npc.skin().playerName()); + obj.add("skin", skin); + + JsonObject action = new JsonObject(); + action.addProperty("type", npc.action().type().name()); + action.addProperty("command", npc.action().command()); + obj.add("action", action); + + JsonObject hol = new JsonObject(); + hol.addProperty("enabled", npc.hologram().enabled()); + JsonArray lines = new JsonArray(); + for (String line : npc.hologram().lines()) lines.add(line); + hol.add("lines", lines); + hol.addProperty("offsetY", npc.hologram().offsetY()); + hol.addProperty("viewDistance", npc.hologram().viewDistance()); + hol.addProperty("shadow", npc.hologram().shadow()); + hol.addProperty("seeThrough", npc.hologram().seeThrough()); + obj.add("hologram", hol); + + JsonObject look = new JsonObject(); + look.addProperty("enabled", npc.lookSettings().enabled()); + look.addProperty("range", npc.lookSettings().range()); + look.addProperty("updateIntervalTicks", npc.lookSettings().updateIntervalTicks()); + look.addProperty("minimumAngleChange", npc.lookSettings().minimumAngleChange()); + look.addProperty("maxYawFromBase", npc.lookSettings().maxYawFromBase()); + look.addProperty("maxPitchUp", npc.lookSettings().maxPitchUp()); + look.addProperty("maxPitchDown", npc.lookSettings().maxPitchDown()); + look.addProperty("rotateBody", npc.lookSettings().rotateBody()); + look.addProperty("resetWhenOutOfRange", npc.lookSettings().resetWhenOutOfRange()); + obj.add("lookAtPlayers", look); + + obj.addProperty("viewDistance", npc.viewDistance()); + obj.addProperty("despawnDistance", npc.despawnDistance()); + + JsonObject interaction = new JsonObject(); + interaction.addProperty("distance", npc.interaction().distance()); + interaction.addProperty("cooldownMillis", npc.interaction().cooldownMillis()); + interaction.addProperty("permission", npc.interaction().permission()); + obj.add("interaction", interaction); + + return obj; + } + + private static int getInt(JsonObject obj, String key, int defaultValue) { + if (!obj.has(key)) return defaultValue; + JsonElement el = obj.get(key); + if (el.isJsonPrimitive() && ((JsonPrimitive) el).isNumber()) return el.getAsInt(); + return defaultValue; + } + + private static long getLong(JsonObject obj, String key, long defaultValue) { + if (!obj.has(key)) return defaultValue; + JsonElement el = obj.get(key); + if (el.isJsonPrimitive() && ((JsonPrimitive) el).isNumber()) return el.getAsLong(); + return defaultValue; + } + + private static double getDouble(JsonObject obj, String key, double defaultValue) { + if (!obj.has(key)) return defaultValue; + JsonElement el = obj.get(key); + if (el.isJsonPrimitive() && ((JsonPrimitive) el).isNumber()) return el.getAsDouble(); + return defaultValue; + } + + private static float getFloat(JsonObject obj, String key, float defaultValue) { + if (!obj.has(key)) return defaultValue; + JsonElement el = obj.get(key); + if (el.isJsonPrimitive() && ((JsonPrimitive) el).isNumber()) return el.getAsFloat(); + return defaultValue; + } + + private static boolean getBool(JsonObject obj, String key, boolean defaultValue) { + if (!obj.has(key)) return defaultValue; + JsonElement el = obj.get(key); + if (el.isJsonPrimitive()) { + JsonPrimitive prim = (JsonPrimitive) el; + if (prim.isBoolean()) return prim.getAsBoolean(); + if (prim.isString()) return Boolean.parseBoolean(prim.getAsString()); + } + return defaultValue; + } +} From af1f80597c6827ac17c1f14c966c1a8faa8c698c Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:11 -0300 Subject: [PATCH 03/10] feat(npcs): add persistent asynchronous skin cache with Mojang resolver --- .../npcs/skin/MojangSkinResolver.java | 148 +++++++++++++ .../npcs/skin/SkinCache.java | 204 ++++++++++++++++++ .../npcs/skin/SkinCacheEntry.java | 64 ++++++ 3 files changed, 416 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/MojangSkinResolver.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCache.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCacheEntry.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/MojangSkinResolver.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/MojangSkinResolver.java new file mode 100644 index 00000000..53dbf49e --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/MojangSkinResolver.java @@ -0,0 +1,148 @@ +package com.pedrodalben.bigbangessentials.npcs.skin; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; + +public class MojangSkinResolver { + private static final Logger LOGGER = LoggerFactory.getLogger(MojangSkinResolver.class); + private static final String UUID_API = "https://api.mojang.com/users/profiles/minecraft/"; + private static final String PROFILE_API = "https://sessionserver.mojang.com/session/minecraft/profile/"; + + private int connectTimeoutMillis; + private int requestTimeoutMillis; + + public MojangSkinResolver(int connectTimeoutMillis, int requestTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.requestTimeoutMillis = requestTimeoutMillis; + } + + public void configure(int connectTimeoutMillis, int requestTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.requestTimeoutMillis = requestTimeoutMillis; + } + + public SkinCacheEntry resolve(String playerName) { + String normalizedName = SkinCache.normalize(playerName); + + String uuid = resolveUuid(playerName); + if (uuid == null || uuid.isEmpty()) { + LOGGER.warn("Could not resolve UUID for player '{}'", playerName); + return SkinCacheEntry.negative(normalizedName, 600_000); + } + + try { + String url = PROFILE_API + uuid + "?unsigned=false"; + HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); + conn.setConnectTimeout(connectTimeoutMillis); + conn.setReadTimeout(requestTimeoutMillis); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + + int status = conn.getResponseCode(); + if (status != 200) { + LOGGER.warn("Mojang profile API returned {} for uuid {}", status, uuid); + conn.disconnect(); + return SkinCacheEntry.negative(normalizedName, 600_000); + } + + try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) { + JsonObject profile = JsonParser.parseReader(reader).getAsJsonObject(); + JsonArray properties = profile.getAsJsonArray("properties"); + if (properties == null) { + return SkinCacheEntry.negative(normalizedName, 600_000); + } + + String textureValue = null; + String textureSignature = null; + String model = "default"; + + for (JsonElement el : properties) { + JsonObject prop = el.getAsJsonObject(); + if ("textures".equals(prop.get("name").getAsString())) { + textureValue = prop.get("value").getAsString(); + if (prop.has("signature")) { + textureSignature = prop.get("signature").getAsString(); + } + + // Decode base64 texture to check for slim model + try { + String decoded = new String(java.util.Base64.getDecoder().decode(textureValue)); + JsonObject tex = JsonParser.parseString(decoded).getAsJsonObject(); + if (tex.has("textures") && tex.getAsJsonObject("textures").has("SKIN")) { + JsonObject skin = tex.getAsJsonObject("textures").getAsJsonObject("SKIN"); + if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) { + String skinModel = skin.getAsJsonObject("metadata").get("model").getAsString(); + if ("slim".equalsIgnoreCase(skinModel)) { + model = "slim"; + } + } + } + } catch (Exception e) { + LOGGER.debug("Could not decode skin metadata for {}", playerName, e); + } + break; + } + } + + conn.disconnect(); + + if (textureValue == null) { + return SkinCacheEntry.negative(normalizedName, 600_000); + } + + long freshTtlMillis = 24 * 3600_000L; + return SkinCacheEntry.resolved(normalizedName, playerName, uuid, + textureValue, textureSignature != null ? textureSignature : "", + model, freshTtlMillis); + } + } catch (Exception e) { + LOGGER.warn("Failed to fetch skin profile for '{}': {}", playerName, e.getMessage()); + return SkinCacheEntry.negative(normalizedName, 600_000); + } + } + + private String resolveUuid(String playerName) { + try { + String url = UUID_API + playerName; + HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); + conn.setConnectTimeout(connectTimeoutMillis); + conn.setReadTimeout(requestTimeoutMillis); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + + int status = conn.getResponseCode(); + if (status != 200) { + conn.disconnect(); + return null; + } + + try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) { + JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject(); + conn.disconnect(); + if (obj == null || !obj.has("id")) return null; + String id = obj.get("id").getAsString(); + return formatUuid(id); + } + } catch (Exception e) { + LOGGER.warn("Failed to resolve UUID for '{}': {}", playerName, e.getMessage()); + return null; + } + } + + public static String formatUuid(String undashed) { + if (undashed == null || undashed.length() != 32) return undashed; + return undashed.substring(0, 8) + "-" + + undashed.substring(8, 12) + "-" + + undashed.substring(12, 16) + "-" + + undashed.substring(16, 20) + "-" + + undashed.substring(20); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCache.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCache.java new file mode 100644 index 00000000..ff9bc963 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCache.java @@ -0,0 +1,204 @@ +package com.pedrodalben.bigbangessentials.npcs.skin; + +import com.google.gson.*; +import com.google.gson.reflect.TypeToken; +import com.pedrodalben.bigbangessentials.util.ResourceUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class SkinCache { + private static final Logger LOGGER = LoggerFactory.getLogger(SkinCache.class); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + private static final String FILE = "npcs/skin-cache.json"; + + private final ConcurrentHashMap memoryCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> inflightRequests = new ConcurrentHashMap<>(); + private final MojangSkinResolver resolver; + private final ExecutorService executor; + + private long freshTtlMillis; + private long staleTtlMillis; + private long negativeTtlMillis; + private int maxConcurrentRequests; + + private int hits; + private int misses; + private int staleHits; + private int negativeHits; + private int failures; + + public SkinCache(int freshTtlHours, int staleTtlDays, int negativeCacheMinutes, + int maxConcurrentRequests, int connectTimeoutMillis, int requestTimeoutMillis) { + this.freshTtlMillis = freshTtlHours * 3600_000L; + this.staleTtlMillis = staleTtlDays * 86_400_000L; + this.negativeTtlMillis = negativeCacheMinutes * 60_000L; + this.maxConcurrentRequests = maxConcurrentRequests; + this.resolver = new MojangSkinResolver(connectTimeoutMillis, requestTimeoutMillis); + this.executor = Executors.newFixedThreadPool(Math.max(1, maxConcurrentRequests), r -> { + Thread t = new Thread(r, "BigBangEssentials-NpcSkin"); + t.setDaemon(true); + return t; + }); + + loadPersistent(); + } + + public CompletableFuture resolve(String playerName) { + String key = normalize(playerName); + + SkinCacheEntry memEntry = memoryCache.get(key); + if (memEntry != null) { + if (!memEntry.negative() && memEntry.isFresh()) { + hits++; + return CompletableFuture.completedFuture(memEntry); + } + if (!memEntry.negative() && memEntry.isStale(staleTtlMillis)) { + staleHits++; + refreshInBackground(key, playerName); + return CompletableFuture.completedFuture(memEntry); + } + if (memEntry.negative() && memEntry.isFresh()) { + negativeHits++; + return CompletableFuture.completedFuture(memEntry); + } + } + + CompletableFuture existing = inflightRequests.get(key); + if (existing != null) return existing; + + misses++; + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + SkinCacheEntry result = resolver.resolve(playerName); + if (result.negative()) { + memoryCache.put(key, result); + } else { + memoryCache.put(key, result); + } + persistAsync(); + return result; + } catch (Exception e) { + failures++; + LOGGER.warn("Skin resolution failed for '{}': {}", playerName, e.getMessage()); + SkinCacheEntry stale = memoryCache.get(key); + if (stale != null) return stale; + return SkinCacheEntry.negative(key, negativeTtlMillis); + } + }, executor).whenComplete((r, t) -> inflightRequests.remove(key)); + + inflightRequests.put(key, future); + return future; + } + + public void configure(int freshTtlHours, int staleTtlDays, int negativeCacheMinutes, + int maxConcurrentRequests, int connectTimeoutMillis, int requestTimeoutMillis) { + this.freshTtlMillis = freshTtlHours * 3600_000L; + this.staleTtlMillis = staleTtlDays * 86_400_000L; + this.negativeTtlMillis = negativeCacheMinutes * 60_000L; + this.maxConcurrentRequests = maxConcurrentRequests; + this.resolver.configure(connectTimeoutMillis, requestTimeoutMillis); + } + + private void refreshInBackground(String key, String playerName) { + CompletableFuture existing = inflightRequests.get(key); + if (existing != null) return; + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + SkinCacheEntry result = resolver.resolve(playerName); + if (!result.negative()) memoryCache.put(key, result); + persistAsync(); + return result; + } catch (Exception e) { + LOGGER.debug("Background skin refresh failed for '{}': {}", playerName, e.getMessage()); + return null; + } + }, executor).whenComplete((r, t) -> inflightRequests.remove(key)); + inflightRequests.put(key, future); + } + + private void loadPersistent() { + Path file = ResourceUtil.getMigratedDataPath(FILE); + if (!Files.exists(file)) return; + try (Reader reader = new FileReader(file.toFile())) { + JsonArray arr = GSON.fromJson(reader, JsonArray.class); + if (arr == null) return; + for (JsonElement el : arr) { + try { + JsonObject obj = el.getAsJsonObject(); + String normalizedName = obj.get("normalizedName").getAsString(); + String originalName = obj.has("originalName") ? obj.get("originalName").getAsString() : normalizedName; + String uuid = obj.has("uuid") ? obj.get("uuid").getAsString() : ""; + String textureValue = obj.has("textureValue") ? obj.get("textureValue").getAsString() : ""; + String textureSignature = obj.has("textureSignature") ? obj.get("textureSignature").getAsString() : ""; + String model = obj.has("model") ? obj.get("model").getAsString() : "default"; + long fetchedAt = obj.has("fetchedAt") ? obj.get("fetchedAt").getAsLong() : 0; + long expiresAt = obj.has("expiresAt") ? obj.get("expiresAt").getAsLong() : 0; + boolean negative = obj.has("negative") && obj.get("negative").getAsBoolean(); + SkinCacheEntry entry = new SkinCacheEntry(normalizedName, originalName, uuid, + textureValue, textureSignature, model, fetchedAt, expiresAt, negative); + memoryCache.put(normalizedName, entry); + } catch (Exception e) { + LOGGER.debug("Skipping invalid skin cache entry", e); + } + } + LOGGER.info("Loaded {} skin cache entries from disk", memoryCache.size()); + } catch (Exception e) { + LOGGER.warn("Failed to load persistent skin cache: {}", e.getMessage()); + } + } + + public void persist() { + try { + Path file = ResourceUtil.getDataPath(FILE); + Path parent = file.getParent(); + if (parent != null) Files.createDirectories(parent); + + JsonArray arr = new JsonArray(); + for (SkinCacheEntry entry : memoryCache.values()) { + JsonObject obj = new JsonObject(); + obj.addProperty("normalizedName", entry.normalizedName()); + obj.addProperty("originalName", entry.originalName()); + obj.addProperty("uuid", entry.uuid()); + obj.addProperty("textureValue", entry.textureValue()); + obj.addProperty("textureSignature", entry.textureSignature()); + obj.addProperty("model", entry.model()); + obj.addProperty("fetchedAt", entry.fetchedAt()); + obj.addProperty("expiresAt", entry.expiresAt()); + obj.addProperty("negative", entry.negative()); + arr.add(obj); + } + Files.writeString(file, GSON.toJson(arr), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception e) { + LOGGER.warn("Failed to persist skin cache: {}", e.getMessage()); + } + } + + private void persistAsync() { + executor.submit(this::persist); + } + + public void shutdown() { + executor.shutdown(); + persist(); + } + + public static String normalize(String name) { + return name == null ? "" : name.trim().toLowerCase(); + } + + public int memorySize() { return memoryCache.size(); } + public int hits() { return hits; } + public int misses() { return misses; } + public int staleHits() { return staleHits; } + public int negativeHits() { return negativeHits; } + public int failures() { return failures; } + public int inflightCount() { return inflightRequests.size(); } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCacheEntry.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCacheEntry.java new file mode 100644 index 00000000..13024dbb --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/skin/SkinCacheEntry.java @@ -0,0 +1,64 @@ +package com.pedrodalben.bigbangessentials.npcs.skin; + +public final class SkinCacheEntry { + private final String normalizedName; + private final String originalName; + private final String uuid; + private final String textureValue; + private final String textureSignature; + private final String model; + private final long fetchedAt; + private final long expiresAt; + private final boolean negative; + + public SkinCacheEntry(String normalizedName, String originalName, String uuid, + String textureValue, String textureSignature, String model, + long fetchedAt, long expiresAt, boolean negative) { + this.normalizedName = normalizedName; + this.originalName = originalName; + this.uuid = uuid; + this.textureValue = textureValue; + this.textureSignature = textureSignature; + this.model = model; + this.fetchedAt = fetchedAt; + this.expiresAt = expiresAt; + this.negative = negative; + } + + public static SkinCacheEntry resolved(String normalizedName, String originalName, String uuid, + String textureValue, String textureSignature, String model, + long freshTtlMillis) { + long now = System.currentTimeMillis(); + return new SkinCacheEntry(normalizedName, originalName, uuid, textureValue, textureSignature, + model != null ? model : "default", now, now + freshTtlMillis, false); + } + + public static SkinCacheEntry negative(String normalizedName, long negativeTtlMillis) { + long now = System.currentTimeMillis(); + return new SkinCacheEntry(normalizedName, normalizedName, "", "", "", "", + now, now + negativeTtlMillis, true); + } + + public boolean isFresh() { + return System.currentTimeMillis() < expiresAt; + } + + public boolean isStale(long staleTtlMillis) { + long now = System.currentTimeMillis(); + return now >= expiresAt && (fetchedAt + staleTtlMillis) > now; + } + + public boolean isExpired(long staleTtlMillis) { + return System.currentTimeMillis() >= (fetchedAt + staleTtlMillis); + } + + public String normalizedName() { return normalizedName; } + public String originalName() { return originalName; } + public String uuid() { return uuid; } + public String textureValue() { return textureValue; } + public String textureSignature() { return textureSignature; } + public String model() { return model; } + public long fetchedAt() { return fetchedAt; } + public long expiresAt() { return expiresAt; } + public boolean negative() { return negative; } +} From 2fed7a7e345456c2e9569846b3ba030cf997c55a Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:15 -0300 Subject: [PATCH 04/10] feat(npcs): add virtual player rendering with reflection-based packet sender --- .../npcs/render/NpcPacketSender.java | 18 ++ .../npcs/render/NpcPacketSenderImpl.java | 99 ++++++++++ .../npcs/render/NpcRenderService.java | 177 ++++++++++++++++++ .../npcs/render/NpcRenderSnapshot.java | 30 +++ .../npcs/render/NpcViewerService.java | 34 ++++ .../npcs/render/NpcViewerSession.java | 67 +++++++ 6 files changed, 425 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSender.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSenderImpl.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderService.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderSnapshot.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerService.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerSession.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSender.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSender.java new file mode 100644 index 00000000..c614dbc4 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSender.java @@ -0,0 +1,18 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import net.minecraft.server.level.ServerPlayer; + +public interface NpcPacketSender { + void addPlayerInfo(ServerPlayer viewer, java.util.UUID uuid, String displayName, + String textureValue, String textureSignature); + + void removePlayerInfo(ServerPlayer viewer, java.util.UUID uuid, String displayName, + String textureValue, String textureSignature); + + void removeEntities(ServerPlayer viewer, int entityId); + + void rotateHead(ServerPlayer viewer, int entityId, float yaw); + + void teleportEntity(ServerPlayer viewer, int entityId, double x, double y, double z, + float yaw, float pitch); +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSenderImpl.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSenderImpl.java new file mode 100644 index 00000000..de040225 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcPacketSenderImpl.java @@ -0,0 +1,99 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.*; +import net.minecraft.server.level.ServerPlayer; + +import java.lang.reflect.Constructor; +import java.util.*; + +public class NpcPacketSenderImpl implements NpcPacketSender { + + private static final Constructor PACKET_CONSTRUCTOR; + + static { + try { + Class entryClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket$Entry"); + Constructor entryCtor = entryClass.getDeclaredConstructor( + UUID.class, GameProfile.class, boolean.class, int.class, + net.minecraft.world.level.GameType.class, Component.class, Object.class); + entryCtor.setAccessible(true); + + Class packetClass = ClientboundPlayerInfoUpdatePacket.class; + PACKET_CONSTRUCTOR = packetClass.getDeclaredConstructor(Set.class); + PACKET_CONSTRUCTOR.setAccessible(true); + + ENTRY_CTOR = entryCtor; + } catch (Exception e) { + throw new RuntimeException("Failed to initialize NPC packet sender", e); + } + } + + private static final Constructor ENTRY_CTOR; + + @Override + public void addPlayerInfo(ServerPlayer viewer, UUID uuid, String displayName, + String textureValue, String textureSignature) { + try { + GameProfile profile = new GameProfile(uuid, displayName); + if (textureValue != null && !textureValue.isEmpty()) { + profile.getProperties().put("textures", + new Property("textures", textureValue, textureSignature != null ? textureSignature : "")); + } + + Object entry = ENTRY_CTOR.newInstance(uuid, profile, false, 0, + net.minecraft.world.level.GameType.CREATIVE, Component.literal(displayName), null); + @SuppressWarnings("unchecked") + ClientboundPlayerInfoUpdatePacket packet = (ClientboundPlayerInfoUpdatePacket) + PACKET_CONSTRUCTOR.newInstance(EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER), Set.of(entry)); + viewer.connection.send(packet); + } catch (Exception e) { + throw new RuntimeException("Failed to send player info add for NPC", e); + } + } + + @Override + public void removePlayerInfo(ServerPlayer viewer, UUID uuid, String displayName, + String textureValue, String textureSignature) { + try { + GameProfile profile = new GameProfile(uuid, displayName); + if (textureValue != null && !textureValue.isEmpty()) { + profile.getProperties().put("textures", + new Property("textures", textureValue, textureSignature != null ? textureSignature : "")); + } + + Object entry = ENTRY_CTOR.newInstance(uuid, profile, true, 0, + net.minecraft.world.level.GameType.CREATIVE, Component.literal(displayName), null); + @SuppressWarnings("unchecked") + ClientboundPlayerInfoUpdatePacket packet = (ClientboundPlayerInfoUpdatePacket) + PACKET_CONSTRUCTOR.newInstance(EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), Set.of(entry)); + viewer.connection.send(packet); + } catch (Exception e) { + throw new RuntimeException("Failed to remove player info for NPC", e); + } + } + + @Override + public void removeEntities(ServerPlayer viewer, int entityId) { + viewer.connection.send(new ClientboundRemoveEntitiesPacket(new int[]{entityId})); + } + + @Override + public void rotateHead(ServerPlayer viewer, int entityId, float yaw) { + byte yawByte = (byte) ((int) (yaw * 256.0F / 360.0F)); + viewer.connection.send(new ClientboundMoveEntityPacket.Rot(entityId, yawByte, (byte) 0, true)); + } + + @Override + public void teleportEntity(ServerPlayer viewer, int entityId, double x, double y, double z, + float yaw, float pitch) { + byte yawByte = (byte) ((int) (yaw * 256.0F / 360.0F)); + byte pitchByte = (byte) ((int) (pitch * 256.0F / 360.0F)); + viewer.connection.send(new ClientboundMoveEntityPacket.PosRot(entityId, + (short) 0, (short) 0, (short) 0, + yawByte, pitchByte, true)); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderService.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderService.java new file mode 100644 index 00000000..70fa5851 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderService.java @@ -0,0 +1,177 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import com.pedrodalben.bigbangessentials.npcs.api.NpcDefinition; +import com.pedrodalben.bigbangessentials.npcs.skin.SkinCache; +import com.pedrodalben.bigbangessentials.npcs.skin.SkinCacheEntry; +import net.minecraft.network.protocol.game.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +public class NpcRenderService { + private static final Logger LOGGER = LoggerFactory.getLogger(NpcRenderService.class); + private static final AtomicInteger NEXT_ENTITY_ID = new AtomicInteger(2_000_000_000); + + private final NpcViewerService viewerService; + private final SkinCache skinCache; + private final NpcPacketSender packetSender; + private final Map renderStates = new LinkedHashMap<>(); + + public NpcRenderService(NpcViewerService viewerService, SkinCache skinCache) { + this.viewerService = viewerService; + this.skinCache = skinCache; + this.packetSender = new NpcPacketSenderImpl(); + } + + public void register(NpcDefinition definition) { + String id = definition.id(); + renderStates.computeIfAbsent(id, k -> { + int entityId = NEXT_ENTITY_ID.getAndIncrement(); + return new NpcRenderState(entityId, definition); + }); + renderStates.get(id).definition = definition; + } + + public void unregister(String id) { + renderStates.remove(id); + } + + public NpcRenderState getState(String id) { + return renderStates.get(id); + } + + public void spawn(ServerPlayer player, NpcDefinition npc) { + String id = npc.id(); + NpcRenderState state = renderStates.get(id); + if (state == null) return; + + NpcViewerSession session = viewerService.getSession(player); + NpcViewerSession.NpcViewState vs = session.getState(id); + vs.setEntityId(state.entityId); + vs.setSpawnedAt(System.currentTimeMillis()); + + skinCache.resolve(npc.skin().playerName()).thenAccept(entry -> { + MinecraftServer server = player.getServer(); + if (server != null) { + server.execute(() -> sendSpawn(player, npc, state, entry, session, vs)); + } + }).exceptionally(e -> { + LOGGER.warn("Failed to resolve skin for NPC '{}': {}", id, e.getMessage()); + return null; + }); + } + + private void sendSpawn(ServerPlayer player, NpcDefinition npc, NpcRenderState state, + SkinCacheEntry skinEntry, NpcViewerSession session, NpcViewerSession.NpcViewState vs) { + try { + java.util.UUID entityUuid = deriveUuid(npc.id()); + + if (!skinEntry.textureValue().isEmpty()) { + packetSender.addPlayerInfo(player, entityUuid, npc.id(), + skinEntry.textureValue(), skinEntry.textureSignature()); + } + + double x = npc.location().x(); + double y = npc.location().y(); + double z = npc.location().z(); + player.connection.send(new ClientboundAddEntityPacket( + state.entityId, entityUuid, x, y, z, + npc.location().pitch(), npc.location().yaw(), + net.minecraft.world.entity.EntityType.PLAYER, 0, + net.minecraft.world.phys.Vec3.ZERO, 0.0)); + + byte skinLayers = (byte) 0x7F; + player.connection.send(new ClientboundSetEntityDataPacket( + state.entityId, + List.of(new net.minecraft.network.syncher.SynchedEntityData.DataValue<>( + 17, net.minecraft.network.syncher.EntityDataSerializers.BYTE, skinLayers)))); + + packetSender.rotateHead(player, state.entityId, npc.location().yaw()); + + if (!skinEntry.textureValue().isEmpty()) { + packetSender.removePlayerInfo(player, entityUuid, npc.id(), + skinEntry.textureValue(), skinEntry.textureSignature()); + } + + vs.setSkinHash(skinEntry.textureValue()); + vs.setLastPosition(x, y, z); + vs.setLastYaw(npc.location().yaw()); + vs.setLastHeadYaw(npc.location().yaw()); + vs.setLastPitch(npc.location().pitch()); + vs.setSlim("slim".equalsIgnoreCase(skinEntry.model())); + + session.visibleNpcIds().add(npc.id()); + session.entityIdToNpc().put(state.entityId, npc.id()); + } catch (Exception e) { + LOGGER.warn("Failed to send NPC '{}' to player: {}", npc.id(), e.getMessage()); + } + } + + public void despawn(ServerPlayer player, NpcDefinition npc) { + NpcRenderState state = renderStates.get(npc.id()); + if (state == null) return; + + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session == null) return; + + session.visibleNpcIds().remove(npc.id()); + session.entityIdToNpc().remove(state.entityId); + + packetSender.removeEntities(player, state.entityId); + } + + public void sendLookUpdate(ServerPlayer player, NpcDefinition npc, float bodyYaw, float headYaw, float pitch, + NpcViewerSession.NpcViewState vs) { + NpcRenderState state = renderStates.get(npc.id()); + if (state == null) return; + + if (vs.lastYaw() == bodyYaw && vs.lastHeadYaw() == headYaw && vs.lastPitch() == pitch) return; + + try { + double x = npc.location().x(); + double y = npc.location().y(); + double z = npc.location().z(); + packetSender.rotateHead(player, state.entityId, headYaw); + packetSender.teleportEntity(player, state.entityId, x, y, z, bodyYaw, pitch); + + vs.setLastYaw(bodyYaw); + vs.setLastHeadYaw(headYaw); + vs.setLastPitch(pitch); + } catch (Exception e) { + LOGGER.debug("Failed to send look update for NPC '{}': {}", npc.id(), e.getMessage()); + } + } + + public void resetLook(ServerPlayer player, NpcDefinition npc, NpcViewerSession.NpcViewState vs) { + float baseYaw = npc.location().yaw(); + float basePitch = npc.location().pitch(); + if (vs.lastYaw() == baseYaw && vs.lastHeadYaw() == baseYaw && vs.lastPitch() == basePitch) return; + sendLookUpdate(player, npc, baseYaw, baseYaw, basePitch, vs); + } + + public int visibleNpcCount(ServerPlayer player) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + return session != null ? session.visibleNpcIds().size() : 0; + } + + private static java.util.UUID deriveUuid(String npcId) { + return java.util.UUID.nameUUIDFromBytes(("bigbang-npc:" + npcId).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + public static final class NpcRenderState { + private final int entityId; + private NpcDefinition definition; + + NpcRenderState(int entityId, NpcDefinition definition) { + this.entityId = entityId; + this.definition = definition; + } + + public int entityId() { return entityId; } + public NpcDefinition definition() { return definition; } + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderSnapshot.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderSnapshot.java new file mode 100644 index 00000000..0ac944f1 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcRenderSnapshot.java @@ -0,0 +1,30 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import com.pedrodalben.bigbangessentials.npcs.api.NpcLocation; +import com.pedrodalben.bigbangessentials.npcs.api.NpcSkin; + +public final class NpcRenderSnapshot { + private final int entityId; + private final String entityUuid; // dashed UUID string + private final NpcLocation location; + private final NpcSkin skin; + private final byte skinLayers; // bitmask: 0x01=cape, 0x02=jacket, 0x04=left_sleeve, 0x08=right_sleeve, 0x10=left_leg, 0x20=right_leg, 0x40=hat + private final boolean slim; + + public NpcRenderSnapshot(int entityId, String entityUuid, NpcLocation location, + NpcSkin skin, byte skinLayers, boolean slim) { + this.entityId = entityId; + this.entityUuid = entityUuid; + this.location = location; + this.skin = skin; + this.skinLayers = skinLayers; + this.slim = slim; + } + + public int entityId() { return entityId; } + public String entityUuid() { return entityUuid; } + public NpcLocation location() { return location; } + public NpcSkin skin() { return skin; } + public byte skinLayers() { return skinLayers; } + public boolean slim() { return slim; } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerService.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerService.java new file mode 100644 index 00000000..501895dc --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerService.java @@ -0,0 +1,34 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import net.minecraft.server.level.ServerPlayer; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class NpcViewerService { + private final Map sessions = new ConcurrentHashMap<>(); + + public NpcViewerSession getSession(ServerPlayer player) { + UUID uuid = player.getUUID(); + NpcViewerSession session = sessions.computeIfAbsent(uuid, NpcViewerSession::new); + session.setDimension(player.level().dimension().location().toString()); + return session; + } + + public NpcViewerSession getSession(UUID playerUuid) { + return sessions.get(playerUuid); + } + + public NpcViewerSession removeSession(UUID uuid) { + return sessions.remove(uuid); + } + + public void clear() { + sessions.clear(); + } + + public int sessionCount() { + return sessions.size(); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerSession.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerSession.java new file mode 100644 index 00000000..a452ca2d --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/render/NpcViewerSession.java @@ -0,0 +1,67 @@ +package com.pedrodalben.bigbangessentials.npcs.render; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public final class NpcViewerSession { + private final UUID playerUuid; + private String dimension; + private final Set visibleNpcIds; + private final Map entityIdToNpc; + private final Map npcStates; + private volatile long lastSyncTick; + + public NpcViewerSession(UUID playerUuid) { + this.playerUuid = playerUuid; + this.visibleNpcIds = ConcurrentHashMap.newKeySet(); + this.entityIdToNpc = new ConcurrentHashMap<>(); + this.npcStates = new ConcurrentHashMap<>(); + } + + public UUID playerUuid() { return playerUuid; } + public String dimension() { return dimension; } + public void setDimension(String dimension) { this.dimension = dimension; } + public Set visibleNpcIds() { return visibleNpcIds; } + public Map entityIdToNpc() { return entityIdToNpc; } + public Map npcStates() { return npcStates; } + public long lastSyncTick() { return lastSyncTick; } + public void setLastSyncTick(long tick) { this.lastSyncTick = tick; } + + public NpcViewState getState(String npcId) { + return npcStates.computeIfAbsent(npcId, k -> new NpcViewState()); + } + + public void clear() { + visibleNpcIds.clear(); + entityIdToNpc.clear(); + npcStates.clear(); + } + + public static final class NpcViewState { + private int entityId; + private String skinHash; // last skin texture hash sent + private double lastX, lastY, lastZ; + private float lastYaw, lastHeadYaw, lastPitch; + private boolean slim; + private long spawnedAt; + + public int entityId() { return entityId; } + public void setEntityId(int id) { this.entityId = id; } + public String skinHash() { return skinHash; } + public void setSkinHash(String hash) { this.skinHash = hash; } + public double lastX() { return lastX; } + public double lastY() { return lastY; } + public double lastZ() { return lastZ; } + public float lastYaw() { return lastYaw; } + public float lastHeadYaw() { return lastHeadYaw; } + public float lastPitch() { return lastPitch; } + public void setLastPosition(double x, double y, double z) { this.lastX = x; this.lastY = y; this.lastZ = z; } + public void setLastYaw(float yaw) { this.lastYaw = yaw; } + public void setLastHeadYaw(float hYaw) { this.lastHeadYaw = hYaw; } + public void setLastPitch(float pitch) { this.lastPitch = pitch; } + public boolean slim() { return slim; } + public void setSlim(boolean slim) { this.slim = slim; } + public long spawnedAt() { return spawnedAt; } + public void setSpawnedAt(long time) { this.spawnedAt = time; } + } +} From d0346a0076babdc77ae0e35ba1685421f9758035 Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:20 -0300 Subject: [PATCH 05/10] feat(npcs): add NpcManager orchestrator, spatial index, and viewer visibility --- .../npcs/service/NpcManager.java | 491 ++++++++++++++++++ .../npcs/spatial/NpcSpatialIndex.java | 69 +++ 2 files changed, 560 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/service/NpcManager.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/spatial/NpcSpatialIndex.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/service/NpcManager.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/service/NpcManager.java new file mode 100644 index 00000000..649e2afa --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/service/NpcManager.java @@ -0,0 +1,491 @@ +package com.pedrodalben.bigbangessentials.npcs.service; + +import com.pedrodalben.bigbangessentials.npcs.api.*; +import com.pedrodalben.bigbangessentials.npcs.config.NpcConfig; +import com.pedrodalben.bigbangessentials.npcs.config.NpcConfigStore; +import com.pedrodalben.bigbangessentials.npcs.hologram.NpcHologramService; +import com.pedrodalben.bigbangessentials.npcs.interaction.NpcInteractionService; +import com.pedrodalben.bigbangessentials.npcs.render.NpcRenderService; +import com.pedrodalben.bigbangessentials.npcs.render.NpcViewerService; +import com.pedrodalben.bigbangessentials.npcs.render.NpcViewerSession; +import com.pedrodalben.bigbangessentials.npcs.skin.SkinCache; +import com.pedrodalben.bigbangessentials.npcs.spatial.NpcSpatialIndex; +import com.pedrodalben.bigbangessentials.util.Platform; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +public final class NpcManager implements NpcService { + private static final Logger LOGGER = LoggerFactory.getLogger(NpcManager.class); + private static final NpcManager INSTANCE = new NpcManager(); + + private final NpcConfigStore configStore; + private final NpcViewerService viewerService; + private final NpcSpatialIndex spatialIndex; + private final NpcHologramService hologramService; + private NpcRenderService renderService; + private NpcInteractionService interactionService; + private SkinCache skinCache; + + private NpcConfig config; + private final Map npcs = new LinkedHashMap<>(); + private volatile boolean initialized; + + private int viewerRoundRobinIndex; + private int lookRoundRobinIndex; + private long tickCounter; + private int lookUpdatesThisTick; + private int lookUpdatesDropped; + private long lastReloadMillis; + private long lastSaveMillis; + private volatile boolean saveScheduled; + private volatile boolean shuttingDown; + + private NpcManager() { + this.configStore = new NpcConfigStore(); + this.viewerService = new NpcViewerService(); + this.spatialIndex = new NpcSpatialIndex(); + this.hologramService = new NpcHologramService(); + } + + public static NpcManager getInstance() { + return INSTANCE; + } + + public synchronized void initialize() { + if (initialized) return; + long start = System.currentTimeMillis(); + + config = configStore.load(); + configStore.createExample(); + + this.skinCache = new SkinCache( + config.freshTtlHours(), config.staleTtlDays(), config.negativeCacheMinutes(), + config.maxConcurrentRequests(), config.connectTimeoutMillis(), config.requestTimeoutMillis()); + this.renderService = new NpcRenderService(viewerService, skinCache); + this.interactionService = new NpcInteractionService(viewerService, renderService); + + for (NpcDefinition def : config.npcs().values()) { + registerNpc(def); + } + + hologramService.cleanupOrphans(npcs); + + initialized = true; + lastReloadMillis = System.currentTimeMillis() - start; + LOGGER.info("NPC Manager initialized with {} NPC(s) in {}ms", npcs.size(), lastReloadMillis); + } + + public synchronized void tick() { + if (!initialized || shuttingDown) return; + tickCounter++; + lookUpdatesThisTick = 0; + lookUpdatesDropped = 0; + + if (tickCounter % config.visibilityScanIntervalTicks() == 0) { + syncViewers(); + } + syncLookAtPlayers(); + + if (saveScheduled && tickCounter % 20 == 0) { + saveScheduled = false; + save(); + } + } + + public synchronized void onPlayerJoin(ServerPlayer player) { + if (!initialized) return; + viewerService.getSession(player); + syncViewer(player); + } + + public synchronized void onPlayerLeave(ServerPlayer player) { + NpcViewerSession session = viewerService.removeSession(player.getUUID()); + if (session == null) return; + for (String npcId : session.visibleNpcIds()) { + NpcDefinition npc = npcs.get(npcId); + if (npc != null) renderService.despawn(player, npc); + } + interactionService.clearCooldowns(player.getUUID()); + } + + public synchronized void onPlayerDimensionChange(ServerPlayer player) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session == null) return; + for (String npcId : new ArrayList<>(session.visibleNpcIds())) { + NpcDefinition npc = npcs.get(npcId); + if (npc != null) renderService.despawn(player, npc); + } + session.clear(); + syncViewer(player); + } + + public NpcInteractionService getInteractionService() { + return interactionService; + } + + @Override + public synchronized Optional find(String id) { + return Optional.ofNullable(npcs.get(NpcDefinition.normalizeId(id))); + } + + @Override + public synchronized Collection list() { + return Collections.unmodifiableCollection(npcs.values()); + } + + @Override + public synchronized NpcDefinition create(NpcDefinition definition) { + NpcDefinition normalized = new NpcDefinition( + definition.id(), definition.enabled(), definition.displayName(), + definition.location(), definition.skin(), definition.action(), + definition.hologram(), definition.lookSettings(), + definition.viewDistance(), definition.despawnDistance(), + definition.interaction()); + registerNpc(normalized); + hologramService.createOrUpdate(normalized); + scheduleSave(); + LOGGER.info("NPC '{}' created", normalized.id()); + return normalized; + } + + @Override + public synchronized NpcDefinition update(NpcDefinition definition) { + String id = NpcDefinition.normalizeId(definition.id()); + if (!npcs.containsKey(id)) throw new IllegalArgumentException("NPC does not exist: " + id); + NpcDefinition old = npcs.get(id); + registerNpc(definition); + hologramService.createOrUpdate(definition); + + if (!old.skin().playerName().equals(definition.skin().playerName())) { + String skinName = definition.skin().playerName(); + skinCache.resolve(skinName).thenAccept(entry -> { + NpcDefinition withSkin = npcs.get(id).withSkin( + new NpcSkin(skinName, entry.uuid(), entry.textureValue(), entry.textureSignature(), entry.model(), entry.fetchedAt())); + npcs.put(id, withSkin); + invalidateViewersForNpc(id); + }); + } + + if (old.location().equals(definition.location()) && old.enabled() == definition.enabled()) { + scheduleSave(); + return definition; + } + + invalidateViewersForNpc(id); + scheduleSave(); + LOGGER.info("NPC '{}' updated", id); + return definition; + } + + @Override + public synchronized boolean delete(String id) { + String normalized = NpcDefinition.normalizeId(id); + NpcDefinition removed = npcs.remove(normalized); + if (removed == null) return false; + + renderService.unregister(normalized); + spatialIndex.remove(normalized); + hologramService.remove(normalized); + + MinecraftServer server = Platform.getCurrentServer(); + if (server != null) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session != null && session.visibleNpcIds().remove(normalized)) { + renderService.despawn(player, removed); + } + } + } + scheduleSave(); + LOGGER.info("NPC '{}' deleted", normalized); + return true; + } + + @Override + public synchronized void reload() { + long start = System.currentTimeMillis(); + NpcConfig newConfig = configStore.load(); + + skinCache.configure(newConfig.freshTtlHours(), newConfig.staleTtlDays(), newConfig.negativeCacheMinutes(), + newConfig.maxConcurrentRequests(), newConfig.connectTimeoutMillis(), newConfig.requestTimeoutMillis()); + + this.config = newConfig; + + Map newNpcs = newConfig.npcs(); + Set added = new HashSet<>(newNpcs.keySet()); + added.removeAll(npcs.keySet()); + Set removed = new HashSet<>(npcs.keySet()); + removed.removeAll(newNpcs.keySet()); + int updated = 0; + int unchanged = 0; + + for (String id : removed) { + NpcDefinition def = npcs.remove(id); + renderService.unregister(id); + spatialIndex.remove(id); + hologramService.remove(id); + MinecraftServer server = Platform.getCurrentServer(); + if (server != null && def != null) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session != null && session.visibleNpcIds().remove(id)) { + renderService.despawn(player, def); + } + } + } + } + + for (var entry : newNpcs.entrySet()) { + if (!npcs.containsKey(entry.getKey())) { + registerNpc(entry.getValue()); + hologramService.createOrUpdate(entry.getValue()); + continue; + } + NpcDefinition old = npcs.get(entry.getKey()); + if (!entry.getValue().equals(old)) { + registerNpc(entry.getValue()); + hologramService.createOrUpdate(entry.getValue()); + if (skinChanged(old, entry.getValue()) || locChanged(old, entry.getValue())) { + invalidateViewersForNpc(entry.getKey()); + } + updated++; + } else { + unchanged++; + } + } + + hologramService.cleanupOrphans(npcs); + lastReloadMillis = System.currentTimeMillis() - start; + + LOGGER.info("NPC reload complete: {} added, {} removed, {} updated, {} unchanged, {} invalid in {}ms", + added.size(), removed.size(), updated, unchanged, 0, lastReloadMillis); + } + + @Override + public synchronized void save() { + long start = System.currentTimeMillis(); + configStore.save(config.withNpcs(new LinkedHashMap<>(npcs))); + skinCache.persist(); + lastSaveMillis = System.currentTimeMillis() - start; + saveScheduled = false; + } + + @Override + public synchronized NpcStats stats() { + int visibleTotal = 0; + MinecraftServer server = Platform.getCurrentServer(); + if (server != null) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + visibleTotal += renderService.visibleNpcCount(player); + } + } + + int enabled = 0; + int invalid = 0; + for (NpcDefinition def : npcs.values()) { + if (def.enabled()) enabled++; + else invalid++; + } + + return new NpcStats( + npcs.size(), enabled, invalid, + viewerService.sessionCount(), visibleTotal, + spatialIndex.size(), 0, 0, 0, + lookUpdatesThisTick, lookUpdatesDropped, + skinCache != null ? skinCache.memorySize() : 0, + skinCache != null ? skinCache.hits() : 0, + skinCache != null ? skinCache.misses() : 0, + skinCache != null ? skinCache.staleHits() : 0, + skinCache != null ? skinCache.negativeHits() : 0, + skinCache != null ? skinCache.inflightCount() : 0, + skinCache != null ? skinCache.failures() : 0, + hologramService.countActive(), + lastReloadMillis, lastSaveMillis + ); + } + + public synchronized void shutdown() { + shuttingDown = true; + if (skinCache != null) skinCache.shutdown(); + viewerService.clear(); + + MinecraftServer server = Platform.getCurrentServer(); + if (server != null) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + onPlayerLeave(player); + } + } + + configStore.save(config.withNpcs(new LinkedHashMap<>(npcs))); + initialized = false; + LOGGER.info("NPC Manager shutdown complete"); + } + + private void registerNpc(NpcDefinition def) { + npcs.put(def.id(), def); + renderService.register(def); + spatialIndex.add(def.id(), def.location()); + } + + private void scheduleSave() { + if (saveScheduled) return; + saveScheduled = true; + } + + private void syncViewers() { + MinecraftServer server = Platform.getCurrentServer(); + if (server == null) return; + List players = server.getPlayerList().getPlayers(); + if (players.isEmpty()) return; + + int budget = Math.min(players.size(), config.maxViewerSyncsPerTick()); + for (int i = 0; i < budget; i++) { + ServerPlayer player = players.get((viewerRoundRobinIndex + i) % players.size()); + syncViewer(player); + } + viewerRoundRobinIndex = (viewerRoundRobinIndex + budget) % players.size(); + } + + private void syncViewer(ServerPlayer player) { + NpcViewerSession session = viewerService.getSession(player); + if (session == null) return; + + int radius = (int) Math.ceil(Math.max(config.defaultViewDistance(), 64.0)); + Set candidates = spatialIndex.query( + player.serverLevel().dimension().location(), player.getX(), player.getZ(), radius); + + Set shouldSee = new LinkedHashSet<>(); + for (String id : candidates) { + NpcDefinition npc = npcs.get(id); + if (npc == null || !npc.enabled()) continue; + if (!player.level().dimension().equals(npc.location().dimension())) continue; + + double distSq = player.distanceToSqr(npc.location().x(), npc.location().y(), npc.location().z()); + double viewDist = npc.viewDistance(); + + if (session.visibleNpcIds().contains(id)) { + if (distSq <= npc.despawnDistance() * npc.despawnDistance()) { + shouldSee.add(id); + continue; + } + } else { + if (distSq <= viewDist * viewDist) { + shouldSee.add(id); + } + } + } + + for (String id : new ArrayList<>(session.visibleNpcIds())) { + if (!shouldSee.contains(id)) { + NpcDefinition npc = npcs.get(id); + if (npc != null) renderService.despawn(player, npc); + } + } + + int spawns = 0; + for (String id : shouldSee) { + if (session.visibleNpcIds().contains(id)) continue; + if (spawns >= config.maxSpawnsPerTick()) break; + NpcDefinition npc = npcs.get(id); + if (npc != null) { + renderService.spawn(player, npc); + spawns++; + } + } + + session.setLastSyncTick(tickCounter); + } + + private void syncLookAtPlayers() { + MinecraftServer server = Platform.getCurrentServer(); + if (server == null) return; + List players = server.getPlayerList().getPlayers(); + if (players.isEmpty()) return; + + int updates = 0; + for (ServerPlayer player : players) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session == null || session.visibleNpcIds().isEmpty()) continue; + + for (String npcId : session.visibleNpcIds()) { + if (updates >= config.maxLookUpdatesPerTick()) { + lookUpdatesDropped++; + continue; + } + + NpcDefinition npc = npcs.get(npcId); + if (npc == null || !npc.lookSettings().enabled()) continue; + if (!player.level().dimension().equals(npc.location().dimension())) continue; + + NpcLookSettings look = npc.lookSettings(); + if (tickCounter % look.updateIntervalTicks() != 0) continue; + + NpcViewerSession.NpcViewState vs = session.getState(npcId); + double distSq = player.distanceToSqr(npc.location().x(), npc.location().y(), npc.location().z()); + double lookRangeSq = look.range() * look.range(); + + if (distSq > lookRangeSq) { + if (look.resetWhenOutOfRange()) { + renderService.resetLook(player, npc, vs); + updates++; + } + continue; + } + + double eyeY = player.getY() + player.getEyeHeight(); + double dx = player.getX() - npc.location().x(); + double dy = eyeY - (npc.location().y() + 1.62); + double dz = player.getZ() - npc.location().z(); + double hDist = Math.sqrt(dx * dx + dz * dz); + + float targetYaw = (float) Math.toDegrees(Math.atan2(-dx, dz)); + float targetPitch = (float) -Math.toDegrees(Math.atan2(dy, hDist)); + + float baseYaw = npc.location().yaw(); + float deltaYaw = targetYaw - baseYaw; + while (deltaYaw > 180.0f) deltaYaw -= 360.0f; + while (deltaYaw < -180.0f) deltaYaw += 360.0f; + deltaYaw = Math.max(-(float) look.maxYawFromBase(), Math.min((float) look.maxYawFromBase(), deltaYaw)); + float clampedYaw = baseYaw + deltaYaw; + + float clampedPitch = Math.max(-(float) look.maxPitchDown(), Math.min((float) look.maxPitchUp(), targetPitch)); + + if (Math.abs(clampedYaw - vs.lastHeadYaw()) < look.minimumAngleChange() + && Math.abs(clampedPitch - vs.lastPitch()) < look.minimumAngleChange()) { + continue; + } + + float bodyYaw = look.rotateBody() ? clampedYaw : vs.lastYaw(); + renderService.sendLookUpdate(player, npc, bodyYaw, clampedYaw, clampedPitch, vs); + updates++; + } + } + lookUpdatesThisTick = updates; + } + + private void invalidateViewersForNpc(String npcId) { + MinecraftServer server = Platform.getCurrentServer(); + if (server == null) return; + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + NpcViewerSession session = viewerService.getSession(player.getUUID()); + if (session == null) continue; + NpcDefinition npc = npcs.get(npcId); + if (session.visibleNpcIds().remove(npcId) && npc != null) { + renderService.despawn(player, npc); + } + } + } + + private static boolean skinChanged(NpcDefinition old, NpcDefinition new_) { + return !old.skin().playerName().equals(new_.skin().playerName()); + } + + private static boolean locChanged(NpcDefinition old, NpcDefinition new_) { + return !old.location().equals(new_.location()); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/spatial/NpcSpatialIndex.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/spatial/NpcSpatialIndex.java new file mode 100644 index 00000000..7f538662 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/spatial/NpcSpatialIndex.java @@ -0,0 +1,69 @@ +package com.pedrodalben.bigbangessentials.npcs.spatial; + +import com.pedrodalben.bigbangessentials.npcs.api.NpcLocation; +import net.minecraft.resources.ResourceLocation; + +import java.util.*; + +public class NpcSpatialIndex { + private final Map>> index = new HashMap<>(); + private final Map reverse = new HashMap<>(); + + public void add(String npcId, NpcLocation location) { + remove(npcId); + int chunkX = chunk(location.x()); + int chunkZ = chunk(location.z()); + long key = pack(chunkX, chunkZ); + index.computeIfAbsent(location.dimension(), k -> new HashMap<>()) + .computeIfAbsent(key, k -> new HashSet<>()) + .add(npcId); + reverse.put(npcId, new ChunkKey(location.dimension(), key)); + } + + public void remove(String npcId) { + ChunkKey existing = reverse.remove(npcId); + if (existing == null) return; + Map> byChunk = index.get(existing.dimension()); + if (byChunk == null) return; + Set ids = byChunk.get(existing.chunkKey()); + if (ids == null) return; + ids.remove(npcId); + if (ids.isEmpty()) byChunk.remove(existing.chunkKey()); + if (byChunk.isEmpty()) index.remove(existing.dimension()); + } + + public Set query(ResourceLocation dimension, double x, double z, int radiusBlocks) { + Set result = new HashSet<>(); + Map> byChunk = index.get(dimension); + if (byChunk == null || byChunk.isEmpty()) return result; + int chunkRadius = Math.max(0, (int) Math.ceil(radiusBlocks / 16.0)); + int centerChunkX = chunk(x); + int centerChunkZ = chunk(z); + for (int dx = -chunkRadius; dx <= chunkRadius; dx++) { + for (int dz = -chunkRadius; dz <= chunkRadius; dz++) { + Set ids = byChunk.get(pack(centerChunkX + dx, centerChunkZ + dz)); + if (ids != null) result.addAll(ids); + } + } + return result; + } + + public int size() { + return reverse.size(); + } + + public void clear() { + index.clear(); + reverse.clear(); + } + + private static int chunk(double coordinate) { + return ((int) Math.floor(coordinate)) >> 4; + } + + private static long pack(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + + private record ChunkKey(ResourceLocation dimension, long chunkKey) {} +} From 22e136d900a103a8936ea8650f6c1cd56137ee2c Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:25 -0300 Subject: [PATCH 06/10] feat(npcs): add click interaction actions and hologram integration --- .../npcs/hologram/NpcHologramService.java | 92 ++++++++++++++ .../interaction/NpcInteractionService.java | 114 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/hologram/NpcHologramService.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/interaction/NpcInteractionService.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/hologram/NpcHologramService.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/hologram/NpcHologramService.java new file mode 100644 index 00000000..c6af1499 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/hologram/NpcHologramService.java @@ -0,0 +1,92 @@ +package com.pedrodalben.bigbangessentials.npcs.hologram; + +import com.pedrodalben.bigbangessentials.holograms.api.*; +import com.pedrodalben.bigbangessentials.npcs.api.NpcDefinition; +import com.pedrodalben.bigbangessentials.npcs.api.NpcHologramConfig; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class NpcHologramService { + private static final Logger LOGGER = LoggerFactory.getLogger(NpcHologramService.class); + private static final String NPC_OWNER = "bigbangessentials:npc"; + + private static String hologramId(String npcId) { + return "bigbangessentials:npc/" + npcId; + } + + public void createOrUpdate(NpcDefinition npc) { + HologramService api = BigBangHolograms.getApi(); + if (api == null) return; + + NpcHologramConfig config = npc.hologram(); + String holId = hologramId(npc.id()); + + if (!config.enabled()) { + api.delete(holId); + return; + } + + if (config.lines().isEmpty()) return; + + var existing = api.findDefinition(holId); + HologramDefinition definition; + try { + List lines = new ArrayList<>(); + for (String text : config.lines()) { + lines.add(HologramLine.text(text)); + } + + HologramPage page = new HologramPage(lines); + ResourceKey dimKey = ResourceKey.create(Registries.DIMENSION, npc.location().dimension()); + HologramLocation loc = new HologramLocation(dimKey, npc.location().x(), + npc.location().y(), npc.location().z()); + + definition = HologramDefinition.builder(holId) + .ownerId(NPC_OWNER) + .location(loc) + .page(page) + .viewDistance((int) config.viewDistance()) + .persistent(true) + .offset(0, config.offsetY(), 0) + .shadow(config.shadow()) + .seeThrough(config.seeThrough()) + .build(); + } catch (Exception e) { + LOGGER.warn("Failed to build hologram definition for NPC '{}': {}", npc.id(), e.getMessage()); + return; + } + + api.createOrUpdate(definition); + } + + public void remove(String npcId) { + HologramService api = BigBangHolograms.getApi(); + if (api != null) api.delete(hologramId(npcId)); + } + + public void cleanupOrphans(Map knownNpcs) { + HologramService api = BigBangHolograms.getApi(); + if (api == null) return; + + int removed = api.deleteByOwner(NPC_OWNER); + + for (NpcDefinition npc : knownNpcs.values()) { + createOrUpdate(npc); + } + } + + public int countActive() { + HologramService api = BigBangHolograms.getApi(); + if (api == null) return 0; + int count = 0; + for (HologramDefinition def : api.getDefinitions()) { + if (def.ownerId().equals(NPC_OWNER) && def.enabled()) count++; + } + return count; + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/interaction/NpcInteractionService.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/interaction/NpcInteractionService.java new file mode 100644 index 00000000..7b4505a1 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/interaction/NpcInteractionService.java @@ -0,0 +1,114 @@ +package com.pedrodalben.bigbangessentials.npcs.interaction; + +import com.pedrodalben.bigbangessentials.api.permissions.PermissionAPI; +import com.pedrodalben.bigbangessentials.npcs.api.NpcAction; +import com.pedrodalben.bigbangessentials.npcs.api.NpcActionType; +import com.pedrodalben.bigbangessentials.npcs.api.NpcDefinition; +import com.pedrodalben.bigbangessentials.npcs.render.NpcRenderService; +import com.pedrodalben.bigbangessentials.npcs.render.NpcViewerService; +import com.pedrodalben.bigbangessentials.npcs.render.NpcViewerSession; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class NpcInteractionService { + private static final Logger LOGGER = LoggerFactory.getLogger(NpcInteractionService.class); + + private final NpcViewerService viewerService; + private final NpcRenderService renderService; + private final Map> cooldowns = new ConcurrentHashMap<>(); + + public NpcInteractionService(NpcViewerService viewerService, NpcRenderService renderService) { + this.viewerService = viewerService; + this.renderService = renderService; + } + + public boolean handleClick(ServerPlayer player, int entityId) { + NpcViewerSession session = viewerService.getSession(player); + if (session == null) return false; + + String npcId = session.entityIdToNpc().get(entityId); + if (npcId == null) return false; + + NpcRenderService.NpcRenderState state = renderService.getState(npcId); + if (state == null) return false; + + NpcDefinition npc = state.definition(); + if (!npc.enabled()) return false; + + if (!player.level().dimension().equals(npc.location().dimension())) return false; + + double distSq = player.distanceToSqr(npc.location().x(), npc.location().y(), npc.location().z()); + double maxDist = npc.interaction().distance(); + if (distSq > maxDist * maxDist) return false; + + if (npc.interaction().hasPermission()) { + if (!player.hasPermissions(4) && !PermissionAPI.hasPermission(player.getUUID(), npc.interaction().permission())) { + player.sendSystemMessage(net.minecraft.network.chat.Component.literal( + "§cVocê não tem permissão para interagir com este NPC.")); + return false; + } + } + + long now = System.currentTimeMillis(); + Map playerCooldowns = cooldowns.computeIfAbsent(player.getUUID(), k -> new ConcurrentHashMap<>()); + long lastClick = playerCooldowns.getOrDefault(npcId, 0L); + if (now - lastClick < npc.interaction().cooldownMillis()) { + return false; + } + + execute(npc.action(), player); + playerCooldowns.put(npcId, now); + return true; + } + + private void execute(NpcAction action, ServerPlayer player) { + switch (action.type()) { + case PLAYER_COMMAND: + executePlayerCommand(action.command(), player); + break; + case CONSOLE_COMMAND: + executeConsoleCommand(action.command(), player); + break; + case NONE: + break; + } + } + + private void executePlayerCommand(String command, ServerPlayer player) { + if (command.isEmpty()) return; + try { + MinecraftServer server = player.getServer(); + if (server != null) { + server.getCommands().performPrefixedCommand( + player.createCommandSourceStack(), command); + } + } catch (Exception e) { + LOGGER.warn("Failed to execute player command '{}' for {}: {}", command, player.getGameProfile().getName(), e.getMessage()); + player.sendSystemMessage(net.minecraft.network.chat.Component.literal("§cErro ao executar comando.")); + } + } + + private void executeConsoleCommand(String command, ServerPlayer player) { + if (command.isEmpty()) return; + try { + MinecraftServer server = player.getServer(); + if (server != null) { + String resolved = command.replace("{player}", player.getGameProfile().getName()); + server.getCommands().performPrefixedCommand( + server.createCommandSourceStack(), resolved); + } + } catch (Exception e) { + LOGGER.warn("Failed to execute console command '{}' for {}: {}", command, player.getGameProfile().getName(), e.getMessage()); + } + } + + public void clearCooldowns(UUID playerUuid) { + cooldowns.remove(playerUuid); + } +} From 73f473aa0deff5f8d043358588838b3642a7fbfd Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:30 -0300 Subject: [PATCH 07/10] feat(npcs): add /npc admin commands with Brigadier and permissions --- .../npcs/command/NpcCommand.java | 460 ++++++++++++++++++ .../npcs/command/NpcPermissions.java | 17 + 2 files changed, 477 insertions(+) create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcCommand.java create mode 100644 common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcPermissions.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcCommand.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcCommand.java new file mode 100644 index 00000000..145d0947 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcCommand.java @@ -0,0 +1,460 @@ +package com.pedrodalben.bigbangessentials.npcs.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.pedrodalben.bigbangessentials.api.permissions.PermissionAPI; +import com.pedrodalben.bigbangessentials.npcs.api.*; +import com.pedrodalben.bigbangessentials.npcs.service.NpcManager; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public final class NpcCommand { + private NpcCommand() {} + + public static void register(CommandDispatcher dispatcher) { + var root = Commands.literal("npc") + .requires(NpcCommand::hasAnyAdminPermission); + + root.then(createCmd()); + root.then(removeCmd()); + root.then(movehereCmd()); + root.then(skinCmd()); + root.then(nameCmd()); + root.then(commandCmd()); + root.then(consolecommandCmd()); + root.then(hologramCmd()); + root.then(lookCmd()); + root.then(enableCmd()); + root.then(disableCmd()); + root.then(teleportCmd()); + root.then(infoCmd()); + root.then(listCmd()); + root.then(reloadCmd()); + root.then(saveCmd()); + root.then(statsCmd()); + + var rootNode = dispatcher.register(root); + dispatcher.register(Commands.literal("npcs").redirect(rootNode)); + } + + private static boolean hasAnyAdminPermission(CommandSourceStack src) { + if (src.hasPermission(4)) return true; + try { + ServerPlayer player = src.getPlayer(); + if (player != null) { + UUID uuid = player.getUUID(); + if (PermissionAPI.hasPermission(uuid, NpcPermissions.ADMIN)) return true; + if (PermissionAPI.hasPermission(uuid, NpcPermissions.CREATE)) return true; + if (PermissionAPI.hasPermission(uuid, NpcPermissions.REMOVE)) return true; + if (PermissionAPI.hasPermission(uuid, NpcPermissions.EDIT)) return true; + if (PermissionAPI.hasPermission(uuid, NpcPermissions.RELOAD)) return true; + if (PermissionAPI.hasPermission(uuid, NpcPermissions.STATS)) return true; + } + } catch (Exception ignored) {} + return false; + } + + private static NpcService api() { return NpcManager.getInstance(); } + private static ServerPlayer player(CommandContext ctx) throws CommandSyntaxException { + return ctx.getSource().getPlayerOrException(); + } + + // /npc create + private static com.mojang.brigadier.builder.LiteralArgumentBuilder createCmd() { + return Commands.literal("create") + .then(Commands.argument("id", StringArgumentType.word()) + .then(Commands.argument("skin", StringArgumentType.word()) + .executes(ctx -> { + ServerPlayer p = player(ctx); + String id = StringArgumentType.getString(ctx, "id"); + String skinName = StringArgumentType.getString(ctx, "skin"); + + NpcLocation loc = new NpcLocation(p.level().dimension().location(), + p.getX(), p.getY(), p.getZ(), p.getYRot(), p.getXRot()); + NpcDefinition def = new NpcDefinition(id, true, id, loc, NpcSkin.unresolved(skinName), + NpcAction.none(), NpcHologramConfig.defaults(id), NpcLookSettings.defaults(), 48.0, 56.0, NpcInteractionConfig.defaults()); + api().create(def); + + p.sendSystemMessage(Component.literal("§aNPC '" + id + "' criado com sucesso.")); + p.sendSystemMessage(Component.literal("§7Use:")); + p.sendSystemMessage(Component.literal("§7- /npc name " + id + " ")); + p.sendSystemMessage(Component.literal("§7- /npc command " + id + " warp end")); + p.sendSystemMessage(Component.literal("§7- /npc hologram " + id + " setline 1 ")); + return 1; + }))); + } + + // /npc remove + private static com.mojang.brigadier.builder.LiteralArgumentBuilder removeCmd() { + return Commands.literal("remove") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + if (api().delete(id)) { + ctx.getSource().sendSystemMessage(Component.literal("§aNPC '" + id + "' removido.")); + } else { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC '" + id + "' não encontrado.")); + } + return 1; + })); + } + + // /npc movehere + private static com.mojang.brigadier.builder.LiteralArgumentBuilder movehereCmd() { + return Commands.literal("movehere") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + ServerPlayer p = player(ctx); + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); + return 0; + } + NpcLocation loc = new NpcLocation(p.level().dimension().location(), + p.getX(), p.getY(), p.getZ(), p.getYRot(), p.getXRot()); + api().update(opt.get().withLocation(loc)); + ctx.getSource().sendSystemMessage(Component.literal("§aNPC '" + id + "' movido para você.")); + return 1; + })); + } + + // /npc skin + private static com.mojang.brigadier.builder.LiteralArgumentBuilder skinCmd() { + return Commands.literal("skin") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(Commands.argument("player", StringArgumentType.word()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + String skinName = StringArgumentType.getString(ctx, "player"); + Optional opt = api().find(id); + if (opt.isEmpty()) { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); + return 0; + } + api().update(opt.get().withSkin(NpcSkin.unresolved(skinName))); + ctx.getSource().sendSystemMessage(Component.literal("§aSkin do NPC '" + id + "' alterada para '" + skinName + "'.")); + return 1; + }))); + } + + // /npc name + private static com.mojang.brigadier.builder.LiteralArgumentBuilder nameCmd() { + return Commands.literal("name") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(Commands.argument("text", StringArgumentType.greedyString()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + String text = StringArgumentType.getString(ctx, "text"); + Optional opt = api().find(id); + if (opt.isEmpty()) { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); + return 0; + } + api().update(opt.get().withDisplayName(text)); + ctx.getSource().sendSystemMessage(Component.literal("§aNome do NPC '" + id + "' atualizado.")); + return 1; + }))); + } + + // /npc command + private static com.mojang.brigadier.builder.LiteralArgumentBuilder commandCmd() { + return Commands.literal("command") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(Commands.argument("command", StringArgumentType.greedyString()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + String cmd = StringArgumentType.getString(ctx, "command"); + Optional opt = api().find(id); + if (opt.isEmpty()) { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); + return 0; + } + api().update(opt.get().withAction(NpcAction.playerCommand(cmd))); + ctx.getSource().sendSystemMessage(Component.literal("§aComando do NPC '" + id + "' atualizado.")); + return 1; + }))); + } + + // /npc consolecommand + private static com.mojang.brigadier.builder.LiteralArgumentBuilder consolecommandCmd() { + return Commands.literal("consolecommand") + .requires(src -> { + if (src.hasPermission(4)) return true; + try { + ServerPlayer p = src.getPlayer(); + return p != null && PermissionAPI.hasPermission(p.getUUID(), NpcPermissions.CONSOLE_COMMAND); + } catch (Exception e) { return false; } + }) + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(Commands.argument("command", StringArgumentType.greedyString()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + String cmd = StringArgumentType.getString(ctx, "command"); + Optional opt = api().find(id); + if (opt.isEmpty()) { + ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); + return 0; + } + api().update(opt.get().withAction(NpcAction.consoleCommand(cmd))); + ctx.getSource().sendSystemMessage(Component.literal("§aComando de console do NPC '" + id + "' atualizado.")); + return 1; + }))); + } + + // /npc hologram on|off|addline|setline|removeline + private static com.mojang.brigadier.builder.LiteralArgumentBuilder hologramCmd() { + var onOff = Commands.literal("on").executes(ctx -> toggleHologram(ctx, true)); + var off = Commands.literal("off").executes(ctx -> toggleHologram(ctx, false)); + var addline = Commands.literal("addline") + .then(Commands.argument("text", StringArgumentType.greedyString()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + String text = StringArgumentType.getString(ctx, "text"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcDefinition npc = opt.get(); + List lines = new ArrayList<>(npc.hologram().lines()); + lines.add(text); + api().update(npc.withHologram(new NpcHologramConfig(true, lines, npc.hologram().offsetY(), + npc.hologram().viewDistance(), npc.hologram().shadow(), npc.hologram().seeThrough()))); + ctx.getSource().sendSystemMessage(Component.literal("§aLinha adicionada ao holograma do NPC '" + id + "'.")); + return 1; + })); + var setline = Commands.literal("setline") + .then(Commands.argument("line", StringArgumentType.word()) + .then(Commands.argument("text", StringArgumentType.greedyString()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + int lineIdx = Integer.parseInt(StringArgumentType.getString(ctx, "line")) - 1; + String text = StringArgumentType.getString(ctx, "text"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcDefinition npc = opt.get(); + List lines = new ArrayList<>(npc.hologram().lines()); + if (lineIdx < 0 || lineIdx >= lines.size()) { + ctx.getSource().sendSystemMessage(Component.literal("§cLinha inválida. O holograma tem " + lines.size() + " linha(s).")); + return 0; + } + lines.set(lineIdx, text); + api().update(npc.withHologram(new NpcHologramConfig(true, lines, npc.hologram().offsetY(), + npc.hologram().viewDistance(), npc.hologram().shadow(), npc.hologram().seeThrough()))); + ctx.getSource().sendSystemMessage(Component.literal("§aLinha " + (lineIdx + 1) + " atualizada no holograma do NPC '" + id + "'.")); + return 1; + }))); + var removeline = Commands.literal("removeline") + .then(Commands.argument("line", StringArgumentType.word()) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + int lineIdx = Integer.parseInt(StringArgumentType.getString(ctx, "line")) - 1; + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcDefinition npc = opt.get(); + List lines = new ArrayList<>(npc.hologram().lines()); + if (lineIdx < 0 || lineIdx >= lines.size()) { + ctx.getSource().sendSystemMessage(Component.literal("§cLinha inválida.")); + return 0; + } + lines.remove(lineIdx); + api().update(npc.withHologram(new NpcHologramConfig(true, lines, npc.hologram().offsetY(), + npc.hologram().viewDistance(), npc.hologram().shadow(), npc.hologram().seeThrough()))); + ctx.getSource().sendSystemMessage(Component.literal("§aLinha " + (lineIdx + 1) + " removida do holograma do NPC '" + id + "'.")); + return 1; + })); + + return Commands.literal("hologram") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(onOff).then(off).then(addline).then(setline).then(removeline)); + } + + private static int toggleHologram(CommandContext ctx, boolean enabled) { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcDefinition npc = opt.get(); + api().update(npc.withHologram(npc.hologram().withEnabled(enabled))); + ctx.getSource().sendSystemMessage(Component.literal("§aHolograma do NPC '" + id + "' " + (enabled ? "ativado" : "desativado") + ".")); + return 1; + } + + // /npc look on|off + private static com.mojang.brigadier.builder.LiteralArgumentBuilder lookCmd() { + return Commands.literal("look") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .then(Commands.literal("on").executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + api().update(opt.get().withLookSettings(opt.get().lookSettings().withEnabled(true))); + ctx.getSource().sendSystemMessage(Component.literal("§aOlhar do NPC '" + id + "' ativado.")); + return 1; + })) + .then(Commands.literal("off").executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + api().update(opt.get().withLookSettings(opt.get().lookSettings().withEnabled(false))); + ctx.getSource().sendSystemMessage(Component.literal("§aOlhar do NPC '" + id + "' desativado.")); + return 1; + }))); + } + + // /npc enable + private static com.mojang.brigadier.builder.LiteralArgumentBuilder enableCmd() { + return Commands.literal("enable") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + api().update(opt.get().withEnabled(true)); + ctx.getSource().sendSystemMessage(Component.literal("§aNPC '" + id + "' ativado.")); + return 1; + })); + } + + // /npc disable + private static com.mojang.brigadier.builder.LiteralArgumentBuilder disableCmd() { + return Commands.literal("disable") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + api().update(opt.get().withEnabled(false)); + ctx.getSource().sendSystemMessage(Component.literal("§aNPC '" + id + "' desativado.")); + return 1; + })); + } + + // /npc teleport + private static com.mojang.brigadier.builder.LiteralArgumentBuilder teleportCmd() { + return Commands.literal("teleport") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + ServerPlayer p = player(ctx); + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcLocation loc = opt.get().location(); + ResourceLocation dim = loc.dimension(); + net.minecraft.resources.ResourceKey key = + net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, dim); + net.minecraft.server.level.ServerLevel level = p.getServer().getLevel(key); + if (level == null) { + ctx.getSource().sendSystemMessage(Component.literal("§cDimensão não encontrada: " + dim)); + return 0; + } + p.teleportTo(level, loc.x(), loc.y(), loc.z(), loc.yaw(), loc.pitch()); + ctx.getSource().sendSystemMessage(Component.literal("§aTeleportado para o NPC '" + id + "'.")); + return 1; + })); + } + + // /npc info + private static com.mojang.brigadier.builder.LiteralArgumentBuilder infoCmd() { + return Commands.literal("info") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests(NpcCommand::suggestNpcIds) + .executes(ctx -> { + String id = StringArgumentType.getString(ctx, "id"); + Optional opt = api().find(id); + if (opt.isEmpty()) { ctx.getSource().sendSystemMessage(Component.literal("§cNPC não encontrado.")); return 0; } + NpcDefinition npc = opt.get(); + NpcLocation loc = npc.location(); + ctx.getSource().sendSystemMessage(Component.literal("§6=== NPC: " + npc.id() + " ===")); + ctx.getSource().sendSystemMessage(Component.literal("§7Nome: " + npc.displayName())); + ctx.getSource().sendSystemMessage(Component.literal("§7Ativo: " + (npc.enabled() ? "§aSim" : "§cNão"))); + ctx.getSource().sendSystemMessage(Component.literal("§7Local: " + loc.dimension() + " " + String.format("%.1f %.1f %.1f", loc.x(), loc.y(), loc.z()))); + ctx.getSource().sendSystemMessage(Component.literal("§7Skin: " + npc.skin().playerName() + (npc.skin().isResolved() ? " §a(resolvida)" : " §7(pendente)"))); + ctx.getSource().sendSystemMessage(Component.literal("§7Ação: " + npc.action().type() + " /" + npc.action().command())); + ctx.getSource().sendSystemMessage(Component.literal("§7Holograma: " + (npc.hologram().enabled() ? "§aAtivo" : "§7Desativado") + " (" + npc.hologram().lines().size() + " linhas)")); + ctx.getSource().sendSystemMessage(Component.literal("§7Olhar: " + (npc.lookSettings().enabled() ? "§aAtivo" : "§7Desativado"))); + ctx.getSource().sendSystemMessage(Component.literal("§7Permissão: " + (npc.interaction().hasPermission() ? npc.interaction().permission() : "§7Nenhuma"))); + return 1; + })); + } + + // /npc list + private static com.mojang.brigadier.builder.LiteralArgumentBuilder listCmd() { + return Commands.literal("list").executes(ctx -> { + Collection npcs = api().list(); + ctx.getSource().sendSystemMessage(Component.literal("§6=== NPCs (" + npcs.size() + ") ===")); + for (NpcDefinition npc : npcs) { + String status = npc.enabled() ? "§a✓" : "§c✗"; + ctx.getSource().sendSystemMessage(Component.literal(" " + status + " " + npc.id() + " §7- " + npc.location().dimension().toString().replace("minecraft:", ""))); + } + return 1; + }); + } + + // /npc reload + private static com.mojang.brigadier.builder.LiteralArgumentBuilder reloadCmd() { + return Commands.literal("reload").executes(ctx -> { + api().reload(); + ctx.getSource().sendSystemMessage(Component.literal("§aNPCs recarregados.")); + return 1; + }); + } + + // /npc save + private static com.mojang.brigadier.builder.LiteralArgumentBuilder saveCmd() { + return Commands.literal("save").executes(ctx -> { + api().save(); + ctx.getSource().sendSystemMessage(Component.literal("§aNPCs salvos.")); + return 1; + }); + } + + // /npc stats + private static com.mojang.brigadier.builder.LiteralArgumentBuilder statsCmd() { + return Commands.literal("stats").executes(ctx -> { + NpcStats s = api().stats(); + ctx.getSource().sendSystemMessage(Component.literal("§6=== NPC Stats ===")); + ctx.getSource().sendSystemMessage(Component.literal("§7Definições: " + s.definitions())); + ctx.getSource().sendSystemMessage(Component.literal("§7Ativos: §a" + s.enabled() + " §7| Inválidos: §c" + s.invalid())); + ctx.getSource().sendSystemMessage(Component.literal("§7Sessões: " + s.viewerSessions() + " | Visíveis: " + s.visibleInstances())); + ctx.getSource().sendSystemMessage(Component.literal("§7Índice espacial: " + s.spatialIndexEntries())); + ctx.getSource().sendSystemMessage(Component.literal("§7Look updates (tick): " + s.lookUpdatesLastTick() + " | Dropped: " + s.lookUpdatesDropped())); + ctx.getSource().sendSystemMessage(Component.literal("§7Skin cache: " + s.skinMemCacheEntries() + " entries | Hits: " + s.skinCacheHits() + " | Misses: " + s.skinCacheMisses() + " | Stale: " + s.skinStaleHits() + " | Neg: " + s.skinNegativeHits())); + ctx.getSource().sendSystemMessage(Component.literal("§7Skin in-flight: " + s.skinRequestsInFlight() + " | Failures: " + s.skinRequestFailures())); + ctx.getSource().sendSystemMessage(Component.literal("§7Hologramas: " + s.hologramsActive())); + ctx.getSource().sendSystemMessage(Component.literal("§7Reload: " + s.lastReloadMillis() + "ms | Save: " + s.lastSaveMillis() + "ms")); + return 1; + }); + } + + private static CompletableFuture suggestNpcIds(CommandContext ctx, SuggestionsBuilder builder) { + for (NpcDefinition npc : api().list()) { + if (npc.id().startsWith(builder.getRemainingLowerCase())) { + builder.suggest(npc.id()); + } + } + return builder.buildFuture(); + } +} diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcPermissions.java b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcPermissions.java new file mode 100644 index 00000000..b96ef964 --- /dev/null +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/npcs/command/NpcPermissions.java @@ -0,0 +1,17 @@ +package com.pedrodalben.bigbangessentials.npcs.command; + +public final class NpcPermissions { + public static final String ADMIN = "bigbangessentials.npcs.admin"; + public static final String CREATE = "bigbangessentials.npcs.create"; + public static final String REMOVE = "bigbangessentials.npcs.remove"; + public static final String EDIT = "bigbangessentials.npcs.edit"; + public static final String MOVE = "bigbangessentials.npcs.move"; + public static final String RELOAD = "bigbangessentials.npcs.reload"; + public static final String SAVE = "bigbangessentials.npcs.save"; + public static final String STATS = "bigbangessentials.npcs.stats"; + public static final String CONSOLE_COMMAND = "bigbangessentials.npcs.consolecommand"; + public static final String USE = "bigbangessentials.npcs.use"; + public static final String USE_PREFIX = "bigbangessentials.npcs.use."; + + private NpcPermissions() {} +} From 5e1f474c1747e00aaf862349b8606d470432e3b0 Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:35 -0300 Subject: [PATCH 08/10] feat(npcs): wire module into lifecycle, platform events, and interaction bridges --- .../bigbangessentials/BigBangEssentials.java | 37 +++++++++++++++++++ .../fabric/listener/FabricEvents.java | 3 ++ .../npcs/FabricNpcInteractionBridge.java | 24 ++++++++++++ .../neoforge/NeoForgeModEntrypoint.java | 1 + .../neoforge/listener/NeoForgeEvents.java | 2 + .../npcs/NeoForgeNpcInteractionBridge.java | 19 ++++++++++ 6 files changed, 86 insertions(+) create mode 100644 fabric/src/main/java/com/pedrodalben/bigbangessentials/npcs/FabricNpcInteractionBridge.java create mode 100644 neoforge/src/main/java/com/pedrodalben/bigbangessentials/npcs/NeoForgeNpcInteractionBridge.java diff --git a/common/src/main/java/com/pedrodalben/bigbangessentials/BigBangEssentials.java b/common/src/main/java/com/pedrodalben/bigbangessentials/BigBangEssentials.java index 5332f6d9..ee668b06 100644 --- a/common/src/main/java/com/pedrodalben/bigbangessentials/BigBangEssentials.java +++ b/common/src/main/java/com/pedrodalben/bigbangessentials/BigBangEssentials.java @@ -219,6 +219,10 @@ private void registerAllManagers() { com.pedrodalben.bigbangessentials.holograms.service.BigBangHologramsManager.class, com.pedrodalben.bigbangessentials.holograms.service.BigBangHologramsManager::getInstance); + registry.registerManager("NpcManager", "npcs", + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.class, + com.pedrodalben.bigbangessentials.npcs.service.NpcManager::getInstance); + // RankUp Manager registry.registerManager("RankupManager", "rankup", com.pedrodalben.bigbangessentials.rankup.RankupManager.class); @@ -240,6 +244,7 @@ private void registerModules() { modules.register("rankup", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("rankup"), "economy", "database"); modules.register("crates", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("crates"), "database"); modules.register("holograms", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("holograms")); + modules.register("npcs", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("npcs"), "holograms"); modules.register("shop", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("shop"), "economy", "database"); modules.register("adminshop", () -> com.pedrodalben.bigbangessentials.config.ConfigManager.isModuleEnabled("adminshop"), "economy", "database"); modules.register("cobblemon", com.pedrodalben.bigbangessentials.pokemarket.PokeMarketManager::isCobblemonPresent); @@ -439,6 +444,18 @@ public static void onServerStarting(net.minecraft.server.MinecraftServer server) ModuleManager.getInstance().failed("holograms", e); } + if (ModuleManager.getInstance().prepare("npcs")) try { + LOGGER.info("⚙ Initializing NPC Module..."); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().initialize(); + ManagerRegistry.getInstance().markInitialized("NpcManager"); + ModuleManager.getInstance().started("npcs", 0); + LOGGER.info("✓ NPC Module initialized successfully"); + } catch (Exception e) { + LOGGER.error("✗ NPC Module failed to initialize: {}", e.getMessage(), e); + ManagerRegistry.getInstance().markFailed("NpcManager", e.getMessage()); + ModuleManager.getInstance().failed("npcs", e); + } + // Initialize FakePlayerIntegration try { LOGGER.info("⚙ Initializing FakePlayer Integration..."); @@ -640,6 +657,10 @@ public static void onPlayerLoggedIn(net.minecraft.server.level.ServerPlayer play } } } + + if (ModuleManager.getInstance().isActive("npcs")) { + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().onPlayerJoin(player); + } } public static void onPlayerLoggedOut(net.minecraft.server.level.ServerPlayer player) { @@ -654,6 +675,10 @@ public static void onPlayerLoggedOut(net.minecraft.server.level.ServerPlayer pla } catch (Exception e) { LOGGER.debug("Failed to persist database-backed player state for {}: {}", player.getName().getString(), e.getMessage(), e); } + + if (ModuleManager.getInstance().isActive("npcs")) { + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().onPlayerLeave(player); + } } public static void onServerStopping(net.minecraft.server.MinecraftServer server) { @@ -783,6 +808,13 @@ public static void onServerStopping(net.minecraft.server.MinecraftServer server) LOGGER.error("Failed to shutdown BigBangHolograms", e); } + if (ModuleManager.getInstance().isActive("npcs")) try { + LOGGER.info("Shutting down NPC Module..."); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().shutdown(); + } catch (Exception e) { + LOGGER.error("Failed to shutdown NPC Module", e); + } + // Shutdown FakeTpaManager try { LOGGER.info("Shutting down Fake TPA Manager..."); @@ -1378,6 +1410,11 @@ private static void registerAllCommands(CommandDispatcher di com.pedrodalben.bigbangessentials.holograms.command.HologramCommand.register(dispatcher); } + if (ModuleManager.getInstance().isActive("npcs")) { + registry.registerCommand("npc", "Manage virtual NPCs", "npcs"); + com.pedrodalben.bigbangessentials.npcs.command.NpcCommand.register(dispatcher); + } + // ========== TABLIST MODULE ========== if (ModuleManager.getInstance().isActive("tablist")) { registry.registerCommand("tablist", "Manage tablist configuration, reload, debug"); diff --git a/fabric/src/main/java/com/pedrodalben/bigbangessentials/fabric/listener/FabricEvents.java b/fabric/src/main/java/com/pedrodalben/bigbangessentials/fabric/listener/FabricEvents.java index a7b4f35f..fef16c0d 100644 --- a/fabric/src/main/java/com/pedrodalben/bigbangessentials/fabric/listener/FabricEvents.java +++ b/fabric/src/main/java/com/pedrodalben/bigbangessentials/fabric/listener/FabricEvents.java @@ -29,6 +29,7 @@ public static void register() { com.pedrodalben.bigbangessentials.menu.integration.kits.KitMenuIntegration.onTick(); com.pedrodalben.bigbangessentials.tablist.TablistEventHandler.onServerTick(server); com.pedrodalben.bigbangessentials.holograms.service.BigBangHologramsManager.getInstance().tick(); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().tick(); ShopSignRegistrationService.tick(); for (ServerPlayer player : server.getPlayerList().getPlayers()) { RankupEventListener.onPlayerTick(player); @@ -71,6 +72,7 @@ public static void register() { ServerEntityWorldChangeEvents.AFTER_PLAYER_CHANGE_WORLD.register((player, origin, destination) -> { if (player instanceof ServerPlayer serverPlayer) { com.pedrodalben.bigbangessentials.tablist.integration.WorldTabIntegration.onWorldChange(serverPlayer); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().onPlayerDimensionChange(serverPlayer); } }); @@ -98,6 +100,7 @@ public static void register() { FabricCrateEvents.register(); FabricShopEvents.register(); + com.pedrodalben.bigbangessentials.npcs.FabricNpcInteractionBridge.register(); // USE-MAGIC session tracking: marks player session for enchanting/brewing completion detection UseBlockCallback.EVENT.register((player, level, hand, hitResult) -> { diff --git a/fabric/src/main/java/com/pedrodalben/bigbangessentials/npcs/FabricNpcInteractionBridge.java b/fabric/src/main/java/com/pedrodalben/bigbangessentials/npcs/FabricNpcInteractionBridge.java new file mode 100644 index 00000000..dbba8af0 --- /dev/null +++ b/fabric/src/main/java/com/pedrodalben/bigbangessentials/npcs/FabricNpcInteractionBridge.java @@ -0,0 +1,24 @@ +package com.pedrodalben.bigbangessentials.npcs; + +import com.pedrodalben.bigbangessentials.npcs.service.NpcManager; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.server.level.ServerPlayer; + +public final class FabricNpcInteractionBridge { + private FabricNpcInteractionBridge() {} + + public static void register() { + UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { + if (world.isClientSide() || !(player instanceof ServerPlayer sp) || hand != InteractionHand.MAIN_HAND) { + return InteractionResult.PASS; + } + var svc = NpcManager.getInstance().getInteractionService(); + if (svc != null && svc.handleClick(sp, entity.getId())) { + return InteractionResult.SUCCESS; + } + return InteractionResult.PASS; + }); + } +} diff --git a/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/NeoForgeModEntrypoint.java b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/NeoForgeModEntrypoint.java index 6c7b9a69..d1817d14 100644 --- a/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/NeoForgeModEntrypoint.java +++ b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/NeoForgeModEntrypoint.java @@ -25,5 +25,6 @@ public NeoForgeModEntrypoint(IEventBus modEventBus) { // Register NeoForge event listeners NeoForge.EVENT_BUS.register(NeoForgeEvents.class); + NeoForge.EVENT_BUS.register(com.pedrodalben.bigbangessentials.npcs.NeoForgeNpcInteractionBridge.class); } } diff --git a/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/listener/NeoForgeEvents.java b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/listener/NeoForgeEvents.java index 728309d9..309ed936 100644 --- a/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/listener/NeoForgeEvents.java +++ b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/neoforge/listener/NeoForgeEvents.java @@ -66,6 +66,7 @@ public static void onServerStopping(ServerStoppingEvent event) { public static void onPlayerChangedDimension(net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerChangedDimensionEvent event) { if (event.getEntity() instanceof ServerPlayer player) { com.pedrodalben.bigbangessentials.tablist.integration.WorldTabIntegration.onWorldChange(player); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().onPlayerDimensionChange(player); } } @@ -102,6 +103,7 @@ public static void onServerTick(ServerTickEvent.Post event) { com.pedrodalben.bigbangessentials.menu.integration.kits.KitMenuIntegration.onTick(); com.pedrodalben.bigbangessentials.tablist.TablistEventHandler.onServerTick(server); com.pedrodalben.bigbangessentials.holograms.service.BigBangHologramsManager.getInstance().tick(); + com.pedrodalben.bigbangessentials.npcs.service.NpcManager.getInstance().tick(); if (server != null) { for (ServerPlayer player : server.getPlayerList().getPlayers()) { RankupEventListener.onPlayerTick(player); diff --git a/neoforge/src/main/java/com/pedrodalben/bigbangessentials/npcs/NeoForgeNpcInteractionBridge.java b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/npcs/NeoForgeNpcInteractionBridge.java new file mode 100644 index 00000000..1fe46d2f --- /dev/null +++ b/neoforge/src/main/java/com/pedrodalben/bigbangessentials/npcs/NeoForgeNpcInteractionBridge.java @@ -0,0 +1,19 @@ +package com.pedrodalben.bigbangessentials.npcs; + +import com.pedrodalben.bigbangessentials.npcs.service.NpcManager; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; + +public final class NeoForgeNpcInteractionBridge { + + @SubscribeEvent + public static void onEntityInteract(PlayerInteractEvent.EntityInteractSpecific event) { + if (event.getEntity() instanceof ServerPlayer player && !event.isCanceled()) { + var svc = NpcManager.getInstance().getInteractionService(); + if (svc != null && svc.handleClick(player, event.getTarget().getId())) { + event.setCanceled(true); + } + } + } +} From d295730b88323861cb683ca61391ab4e165707af Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:39 -0300 Subject: [PATCH 09/10] test(npcs): add unit tests for domain, skin cache, spatial index, and config --- .../bigbangessentials/npcs/NpcModuleTest.java | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 common/src/test/java/com/pedrodalben/bigbangessentials/npcs/NpcModuleTest.java diff --git a/common/src/test/java/com/pedrodalben/bigbangessentials/npcs/NpcModuleTest.java b/common/src/test/java/com/pedrodalben/bigbangessentials/npcs/NpcModuleTest.java new file mode 100644 index 00000000..db32ad28 --- /dev/null +++ b/common/src/test/java/com/pedrodalben/bigbangessentials/npcs/NpcModuleTest.java @@ -0,0 +1,259 @@ +package com.pedrodalben.bigbangessentials.npcs; + +import com.pedrodalben.bigbangessentials.npcs.api.*; +import com.pedrodalben.bigbangessentials.npcs.config.NpcConfig; +import com.pedrodalben.bigbangessentials.npcs.config.NpcConfigStore; +import com.pedrodalben.bigbangessentials.npcs.skin.SkinCache; +import com.pedrodalben.bigbangessentials.npcs.skin.SkinCacheEntry; +import com.pedrodalben.bigbangessentials.npcs.skin.MojangSkinResolver; +import com.pedrodalben.bigbangessentials.npcs.spatial.NpcSpatialIndex; +import net.minecraft.resources.ResourceLocation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class NpcModuleTest { + + // ─── NpcDefinition ID Validation ──────────────────────────────── + + @Test + @DisplayName("should normalize id to lowercase") + void normalizeIdLowercase() { + assertEquals("warp_end", NpcDefinition.normalizeId("WARP_END")); + assertEquals("shop_npc", NpcDefinition.normalizeId("Shop_NPC")); + } + + @Test + @DisplayName("should reject invalid id characters") + void rejectInvalidId() { + assertThrows(IllegalArgumentException.class, () -> NpcDefinition.normalizeId("npc name")); + assertThrows(IllegalArgumentException.class, () -> NpcDefinition.normalizeId("npc.name")); + assertThrows(IllegalArgumentException.class, () -> NpcDefinition.normalizeId(null)); + } + + @Test + @DisplayName("should accept valid ids") + void acceptValidIds() { + assertEquals("warp_end", NpcDefinition.normalizeId("warp_end")); + assertEquals("shop", NpcDefinition.normalizeId("shop")); + assertEquals("test-npc-01", NpcDefinition.normalizeId("test-npc-01")); + } + + // ─── NpcAction ─────────────────────────────────────────────────── + + @Test + @DisplayName("should strip leading slash") + void stripSlash() { + NpcAction action = NpcAction.playerCommand("/warp end"); + assertEquals("warp end", action.command()); + assertEquals(NpcActionType.PLAYER_COMMAND, action.type()); + } + + @Test + @DisplayName("should preserve command without slash") + void preserveNoSlash() { + NpcAction action = NpcAction.playerCommand("warp end"); + assertEquals("warp end", action.command()); + } + + @Test + @DisplayName("should support console command") + void consoleCommand() { + NpcAction action = NpcAction.consoleCommand("/give {player} diamond 1"); + assertEquals("give {player} diamond 1", action.command()); + assertEquals(NpcActionType.CONSOLE_COMMAND, action.type()); + } + + @Test + @DisplayName("should support none action") + void noneAction() { + NpcAction action = NpcAction.none(); + assertEquals(NpcActionType.NONE, action.type()); + assertEquals("", action.command()); + } + + // ─── NpcLocation ───────────────────────────────────────────────── + + @Test + @DisplayName("should preserve location fields") + void locationFields() { + NpcLocation loc = new NpcLocation(ResourceLocation.parse("minecraft:overworld"), 100.5, 64.0, -200.3, 90f, 0f); + assertEquals("minecraft:overworld", loc.dimension().toString()); + assertEquals(100.5, loc.x()); + assertEquals(64.0, loc.y()); + assertEquals(-200.3, loc.z()); + assertEquals(90f, loc.yaw()); + assertEquals(0f, loc.pitch()); + } + + // ─── SkinCache Normalization ───────────────────────────────────── + + @Test + @DisplayName("should normalize skin names to lowercase trimmed") + void normalizeSkinNames() { + assertEquals("dalbesmr", SkinCache.normalize("Dalbesmr")); + assertEquals("dalbesmr", SkinCache.normalize("dalbesmr")); + assertEquals("dalbesmr", SkinCache.normalize("DALBESMR")); + assertEquals("dalbesmr", SkinCache.normalize(" Dalbesmr ")); + assertEquals("", SkinCache.normalize(null)); + } + + // ─── SkinCacheEntry ────────────────────────────────────────────── + + @Test + @DisplayName("should detect fresh entry") + void freshEntry() { + SkinCacheEntry entry = SkinCacheEntry.resolved("test", "Test", "uuid", "tex", "sig", "default", 3600_000); + assertTrue(entry.isFresh()); + assertFalse(entry.negative()); + } + + @Test + @DisplayName("should detect stale entry") + void staleEntry() { + SkinCacheEntry entry = new SkinCacheEntry("test", "Test", "uuid", "tex", "sig", "default", + System.currentTimeMillis() - 86_400_000, System.currentTimeMillis() - 1000, false); + assertTrue(entry.isStale(30 * 86_400_000L)); + } + + @Test + @DisplayName("should detect fully expired entry") + void expiredEntry() { + SkinCacheEntry entry = new SkinCacheEntry("test", "Test", "uuid", "tex", "sig", "default", + System.currentTimeMillis() - 31 * 86_400_000L, System.currentTimeMillis() - 30 * 86_400_000L, false); + assertTrue(entry.isExpired(30 * 86_400_000L)); + } + + @Test + @DisplayName("should detect negative entry") + void negativeEntry() { + SkinCacheEntry entry = SkinCacheEntry.negative("test", 600_000); + assertTrue(entry.negative()); + assertTrue(entry.isFresh()); + } + + // ─── Mojang UUID formatting ───────────────────────────────────── + + @Test + @DisplayName("should format UUID with dashes") + void formatUuid() { + String result = MojangSkinResolver.formatUuid("1234567890abcdef1234567890abcdef"); + assertEquals("12345678-90ab-cdef-1234-567890abcdef", result); + } + + // ─── NpcSpatialIndex ───────────────────────────────────────────── + + @Test + @DisplayName("should find NPCs by chunk proximity") + void spatialIndexQuery() { + NpcSpatialIndex index = new NpcSpatialIndex(); + ResourceLocation dim = ResourceLocation.parse("minecraft:overworld"); + NpcLocation loc = new NpcLocation(dim, 100, 64, 200, 0, 0); + index.add("npc1", loc); + + Set result = index.query(dim, 100, 200, 64); + assertTrue(result.contains("npc1")); + assertEquals(1, index.size()); + } + + @Test + @DisplayName("should not find NPCs in different dimension") + void spatialIndexDifferentDimension() { + NpcSpatialIndex index = new NpcSpatialIndex(); + ResourceLocation overworld = ResourceLocation.parse("minecraft:overworld"); + ResourceLocation nether = ResourceLocation.parse("minecraft:the_nether"); + index.add("npc1", new NpcLocation(overworld, 100, 64, 200, 0, 0)); + + Set result = index.query(nether, 100, 200, 64); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should remove NPC from index") + void spatialIndexRemove() { + NpcSpatialIndex index = new NpcSpatialIndex(); + ResourceLocation dim = ResourceLocation.parse("minecraft:overworld"); + index.add("npc1", new NpcLocation(dim, 100, 64, 200, 0, 0)); + index.remove("npc1"); + assertEquals(0, index.size()); + } + + @Test + @DisplayName("should find NPC at chunk boundary") + void spatialIndexChunkBoundary() { + NpcSpatialIndex index = new NpcSpatialIndex(); + ResourceLocation dim = ResourceLocation.parse("minecraft:overworld"); + index.add("npc1", new NpcLocation(dim, 0, 64, 0, 0, 0)); + index.add("npc2", new NpcLocation(dim, 200, 64, 200, 0, 0)); + + Set near = index.query(dim, 10, 10, 32); + assertTrue(near.contains("npc1")); + assertFalse(near.contains("npc2")); + } + + // ─── NpcInteractionConfig ──────────────────────────────────────── + + @Test + @DisplayName("should have empty permission by default") + void defaultNoPermission() { + NpcInteractionConfig config = NpcInteractionConfig.defaults(); + assertFalse(config.hasPermission()); + assertEquals(4.5, config.distance()); + assertEquals(750, config.cooldownMillis()); + } + + @Test + @DisplayName("should recognize permission when set") + void hasPermission() { + NpcInteractionConfig config = new NpcInteractionConfig(5.0, 1000, "bigbangessentials.npcs.use"); + assertTrue(config.hasPermission()); + assertEquals("bigbangessentials.npcs.use", config.permission()); + } + + // ─── NpcLookSettings ───────────────────────────────────────────── + + @Test + @DisplayName("should have default look settings enabled") + void defaultLookSettings() { + NpcLookSettings settings = NpcLookSettings.defaults(); + assertTrue(settings.enabled()); + assertEquals(10.0, settings.range()); + assertEquals(4, settings.updateIntervalTicks()); + assertTrue(settings.rotateBody()); + } + + // ─── NpcHologramConfig ────────────────────────────────────────── + + @Test + @DisplayName("should create default hologram config") + void defaultHologram() { + NpcHologramConfig config = NpcHologramConfig.defaults("Test NPC"); + assertTrue(config.enabled()); + assertEquals(1, config.lines().size()); + assertEquals("Test NPC", config.lines().get(0)); + } + + @Test + @DisplayName("should create disabled hologram config") + void disabledHologram() { + NpcHologramConfig config = NpcHologramConfig.disabled(); + assertFalse(config.enabled()); + } + + // ─── NpcConfig ─────────────────────────────────────────────────── + + @Test + @DisplayName("should create default NPC config") + void defaultConfig() { + NpcConfig config = NpcConfig.defaults(); + assertEquals(1, config.schemaVersion()); + assertEquals(48.0, config.defaultViewDistance()); + assertEquals(56.0, config.defaultDespawnDistance()); + assertTrue(config.npcs().isEmpty()); + } +} From dc9b33d8b8411dc293a79bb0bd01c6526cbad94b Mon Sep 17 00:00:00 2001 From: pedro dalben Date: Thu, 6 Aug 2026 12:26:44 -0300 Subject: [PATCH 10/10] docs(npcs): add module documentation, rendering, performance, and test plan --- .../npcs/02-protocol-and-rendering.md | 48 +++++ docs/engineering/npcs/03-performance-model.md | 44 +++++ docs/engineering/npcs/04-manual-test-plan.md | 115 ++++++++++++ docs/modules/npcs.md | 176 ++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 docs/engineering/npcs/02-protocol-and-rendering.md create mode 100644 docs/engineering/npcs/03-performance-model.md create mode 100644 docs/engineering/npcs/04-manual-test-plan.md create mode 100644 docs/modules/npcs.md diff --git a/docs/engineering/npcs/02-protocol-and-rendering.md b/docs/engineering/npcs/02-protocol-and-rendering.md new file mode 100644 index 00000000..d245b257 --- /dev/null +++ b/docs/engineering/npcs/02-protocol-and-rendering.md @@ -0,0 +1,48 @@ +# NPC Module — Protocol and Rendering + +**Minecraft 1.21.1** + +## Rendering Pipeline + +NPCs are rendered as **virtual player entities** — they don't exist as `ServerPlayer` or `Entity` instances on the server. Each viewer receives independent packets. + +### Spawn Sequence (Per Viewer) + +1. **ClientboundPlayerInfoUpdatePacket (ADD_PLAYER)** — Adds fake player info to the tab list, enabling skin download +2. **ClientboundAddEntityPacket** — Spawns entity with `EntityType.PLAYER` at NPC location +3. **ClientboundSetEntityDataPacket** — Sets skin layer metadata (byte 0x7F = all layers) +4. **ClientboundMoveEntityPacket.Rot** — Sets initial head/body rotation +5. **ClientboundPlayerInfoUpdatePacket (UPDATE_LISTED, listed=false)** — Hides from tab while keeping entity visible + +### Look Update (Per Viewer, Per Tick) + +- **ClientboundMoveEntityPacket.Rot** — Updates body yaw + pitch for look-at-player +- Rotation calculated using `Math.atan2(dx, dz)` for yaw, `Math.atan2(dy, distance)` for pitch +- Clamped to `maxYawFromBase`, `maxPitchUp`, `maxPitchDown` settings +- Only sent when delta exceeds `minimumAngleChange` threshold + +### Despawn (Per Viewer) + +- **ClientboundRemoveEntitiesPacket** — Removes entity by ID + +## Packet Sender Architecture + +Due to 1.21.1 `ClientboundPlayerInfoUpdatePacket` constructor restrictions (takes only `ServerPlayer` objects, not raw `Entry` objects), a reflection-based `NpcPacketSenderImpl` bridges virtual profiles into the packet system: + +- Constructs `Entry` records via reflection +- Constructs the packet via its package-private constructor (`Set`) +- Common code with no Fabric/NeoForge-specific dependencies + +## Entity ID Allocation + +- NPC virtual entity IDs: `2_000_000_000` onward (via `AtomicInteger`) +- Hologram virtual entity IDs: `1_500_000_000` onward (existing) +- No collision risk between modules + +## Skin Flow + +1. Admin sets `skin.playerName: "Dalbesmr"` in config +2. `NpcRenderService.spawn()` calls `SkinCache.resolve("Dalbesmr")` asynchronously +3. Result is posted to server thread via `server.execute()` +4. Packet sequence sent with resolved texture value and signature +5. UUID derived deterministically from NPC id: `UUID.nameUUIDFromBytes("bigbang-npc:" + npcId)` diff --git a/docs/engineering/npcs/03-performance-model.md b/docs/engineering/npcs/03-performance-model.md new file mode 100644 index 00000000..5e3c37d2 --- /dev/null +++ b/docs/engineering/npcs/03-performance-model.md @@ -0,0 +1,44 @@ +# NPC Module — Performance Model + +## Design Constraints — 100 NPCs, 50 Players + +| Operation | Bound | Implementation | +|-----------|-------|----------------| +| Visibility sync | maxViewerSyncsPerTick = 50 | Round-robin, step = 10 ticks | +| Spawn rate | maxSpawnsPerTick = 20 | Per-viewer, budgeted | +| Despawn rate | maxDespawnsPerTick = 50 | Per-viewer, budgeted | +| Look updates | maxLookUpdatesPerTick = 200 | Single loop over visible NPCs | +| Spatial query | O(chunkRadius²) per player | HashMap-based ChunkSpatialIndex | +| Skin resolution | 2 concurrent HTTP max | Deduplicated async executor | + +## Memory Budget (100 NPCs, 50 Players) + +| Component | Per Unit | Total | +|-----------|----------|-------| +| NpcDefinition (100) | ~2 KB | 200 KB | +| NpcRenderState (100) | ~100 B | 10 KB | +| NpcViewerSession (50) | ~1 KB each | 50 KB | +| SkinCacheEntry (100) | ~2 KB each | 200 KB | +| HologramDefinition (100) | ~1 KB each | 100 KB | +| **Total** | | **~560 KB** | + +## Network Budget + +| Operation | Packets | ~Bytes | +|-----------|---------|--------| +| Spawn (per NPC per player) | 5 | ~2 KB | +| Despawn | 1 | ~50 B | +| Look update | 1-2 | ~100 B | +| **50 players, 5 NPCs each** | | | +| Initial spawn burst | 250 × 2 KB | ~500 KB (spread over ticks) | +| Per-tick look | 50 × 100 B | ~5 KB | + +## What We DON'T Do + +- No N×M loop (never: `for each player: for each NPC: check distance`) +- No HTTP on server thread +- No file IO per tick +- No individual scheduled task per NPC +- No real entity ticking (no AI, no pathfinding, no chunk loading) +- No duplicate packets (fingerprint comparison before sending) +- No global skin re-resolution (per-viewer skin sharing via async dedup) diff --git a/docs/engineering/npcs/04-manual-test-plan.md b/docs/engineering/npcs/04-manual-test-plan.md new file mode 100644 index 00000000..36b6f8db --- /dev/null +++ b/docs/engineering/npcs/04-manual-test-plan.md @@ -0,0 +1,115 @@ +# NPC Module — Manual Test Plan + +## Setup + +1. Ensure `modules.json` has `"npcsEnabled": true` +2. Ensure holograms module is enabled + +## Test Scenarios + +### TC-01: Create NPC +``` +1. Enter server +2. /npc create warp_end Dalbesmr +3. Expected: NPC created at player position with skin Dalbesmr +``` + +### TC-02: Configure NPC +``` +1. /npc name warp_end Explorar o End +2. /npc command warp_end warp end +3. /npc hologram warp_end setline 1 Explorar o End +4. /npc hologram warp_end addline Clique para viajar +5. Expected: All commands succeed +``` + +### TC-03: Visibility +``` +1. Walk away from NPC beyond view distance (48 blocks) +2. Expected: NPC disappears +3. Walk back within view distance +4. Expected: NPC reappears +``` + +### TC-04: Look-at-player +``` +1. Circle around the NPC +2. Expected: NPC's head follows you +``` + +### TC-05: Click interaction +``` +1. Right-click NPC +2. Expected: /warp end executes +3. Click again immediately (within 750ms) +4. Expected: Cooldown prevents double execution +``` + +### TC-06: Persistence after restart +``` +1. Restart server completely +2. Enter world +3. Expected: NPC still exists at same position, with same skin, hologram, command +``` + +### TC-07: Skin cache persistence +``` +1. Disconnect internet +2. Restart server +3. Expected: NPC still shows correct skin from cache +4. /npc stats — skinStaleHits > 0 +``` + +### TC-08: Reload +``` +1. /npc reload +2. Expected: No duplication, NPCs remain +3. Expected: Reload summary message +``` + +### TC-09: Dimension change +``` +1. Teleport to nether +2. Expected: NPCs from overworld are not visible +3. Return to overworld +4. Expected: NPCs reappear +``` + +### TC-10: Disconnect +``` +1. Log out completely +2. Log back in +3. Expected: NPC state clean, no duplicate entities +``` + +### TC-11: /npc info +``` +1. /npc info warp_end +2. Expected: Shows name, status, location, skin, action, hologram, look, permission +``` + +### TC-12: /npc stats +``` +1. /npc stats +2. Expected: Shows definitions, sessions, spatial index, skin cache stats, holograms +``` + +### TC-13: /npc list +``` +1. /npc list +2. Expected: Lists all NPCs with status indicators +``` + +### TC-14: Invalid NPC doesn't crash others +``` +1. Manually edit npcs.json, add NPC with invalid ID +2. /npc reload +3. Expected: Invalid NPC disabled, others still functional +``` + +### TC-15: Corrupted config +``` +1. Delete npcs.json +2. Restart server +3. Expected: Default config created automatically +``` diff --git a/docs/modules/npcs.md b/docs/modules/npcs.md new file mode 100644 index 00000000..bf803725 --- /dev/null +++ b/docs/modules/npcs.md @@ -0,0 +1,176 @@ +# NPC Module (npcs) + +Virtual NPC objects with player appearance — skins, holograms, player tracking, and click commands. + +## Installation + +1. Ensure `modules.json` contains: +```json +{ "npcsEnabled": true } +``` + +2. Restart or `/bigbangessentials reload` + +## Commands + +| Command | Permission | Description | +|---------|-----------|-------------| +| `/npc create ` | `bigbangessentials.npcs.create` | Create NPC at your position | +| `/npc remove ` | `bigbangessentials.npcs.remove` | Delete an NPC | +| `/npc movehere ` | `bigbangessentials.npcs.move` | Move NPC to your position | +| `/npc skin ` | `bigbangessentials.npcs.edit` | Change NPC skin | +| `/npc name ` | `bigbangessentials.npcs.edit` | Set display name | +| `/npc command ` | `bigbangessentials.npcs.edit` | Set click command (player) | +| `/npc consolecommand ` | `bigbangessentials.npcs.consolecommand` | Set click command (console) | +| `/npc hologram on` | `bigbangessentials.npcs.edit` | Enable hologram | +| `/npc hologram off` | `bigbangessentials.npcs.edit` | Disable hologram | +| `/npc hologram addline ` | `bigbangessentials.npcs.edit` | Add hologram line | +| `/npc hologram setline ` | `bigbangessentials.npcs.edit` | Set hologram line | +| `/npc hologram removeline ` | `bigbangessentials.npcs.edit` | Remove hologram line | +| `/npc look on` | `bigbangessentials.npcs.edit` | Enable look-at-player | +| `/npc look off` | `bigbangessentials.npcs.edit` | Disable look-at-player | +| `/npc enable ` | `bigbangessentials.npcs.edit` | Enable NPC | +| `/npc disable ` | `bigbangessentials.npcs.edit` | Disable NPC | +| `/npc teleport ` | `bigbangessentials.npcs.admin` | Teleport to NPC | +| `/npc info ` | `bigbangessentials.npcs.admin` | Show NPC details | +| `/npc list` | `bigbangessentials.npcs.admin` | List all NPCs | +| `/npc reload` | `bigbangessentials.npcs.reload` | Reload from config | +| `/npc save` | `bigbangessentials.npcs.save` | Force save to disk | +| `/npc stats` | `bigbangessentials.npcs.stats` | Show metrics | +| `/npcs` | (same) | Alias for `/npc` | + +## Permissions + +``` +bigbangessentials.npcs.admin - All NPC permissions +bigbangessentials.npcs.create - Create NPCs +bigbangessentials.npcs.remove - Remove NPCs +bigbangessentials.npcs.edit - Edit NPC properties +bigbangessentials.npcs.move - Move NPCs +bigbangessentials.npcs.reload - Reload configuration +bigbangessentials.npcs.save - Force save +bigbangessentials.npcs.stats - View metrics +bigbangessentials.npcs.consolecommand - Set console commands +bigbangessentials.npcs.use - Interact with NPCs +bigbangessentials.npcs.use. - Interact with specific NPC +``` + +## Configuration + +File: `world/serverconfig/bigbangessentials/npcs/npcs.json` + +```json +{ + "schemaVersion": 1, + "defaults": { + "viewDistance": 48.0, + "despawnDistance": 56.0, + "interactionDistance": 4.5, + "interactionCooldownMillis": 750 + }, + "performance": { + "visibilityScanIntervalTicks": 10, + "maxViewerSyncsPerTick": 50, + "maxSpawnsPerTick": 20, + "maxDespawnsPerTick": 50, + "maxLookUpdatesPerTick": 200 + }, + "skinCache": { + "freshTtlHours": 24, + "staleTtlDays": 30, + "negativeCacheMinutes": 10, + "maxConcurrentRequests": 2, + "connectTimeoutMillis": 3000, + "requestTimeoutMillis": 5000 + }, + "npcs": { + "warp_end": { + "enabled": true, + "displayName": "Explorar o End", + "location": { + "dimension": "minecraft:overworld", + "x": 125.5, + "y": 72.0, + "z": -34.5, + "yaw": 180.0, + "pitch": 0.0 + }, + "skin": { + "playerName": "Dalbesmr" + }, + "action": { + "type": "PLAYER_COMMAND", + "command": "warp end" + }, + "hologram": { + "enabled": true, + "lines": [ + "Explorar o End", + "Clique para viajar" + ], + "offsetY": 2.25, + "viewDistance": 32.0 + }, + "lookAtPlayers": { + "enabled": true, + "range": 10.0, + "updateIntervalTicks": 4, + "minimumAngleChange": 2.0, + "maxYawFromBase": 100.0, + "maxPitchUp": 45.0, + "maxPitchDown": 35.0, + "rotateBody": true, + "resetWhenOutOfRange": true + }, + "interaction": { + "distance": 4.5, + "cooldownMillis": 750, + "permission": "" + } + } + } +} +``` + +## Action Types + +| Type | Behavior | +|------|----------| +| `PLAYER_COMMAND` | Executes command as the clicking player | +| `CONSOLE_COMMAND` | Executes command as console (`{player}` placeholder supported) | +| `NONE` | No action on click | + +## Skin Cache + +- **Persistent**: `bigbangessentials/npcs/skin-cache.json` +- **In-memory**: ConcurrentHashMap with dedup of in-flight requests +- **Fresh TTL**: 24 hours — use cached skin immediately +- **Stale TTL**: 30 days — use cached skin while refreshing in background +- **Negative cache**: 10 minutes — remember that a name doesn't exist +- **Mojang offline**: Use last valid cached skin; fallback to Steve + +## Architecture + +NPCs are **virtual entities** sent individually to each viewer via packets: +- Never persisted in chunk NBT +- Never loaded as real `ServerPlayer` +- Never processed by AI/pathfinding +- Each viewer sees independent rotation (look-at-player) + +Reuses: +- ModuleManager lifecycle +- BigBangHolograms API for holograms +- PermissionAPI for access control +- ConfigManager for module toggle +- Atomic JSON persistence pattern +- ChunkSpatialIndex pattern + +## Troubleshooting + +| Issue | Diagnostic | +|-------|-----------| +| NPC not visible | `/npc info ` — check enabled, dimension, location | +| Skin not loading | `/npc stats` — check skin request failures | +| Hologram missing | Check holograms module is enabled | +| Permission denied | Check `interaction.permission` on NPC config | +| NPC not spawning after restart | Check `npcs.json` for validity |