35 Commits

Author SHA1 Message Date
NotEvil
df56ebb6bc fix(D-01/A): adversarial review fixes — 4 logic bugs
1. NECK region explicitly blocked in interactLivingEntity() — prevents
   V2 collars equipping without ownership setup (latent, no JSONs yet)

2. Swap rollback safety: if re-equip fails after swap failure, drop the
   old bind as item entity instead of losing it silently

3. GaggingComponent: cache GagMaterial at construction time — eliminates
   valueOf() log spam on every chat message with misconfigured material

4. Dual-bind prevention: check both V1 isTiedUp() AND V2 region occupied
   in TyingInteractionHelper and PacketSelfBondage to prevent equipping
   V2 bind on top of V1 bind
2026-04-14 16:48:50 +02:00
NotEvil
b97bdf367e fix(D-01/A): V2 bind/collar resistance completely broken (CRITICAL)
PlayerEquipment.getCurrentBindResistance/setCurrentBindResistance and
getCurrentCollarResistance/setCurrentCollarResistance all checked
instanceof ItemBind/ItemCollar — V2 DataDrivenBondageItem silently
returned 0, making V2 items escapable in 1 struggle roll.

Fix: use instanceof IHasResistance which both V1 and V2 implement.

Also fix StruggleCollar.tighten() to read ResistanceComponent directly
for V2 collars instead of IHasResistance.getBaseResistance(entity)
which triggers the singleton MAX-scan across all equipped items.

Note: isItemLocked() dead code in StruggleState is a PRE-EXISTING bug
(x10 locked penalty never applied) — tracked for separate fix.
2026-04-14 16:44:59 +02:00
NotEvil
eb7f06bfc8 fix(D-01/A): 3 review bugs + null guards (BUG-001, BUG-002, BUG-003, RISK-003)
BUG-001: TyingInteractionHelper swap now checks V2EquipResult — on failure,
rolls back by re-equipping the old bind instead of leaving target untied

BUG-002: OwnershipComponent.onUnequipped no longer double-calls
unregisterWearer — onCollarRemoved already handles it. Suppressed-alert
path calls unregister directly since onCollarRemoved is skipped.

BUG-003: PacketSelfBondage handleV2SelfBind now clears completed tying
task from PlayerBindState to prevent blocking future tying interactions

RISK-003: StruggleCollar get/setResistanceState null-guard on player
2026-04-14 16:38:09 +02:00
NotEvil
5c4e4c2352 fix(D-01/A): double item consumption + unchecked cast in TyingInteractionHelper
QA-001: add instanceof V2TyingPlayerTask guard before cast to prevent
ClassCastException when a V1 TyingPlayerTask was still active

QA-002: remove stack.shrink(1) after tying completion — V2TyingPlayerTask
.onComplete() already consumes the held item via heldStack.shrink(1)
2026-04-14 16:35:05 +02:00
NotEvil
eee4825aba feat(D-01/A): NPC speed reduction for V2 items (A12)
- onEquipped: apply RestraintEffectUtils speed reduction for non-Player
  entities with ARMS region and legs bound. Full immobilization for
  WRAP/LATEX_SACK pose types.
- onUnequipped: remove speed reduction for non-Player entities
- Players use MovementStyleManager (V2 tick-based), not this legacy path
2026-04-14 16:07:41 +02:00
NotEvil
b359c6be35 feat(D-01/A): self-bondage region routing (A11)
- handleV2SelfBondage: split into region-based routing
  - NECK → blocked (cannot self-collar)
  - ARMS → handleV2SelfBind (tying task with progress bar)
  - Other → handleV2SelfAccessory (instant equip)
- handleV2SelfAccessory: arms-bound check via BindModeHelper,
  locked check for swap, V2EquipmentHelper for conflict resolution
2026-04-14 16:06:01 +02:00
NotEvil
19cc69985d feat(D-01/A): V2-aware struggle system (A9)
StruggleBinds:
- canStruggle(): instanceof ItemBind → BindModeHelper.isBindItem()
- isItemLocked(): instanceof ItemBind → instanceof ILockable (fixes R4)
- onAttempt(): instanceof ItemShockCollar → CollarHelper.canShock() (fixes R5)
- tighten(): reads ResistanceComponent directly for V2, avoids MAX scan bug

StruggleCollar:
- getResistanceState/setResistanceState: instanceof ItemCollar → IHasResistance
- canStruggle(): instanceof ItemCollar → CollarHelper.isCollar() + ILockable
- onAttempt(): shock check via CollarHelper.canShock()
- successAction(): unlock via ILockable
- tighten(): resistance via IHasResistance

All V1 items continue working through the same interfaces they already implement.
2026-04-14 15:47:20 +02:00
NotEvil
737a4fd59b feat(D-01/A): interaction routing + TyingInteractionHelper (A8)
- DataDrivenBondageItem.use(): shift+click cycles bind mode for ARMS items
- DataDrivenBondageItem.interactLivingEntity(): region-based routing
  - ARMS → TyingInteractionHelper (tying task with progress bar)
  - NECK → deferred to Branch C (no V2 collar JSONs yet)
  - Other regions → instant equip via parent AbstractV2BondageItem
- TyingInteractionHelper: extracted tying flow using V2TyingPlayerTask
  - Distance/LoS validation, swap if already tied, task lifecycle
2026-04-14 15:35:31 +02:00
NotEvil
b79225d684 feat(D-01/A): OwnershipComponent lifecycle hooks (A7)
- onEquipped: register collar owners in CollarRegistry (server-side only)
- onUnequipped: alert kidnappers + unregister from CollarRegistry
- Guards: client-side check, ServerLevel cast, empty owners skip, try-catch
- appendTooltip: nickname, owner count, shock/GPS/choke capabilities
- Delegates alert suppression to ItemCollar.isRemovalAlertSuppressed()
2026-04-14 15:29:31 +02:00
NotEvil
751bad418d feat(D-01/A): poseType, helpers, OWNERSHIP ComponentType (A4, A5, A6)
- DataDrivenItemDefinition: add poseType field, parsed from JSON "pose_type"
- PoseTypeHelper: resolves PoseType from V2 definition or V1 ItemBind fallback
- BindModeHelper: static bind mode NBT utilities (isBindItem, hasArmsBound,
  hasLegsBound, cycleBindModeId) — works for V1 and V2 items
- CollarHelper: complete static utility class for collar operations with
  dual-path V2/V1 dispatch (ownership, features, shock, GPS, choke, alert)
- ComponentType: add OWNERSHIP enum value
- OwnershipComponent: stub class (lifecycle hooks added in next commit)
2026-04-14 15:23:08 +02:00
NotEvil
b81d3eed95 feat(D-01/A): config-driven components + tooltip hook (A1, A2, A3)
- ResistanceComponent: resistanceId delegates to SettingsAccessor at runtime,
  fallback to hardcoded base for backward compat
- GaggingComponent: material field delegates to GagMaterial enum from ModConfig,
  explicit comprehension/range overrides take priority
- IItemComponent: add default appendTooltip() method
- ComponentHolder: iterate components for tooltip contribution
- 6 components implement appendTooltip (lockable, resistance, gagging, shock,
  gps, choking)
- DataDrivenBondageItem: call holder.appendTooltip() in appendHoverText()
2026-04-14 15:05:48 +02:00
fa4c332a10 Merge pull request 'feature/d01-component-system' (#5) from feature/d01-component-system into develop
Reviewed-on: #5
2026-04-14 00:54:16 +00:00
NotEvil
7bd840705a docs: add components section to ARTIST_GUIDE.md
Documents the 8 available gameplay components (lockable, resistance,
gagging, blinding, shock, gps, choking, adjustable) with config fields,
examples (GPS shock collar, adjustable blindfold), and usage tips.
2026-04-14 02:51:37 +02:00
NotEvil
90bc890b95 fix(D-01): synchronize reload paths and capture snapshot locally (RISK-001, RISK-002) 2026-04-14 02:46:09 +02:00
NotEvil
185ac63a44 feat(D-01): implement 5 remaining components (blinding, shock, gps, choking, adjustable) 2026-04-14 02:37:14 +02:00
NotEvil
dcc8493e5e fix(D-01): pre-built map for O(1) ComponentType.fromKey() lookup (RISK-005)
Replace linear values() scan with a static unmodifiable HashMap lookup.
While only 3 entries currently exist, this establishes the correct
pattern for when more component types are added.
2026-04-14 02:29:46 +02:00
NotEvil
bfcc20d242 fix(D-01): compact constructor defaults null componentConfigs to empty (RISK-004)
Add compact constructor to DataDrivenItemDefinition that defaults null
componentConfigs to Map.of(). This makes the field guaranteed non-null,
allowing removal of null checks in hasComponent() and
DataDrivenItemRegistry.buildComponentHolders().
2026-04-14 02:29:25 +02:00
NotEvil
3a81bb6e12 fix(D-01): clamp component config values to valid ranges (RISK-003)
- LockableComponent: lock_resistance clamped to >= 0
- ResistanceComponent: base resistance clamped to >= 0
- GaggingComponent: comprehension clamped to [0.0, 1.0], range to >= 0.0

Prevents nonsensical negative values from malformed JSON configs.
2026-04-14 02:28:42 +02:00
NotEvil
bb589d44f8 fix(D-01): warn on non-object component config, deep-copy configs (RISK-001, RISK-002)
- Deep-copy JsonObject configs via deepCopy() before storing in the
  definition to prevent external mutation of the parsed JSON tree
- Log a warning when a component config value is not a JsonObject,
  making misconfigured JSON easier to diagnose
2026-04-14 02:27:59 +02:00
NotEvil
456335e0dd fix(D-01): wire LockableComponent.lockResistance via getItemLockResistance() (BUG-003)
- Remove redundant blocksUnequip() from LockableComponent since
  AbstractV2BondageItem.canUnequip() already checks ILockable.isLocked()
- Add DataDrivenBondageItem.getItemLockResistance(ItemStack) that reads
  the per-item lock resistance from the LockableComponent, falling back
  to the global config value when absent
2026-04-14 02:27:37 +02:00
NotEvil
bb209bcd8e fix(D-01): remove dead onWornTick() until V2 tick mechanism exists (BUG-002)
Remove onWornTick() from IItemComponent (default method) and
ComponentHolder (aggregate method). No V2 tick caller invokes these,
so they create a broken contract. Can be re-added when a tick
mechanism is implemented.
2026-04-14 02:26:31 +02:00
NotEvil
1327e3bfc3 fix(D-01): atomic snapshot for registry to prevent torn reads (BUG-001)
Replace two separate volatile fields (DEFINITIONS, COMPONENT_HOLDERS)
with a single RegistrySnapshot record swapped atomically. This prevents
race conditions where a reader thread could see new definitions paired
with stale/empty component holders between the two volatile writes.
2026-04-14 02:25:57 +02:00
NotEvil
dbacef66d5 feat(D-01): add test_component_gag.json demonstrating component system
JSON item using all 3 implemented components: lockable (lock_resistance: 200),
resistance (base: 80), and gagging (comprehension: 0.15, range: 8.0).
2026-04-14 02:03:50 +02:00
NotEvil
231522c68e feat(D-01): implement GaggingComponent with comprehension and range 2026-04-14 02:01:50 +02:00
NotEvil
84f4c3a53f feat(D-01): implement ResistanceComponent, improve stack-aware resistance lookup 2026-04-14 02:01:46 +02:00
NotEvil
caeb4469b1 feat(D-01): implement LockableComponent with configurable lock resistance 2026-04-14 02:01:41 +02:00
NotEvil
3a1f401ccf feat(D-01): delegate DataDrivenBondageItem lifecycle to components
Override onEquipped(), onUnequipped(), and canUnequip() in
DataDrivenBondageItem to delegate to the item's ComponentHolder.
The canUnequip() override preserves the existing lock check from
AbstractV2BondageItem via super.canUnequip().

Add a static getComponent() helper for external code to retrieve
a typed component from any data-driven item stack.
2026-04-14 01:47:19 +02:00
NotEvil
a781dad597 feat(D-01): instantiate ComponentHolder per item definition on reload
Add a parallel COMPONENT_HOLDERS volatile cache to DataDrivenItemRegistry,
rebuilt from raw componentConfigs every time definitions are loaded via
reload() or mergeAll(). Cleared alongside DEFINITIONS in clear().

Two accessor methods allow looking up a ComponentHolder by ItemStack
(reads tiedup_item_id NBT) or by ResourceLocation directly.
2026-04-14 01:46:19 +02:00
NotEvil
750be66d80 feat(D-01): parse component configs from item JSON definitions
Add componentConfigs field (Map<ComponentType, JsonObject>) to
DataDrivenItemDefinition record. The parser now reads an optional
"components" JSON block, resolves each key via ComponentType.fromKey(),
and stores the raw JsonObject configs for later instantiation.
2026-04-14 01:44:19 +02:00
NotEvil
1b70041c36 feat(D-01): add ComponentHolder container for item components 2026-04-14 01:38:09 +02:00
NotEvil
b8a0d839f5 feat(D-01): add ComponentType enum with stub component classes 2026-04-14 01:33:37 +02:00
NotEvil
edfc3c6506 feat(D-01): add IItemComponent interface for data-driven item behaviors 2026-04-14 01:29:24 +02:00
3fe3e16e0a Merge pull request 'feature/item-tooltip-creator' (#4) from feature/item-tooltip-creator into develop
Reviewed-on: #4
2026-04-13 02:01:21 +00:00
NotEvil
e17998933c Add creator field and enriched tooltip for data-driven items
- Add optional "creator" JSON field to display author name in tooltip
- Show body regions, movement style, lock status, and escape difficulty
- Show pose priority and item ID in advanced mode (F3+H)
- Update ARTIST_GUIDE.md field reference and test JSON
2026-04-13 04:00:27 +02:00
3a1082dc38 Merge pull request 'Actualiser README.md' (#3) from notevil-patch-1 into develop
Reviewed-on: #3
2026-04-13 00:59:12 +00:00
29 changed files with 2197 additions and 189 deletions

View File

@@ -105,7 +105,7 @@ Some dependencies are included as local JARs in `libs/` because they are not ava
GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details.
**TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall.
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission.
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission otherwise me.
## Status

View File

@@ -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)
@@ -762,7 +762,9 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `animations` | string/object | No | `"auto"` (default) or explicit name mapping |
| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` |
| `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)
@@ -784,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.01.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.

View File

@@ -14,8 +14,10 @@ import com.tiedup.remake.tasks.TyingTask;
import com.tiedup.remake.tasks.V2TyingPlayerTask;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.V2EquipResult;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
@@ -222,69 +224,116 @@ public class PacketSelfBondage {
IV2BondageItem v2Item,
IBondageState state
) {
java.util.Set<BodyRegionV2> regions = v2Item.getOccupiedRegions(stack);
// Cannot self-collar
if (regions.contains(BodyRegionV2.NECK)) {
TiedUpMod.LOGGER.debug("[SelfBondage] {} tried to self-collar — blocked", player.getName().getString());
return;
}
// ARMS: tying task with progress bar
if (regions.contains(BodyRegionV2.ARMS)) {
handleV2SelfBind(player, stack, v2Item, state);
return;
}
// Accessories (MOUTH, EYES, EARS, HANDS): instant equip
handleV2SelfAccessory(player, stack, v2Item, state);
}
private static void handleV2SelfBind(
ServerPlayer player,
ItemStack stack,
IV2BondageItem v2Item,
IBondageState state
) {
// Can't self-tie if already tied (check both V1 state and V2 region to prevent dual-bind)
if (state.isTiedUp() || V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) {
TiedUpMod.LOGGER.debug("[SelfBondage] {} tried V2 self-tie but already tied", player.getName().getString());
return;
}
// Check if all target regions are already occupied or blocked
boolean allBlocked = true;
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
if (
!V2EquipmentHelper.isRegionOccupied(player, region) &&
!V2EquipmentHelper.isRegionBlocked(player, region)
) {
if (!V2EquipmentHelper.isRegionOccupied(player, region)
&& !V2EquipmentHelper.isRegionBlocked(player, region)) {
allBlocked = false;
break;
}
}
if (allBlocked) {
TiedUpMod.LOGGER.debug(
"[SelfBondage] {} tried V2 self-equip but all regions occupied",
player.getName().getString()
);
TiedUpMod.LOGGER.debug("[SelfBondage] {} tried V2 self-equip but all regions occupied", player.getName().getString());
return;
}
PlayerBindState playerState = PlayerBindState.getInstance(player);
if (playerState == null) return;
int tyingSeconds = SettingsAccessor.getTyingPlayerTime(
player.level().getGameRules()
);
int tyingSeconds = SettingsAccessor.getTyingPlayerTime(player.level().getGameRules());
// Create V2 tying task (uses V2EquipmentHelper on completion, NOT putBindOn)
V2TyingPlayerTask newTask = new V2TyingPlayerTask(
stack.copy(), // copy for display/matching
stack, // live reference for consumption
state,
player, // target is self
tyingSeconds,
player.level(),
player // kidnapper is also self
stack.copy(), stack, state, player, tyingSeconds, player.level(), player
);
TyingTask currentTask = playerState.getCurrentTyingTask();
if (
currentTask == null ||
!currentTask.isSameTarget(player) ||
currentTask.isOutdated() ||
!ItemStack.matches(currentTask.getBind(), stack)
) {
// Start new task
if (currentTask == null
|| !currentTask.isSameTarget(player)
|| currentTask.isOutdated()
|| !ItemStack.matches(currentTask.getBind(), stack)) {
playerState.setCurrentTyingTask(newTask);
newTask.start();
TiedUpMod.LOGGER.debug(
"[SelfBondage] {} started V2 self-tying ({} seconds)",
player.getName().getString(),
tyingSeconds
);
} else {
// Continue existing task — just mark active
currentTask.update();
}
// If we started a new task, mark it active too
if (playerState.getCurrentTyingTask() == newTask) {
newTask.update();
}
// Clear completed task to prevent blocking future tying interactions
TyingTask activeTask = playerState.getCurrentTyingTask();
if (activeTask != null && activeTask.isStopped()) {
playerState.setCurrentTyingTask(null);
}
}
private static void handleV2SelfAccessory(
ServerPlayer player,
ItemStack stack,
IV2BondageItem v2Item,
IBondageState state
) {
// Can't equip accessories if arms are fully bound
ItemStack currentBind = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS);
if (!currentBind.isEmpty() && BindModeHelper.hasArmsBound(currentBind)) {
TiedUpMod.LOGGER.debug("[SelfBondage] {} can't self-accessory — arms bound", player.getName().getString());
return;
}
// Check if region is occupied — try to swap
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
if (V2EquipmentHelper.isRegionOccupied(player, region)) {
ItemStack existing = V2EquipmentHelper.getInRegion(player, region);
// Can't swap if locked
if (existing.getItem() instanceof ILockable lockable && lockable.isLocked(existing)) {
TiedUpMod.LOGGER.debug("[SelfBondage] {} can't swap — current is locked", player.getName().getString());
return;
}
}
}
// Equip via V2EquipmentHelper (handles conflict resolution, displaced items)
V2EquipResult result = V2EquipmentHelper.equipItem(player, stack.copy());
if (result.isSuccess()) {
for (ItemStack displaced : result.displaced()) {
player.spawnAtLocation(displaced);
}
stack.shrink(1);
SyncManager.syncInventory(player);
TiedUpMod.LOGGER.info("[SelfBondage] {} self-equipped V2 accessory", player.getName().getString());
}
}
/**

View File

@@ -320,10 +320,12 @@ public class PlayerEquipment {
player,
BodyRegionV2.ARMS
);
if (
stack.isEmpty() || !(stack.getItem() instanceof ItemBind bind)
) return 0;
return bind.getCurrentResistance(stack, player);
if (stack.isEmpty()) return 0;
// V1 and V2 both implement IHasResistance
if (stack.getItem() instanceof com.tiedup.remake.items.base.IHasResistance resistance) {
return resistance.getCurrentResistance(stack, player);
}
return 0;
}
/**
@@ -334,10 +336,11 @@ public class PlayerEquipment {
player,
BodyRegionV2.ARMS
);
if (
stack.isEmpty() || !(stack.getItem() instanceof ItemBind bind)
) return;
bind.setCurrentResistance(stack, resistance);
if (stack.isEmpty()) return;
// V1 and V2 both implement IHasResistance
if (stack.getItem() instanceof com.tiedup.remake.items.base.IHasResistance resistanceItem) {
resistanceItem.setCurrentResistance(stack, resistance);
}
}
/**
@@ -348,10 +351,12 @@ public class PlayerEquipment {
player,
BodyRegionV2.NECK
);
if (
stack.isEmpty() || !(stack.getItem() instanceof ItemCollar collar)
) return 0;
return collar.getCurrentResistance(stack, player);
if (stack.isEmpty()) return 0;
// V1 and V2 both implement IHasResistance
if (stack.getItem() instanceof com.tiedup.remake.items.base.IHasResistance resistance) {
return resistance.getCurrentResistance(stack, player);
}
return 0;
}
/**
@@ -362,10 +367,11 @@ public class PlayerEquipment {
player,
BodyRegionV2.NECK
);
if (
stack.isEmpty() || !(stack.getItem() instanceof ItemCollar collar)
) return;
collar.setCurrentResistance(stack, resistance);
if (stack.isEmpty()) return;
// V1 and V2 both implement IHasResistance
if (stack.getItem() instanceof com.tiedup.remake.items.base.IHasResistance resistanceItem) {
resistanceItem.setCurrentResistance(stack, resistance);
}
}
// ========== Helper Methods ==========

View File

@@ -4,12 +4,18 @@ import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.SystemMessageManager.MessageCategory;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.IHasResistance;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.state.IPlayerLeashAccess;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.ResistanceComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
@@ -75,16 +81,17 @@ public class StruggleBinds extends StruggleState {
player,
BodyRegionV2.ARMS
);
if (
bindStack.isEmpty() ||
!(bindStack.getItem() instanceof ItemBind bind)
) {
if (bindStack.isEmpty() || !BindModeHelper.isBindItem(bindStack)) {
return false;
}
// The locked check has been moved to struggle() where decrease is reduced
return bind.canBeStruggledOut(bindStack);
// Check canBeStruggledOut — works for both V1 and V2 via IHasResistance
if (bindStack.getItem() instanceof IHasResistance resistance) {
return resistance.canBeStruggledOut(bindStack);
}
return true;
}
/**
@@ -103,14 +110,13 @@ public class StruggleBinds extends StruggleState {
player,
BodyRegionV2.ARMS
);
if (
bindStack.isEmpty() ||
!(bindStack.getItem() instanceof ItemBind bind)
) {
return false;
}
if (bindStack.isEmpty()) return false;
return bind.isLocked(bindStack);
// Works for both V1 (ItemBind) and V2 (DataDrivenBondageItem) via ILockable
if (bindStack.getItem() instanceof ILockable lockable) {
return lockable.isLocked(bindStack);
}
return false;
}
/**
@@ -148,14 +154,18 @@ public class StruggleBinds extends StruggleState {
BodyRegionV2.NECK
);
if (
!collar.isEmpty() &&
collar.getItem() instanceof
com.tiedup.remake.items.ItemShockCollar shockCollar
) {
return shockCollar.notifyStruggle(player, collar);
if (!collar.isEmpty() && CollarHelper.canShock(collar)) {
// V1 shock collar
if (collar.getItem() instanceof com.tiedup.remake.items.ItemShockCollar shockCollar) {
return shockCollar.notifyStruggle(player, collar);
}
// V2 shock collarnotify via IHasResistance if available
if (collar.getItem() instanceof IHasResistance resistance) {
resistance.notifyStruggle(player);
}
return true;
}
return true; // No collar, proceed normally
return true; // No shock collar, proceed normally
}
/**
@@ -317,18 +327,23 @@ public class StruggleBinds extends StruggleState {
target,
BodyRegionV2.ARMS
);
if (
bindStack.isEmpty() ||
!(bindStack.getItem() instanceof ItemBind bind)
) {
if (bindStack.isEmpty() || !BindModeHelper.isBindItem(bindStack)) {
return;
}
// Get base resistance from config (BUG-003 fix: was using ModGameRules which
// only knew 4 types and returned hardcoded 100 for the other 10)
int baseResistance = SettingsAccessor.getBindResistance(
bind.getItemName()
// Get base resistance: V2 reads from ResistanceComponent directly,
// V1 reads from SettingsAccessor via item name (BUG-003 fix)
int baseResistance;
ResistanceComponent comp = DataDrivenBondageItem.getComponent(
bindStack, ComponentType.RESISTANCE, ResistanceComponent.class
);
if (comp != null) {
baseResistance = comp.getBaseResistance();
} else if (bindStack.getItem() instanceof ItemBind bind) {
baseResistance = SettingsAccessor.getBindResistance(bind.getItemName());
} else {
baseResistance = 100;
}
// Set current resistance to base (full restore)
setResistanceState(state, baseResistance);

View File

@@ -2,10 +2,16 @@ package com.tiedup.remake.state.struggle;
import com.tiedup.remake.core.SystemMessageManager.MessageCategory;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.IHasResistance;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.ResistanceComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
@@ -44,19 +50,15 @@ public class StruggleCollar extends StruggleState {
@Override
protected int getResistanceState(PlayerBindState state) {
Player player = state.getPlayer();
ItemStack collar = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.NECK
);
if (player == null) return 0;
ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
return 0;
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) return 0;
if (collar.getItem() instanceof IHasResistance resistance) {
return resistance.getCurrentResistance(collar, player);
}
return collarItem.getCurrentResistance(collar, player);
return 0;
}
/**
@@ -68,19 +70,14 @@ public class StruggleCollar extends StruggleState {
@Override
protected void setResistanceState(PlayerBindState state, int resistance) {
Player player = state.getPlayer();
ItemStack collar = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.NECK
);
if (player == null) return;
ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
return;
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) return;
if (collar.getItem() instanceof IHasResistance resistanceItem) {
resistanceItem.setCurrentResistance(collar, resistance);
}
collarItem.setCurrentResistance(collar, resistance);
}
/**
@@ -104,31 +101,29 @@ public class StruggleCollar extends StruggleState {
return false;
}
ItemStack collar = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.NECK
);
ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] No collar equipped");
return false;
}
// Check if locked
if (!collarItem.isLocked(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] Collar is not locked");
// Check if locked (works for V1 and V2 via ILockable)
if (collar.getItem() instanceof ILockable lockable) {
if (!lockable.isLocked(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] Collar is not locked");
return false;
}
} else {
return false;
}
// Check if struggle is enabled
if (!collarItem.canBeStruggledOut(collar)) {
TiedUpMod.LOGGER.debug(
"[StruggleCollar] Collar struggle is disabled"
);
return false;
// Check if struggle is enabled (works for V1 and V2 via IHasResistance)
if (collar.getItem() instanceof IHasResistance resistance) {
if (!resistance.canBeStruggledOut(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] Collar struggle is disabled");
return false;
}
}
return true;
@@ -141,17 +136,17 @@ public class StruggleCollar extends StruggleState {
@Override
protected boolean onAttempt(PlayerBindState state) {
Player player = state.getPlayer();
ItemStack collar = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.NECK
);
ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK);
if (
!collar.isEmpty() &&
collar.getItem() instanceof
com.tiedup.remake.items.ItemShockCollar shockCollar
) {
return shockCollar.notifyStruggle(player, collar);
if (!collar.isEmpty() && CollarHelper.canShock(collar)) {
// V1 shock collar
if (collar.getItem() instanceof com.tiedup.remake.items.ItemShockCollar shockCollar) {
return shockCollar.notifyStruggle(player, collar);
}
// V2 shock collarnotify via IHasResistance
if (collar.getItem() instanceof IHasResistance resistance) {
resistance.notifyStruggle(player);
}
}
return true;
}
@@ -167,31 +162,19 @@ public class StruggleCollar extends StruggleState {
@Override
protected void successAction(PlayerBindState state) {
Player player = state.getPlayer();
ItemStack collar = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.NECK
);
ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
TiedUpMod.LOGGER.warn(
"[StruggleCollar] successAction called but no collar equipped"
);
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) {
TiedUpMod.LOGGER.warn("[StruggleCollar] successAction called but no collar equipped");
return;
}
// Unlock the collar
collarItem.setLocked(collar, false);
// Unlock the collar (works for V1 and V2 via ILockable)
if (collar.getItem() instanceof ILockable lockable) {
lockable.setLocked(collar, false);
}
TiedUpMod.LOGGER.info(
"[StruggleCollar] {} unlocked their collar!",
player.getName().getString()
);
// Note: Collar is NOT removed, just unlocked
// Player can now manually remove it
TiedUpMod.LOGGER.info("[StruggleCollar] {} unlocked their collar!", player.getName().getString());
}
@Override
@@ -230,30 +213,36 @@ public class StruggleCollar extends StruggleState {
return;
}
ItemStack collar = V2EquipmentHelper.getInRegion(
target,
BodyRegionV2.NECK
);
ItemStack collar = V2EquipmentHelper.getInRegion(target, BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] No collar to tighten");
return;
}
// Check if collar is locked
if (!collarItem.isLocked(collar)) {
TiedUpMod.LOGGER.debug(
"[StruggleCollar] Collar must be locked to tighten"
);
// Check if collar is locked (V1 and V2 via ILockable)
if (collar.getItem() instanceof ILockable lockable) {
if (!lockable.isLocked(collar)) {
TiedUpMod.LOGGER.debug("[StruggleCollar] Collar must be locked to tighten");
return;
}
} else {
return;
}
// Get base resistance from GameRules
int baseResistance = collarItem.getBaseResistance(target);
int currentResistance = collarItem.getCurrentResistance(collar, target);
// Get resistance — V2: read ResistanceComponent directly (avoids MAX-scan bug),
// V1: use IHasResistance.getBaseResistance()
if (!(collar.getItem() instanceof IHasResistance resistanceItem)) return;
int baseResistance;
ResistanceComponent comp = DataDrivenBondageItem.getComponent(
collar, ComponentType.RESISTANCE, ResistanceComponent.class
);
if (comp != null) {
baseResistance = comp.getBaseResistance();
} else {
baseResistance = resistanceItem.getBaseResistance(target);
}
int currentResistance = resistanceItem.getCurrentResistance(collar, target);
// Only tighten if current resistance is lower than base
if (currentResistance >= baseResistance) {
@@ -264,7 +253,7 @@ public class StruggleCollar extends StruggleState {
}
// Restore to base resistance
collarItem.setCurrentResistance(collar, baseResistance);
resistanceItem.setCurrentResistance(collar, baseResistance);
TiedUpMod.LOGGER.info(
"[StruggleCollar] {} tightened {}'s collar (resistance {} -> {})",

View File

@@ -0,0 +1,95 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
/**
* Static utilities for bind mode operations on any bondage item stack (V1 or V2).
*
* <p>Bind mode determines whether a bind restrains arms, legs, or both.
* The mode is stored in the stack's NBT tag {@code "bindMode"}.</p>
*/
public final class BindModeHelper {
private BindModeHelper() {}
private static final String NBT_BIND_MODE = "bindMode";
public static final String MODE_FULL = "full";
public static final String MODE_ARMS = "arms";
public static final String MODE_LEGS = "legs";
private static final String[] MODE_CYCLE = { MODE_FULL, MODE_ARMS, MODE_LEGS };
private static final Map<String, String> MODE_TRANSLATION_KEYS = Map.of(
MODE_FULL, "tiedup.bindmode.full",
MODE_ARMS, "tiedup.bindmode.arms",
MODE_LEGS, "tiedup.bindmode.legs"
);
/**
* Check if the given stack is a bind item (V2 ARMS-region item or V1 ItemBind).
*/
public static boolean isBindItem(ItemStack stack) {
if (stack.isEmpty()) return false;
// V2: check data-driven definition
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
return def.occupiedRegions().contains(BodyRegionV2.ARMS);
}
// V1 fallback
return stack.getItem() instanceof ItemBind;
}
/**
* Get the bind mode ID from the stack's NBT.
* @return "full", "arms", or "legs" (defaults to "full")
*/
public static String getBindModeId(ItemStack stack) {
if (stack.isEmpty()) return MODE_FULL;
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains(NBT_BIND_MODE)) return MODE_FULL;
String value = tag.getString(NBT_BIND_MODE);
if (MODE_ARMS.equals(value) || MODE_LEGS.equals(value)) return value;
return MODE_FULL;
}
/** True if arms are restrained (mode is "arms" or "full"). */
public static boolean hasArmsBound(ItemStack stack) {
String mode = getBindModeId(stack);
return MODE_ARMS.equals(mode) || MODE_FULL.equals(mode);
}
/** True if legs are restrained (mode is "legs" or "full"). */
public static boolean hasLegsBound(ItemStack stack) {
String mode = getBindModeId(stack);
return MODE_LEGS.equals(mode) || MODE_FULL.equals(mode);
}
/**
* Cycle bind mode: full → arms → legs → full.
* @return the new mode ID
*/
public static String cycleBindModeId(ItemStack stack) {
String current = getBindModeId(stack);
String next = MODE_FULL;
for (int i = 0; i < MODE_CYCLE.length; i++) {
if (MODE_CYCLE[i].equals(current)) {
next = MODE_CYCLE[(i + 1) % MODE_CYCLE.length];
break;
}
}
stack.getOrCreateTag().putString(NBT_BIND_MODE, next);
return next;
}
/** Get the translation key for the current bind mode. */
public static String getBindModeTranslationKey(ItemStack stack) {
return MODE_TRANSLATION_KEYS.getOrDefault(getBindModeId(stack), "tiedup.bindmode.full");
}
}

View File

@@ -0,0 +1,371 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.items.ItemChokeCollar;
import com.tiedup.remake.items.ItemGpsCollar;
import com.tiedup.remake.items.ItemShockCollar;
import com.tiedup.remake.items.ItemShockCollarAuto;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.v2.bondage.component.ChokingComponent;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.GpsComponent;
import com.tiedup.remake.v2.bondage.component.OwnershipComponent;
import com.tiedup.remake.v2.bondage.component.ShockComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Static utility for collar operations bridging V1 (ItemCollar subclasses)
* and V2 (data-driven items with OwnershipComponent).
*/
public final class CollarHelper {
private CollarHelper() {}
// ===== DETECTION =====
// True if the stack is any kind of collar (V2 ownership component or V1 ItemCollar)
public static boolean isCollar(ItemStack stack) {
if (stack.isEmpty()) return false;
if (DataDrivenBondageItem.getComponent(stack, ComponentType.OWNERSHIP, OwnershipComponent.class) != null) {
return true;
}
return stack.getItem() instanceof ItemCollar;
}
// ===== OWNERSHIP (NBT: "owners") =====
// Returns all owner UUIDs stored in the collar's "owners" ListTag
public static List<UUID> getOwners(ItemStack stack) {
return getListUUIDs(stack, "owners");
}
// True if the given UUID is in the owners list
public static boolean isOwner(ItemStack stack, UUID uuid) {
return hasUUIDInList(stack, "owners", uuid);
}
// True if the given player is an owner
public static boolean isOwner(ItemStack stack, Player player) {
return isOwner(stack, player.getUUID());
}
// True if the collar has at least one owner
public static boolean hasOwner(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("owners", Tag.TAG_LIST)) return false;
return !tag.getList("owners", Tag.TAG_COMPOUND).isEmpty();
}
// Adds an owner entry {uuid, name} to the "owners" ListTag
public static void addOwner(ItemStack stack, UUID uuid, String name) {
addToList(stack, "owners", uuid, name);
}
// Convenience: add a player as owner
public static void addOwner(ItemStack stack, Player player) {
addOwner(stack, player.getUUID(), player.getGameProfile().getName());
}
// Removes an owner by UUID
public static void removeOwner(ItemStack stack, UUID uuid) {
removeFromList(stack, "owners", uuid);
}
// ===== BLACKLIST (NBT: "blacklist") =====
public static List<UUID> getBlacklist(ItemStack stack) {
return getListUUIDs(stack, "blacklist");
}
public static boolean isBlacklisted(ItemStack stack, UUID uuid) {
return hasUUIDInList(stack, "blacklist", uuid);
}
public static void addToBlacklist(ItemStack stack, UUID uuid, String name) {
addToList(stack, "blacklist", uuid, name);
}
public static void removeFromBlacklist(ItemStack stack, UUID uuid) {
removeFromList(stack, "blacklist", uuid);
}
// ===== WHITELIST (NBT: "whitelist") =====
public static List<UUID> getWhitelist(ItemStack stack) {
return getListUUIDs(stack, "whitelist");
}
public static boolean isWhitelisted(ItemStack stack, UUID uuid) {
return hasUUIDInList(stack, "whitelist", uuid);
}
public static void addToWhitelist(ItemStack stack, UUID uuid, String name) {
addToList(stack, "whitelist", uuid, name);
}
public static void removeFromWhitelist(ItemStack stack, UUID uuid) {
removeFromList(stack, "whitelist", uuid);
}
// ===== LIST INTERNALS =====
private static List<UUID> getListUUIDs(ItemStack stack, String listKey) {
List<UUID> result = new ArrayList<>();
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains(listKey, Tag.TAG_LIST)) return result;
ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND);
for (int i = 0; i < list.size(); i++) {
CompoundTag entry = list.getCompound(i);
if (entry.contains("uuid")) {
try {
result.add(UUID.fromString(entry.getString("uuid")));
} catch (IllegalArgumentException ignored) {
// Malformed UUID in NBT, skip
}
}
}
return result;
}
private static boolean hasUUIDInList(ItemStack stack, String listKey, UUID uuid) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains(listKey, Tag.TAG_LIST)) return false;
String uuidStr = uuid.toString();
ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND);
for (int i = 0; i < list.size(); i++) {
if (uuidStr.equals(list.getCompound(i).getString("uuid"))) {
return true;
}
}
return false;
}
private static void addToList(ItemStack stack, String listKey, UUID uuid, String name) {
CompoundTag tag = stack.getOrCreateTag();
ListTag list = tag.contains(listKey, Tag.TAG_LIST)
? tag.getList(listKey, Tag.TAG_COMPOUND)
: new ListTag();
// Prevent duplicates
String uuidStr = uuid.toString();
for (int i = 0; i < list.size(); i++) {
if (uuidStr.equals(list.getCompound(i).getString("uuid"))) return;
}
CompoundTag entry = new CompoundTag();
entry.putString("uuid", uuidStr);
entry.putString("name", name);
list.add(entry);
tag.put(listKey, list);
}
private static void removeFromList(ItemStack stack, String listKey, UUID uuid) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains(listKey, Tag.TAG_LIST)) return;
String uuidStr = uuid.toString();
ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND);
list.removeIf(element ->
uuidStr.equals(((CompoundTag) element).getString("uuid"))
);
}
// ===== FEATURES =====
@Nullable
public static String getNickname(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("nickname")) return null;
return tag.getString("nickname");
}
public static void setNickname(ItemStack stack, String nickname) {
stack.getOrCreateTag().putString("nickname", nickname);
}
public static boolean hasNickname(ItemStack stack) {
return stack.hasTag() && stack.getTag().contains("nickname");
}
@Nullable
public static UUID getCellId(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("cellId")) return null;
try {
return UUID.fromString(tag.getString("cellId"));
} catch (IllegalArgumentException e) {
return null;
}
}
public static void setCellId(ItemStack stack, UUID cellId) {
stack.getOrCreateTag().putString("cellId", cellId.toString());
}
public static boolean hasCellAssigned(ItemStack stack) {
return getCellId(stack) != null;
}
public static boolean isKidnappingModeEnabled(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("kidnappingMode");
}
public static void setKidnappingModeEnabled(ItemStack stack, boolean enabled) {
stack.getOrCreateTag().putBoolean("kidnappingMode", enabled);
}
// Kidnapping mode is ready when enabled AND a cell is assigned
public static boolean isKidnappingModeReady(ItemStack stack) {
return isKidnappingModeEnabled(stack) && hasCellAssigned(stack);
}
public static boolean shouldTieToPole(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("tieToPole");
}
public static void setShouldTieToPole(ItemStack stack, boolean value) {
stack.getOrCreateTag().putBoolean("tieToPole", value);
}
// Default true when tag is absent or key is missing
public static boolean shouldWarnMasters(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("warnMasters")) return true;
return tag.getBoolean("warnMasters");
}
public static void setShouldWarnMasters(ItemStack stack, boolean value) {
stack.getOrCreateTag().putBoolean("warnMasters", value);
}
public static boolean isBondageServiceEnabled(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("bondageservice");
}
public static void setBondageServiceEnabled(ItemStack stack, boolean enabled) {
stack.getOrCreateTag().putBoolean("bondageservice", enabled);
}
@Nullable
public static String getServiceSentence(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("servicesentence")) return null;
return tag.getString("servicesentence");
}
public static void setServiceSentence(ItemStack stack, String sentence) {
stack.getOrCreateTag().putString("servicesentence", sentence);
}
// ===== SHOCK =====
// True if the collar can shock (V2 ShockComponent or V1 ItemShockCollar)
public static boolean canShock(ItemStack stack) {
if (stack.isEmpty()) return false;
if (DataDrivenBondageItem.getComponent(stack, ComponentType.SHOCK, ShockComponent.class) != null) {
return true;
}
return stack.getItem() instanceof ItemShockCollar;
}
public static boolean isPublicShock(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("public_mode");
}
public static void setPublicShock(ItemStack stack, boolean publicMode) {
stack.getOrCreateTag().putBoolean("public_mode", publicMode);
}
// V2: from ShockComponent auto interval, V1: from ItemShockCollarAuto field, else 0
public static int getShockInterval(ItemStack stack) {
ShockComponent comp = DataDrivenBondageItem.getComponent(
stack, ComponentType.SHOCK, ShockComponent.class
);
if (comp != null) return comp.getAutoInterval();
if (stack.getItem() instanceof ItemShockCollarAuto auto) {
return auto.getInterval();
}
return 0;
}
// ===== GPS =====
// True if the collar has GPS capabilities
public static boolean hasGPS(ItemStack stack) {
if (stack.isEmpty()) return false;
if (DataDrivenBondageItem.getComponent(stack, ComponentType.GPS, GpsComponent.class) != null) {
return true;
}
return stack.getItem() instanceof ItemGpsCollar;
}
public static boolean hasPublicTracking(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("publicTracking");
}
public static void setPublicTracking(ItemStack stack, boolean publicTracking) {
stack.getOrCreateTag().putBoolean("publicTracking", publicTracking);
}
// GPS active defaults to true when absent
public static boolean isActive(ItemStack stack) {
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains("gpsActive")) return true;
return tag.getBoolean("gpsActive");
}
public static void setActive(ItemStack stack, boolean active) {
stack.getOrCreateTag().putBoolean("gpsActive", active);
}
// ===== CHOKE =====
// True if the collar is a choke collar
public static boolean isChokeCollar(ItemStack stack) {
if (stack.isEmpty()) return false;
if (DataDrivenBondageItem.getComponent(stack, ComponentType.CHOKING, ChokingComponent.class) != null) {
return true;
}
return stack.getItem() instanceof ItemChokeCollar;
}
public static boolean isChoking(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("choking");
}
public static void setChoking(ItemStack stack, boolean choking) {
stack.getOrCreateTag().putBoolean("choking", choking);
}
public static boolean isPetPlayMode(ItemStack stack) {
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean("petPlayMode");
}
public static void setPetPlayMode(ItemStack stack, boolean petPlay) {
stack.getOrCreateTag().putBoolean("petPlayMode", petPlay);
}
// ===== ALERT SUPPRESSION =====
// Executes the action with collar removal alerts suppressed
public static void runWithSuppressedAlert(Runnable action) {
ItemCollar.runWithSuppressedAlert(action);
}
// True if removal alerts are currently suppressed (ThreadLocal state)
public static boolean isRemovalAlertSuppressed() {
return ItemCollar.isRemovalAlertSuppressed();
}
}

View File

@@ -0,0 +1,35 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import net.minecraft.world.item.ItemStack;
/**
* Resolves the {@link PoseType} for any bondage item stack (V1 or V2).
*
* <p>V2 items read from the data-driven definition's {@code pose_type} field.
* V1 items fall back to {@code ItemBind.getPoseType()}.</p>
*/
public final class PoseTypeHelper {
private PoseTypeHelper() {}
public static PoseType getPoseType(ItemStack stack) {
// V2: read from data-driven definition
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.poseType() != null) {
try {
return PoseType.valueOf(def.poseType().toUpperCase());
} catch (IllegalArgumentException e) {
return PoseType.STANDARD;
}
}
// V1 fallback
if (stack.getItem() instanceof ItemBind bind) {
return bind.getPoseType();
}
return PoseType.STANDARD;
}
}

View File

@@ -0,0 +1,123 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.tasks.TyingTask;
import com.tiedup.remake.tasks.V2TyingPlayerTask;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.V2EquipResult;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
/**
* Handles the tying interaction flow for V2 data-driven ARMS items.
*
* <p>Extracted from {@code ItemBind.interactLivingEntity()} to support
* the same tying task flow for data-driven items.</p>
*/
public final class TyingInteractionHelper {
private TyingInteractionHelper() {}
/**
* Handle right-click tying of a target entity with a V2 ARMS item.
* Creates/continues a V2TyingPlayerTask.
*/
public static InteractionResult handleTying(
ServerPlayer player,
LivingEntity target,
ItemStack stack,
InteractionHand hand
) {
IBondageState targetState = KidnappedHelper.getKidnappedState(target);
if (targetState == null) return InteractionResult.PASS;
// Kidnapper can't be tied themselves
IBondageState kidnapperState = KidnappedHelper.getKidnappedState(player);
if (kidnapperState != null && kidnapperState.isTiedUp()) {
return InteractionResult.PASS;
}
// Already tied — try to swap (check both V1 state and V2 region to prevent dual-bind)
if (targetState.isTiedUp() || V2EquipmentHelper.isRegionOccupied(target, BodyRegionV2.ARMS)) {
if (stack.isEmpty()) return InteractionResult.PASS;
ItemStack oldBind = V2EquipmentHelper.unequipFromRegion(target, BodyRegionV2.ARMS);
if (!oldBind.isEmpty()) {
V2EquipResult result = V2EquipmentHelper.equipItem(target, stack.copy());
if (result.isSuccess()) {
stack.shrink(1);
target.spawnAtLocation(oldBind);
TiedUpMod.LOGGER.debug("[TyingInteraction] Swapped bind on {}", target.getName().getString());
return InteractionResult.SUCCESS;
} else {
// Equip failed — rollback: re-equip old bind
V2EquipResult rollback = V2EquipmentHelper.equipItem(target, oldBind);
if (!rollback.isSuccess()) {
// Rollback also failed — drop old bind as safety net
target.spawnAtLocation(oldBind);
TiedUpMod.LOGGER.warn("[TyingInteraction] Swap AND rollback failed, dropped old bind for {}", target.getName().getString());
} else {
TiedUpMod.LOGGER.debug("[TyingInteraction] Swap failed, rolled back old bind on {}", target.getName().getString());
}
return InteractionResult.PASS;
}
}
return InteractionResult.PASS;
}
// Distance + line-of-sight (skip for self-tying)
boolean isSelfTying = player.equals(target);
if (!isSelfTying) {
if (player.distanceTo(target) > 4.0 || !player.hasLineOfSight(target)) {
return InteractionResult.PASS;
}
}
// Create/continue tying task
PlayerBindState playerState = PlayerBindState.getInstance(player);
if (playerState == null) return InteractionResult.PASS;
int tyingSeconds = SettingsAccessor.getTyingPlayerTime(player.level().getGameRules());
V2TyingPlayerTask newTask = new V2TyingPlayerTask(
stack.copy(),
stack,
targetState,
target,
tyingSeconds,
player.level(),
player
);
TyingTask currentTask = playerState.getCurrentTyingTask();
if (currentTask == null
|| !(currentTask instanceof V2TyingPlayerTask)
|| !currentTask.isSameTarget(target)
|| currentTask.isOutdated()
|| !ItemStack.matches(currentTask.getBind(), stack)) {
// Start new task (also handles case where existing task is V1 TyingPlayerTask)
playerState.setCurrentTyingTask(newTask);
newTask.start();
} else {
newTask = (V2TyingPlayerTask) currentTask;
}
newTask.update();
if (newTask.isStopped()) {
// Item already consumed by V2TyingPlayerTask.onComplete() — don't shrink again
playerState.setCurrentTyingTask(null);
TiedUpMod.LOGGER.info("[TyingInteraction] {} tied {}", player.getName().getString(), target.getName().getString());
}
return InteractionResult.SUCCESS;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,58 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* 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;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
tooltip.add(Component.translatable("item.tiedup.tooltip.choking").withStyle(ChatFormatting.DARK_PURPLE));
}
}

View File

@@ -0,0 +1,74 @@
package com.tiedup.remake.v2.bondage.component;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
public final class ComponentHolder {
public static final ComponentHolder EMPTY = new ComponentHolder(Map.of());
private final Map<ComponentType, IItemComponent> components;
public ComponentHolder(Map<ComponentType, IItemComponent> 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 extends IItemComponent> T get(
ComponentType type,
Class<T> 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 void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
for (IItemComponent c : components.values()) {
c.appendTooltip(stack, level, tooltip, flag);
}
}
public boolean isEmpty() {
return components.isEmpty();
}
}

View File

@@ -0,0 +1,54 @@
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),
OWNERSHIP("ownership", OwnershipComponent::fromJson);
private final String jsonKey;
private final Function<JsonObject, IItemComponent> factory;
/** Pre-built lookup map for O(1) fromKey() instead of linear scan. */
private static final Map<String, ComponentType> BY_KEY;
static {
Map<String, ComponentType> map = new HashMap<>();
for (ComponentType type : values()) {
map.put(type.jsonKey, type);
}
BY_KEY = Collections.unmodifiableMap(map);
}
ComponentType(
String jsonKey,
Function<JsonObject, IItemComponent> 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);
}
}

View File

@@ -0,0 +1,100 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.util.GagMaterial;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Component: gagging behavior for data-driven items.
*
* <p>Config-driven: {@code "gagging": {"material": "ball"}} delegates to
* {@link GagMaterial} for comprehension/range from ModConfig at runtime.</p>
*
* <p>Override: {@code "gagging": {"comprehension": 0.15, "range": 8.0}} uses
* explicit values that take priority over the material lookup.</p>
*/
public class GaggingComponent implements IItemComponent {
private final @Nullable String material;
private final @Nullable GagMaterial cachedMaterial;
private final double comprehensionOverride;
private final double rangeOverride;
private GaggingComponent(@Nullable String material, @Nullable GagMaterial cachedMaterial,
double comprehensionOverride, double rangeOverride) {
this.material = material;
this.cachedMaterial = cachedMaterial;
this.comprehensionOverride = comprehensionOverride;
this.rangeOverride = rangeOverride;
}
public static IItemComponent fromJson(JsonObject config) {
String material = null;
double comprehension = -1;
double range = -1;
if (config != null) {
if (config.has("material")) {
material = config.get("material").getAsString();
}
if (config.has("comprehension")) {
comprehension = Math.max(0.0, Math.min(1.0, config.get("comprehension").getAsDouble()));
}
if (config.has("range")) {
range = Math.max(0.0, config.get("range").getAsDouble());
}
}
// Resolve and cache GagMaterial at load time to avoid valueOf() on every chat message
GagMaterial resolved = null;
if (material != null) {
try {
resolved = GagMaterial.valueOf(material.toUpperCase());
} catch (IllegalArgumentException e) {
TiedUpMod.LOGGER.warn("[GaggingComponent] Unknown gag material '{}' — using defaults", material);
}
}
return new GaggingComponent(material, resolved, comprehension, range);
}
/** How much of the gagged speech is comprehensible (0.0 = nothing, 1.0 = full). */
public double getComprehension() {
if (comprehensionOverride >= 0) return comprehensionOverride;
GagMaterial gag = getMaterial();
if (gag != null) return gag.getComprehension();
return 0.2;
}
/** Maximum range in blocks where muffled speech can be heard. */
public double getRange() {
if (rangeOverride >= 0) return rangeOverride;
GagMaterial gag = getMaterial();
if (gag != null) return gag.getTalkRange();
return 10.0;
}
/** The gag material enum, or null if not configured or invalid. Cached at load time. */
public @Nullable GagMaterial getMaterial() {
return cachedMaterial;
}
/** The raw material string from JSON, or null. */
public @Nullable String getMaterialName() {
return material;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
if (material != null) {
tooltip.add(Component.translatable("item.tiedup.tooltip.gag_material", material)
.withStyle(ChatFormatting.RED));
} else {
tooltip.add(Component.translatable("item.tiedup.tooltip.gagging").withStyle(ChatFormatting.RED));
}
}
}

View File

@@ -0,0 +1,62 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* 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;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
tooltip.add(Component.translatable("item.tiedup.tooltip.gps_tracking")
.withStyle(ChatFormatting.AQUA));
if (safeZoneRadius > 0) {
tooltip.add(Component.translatable("item.tiedup.tooltip.gps_zone_radius", safeZoneRadius)
.withStyle(ChatFormatting.DARK_AQUA));
}
}
}

View File

@@ -0,0 +1,26 @@
package com.tiedup.remake.v2.bondage.component;
import java.util.List;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* 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;
}
default void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {}
}

View File

@@ -0,0 +1,60 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Component: lockable behavior for data-driven items.
*
* <p>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.</p>
*
* <p>Consumers retrieve the per-item lock resistance via
* {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem#getItemLockResistance(net.minecraft.world.item.ItemStack)}.</p>
*
* 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;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
tooltip.add(Component.translatable("item.tiedup.tooltip.lockable").withStyle(ChatFormatting.GOLD));
if (flag.isAdvanced()) {
tooltip.add(Component.translatable("item.tiedup.tooltip.lock_resistance", lockResistance)
.withStyle(ChatFormatting.DARK_GRAY));
}
}
}

View File

@@ -0,0 +1,122 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.CollarRegistry;
import com.tiedup.remake.v2.bondage.CollarHelper;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Component: collar ownership behavior for data-driven items.
*
* <p>Marks an item as a collar with ownership capabilities.
* Lifecycle hooks handle CollarRegistry registration/unregistration.</p>
*
* <p>JSON config: {@code "ownership": {}}</p>
*/
public class OwnershipComponent implements IItemComponent {
private OwnershipComponent() {}
public static IItemComponent fromJson(JsonObject config) {
return new OwnershipComponent();
}
@Override
public void onEquipped(ItemStack stack, LivingEntity entity) {
if (entity.level().isClientSide()) return;
if (!(entity.level() instanceof ServerLevel serverLevel)) return;
List<UUID> owners = CollarHelper.getOwners(stack);
if (owners.isEmpty()) {
TiedUpMod.LOGGER.debug(
"[OwnershipComponent] Collar equipped on {} with no owners in NBT — skipping registry",
entity.getName().getString()
);
return;
}
try {
CollarRegistry registry = CollarRegistry.get(serverLevel);
registry.registerCollar(entity.getUUID(), new HashSet<>(owners));
TiedUpMod.LOGGER.debug(
"[OwnershipComponent] Registered collar for {} with {} owner(s)",
entity.getName().getString(), owners.size()
);
} catch (Exception e) {
TiedUpMod.LOGGER.warn(
"[OwnershipComponent] Failed to register collar for {}: {}",
entity.getName().getString(), e.getMessage()
);
}
}
@Override
public void onUnequipped(ItemStack stack, LivingEntity entity) {
if (entity.level().isClientSide()) return;
if (!(entity.level() instanceof ServerLevel serverLevel)) return;
// Alert kidnappers + unregister from CollarRegistry
// onCollarRemoved handles both the alert AND the unregister call internally,
// so we do NOT call registry.unregisterWearer() separately to avoid double unregister.
if (!CollarHelper.isRemovalAlertSuppressed()) {
ItemCollar.onCollarRemoved(entity, true);
} else {
// Suppressed alert path: still need to unregister, just skip the alert
try {
CollarRegistry registry = CollarRegistry.get(serverLevel);
registry.unregisterWearer(entity.getUUID());
TiedUpMod.LOGGER.debug(
"[OwnershipComponent] Unregistered collar for {} (alert suppressed)",
entity.getName().getString()
);
} catch (Exception e) {
TiedUpMod.LOGGER.warn(
"[OwnershipComponent] Failed to unregister collar for {}: {}",
entity.getName().getString(), e.getMessage()
);
}
}
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
String nickname = CollarHelper.getNickname(stack);
if (nickname != null && !nickname.isEmpty()) {
tooltip.add(Component.translatable("item.tiedup.tooltip.nickname", nickname)
.withStyle(ChatFormatting.LIGHT_PURPLE));
}
List<UUID> owners = CollarHelper.getOwners(stack);
if (!owners.isEmpty()) {
tooltip.add(Component.translatable("item.tiedup.tooltip.owners", owners.size())
.withStyle(ChatFormatting.GOLD));
}
if (CollarHelper.canShock(stack)) {
tooltip.add(Component.translatable("item.tiedup.tooltip.shock_capable")
.withStyle(ChatFormatting.DARK_RED));
}
if (CollarHelper.hasGPS(stack)) {
tooltip.add(Component.translatable("item.tiedup.tooltip.gps_capable")
.withStyle(ChatFormatting.AQUA));
}
if (CollarHelper.isChokeCollar(stack)) {
tooltip.add(Component.translatable("item.tiedup.tooltip.choke_capable")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
}

View File

@@ -0,0 +1,70 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import com.tiedup.remake.core.SettingsAccessor;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Component: struggle resistance for data-driven items.
*
* <p>Config-driven: {@code "resistance": {"id": "rope"}} delegates to
* {@link SettingsAccessor#getBindResistance(String)} at runtime.</p>
*
* <p>Legacy/override: {@code "resistance": {"base": 150}} uses a hardcoded value.</p>
*/
public class ResistanceComponent implements IItemComponent {
private final @Nullable String resistanceId;
private final int fallbackBase;
private ResistanceComponent(@Nullable String resistanceId, int fallbackBase) {
this.resistanceId = resistanceId;
this.fallbackBase = fallbackBase;
}
public static IItemComponent fromJson(JsonObject config) {
String id = null;
int base = 100;
if (config != null) {
if (config.has("id")) {
id = config.get("id").getAsString();
}
if (config.has("base")) {
base = config.get("base").getAsInt();
}
}
base = Math.max(0, base);
return new ResistanceComponent(id, base);
}
/**
* Get the base resistance for this item.
* If a {@code resistanceId} is configured, delegates to server config at runtime.
* Otherwise returns the hardcoded fallback value.
*/
public int getBaseResistance() {
if (resistanceId != null) {
return SettingsAccessor.getBindResistance(resistanceId);
}
return fallbackBase;
}
/** The config key used for runtime resistance lookup, or null if hardcoded. */
public @Nullable String getResistanceId() {
return resistanceId;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
if (flag.isAdvanced()) {
tooltip.add(Component.translatable("item.tiedup.tooltip.resistance", getBaseResistance())
.withStyle(ChatFormatting.DARK_GRAY));
}
}
}

View File

@@ -0,0 +1,71 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* 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;
}
@Override
public void appendTooltip(ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag) {
if (hasAutoShock()) {
float seconds = autoInterval / 20.0f;
tooltip.add(Component.translatable("item.tiedup.tooltip.shock_auto", String.format("%.1f", seconds))
.withStyle(ChatFormatting.DARK_RED));
} else {
tooltip.add(Component.translatable("item.tiedup.tooltip.shock_manual")
.withStyle(ChatFormatting.DARK_RED));
}
}
}

View File

@@ -1,16 +1,35 @@
package com.tiedup.remake.v2.bondage.datadriven;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.TyingInteractionHelper;
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;
import java.util.Set;
import java.util.stream.Collectors;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
@@ -106,6 +125,60 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem {
return def != null && def.supportsColor();
}
// ===== INTERACTION ROUTING =====
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack stack = player.getItemInHand(hand);
Set<BodyRegionV2> regions = getOccupiedRegions(stack);
// ARMS items: shift+click cycles bind mode
if (regions.contains(BodyRegionV2.ARMS) && player.isShiftKeyDown() && !level.isClientSide) {
BindModeHelper.cycleBindModeId(stack);
player.playSound(SoundEvents.CHAIN_STEP, 0.5f, 1.2f);
player.displayClientMessage(
Component.translatable(
"tiedup.message.bindmode_changed",
Component.translatable(BindModeHelper.getBindModeTranslationKey(stack))
),
true
);
return InteractionResultHolder.success(stack);
}
return super.use(level, player, hand);
}
@Override
public InteractionResult interactLivingEntity(
ItemStack stack, Player player, LivingEntity target, InteractionHand hand
) {
// Client: arm swing
if (player.level().isClientSide) {
return InteractionResult.SUCCESS;
}
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def == null) return InteractionResult.PASS;
Set<BodyRegionV2> regions = def.occupiedRegions();
// ARMS: tying flow (do NOT call super — avoids double equip)
if (regions.contains(BodyRegionV2.ARMS) && player instanceof ServerPlayer serverPlayer) {
return TyingInteractionHelper.handleTying(serverPlayer, target, stack, hand);
}
// NECK: blocked until Branch C wires the full collar equip flow
// (add owner to NBT, register in CollarRegistry, play sound, sync).
// Without this, V2 collars equip without ownership — breaking GPS, shock, alerts.
if (regions.contains(BodyRegionV2.NECK)) {
TiedUpMod.LOGGER.debug("[DataDrivenBondageItem] NECK equip blocked — collar flow not wired yet");
return InteractionResult.PASS;
}
// All other regions (MOUTH, EYES, EARS, HANDS): instant equip via parent
return super.interactLivingEntity(stack, player, target, hand);
}
// ===== IHasResistance IMPLEMENTATION =====
@Override
@@ -142,6 +215,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) {
@@ -177,6 +265,75 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem {
);
}
// ===== TOOLTIPS =====
@Override
public void appendHoverText(
ItemStack stack,
@Nullable Level level,
List<Component> tooltip,
TooltipFlag flag
) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
// Creator
if (def.creator() != null && !def.creator().isEmpty()) {
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.creator",
Component.literal(def.creator())
.withStyle(ChatFormatting.WHITE)
).withStyle(ChatFormatting.GRAY)
);
}
// Regions
String regions = def.occupiedRegions().stream()
.map(r -> r.name().toLowerCase())
.collect(Collectors.joining(", "));
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.regions",
regions
).withStyle(ChatFormatting.DARK_AQUA)
);
// Movement style
if (def.movementStyle() != null) {
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.movement_style",
def.movementStyle().name().toLowerCase()
).withStyle(ChatFormatting.DARK_PURPLE)
);
}
}
// Component tooltips
ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
if (holder != null) {
holder.appendTooltip(stack, level, tooltip, flag);
}
// Lock status + escape difficulty (from AbstractV2BondageItem)
super.appendHoverText(stack, level, tooltip, flag);
if (def != null && flag.isAdvanced()) {
// Advanced info (F3+H): pose priority + item ID
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.pose_priority",
def.posePriority()
).withStyle(ChatFormatting.DARK_GRAY)
);
tooltip.add(
Component.literal(
def.id().toString()
).withStyle(ChatFormatting.DARK_GRAY)
);
}
}
// ===== DISPLAY NAME =====
@Override
@@ -189,6 +346,96 @@ 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);
}
// NPC speed reduction (players use MovementStyleManager, not this legacy path)
if (!(entity instanceof Player)) {
Set<BodyRegionV2> regions = getOccupiedRegions(stack);
if (regions.contains(BodyRegionV2.ARMS) && BindModeHelper.hasLegsBound(stack)) {
com.tiedup.remake.items.base.PoseType pose = com.tiedup.remake.v2.bondage.PoseTypeHelper.getPoseType(stack);
boolean fullImmobilization = pose == com.tiedup.remake.items.base.PoseType.WRAP
|| pose == com.tiedup.remake.items.base.PoseType.LATEX_SACK;
com.tiedup.remake.util.RestraintEffectUtils.applyBindSpeedReduction(entity, fullImmobilization);
}
}
}
@Override
public void onUnequipped(ItemStack stack, LivingEntity entity) {
ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack);
if (holder != null) {
holder.onUnequipped(stack, entity);
}
// NPC speed cleanup
if (!(entity instanceof Player)) {
Set<BodyRegionV2> regions = getOccupiedRegions(stack);
if (regions.contains(BodyRegionV2.ARMS)) {
com.tiedup.remake.util.RestraintEffectUtils.removeBindSpeedReduction(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 <T> the component type
* @return the component, or null if the item is not data-driven or lacks this component
*/
@Nullable
public static <T extends IItemComponent> T getComponent(
ItemStack stack,
ComponentType type,
Class<T> 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.
*
* <p>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}.</p>
*
* @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 =====
/**

View File

@@ -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;
@@ -44,6 +46,9 @@ public record DataDrivenItemDefinition(
/** Body regions this item blocks. Defaults to occupiedRegions if not specified. */
Set<BodyRegionV2> blockedRegions,
/** Optional pose type identifier (e.g., "STANDARD", "STRAITJACKET", "DOG"). */
@Nullable String poseType,
/** Pose priority for conflict resolution. Higher wins. */
int posePriority,
@@ -81,6 +86,9 @@ public record DataDrivenItemDefinition(
@Nullable
com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier,
/** Optional creator/author name displayed in the item tooltip. */
@Nullable String creator,
/**
* Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle")
* to the set of PlayerAnimator bone names this item is allowed to animate.
@@ -94,5 +102,19 @@ public record DataDrivenItemDefinition(
*
* <p>This field is required in the JSON definition. Never null, never empty.</p>
*/
Map<String, Set<String>> animationBones
) {}
Map<String, Set<String>> animationBones,
/** Raw component configs from JSON, keyed by ComponentType. */
Map<ComponentType, JsonObject> 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);
}
}

View File

@@ -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;
@@ -186,6 +188,9 @@ public final class DataDrivenItemParser {
blockedRegions = occupiedRegions;
}
// Optional: pose_type (e.g., "STANDARD", "STRAITJACKET", "DOG")
String poseType = getStringOrNull(root, "pose_type");
// Optional: pose_priority (default 0)
int posePriority = getIntOrDefault(root, "pose_priority", 0);
@@ -250,6 +255,9 @@ public final class DataDrivenItemParser {
);
}
// Optional: creator (author name for tooltip)
String creator = getStringOrNull(root, "creator");
// Required: animation_bones (per-animation bone whitelist)
Map<String, Set<String>> animationBones = parseAnimationBones(
root,
@@ -263,6 +271,39 @@ public final class DataDrivenItemParser {
return null;
}
// Optional: components (per-component JSON configs)
Map<ComponentType, JsonObject> componentConfigs =
new EnumMap<>(ComponentType.class);
if (root.has("components")) {
JsonObject componentsObj = root.getAsJsonObject("components");
for (Map.Entry<String, JsonElement> 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"
@@ -290,6 +331,7 @@ public final class DataDrivenItemParser {
animationSource,
occupiedRegions,
blockedRegions,
poseType,
posePriority,
escapeDifficulty,
lockable,
@@ -298,7 +340,9 @@ public final class DataDrivenItemParser {
icon,
movementStyle,
movementModifier,
animationBones
creator,
animationBones,
componentConfigs
);
}

View File

@@ -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.
*
* <p>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.</p>
* 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.</p>
*
* <p>Lookup methods accept either a {@link ResourceLocation} ID directly
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).</p>
@@ -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<ResourceLocation, DataDrivenItemDefinition> definitions,
Map<ResourceLocation, ComponentHolder> 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<ResourceLocation, DataDrivenItemDefinition> newDefs
) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
synchronized (RELOAD_LOCK) {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(new HashMap<>(newDefs));
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
}
}
/**
@@ -54,18 +75,23 @@ public final class DataDrivenItemRegistry {
* <p>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.</p>
* from the existing snapshot + the new entries, then swaps atomically as a
* single snapshot to prevent torn reads.</p>
*
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
*/
public static void mergeAll(
Map<ResourceLocation, DataDrivenItemDefinition> newDefs
) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(
DEFINITIONS
);
merged.putAll(newDefs);
DEFINITIONS = Collections.unmodifiableMap(merged);
synchronized (RELOAD_LOCK) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(
SNAPSHOT.definitions
);
merged.putAll(newDefs);
Map<ResourceLocation, DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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.
*
* <p>Captures the snapshot reference once to ensure consistent reads
* between the definition lookup and the holder lookup.</p>
*
* @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<ResourceLocation, ComponentHolder> buildComponentHolders(
Map<ResourceLocation, DataDrivenItemDefinition> definitions
) {
Map<ResourceLocation, ComponentHolder> holders = new HashMap<>();
for (Map.Entry<ResourceLocation, DataDrivenItemDefinition> entry :
definitions.entrySet()) {
DataDrivenItemDefinition def = entry.getValue();
Map<ComponentType, IItemComponent> components =
new EnumMap<>(ComponentType.class);
for (Map.Entry<ComponentType, JsonObject> 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);
}
}

View File

@@ -229,6 +229,10 @@
"item.tiedup.tooltip.lockable": "Lockable (has padlock)",
"item.tiedup.tooltip.jammed": "Jammed (lockpick blocked)",
"item.tiedup.tooltip.escape_difficulty": "Escape Difficulty: %s",
"item.tiedup.tooltip.creator": "By %s",
"item.tiedup.tooltip.regions": "Regions: %s",
"item.tiedup.tooltip.movement_style": "Movement: %s",
"item.tiedup.tooltip.pose_priority": "Pose Priority: %s",
"item.tiedup.v2_handcuffs": "Handcuffs",

View File

@@ -1,6 +1,7 @@
{
"type": "tiedup:bondage_item",
"display_name": "Data-Driven Handcuffs",
"creator": "TiedUp! Team",
"model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb",
"regions": ["ARMS"],
"pose_priority": 30,

View File

@@ -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
}
}
}