diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md
index 3b72aeb..aa1eb79 100644
--- a/docs/ARTIST_GUIDE.md
+++ b/docs/ARTIST_GUIDE.md
@@ -16,7 +16,7 @@
7. [Animations](#animations) — item poses, fallback chain, variants, context animations
8. [Animation Templates](#animation-templates)
9. [Exporting from Blender](#exporting-from-blender)
-10. [The JSON Definition](#the-json-definition)
+10. [The JSON Definition](#the-json-definition) — field reference, components, pose priority, movement styles
11. [Packaging as a Resource Pack](#packaging-as-a-resource-pack)
12. [Common Mistakes](#common-mistakes)
13. [Examples](#examples)
@@ -764,6 +764,7 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) |
| `creator` | string | No | Author/creator name, shown in the item tooltip |
| `animation_bones` | object | Yes | Per-animation bone whitelist (see below) |
+| `components` | object | No | Gameplay behavior components (see [Components](#components-gameplay-behaviors) below) |
### animation_bones (required)
@@ -785,6 +786,87 @@ At runtime, the effective bones for a given animation clip are computed as the *
This field is **required**. Items without `animation_bones` will be rejected by the parser.
+### Components (Gameplay Behaviors)
+
+Components add gameplay behaviors to your item without requiring Java code. Each component is a self-contained module you declare in the `"components"` block of your JSON definition.
+
+**Format:** A JSON object where each key is a component name and each value is the component's configuration (an object, or `true` for defaults).
+
+```json
+"components": {
+ "lockable": { "lock_resistance": 200 },
+ "resistance": { "base": 150 },
+ "gagging": { "comprehension": 0.2, "range": 10.0 }
+}
+```
+
+Items without the `"components"` field work normally — components are entirely optional.
+
+#### Available Components
+
+| Component | Description | Config Fields |
+|-----------|-------------|---------------|
+| `lockable` | Item can be locked with a padlock. Locked items cannot be unequipped. | `lock_resistance` (int, default: 250) — resistance added by the lock for struggle mechanics |
+| `resistance` | Struggle resistance. Higher = harder to escape. | `base` (int, default: 100) — base resistance value |
+| `gagging` | Muffles the wearer's speech. | `comprehension` (0.0–1.0, default: 0.2) — how much speech is understandable. `range` (float, default: 10.0) — max hearing distance in blocks |
+| `blinding` | Applies a blindfold overlay to the wearer's screen. | `overlay` (string, optional) — custom overlay texture path. Omit for default |
+| `shock` | Item can shock the wearer (manually or automatically). | `damage` (float, default: 2.0) — damage per shock. `auto_interval` (int, default: 0) — ticks between auto-shocks (0 = manual only) |
+| `gps` | GPS tracking and safe zone enforcement. | `safe_zone_radius` (int, default: 50) — safe zone in blocks (0 = tracking only). `public_tracking` (bool, default: false) — anyone can track, not just owner |
+| `choking` | Drains air, applies darkness/slowness, deals damage when activated. | `air_drain_per_tick` (int, default: 8) — air drained per tick. `non_lethal_for_master` (bool, default: true) — won't kill if worn by a master's pet |
+| `adjustable` | Allows Y-offset adjustment via GUI slider. | `default` (float, default: 0.0), `min` (float, default: -4.0), `max` (float, default: 4.0), `step` (float, default: 0.25) — all in pixels (1px = 1/16 block) |
+
+#### Example: Shock Collar with GPS
+
+```json
+{
+ "type": "tiedup:bondage_item",
+ "display_name": "GPS Shock Collar",
+ "model": "mycreator:models/gltf/gps_shock_collar.glb",
+ "regions": ["NECK"],
+ "animation_bones": {
+ "idle": []
+ },
+ "pose_priority": 10,
+ "escape_difficulty": 5,
+ "components": {
+ "lockable": { "lock_resistance": 300 },
+ "resistance": { "base": 150 },
+ "shock": { "damage": 3.0, "auto_interval": 200 },
+ "gps": { "safe_zone_radius": 50 }
+ }
+}
+```
+
+This collar can be locked (300 resistance to break the lock), has 150 base struggle resistance, shocks every 200 ticks (10 seconds) automatically, and enforces a 50-block safe zone.
+
+#### Example: Adjustable Blindfold
+
+```json
+{
+ "type": "tiedup:bondage_item",
+ "display_name": "Leather Blindfold",
+ "model": "mycreator:models/gltf/blindfold.glb",
+ "regions": ["EYES"],
+ "animation_bones": {
+ "idle": ["head"]
+ },
+ "pose_priority": 10,
+ "escape_difficulty": 2,
+ "components": {
+ "blinding": {},
+ "resistance": { "base": 80 },
+ "adjustable": { "min": -2.0, "max": 2.0, "step": 0.5 }
+ }
+}
+```
+
+#### Component Tips
+
+- **You can combine any components.** A gag with `gagging` + `lockable` + `resistance` + `adjustable` is perfectly valid.
+- **Omit components you don't need.** A decorative collar with no shock/GPS just omits those components entirely.
+- **Default values are sensible.** `"lockable": {}` gives you standard lock behavior with default resistance. You only need to specify fields you want to customize.
+- **Components don't affect rendering.** They are purely gameplay — your GLB model and animations are independent of which components you use.
+
### Pose Priority
When multiple items affect the same bones, the highest `pose_priority` wins.
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java
new file mode 100644
index 0000000..63238e7
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java
@@ -0,0 +1,67 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: positional adjustment for data-driven items.
+ * Allows Y offset adjustment via GUI slider.
+ *
+ * JSON config:
+ * {@code "adjustable": {"default": 0.0, "min": -4.0, "max": 4.0, "step": 0.25}}
+ */
+public class AdjustableComponent implements IItemComponent {
+
+ private final float defaultValue;
+ private final float min;
+ private final float max;
+ private final float step;
+
+ private AdjustableComponent(float defaultValue, float min, float max, float step) {
+ this.defaultValue = defaultValue;
+ this.min = min;
+ this.max = max;
+ this.step = step;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ float defaultVal = 0.0f;
+ float min = -4.0f;
+ float max = 4.0f;
+ float step = 0.25f;
+ if (config != null) {
+ if (config.has("default")) defaultVal = config.get("default").getAsFloat();
+ if (config.has("min")) min = config.get("min").getAsFloat();
+ if (config.has("max")) max = config.get("max").getAsFloat();
+ if (config.has("step")) step = config.get("step").getAsFloat();
+ }
+ // Ensure min <= max
+ if (min > max) {
+ float tmp = min;
+ min = max;
+ max = tmp;
+ }
+ step = Math.max(0.01f, step);
+ defaultVal = Math.max(min, Math.min(max, defaultVal));
+ return new AdjustableComponent(defaultVal, min, max, step);
+ }
+
+ /** Default Y offset value. */
+ public float getDefaultValue() {
+ return defaultValue;
+ }
+
+ /** Minimum Y offset. */
+ public float getMin() {
+ return min;
+ }
+
+ /** Maximum Y offset. */
+ public float getMax() {
+ return max;
+ }
+
+ /** Step increment for the adjustment slider. */
+ public float getStep() {
+ return step;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java
new file mode 100644
index 0000000..d9142f6
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java
@@ -0,0 +1,36 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Component: blinding effect for data-driven items.
+ * Replaces IHasBlindingEffect marker interface.
+ *
+ * JSON config:
+ * {@code "blinding": {}} or {@code "blinding": {"overlay": "tiedup:textures/overlay/custom.png"}}
+ */
+public class BlindingComponent implements IItemComponent {
+
+ private final String overlay; // nullable — null means default overlay
+
+ private BlindingComponent(String overlay) {
+ this.overlay = overlay;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ String overlay = null;
+ if (config != null && config.has("overlay")) {
+ overlay = config.get("overlay").getAsString();
+ }
+ return new BlindingComponent(overlay);
+ }
+
+ /**
+ * Custom overlay texture path, or null for the mod's default blindfold overlay.
+ */
+ @Nullable
+ public String getOverlay() {
+ return overlay;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java
new file mode 100644
index 0000000..5860159
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java
@@ -0,0 +1,46 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: choking effect for data-driven items.
+ * When active, drains air, applies darkness/slowness, deals damage.
+ *
+ * JSON config:
+ * {@code "choking": {"air_drain_per_tick": 8, "non_lethal_for_master": true}}
+ */
+public class ChokingComponent implements IItemComponent {
+
+ private final int airDrainPerTick;
+ private final boolean nonLethalForMaster;
+
+ private ChokingComponent(int airDrainPerTick, boolean nonLethalForMaster) {
+ this.airDrainPerTick = airDrainPerTick;
+ this.nonLethalForMaster = nonLethalForMaster;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ int drain = 8;
+ boolean nonLethal = true;
+ if (config != null) {
+ if (config.has("air_drain_per_tick")) {
+ drain = config.get("air_drain_per_tick").getAsInt();
+ }
+ if (config.has("non_lethal_for_master")) {
+ nonLethal = config.get("non_lethal_for_master").getAsBoolean();
+ }
+ }
+ drain = Math.max(1, drain);
+ return new ChokingComponent(drain, nonLethal);
+ }
+
+ /** Air drained per tick (net after vanilla +4 restoration). */
+ public int getAirDrainPerTick() {
+ return airDrainPerTick;
+ }
+
+ /** Whether the choke is non-lethal when used by a master on their pet. */
+ public boolean isNonLethalForMaster() {
+ return nonLethalForMaster;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java
new file mode 100644
index 0000000..6a465ae
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java
@@ -0,0 +1,64 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Map;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.item.ItemStack;
+import org.jetbrains.annotations.Nullable;
+
+public final class ComponentHolder {
+
+ public static final ComponentHolder EMPTY = new ComponentHolder(Map.of());
+
+ private final Map components;
+
+ public ComponentHolder(Map components) {
+ this.components = components.isEmpty()
+ ? Map.of()
+ : Collections.unmodifiableMap(new EnumMap<>(components));
+ }
+
+ @Nullable
+ public IItemComponent get(ComponentType type) {
+ return components.get(type);
+ }
+
+ @Nullable
+ @SuppressWarnings("unchecked")
+ public T get(
+ ComponentType type,
+ Class clazz
+ ) {
+ IItemComponent component = components.get(type);
+ if (clazz.isInstance(component)) return (T) component;
+ return null;
+ }
+
+ public boolean has(ComponentType type) {
+ return components.containsKey(type);
+ }
+
+ public void onEquipped(ItemStack stack, LivingEntity entity) {
+ for (IItemComponent c : components.values()) {
+ c.onEquipped(stack, entity);
+ }
+ }
+
+ public void onUnequipped(ItemStack stack, LivingEntity entity) {
+ for (IItemComponent c : components.values()) {
+ c.onUnequipped(stack, entity);
+ }
+ }
+
+ public boolean blocksUnequip(ItemStack stack, LivingEntity entity) {
+ for (IItemComponent c : components.values()) {
+ if (c.blocksUnequip(stack, entity)) return true;
+ }
+ return false;
+ }
+
+ public boolean isEmpty() {
+ return components.isEmpty();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java
new file mode 100644
index 0000000..3964db4
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java
@@ -0,0 +1,53 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.jetbrains.annotations.Nullable;
+
+public enum ComponentType {
+ LOCKABLE("lockable", LockableComponent::fromJson),
+ RESISTANCE("resistance", ResistanceComponent::fromJson),
+ GAGGING("gagging", GaggingComponent::fromJson),
+ BLINDING("blinding", BlindingComponent::fromJson),
+ SHOCK("shock", ShockComponent::fromJson),
+ GPS("gps", GpsComponent::fromJson),
+ CHOKING("choking", ChokingComponent::fromJson),
+ ADJUSTABLE("adjustable", AdjustableComponent::fromJson);
+
+ private final String jsonKey;
+ private final Function factory;
+
+ /** Pre-built lookup map for O(1) fromKey() instead of linear scan. */
+ private static final Map BY_KEY;
+ static {
+ Map map = new HashMap<>();
+ for (ComponentType type : values()) {
+ map.put(type.jsonKey, type);
+ }
+ BY_KEY = Collections.unmodifiableMap(map);
+ }
+
+ ComponentType(
+ String jsonKey,
+ Function factory
+ ) {
+ this.jsonKey = jsonKey;
+ this.factory = factory;
+ }
+
+ public String getJsonKey() {
+ return jsonKey;
+ }
+
+ public IItemComponent create(JsonObject config) {
+ return factory.apply(config);
+ }
+
+ @Nullable
+ public static ComponentType fromKey(String key) {
+ return BY_KEY.get(key);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java
new file mode 100644
index 0000000..0e401a5
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java
@@ -0,0 +1,45 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: gagging behavior for data-driven items.
+ *
+ * JSON config: {@code "gagging": {"comprehension": 0.2, "range": 10.0}}
+ */
+public class GaggingComponent implements IItemComponent {
+
+ private final double comprehension;
+ private final double range;
+
+ private GaggingComponent(double comprehension, double range) {
+ this.comprehension = comprehension;
+ this.range = range;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ double comprehension = 0.2;
+ double range = 10.0;
+ if (config != null) {
+ if (config.has("comprehension")) {
+ comprehension = config.get("comprehension").getAsDouble();
+ }
+ if (config.has("range")) {
+ range = config.get("range").getAsDouble();
+ }
+ }
+ comprehension = Math.max(0.0, Math.min(1.0, comprehension));
+ range = Math.max(0.0, range);
+ return new GaggingComponent(comprehension, range);
+ }
+
+ /** How much of the gagged speech is comprehensible (0.0 = nothing, 1.0 = full). */
+ public double getComprehension() {
+ return comprehension;
+ }
+
+ /** Maximum range in blocks where muffled speech can be heard. */
+ public double getRange() {
+ return range;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java
new file mode 100644
index 0000000..272792a
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java
@@ -0,0 +1,45 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: GPS tracking and safe zone for data-driven items.
+ *
+ * JSON config:
+ * {@code "gps": {"safe_zone_radius": 50, "public_tracking": false}}
+ */
+public class GpsComponent implements IItemComponent {
+
+ private final int safeZoneRadius;
+ private final boolean publicTracking;
+
+ private GpsComponent(int safeZoneRadius, boolean publicTracking) {
+ this.safeZoneRadius = safeZoneRadius;
+ this.publicTracking = publicTracking;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ int radius = 50;
+ boolean publicTracking = false;
+ if (config != null) {
+ if (config.has("safe_zone_radius")) {
+ radius = config.get("safe_zone_radius").getAsInt();
+ }
+ if (config.has("public_tracking")) {
+ publicTracking = config.get("public_tracking").getAsBoolean();
+ }
+ }
+ radius = Math.max(0, radius);
+ return new GpsComponent(radius, publicTracking);
+ }
+
+ /** Safe zone radius in blocks. 0 = no safe zone (tracking only). */
+ public int getSafeZoneRadius() {
+ return safeZoneRadius;
+ }
+
+ /** Whether any player can track (not just the owner). */
+ public boolean isPublicTracking() {
+ return publicTracking;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java
new file mode 100644
index 0000000..4af99c0
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java
@@ -0,0 +1,19 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.item.ItemStack;
+
+/**
+ * A reusable behavior module for data-driven bondage items.
+ * Components are declared in JSON and instantiated per item definition.
+ */
+public interface IItemComponent {
+
+ default void onEquipped(ItemStack stack, LivingEntity entity) {}
+
+ default void onUnequipped(ItemStack stack, LivingEntity entity) {}
+
+ default boolean blocksUnequip(ItemStack stack, LivingEntity entity) {
+ return false;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java
new file mode 100644
index 0000000..3a57c71
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java
@@ -0,0 +1,44 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: lockable behavior for data-driven items.
+ *
+ * Stores the per-item lock resistance parsed from JSON. The lock check
+ * itself is NOT done here because {@code AbstractV2BondageItem.canUnequip()}
+ * already delegates to {@code ILockable.isLocked()} -- duplicating it in
+ * {@code blocksUnequip()} would be redundant.
+ *
+ * Consumers retrieve the per-item lock resistance via
+ * {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem#getItemLockResistance(net.minecraft.world.item.ItemStack)}.
+ *
+ * JSON config:
+ * {@code "lockable": {}} or {@code "lockable": {"lock_resistance": 300}}
+ */
+public class LockableComponent implements IItemComponent {
+
+ private final int lockResistance;
+
+ private LockableComponent(int lockResistance) {
+ this.lockResistance = lockResistance;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ int resistance = 250; // default
+ if (config != null && config.has("lock_resistance")) {
+ resistance = config.get("lock_resistance").getAsInt();
+ }
+ resistance = Math.max(0, resistance);
+ return new LockableComponent(resistance);
+ }
+
+ /**
+ * Get the per-item lock resistance value parsed from JSON.
+ *
+ * @return lock resistance (always >= 0 after RISK-003 clamping)
+ */
+ public int getLockResistance() {
+ return lockResistance;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java
new file mode 100644
index 0000000..d59f838
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java
@@ -0,0 +1,33 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: struggle resistance for data-driven items.
+ *
+ * JSON config: {@code "resistance": {"base": 150}}
+ */
+public class ResistanceComponent implements IItemComponent {
+
+ private final int baseResistance;
+
+ private ResistanceComponent(int baseResistance) {
+ this.baseResistance = baseResistance;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ int base = 100;
+ if (config != null && config.has("base")) {
+ base = config.get("base").getAsInt();
+ }
+ base = Math.max(0, base);
+ return new ResistanceComponent(base);
+ }
+
+ /**
+ * Get the base resistance for this item.
+ */
+ public int getBaseResistance() {
+ return baseResistance;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java
new file mode 100644
index 0000000..1c6d867
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java
@@ -0,0 +1,52 @@
+package com.tiedup.remake.v2.bondage.component;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Component: shock collar behavior for data-driven items.
+ *
+ * JSON config:
+ * {@code "shock": {"damage": 2.0, "auto_interval": 0}}
+ * auto_interval: ticks between auto-shocks (0 = no auto-shock, manual only)
+ */
+public class ShockComponent implements IItemComponent {
+
+ private final float damage;
+ private final int autoInterval; // 0 = manual only
+
+ private ShockComponent(float damage, int autoInterval) {
+ this.damage = damage;
+ this.autoInterval = autoInterval;
+ }
+
+ public static IItemComponent fromJson(JsonObject config) {
+ float damage = 2.0f;
+ int autoInterval = 0;
+ if (config != null) {
+ if (config.has("damage")) {
+ damage = config.get("damage").getAsFloat();
+ }
+ if (config.has("auto_interval")) {
+ autoInterval = config.get("auto_interval").getAsInt();
+ }
+ }
+ damage = Math.max(0.0f, damage);
+ autoInterval = Math.max(0, autoInterval);
+ return new ShockComponent(damage, autoInterval);
+ }
+
+ /** Damage dealt per shock. */
+ public float getDamage() {
+ return damage;
+ }
+
+ /** Ticks between auto-shocks. 0 = manual shock only (via controller). */
+ public int getAutoInterval() {
+ return autoInterval;
+ }
+
+ /** Whether this item has auto-shock capability. */
+ public boolean hasAutoShock() {
+ return autoInterval > 0;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java
index 9f215a2..56d6738 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java
@@ -4,6 +4,11 @@ import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.V2BondageItems;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
+import com.tiedup.remake.v2.bondage.component.ComponentHolder;
+import com.tiedup.remake.v2.bondage.component.ComponentType;
+import com.tiedup.remake.v2.bondage.component.IItemComponent;
+import com.tiedup.remake.v2.bondage.component.LockableComponent;
+import com.tiedup.remake.v2.bondage.component.ResistanceComponent;
import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem;
import java.util.List;
import java.util.Map;
@@ -147,6 +152,21 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem {
.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() == this) {
+ // Try component first (stack-aware, fixes I-03)
+ ResistanceComponent comp = DataDrivenBondageItem
+ .getComponent(
+ stack,
+ ComponentType.RESISTANCE,
+ ResistanceComponent.class
+ );
+ if (comp != null) {
+ maxDifficulty = Math.max(
+ maxDifficulty,
+ comp.getBaseResistance()
+ );
+ continue;
+ }
+ // Fallback: read from definition directly
DataDrivenItemDefinition def =
DataDrivenItemRegistry.get(stack);
if (def != null) {
@@ -257,6 +277,77 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem {
return Component.literal(def.displayName());
}
+ // ===== COMPONENT LIFECYCLE DELEGATION =====
+
+ @Override
+ public void onEquipped(ItemStack stack, LivingEntity entity) {
+ ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
+ if (holder != null) {
+ holder.onEquipped(stack, entity);
+ }
+ }
+
+ @Override
+ public void onUnequipped(ItemStack stack, LivingEntity entity) {
+ ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
+ if (holder != null) {
+ holder.onUnequipped(stack, entity);
+ }
+ }
+
+ @Override
+ public boolean canUnequip(ItemStack stack, LivingEntity entity) {
+ ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
+ if (holder != null && holder.blocksUnequip(stack, entity)) {
+ return false;
+ }
+ return super.canUnequip(stack, entity);
+ }
+
+ /**
+ * Get a specific component from a data-driven item stack.
+ *
+ * @param stack the ItemStack to inspect
+ * @param type the component type to look up
+ * @param clazz the expected component class
+ * @param the component type
+ * @return the component, or null if the item is not data-driven or lacks this component
+ */
+ @Nullable
+ public static T getComponent(
+ ItemStack stack,
+ ComponentType type,
+ Class clazz
+ ) {
+ ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
+ if (holder == null) return null;
+ return holder.get(type, clazz);
+ }
+
+ /**
+ * Get per-item lock resistance from the LockableComponent, if present.
+ *
+ * Returns the component's JSON-configured value if the stack has a
+ * LockableComponent, otherwise falls back to the global config value
+ * from {@link com.tiedup.remake.core.SettingsAccessor#getPadlockResistance}.
+ *
+ * @param stack the ItemStack to inspect
+ * @return lock resistance value for this specific item
+ */
+ public static int getItemLockResistance(ItemStack stack) {
+ LockableComponent comp = getComponent(
+ stack,
+ ComponentType.LOCKABLE,
+ LockableComponent.class
+ );
+ if (comp != null) {
+ return comp.getLockResistance();
+ }
+ return com.tiedup.remake.core.SettingsAccessor.getPadlockResistance(
+ null
+ );
+ }
+
// ===== FACTORY =====
/**
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
index 0c5f5f4..8d3e972 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
@@ -1,6 +1,8 @@
package com.tiedup.remake.v2.bondage.datadriven;
+import com.google.gson.JsonObject;
import com.tiedup.remake.v2.BodyRegionV2;
+import com.tiedup.remake.v2.bondage.component.ComponentType;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
@@ -97,5 +99,19 @@ public record DataDrivenItemDefinition(
*
* This field is required in the JSON definition. Never null, never empty.
*/
- Map> animationBones
-) {}
+ Map> animationBones,
+
+ /** Raw component configs from JSON, keyed by ComponentType. */
+ Map componentConfigs
+) {
+
+ /** Compact constructor: default null componentConfigs to empty immutable map. */
+ public DataDrivenItemDefinition {
+ if (componentConfigs == null) componentConfigs = Map.of();
+ }
+
+ /** Check whether this definition declares a given component type. */
+ public boolean hasComponent(ComponentType type) {
+ return componentConfigs.containsKey(type);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
index ff9e9fb..221b2ae 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
@@ -5,12 +5,14 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.v2.BodyRegionV2;
+import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
+import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -266,6 +268,39 @@ public final class DataDrivenItemParser {
return null;
}
+ // Optional: components (per-component JSON configs)
+ Map componentConfigs =
+ new EnumMap<>(ComponentType.class);
+ if (root.has("components")) {
+ JsonObject componentsObj = root.getAsJsonObject("components");
+ for (Map.Entry entry :
+ componentsObj.entrySet()) {
+ ComponentType compType = ComponentType.fromKey(
+ entry.getKey()
+ );
+ if (compType != null) {
+ JsonObject config;
+ if (entry.getValue().isJsonObject()) {
+ config = entry.getValue().getAsJsonObject().deepCopy();
+ } else {
+ LOGGER.warn(
+ "[DataDrivenItemParser] Component '{}' in item '{}' has non-object config, using defaults",
+ entry.getKey(),
+ fileId
+ );
+ config = new JsonObject();
+ }
+ componentConfigs.put(compType, config);
+ } else {
+ LOGGER.warn(
+ "[DataDrivenItemParser] Unknown component type '{}' in item '{}'",
+ entry.getKey(),
+ fileId
+ );
+ }
+ }
+ }
+
// Build the item ID from the file path
// fileId is like "tiedup:tiedup_items/leather_armbinder.json"
// We want "tiedup:leather_armbinder"
@@ -302,7 +337,8 @@ public final class DataDrivenItemParser {
movementStyle,
movementModifier,
creator,
- animationBones
+ animationBones,
+ componentConfigs
);
}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java
index b721b0f..1c72af3 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java
@@ -1,7 +1,12 @@
package com.tiedup.remake.v2.bondage.datadriven;
+import com.google.gson.JsonObject;
+import com.tiedup.remake.v2.bondage.component.ComponentHolder;
+import com.tiedup.remake.v2.bondage.component.ComponentType;
+import com.tiedup.remake.v2.bondage.component.IItemComponent;
import java.util.Collection;
import java.util.Collections;
+import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
@@ -13,9 +18,9 @@ import org.jetbrains.annotations.Nullable;
* Thread-safe registry for data-driven bondage item definitions.
*
* Populated by the reload listener that scans {@code tiedup_items/} JSON files.
- * Uses volatile atomic swap (same pattern as {@link
- * com.tiedup.remake.client.animation.context.ContextGlbRegistry}) to ensure
- * the render thread always sees a consistent snapshot.
+ * Uses a single volatile snapshot reference to ensure readers always see a
+ * consistent pair of definitions + component holders. This prevents torn reads
+ * where one map is updated but the other is stale.
*
* Lookup methods accept either a {@link ResourceLocation} ID directly
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).
@@ -26,13 +31,25 @@ public final class DataDrivenItemRegistry {
public static final String NBT_ITEM_ID = "tiedup_item_id";
/**
- * Volatile reference to an unmodifiable map. Reload builds a new map
- * and swaps atomically; consumer threads always see a consistent snapshot.
+ * Immutable snapshot combining definitions and their component holders.
+ * Swapped atomically via a single volatile write to prevent torn reads.
*/
- private static volatile Map<
- ResourceLocation,
- DataDrivenItemDefinition
- > DEFINITIONS = Map.of();
+ private record RegistrySnapshot(
+ Map definitions,
+ Map holders
+ ) {
+ static final RegistrySnapshot EMPTY = new RegistrySnapshot(Map.of(), Map.of());
+ }
+
+ /**
+ * Single volatile reference to the current registry state.
+ * All read methods capture this reference ONCE at the start to ensure
+ * consistency within a single call.
+ */
+ private static volatile RegistrySnapshot SNAPSHOT = RegistrySnapshot.EMPTY;
+
+ /** Guards read-then-write sequences in {@link #reload} and {@link #mergeAll}. */
+ private static final Object RELOAD_LOCK = new Object();
private DataDrivenItemRegistry() {}
@@ -45,7 +62,11 @@ public final class DataDrivenItemRegistry {
public static void reload(
Map newDefs
) {
- DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
+ synchronized (RELOAD_LOCK) {
+ Map defs =
+ Collections.unmodifiableMap(new HashMap<>(newDefs));
+ SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
+ }
}
/**
@@ -54,18 +75,23 @@ public final class DataDrivenItemRegistry {
* On an integrated server, both the client (assets/) and server (data/) reload
* listeners populate this registry. Using {@link #reload} would cause the second
* listener to overwrite the first's definitions. This method builds a new map
- * from the existing snapshot + the new entries, then swaps atomically.
+ * from the existing snapshot + the new entries, then swaps atomically as a
+ * single snapshot to prevent torn reads.
*
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
*/
public static void mergeAll(
Map newDefs
) {
- Map merged = new HashMap<>(
- DEFINITIONS
- );
- merged.putAll(newDefs);
- DEFINITIONS = Collections.unmodifiableMap(merged);
+ synchronized (RELOAD_LOCK) {
+ Map merged = new HashMap<>(
+ SNAPSHOT.definitions
+ );
+ merged.putAll(newDefs);
+ Map defs =
+ Collections.unmodifiableMap(merged);
+ SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
+ }
}
/**
@@ -76,7 +102,8 @@ public final class DataDrivenItemRegistry {
*/
@Nullable
public static DataDrivenItemDefinition get(ResourceLocation id) {
- return DEFINITIONS.get(id);
+ RegistrySnapshot snap = SNAPSHOT;
+ return snap.definitions.get(id);
}
/**
@@ -94,7 +121,8 @@ public final class DataDrivenItemRegistry {
tag.getString(NBT_ITEM_ID)
);
if (id == null) return null;
- return DEFINITIONS.get(id);
+ RegistrySnapshot snap = SNAPSHOT;
+ return snap.definitions.get(id);
}
/**
@@ -103,13 +131,85 @@ public final class DataDrivenItemRegistry {
* @return unmodifiable collection of all definitions
*/
public static Collection getAll() {
- return DEFINITIONS.values();
+ RegistrySnapshot snap = SNAPSHOT;
+ return snap.definitions.values();
}
/**
* Clear all definitions. Called on world unload or for testing.
*/
public static void clear() {
- DEFINITIONS = Map.of();
+ SNAPSHOT = RegistrySnapshot.EMPTY;
+ }
+
+ // ===== COMPONENT HOLDERS =====
+
+ /**
+ * Get the ComponentHolder for a data-driven item stack.
+ *
+ * Captures the snapshot reference once to ensure consistent reads
+ * between the definition lookup and the holder lookup.
+ *
+ * @param stack the ItemStack to inspect
+ * @return the holder, or null if the stack is not data-driven or has no definition
+ */
+ @Nullable
+ public static ComponentHolder getComponents(ItemStack stack) {
+ if (stack.isEmpty()) return null;
+ CompoundTag tag = stack.getTag();
+ if (tag == null || !tag.contains(NBT_ITEM_ID)) return null;
+ ResourceLocation id = ResourceLocation.tryParse(
+ tag.getString(NBT_ITEM_ID)
+ );
+ if (id == null) return null;
+ // Capture snapshot once to ensure definition and holder come from
+ // the same atomic snapshot, preventing torn reads.
+ RegistrySnapshot snap = SNAPSHOT;
+ DataDrivenItemDefinition def = snap.definitions.get(id);
+ if (def == null) return null;
+ return snap.holders.get(def.id());
+ }
+
+ /**
+ * Get the ComponentHolder for a data-driven item by its definition ID.
+ *
+ * @param id the definition ID
+ * @return the holder, or null if not found
+ */
+ @Nullable
+ public static ComponentHolder getComponents(ResourceLocation id) {
+ RegistrySnapshot snap = SNAPSHOT;
+ return snap.holders.get(id);
+ }
+
+ /**
+ * Build component holders from a definitions map.
+ * Each definition's raw componentConfigs are instantiated via
+ * {@link ComponentType#create(JsonObject)}.
+ */
+ private static Map buildComponentHolders(
+ Map definitions
+ ) {
+ Map holders = new HashMap<>();
+ for (Map.Entry entry :
+ definitions.entrySet()) {
+ DataDrivenItemDefinition def = entry.getValue();
+ Map components =
+ new EnumMap<>(ComponentType.class);
+ for (Map.Entry compEntry :
+ def.componentConfigs().entrySet()) {
+ components.put(
+ compEntry.getKey(),
+ compEntry.getKey().create(compEntry.getValue())
+ );
+ }
+ holders.put(
+ entry.getKey(),
+ components.isEmpty()
+ ? ComponentHolder.EMPTY
+ : new ComponentHolder(components)
+ );
+ }
+ return Collections.unmodifiableMap(holders);
}
}
diff --git a/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json b/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json
new file mode 100644
index 0000000..b7adc12
--- /dev/null
+++ b/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json
@@ -0,0 +1,24 @@
+{
+ "type": "tiedup:bondage_item",
+ "display_name": "Component Test Gag",
+ "model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb",
+ "regions": ["MOUTH"],
+ "pose_priority": 10,
+ "escape_difficulty": 3,
+ "lockable": true,
+ "animation_bones": {
+ "idle": []
+ },
+ "components": {
+ "lockable": {
+ "lock_resistance": 200
+ },
+ "resistance": {
+ "base": 80
+ },
+ "gagging": {
+ "comprehension": 0.15,
+ "range": 8.0
+ }
+ }
+}