diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/AnimationAction.java b/src/main/java/com/tiedup/remake/rig/anim/action/AnimationAction.java
new file mode 100644
index 0000000..29f3e8b
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/AnimationAction.java
@@ -0,0 +1,78 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action;
+
+import com.mojang.serialization.Codec;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Phase 3 D2 — biggest artist unlock : an {@code AnimationAction} is a
+ * data-driven unit of behaviour triggered at animation begin / end / a specific
+ * frame / a period, authored from a datapack JSON block without any Java code.
+ *
+ *
Serialization goes through {@link AnimationActionRegistry#dispatchCodec()}
+ * which reads a {@code "type"} field and dispatches to the codec registered
+ * for that {@link ResourceLocation}. Example JSON :
+ *
{@code
+ * { "type": "tiedup:play_sound",
+ * "sound": "minecraft:entity.player.levelup",
+ * "volume": 0.8 }
+ * }
+ *
+ * Each implementation is responsible for its own sidedness — actions that
+ * mutate world state should early-return on the wrong side, actions that spawn
+ * client particles should early-return on server. This is by design : the outer
+ * {@link com.tiedup.remake.rig.anim.property.AnimationEvent.Side} filter still
+ * applies but individual actions can tighten it further when the outer event
+ * is configured as {@code BOTH}.
+ *
+ *
The {@code prevElapsed} / {@code elapsed} arguments are forwarded verbatim
+ * from the outer {@link com.tiedup.remake.rig.anim.property.AnimationEvent#execute}
+ * call so that timing-aware actions (future extension) can key off the exact
+ * trigger frame. Today's core actions ignore them — they execute atomically
+ * on trigger.
+ */
+public interface AnimationAction {
+
+ /**
+ * Dispatch codec — reads the {@code "type"} field of the JSON object and
+ * delegates to the codec of the matching registered action. Unknown types
+ * return a {@link com.mojang.serialization.DataResult} error (logged via
+ * {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
+ * and the containing list entry is dropped).
+ *
+ *
Uses {@code partialDispatch} rather than {@code dispatch} because
+ * {@code dispatch} requires a total function {@code ResourceLocation ->
+ * Codec extends AnimationAction>}, which cannot express «unknown
+ * type» without throwing.
+ */
+ Codec CODEC = AnimationActionRegistry.dispatchCodec();
+
+ /**
+ * The registered type id of this action (e.g. {@code tiedup:play_sound}).
+ * Used by the dispatch codec to serialize back to JSON.
+ */
+ ResourceLocation type();
+
+ /**
+ * Execute this action against the entity playing {@code animation}.
+ *
+ * @param patch the living entity patch currently playing the animation
+ * @param animation the animation accessor (forwarded from the outer event)
+ * @param prevElapsed previous frame time in the animation (seconds)
+ * @param elapsed current frame time in the animation (seconds)
+ */
+ void execute(
+ LivingEntityPatch> patch,
+ AssetAccessor extends DynamicAnimation> animation,
+ float prevElapsed,
+ float elapsed
+ );
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistry.java
new file mode 100644
index 0000000..bcfa4d8
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistry.java
@@ -0,0 +1,100 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.action.impl.ApplyEffectAction;
+import com.tiedup.remake.rig.anim.action.impl.DamageEntityAction;
+import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
+import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
+
+/**
+ * Registry of {@link AnimationAction} type ids → codecs, used by the dispatch
+ * codec exposed via {@link AnimationAction#CODEC}.
+ *
+ * The four core actions ({@code play_sound}, {@code spawn_particle},
+ * {@code apply_effect}, {@code damage_entity}) are registered in the static
+ * initializer of this class so that a single reference to
+ * {@link AnimationAction#CODEC} in a parse path is enough to bootstrap the
+ * full dispatch table.
+ *
+ *
Third-party mods may register additional action types by calling
+ * {@link #register(ResourceLocation, Codec)} from their common setup event
+ * (post-{@code FMLCommonSetup} to avoid class-loading order surprises with
+ * the static init of this class).
+ */
+public final class AnimationActionRegistry {
+
+ private static final Map> TYPES = new HashMap<>();
+
+ private AnimationActionRegistry() {}
+
+ /**
+ * Register a new action type. Throws {@link IllegalStateException} if the
+ * id is already registered — this is intentional : silent shadowing of a
+ * built-in action type would be a nasty debugging experience for artists.
+ *
+ * @throws IllegalStateException if {@code id} is already registered
+ */
+ public static void register(ResourceLocation id, Codec codec) {
+ if (TYPES.containsKey(id)) {
+ throw new IllegalStateException("Animation action type " + id + " is already registered.");
+ }
+
+ TYPES.put(id, codec);
+ }
+
+ /**
+ * Unmodifiable view of all registered action types (for debug / introspection).
+ */
+ public static Map> types() {
+ return Collections.unmodifiableMap(TYPES);
+ }
+
+ /**
+ * Look up the codec for a given type id. Returns {@code null} if the id is
+ * not registered.
+ */
+ public static Codec extends AnimationAction> getCodec(ResourceLocation id) {
+ return TYPES.get(id);
+ }
+
+ /**
+ * Build the dispatch codec used by {@link AnimationAction#CODEC}.
+ *
+ * Uses {@link Codec#partialDispatch} so that unknown types surface as a
+ * {@link DataResult} error rather than a thrown exception. The error is
+ * bubbled up by the standard {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
+ * pipeline (WARN log + {@code orElseThrow}).
+ */
+ public static Codec dispatchCodec() {
+ return ResourceLocation.CODEC.partialDispatch(
+ "type",
+ action -> DataResult.success(action.type()),
+ id -> {
+ Codec extends AnimationAction> codec = TYPES.get(id);
+ if (codec == null) {
+ return DataResult.error(() -> "Unknown animation action type: " + id);
+ }
+ return DataResult.success(codec);
+ }
+ );
+ }
+
+ static {
+ register(PlaySoundAction.ID, PlaySoundAction.CODEC);
+ register(SpawnParticleAction.ID, SpawnParticleAction.CODEC);
+ register(ApplyEffectAction.ID, ApplyEffectAction.CODEC);
+ register(DamageEntityAction.ID, DamageEntityAction.CODEC);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/DataDrivenAnimationEvents.java b/src/main/java/com/tiedup/remake/rig/anim/action/DataDrivenAnimationEvents.java
new file mode 100644
index 0000000..9e8ed03
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/DataDrivenAnimationEvents.java
@@ -0,0 +1,209 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import com.tiedup.remake.rig.anim.property.AnimationEvent;
+import com.tiedup.remake.rig.anim.property.AnimationEvent.E0;
+import com.tiedup.remake.rig.anim.property.AnimationEvent.InPeriodEvent;
+import com.tiedup.remake.rig.anim.property.AnimationEvent.InTimeEvent;
+import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
+
+/**
+ * Phase 3 D2 — adapter layer between JSON-authored event blocks and the
+ * runtime {@link AnimationEvent} hierarchy. Three serialized event shapes are
+ * supported :
+ *
+ *
+ * - {@link SimpleSerializedEvent} — fires on animation begin or end
+ * (no timing predicate).
+ * - {@link TimeSerializedEvent} — fires once when the animation crosses
+ * the {@code frame} timestamp (seconds since anim start).
+ * - {@link PeriodSerializedEvent} — fires every tick while the animation
+ * is between {@code start} and {@code end}.
+ *
+ *
+ * Each shape carries a list of {@link AnimationAction}s that all run when
+ * the event fires. Actions drive their own sidedness — see individual impls —
+ * so the outer {@link AnimationEvent.Side} defaults to {@link AnimationEvent.Side#BOTH}
+ * and can be overridden via an optional {@code "side"} field
+ * ({@code "CLIENT"}, {@code "SERVER"}, {@code "LOCAL_CLIENT"}, {@code "BOTH"}).
+ *
+ *
The adapter returns thin {@code E0} lambdas : the outer AnimationEvent
+ * machinery keeps handling side filtering + time-window checking, and the
+ * lambda body simply iterates the action list. This keeps the runtime path
+ * identical to the hand-coded Java path (no perf regression, no new class
+ * to pool).
+ */
+public final class DataDrivenAnimationEvents {
+
+ private DataDrivenAnimationEvents() {}
+
+ private static final Codec SIDE_CODEC = Codec.STRING.xmap(
+ s -> AnimationEvent.Side.valueOf(s.toUpperCase()),
+ Enum::name
+ );
+
+ /**
+ * Serialized shape for {@code on_begin} / {@code on_end} events.
+ */
+ public record SimpleSerializedEvent(
+ List actions,
+ AnimationEvent.Side side
+ ) {
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(SimpleSerializedEvent::actions),
+ SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(SimpleSerializedEvent::side)
+ ).apply(i, SimpleSerializedEvent::new));
+
+ /**
+ * Sugar accepting either a full object {@code {"actions":[...], "side":...}}
+ * or a bare action list {@code [...]} — in which case {@code side}
+ * defaults to {@link AnimationEvent.Side#BOTH}. This is the shape most
+ * artists reach for.
+ */
+ public static final Codec SUGAR_CODEC = Codec.either(
+ AnimationAction.CODEC.listOf(),
+ CODEC
+ ).xmap(
+ either -> either.map(
+ actions -> new SimpleSerializedEvent(actions, AnimationEvent.Side.BOTH),
+ full -> full
+ ),
+ ev -> com.mojang.datafixers.util.Either.right(ev)
+ );
+
+ /**
+ * Convert this serialized record into the runtime {@link SimpleEvent}
+ * representation.
+ */
+ public SimpleEvent toRuntime() {
+ final List captured = List.copyOf(this.actions);
+ E0 fire = (patch, anim, params) -> {
+ for (AnimationAction action : captured) {
+ action.execute(patch, anim, 0.0F, 0.0F);
+ }
+ };
+ return SimpleEvent.create(fire, this.side);
+ }
+ }
+
+ /**
+ * Serialized shape for a {@code tick_events} entry that fires once when
+ * the animation crosses {@code frame} (seconds).
+ */
+ public record TimeSerializedEvent(
+ float frame,
+ List actions,
+ AnimationEvent.Side side
+ ) {
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("frame").forGetter(TimeSerializedEvent::frame),
+ AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(TimeSerializedEvent::actions),
+ SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(TimeSerializedEvent::side)
+ ).apply(i, TimeSerializedEvent::new));
+
+ public InTimeEvent toRuntime() {
+ final List captured = List.copyOf(this.actions);
+ E0 fire = (patch, anim, params) -> {
+ for (AnimationAction action : captured) {
+ action.execute(patch, anim, 0.0F, 0.0F);
+ }
+ };
+ return InTimeEvent.create(this.frame, fire, this.side);
+ }
+ }
+
+ /**
+ * Serialized shape for a {@code tick_events} entry that fires every tick
+ * while the animation elapsed-time is between {@code start} and {@code end}.
+ */
+ public record PeriodSerializedEvent(
+ float start,
+ float end,
+ List actions,
+ AnimationEvent.Side side
+ ) {
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("start").forGetter(PeriodSerializedEvent::start),
+ Codec.FLOAT.fieldOf("end").forGetter(PeriodSerializedEvent::end),
+ AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(PeriodSerializedEvent::actions),
+ SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(PeriodSerializedEvent::side)
+ ).apply(i, PeriodSerializedEvent::new));
+
+ public InPeriodEvent toRuntime() {
+ final List captured = List.copyOf(this.actions);
+ E0 fire = (patch, anim, params) -> {
+ for (AnimationAction action : captured) {
+ action.execute(patch, anim, 0.0F, 0.0F);
+ }
+ };
+ return InPeriodEvent.create(this.start, this.end, fire, this.side);
+ }
+ }
+
+ /**
+ * Discriminator codec for a single {@code tick_events} entry — picks
+ * between time and period by looking for the {@code "frame"} vs
+ * {@code "start"}/{@code "end"} keys. Implemented as an {@link Codec#either}
+ * because the two shapes are structurally disjoint (a time event cannot
+ * also be a period event).
+ */
+ public static final Codec> TICK_EVENT_ENTRY_CODEC = Codec.either(
+ TimeSerializedEvent.CODEC,
+ PeriodSerializedEvent.CODEC
+ ).xmap(
+ either -> either.map(TimeSerializedEvent::toRuntime, PeriodSerializedEvent::toRuntime),
+ // Encode path — best-effort : runtime events don't carry enough
+ // structural info to re-serialize losslessly (the action list is lost
+ // inside the opaque E0 lambda). We fall through to the period shape
+ // which is the richer of the two — encoding is not a supported
+ // round-trip path for these properties (see D2 scope notes).
+ ev -> com.mojang.datafixers.util.Either.right(new PeriodSerializedEvent(0.0F, 0.0F, List.of(), AnimationEvent.Side.BOTH))
+ );
+
+ /**
+ * Codec for the full {@code tick_events} list property.
+ */
+ public static final Codec>> TICK_EVENTS_CODEC =
+ TICK_EVENT_ENTRY_CODEC.listOf().xmap(
+ ArrayList::new,
+ ArrayList::new
+ );
+
+ /**
+ * Codec for an {@code on_begin} / {@code on_end} list property. The entry
+ * codec accepts either a bare action list or a full object, see
+ * {@link SimpleSerializedEvent#SUGAR_CODEC}.
+ */
+ public static final Codec>> BEGIN_END_EVENTS_CODEC =
+ SimpleSerializedEvent.SUGAR_CODEC.listOf().xmap(
+ list -> {
+ List> out = new ArrayList<>(list.size());
+ for (SimpleSerializedEvent ev : list) {
+ out.add(ev.toRuntime());
+ }
+ return out;
+ },
+ // Lossy encode — runtime SimpleEvent> doesn't retain action list
+ // after toRuntime wraps them in an opaque E0 lambda. Return empty
+ // serialized list (see class Javadoc).
+ runtime -> {
+ List out = new ArrayList<>(runtime.size());
+ for (int i = 0; i < runtime.size(); i++) {
+ out.add(new SimpleSerializedEvent(List.of(), AnimationEvent.Side.BOTH));
+ }
+ return out;
+ }
+ );
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectAction.java b/src/main/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectAction.java
new file mode 100644
index 0000000..5aaf72a
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectAction.java
@@ -0,0 +1,91 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.effect.MobEffect;
+import net.minecraft.world.effect.MobEffectInstance;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraftforge.registries.ForgeRegistries;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.action.AnimationAction;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Apply a potion effect to the animating entity. Server-side authoritative —
+ * {@code LivingEntity.addEffect} is a no-op on the client (the effect will
+ * be synced down when the server accepts the change).
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:apply_effect",
+ * "effect": "minecraft:slowness",
+ * "duration_ticks": 60,
+ * "amplifier": 1,
+ * "ambient": false,
+ * "show_particles": true,
+ * "show_icon": true }
+ * }
+ *
+ * {@code amplifier} defaults to {@code 0} (level 1), {@code ambient} /
+ * {@code show_particles} / {@code show_icon} mirror {@link MobEffectInstance}'s
+ * defaults ({@code false} / {@code true} / {@code true}). Unknown effect ids
+ * are no-op + WARN.
+ */
+public record ApplyEffectAction(
+ ResourceLocation effect,
+ int durationTicks,
+ int amplifier,
+ boolean ambient,
+ boolean showParticles,
+ boolean showIcon
+) implements AnimationAction {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("apply_effect");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ ResourceLocation.CODEC.fieldOf("effect").forGetter(ApplyEffectAction::effect),
+ Codec.INT.fieldOf("duration_ticks").forGetter(ApplyEffectAction::durationTicks),
+ Codec.INT.optionalFieldOf("amplifier", 0).forGetter(ApplyEffectAction::amplifier),
+ Codec.BOOL.optionalFieldOf("ambient", false).forGetter(ApplyEffectAction::ambient),
+ Codec.BOOL.optionalFieldOf("show_particles", true).forGetter(ApplyEffectAction::showParticles),
+ Codec.BOOL.optionalFieldOf("show_icon", true).forGetter(ApplyEffectAction::showIcon)
+ ).apply(i, ApplyEffectAction::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void execute(
+ LivingEntityPatch> patch,
+ AssetAccessor extends DynamicAnimation> animation,
+ float prevElapsed,
+ float elapsed
+ ) {
+ MobEffect mobEffect = ForgeRegistries.MOB_EFFECTS.getValue(this.effect);
+ if (mobEffect == null) {
+ TiedUpRigConstants.LOGGER.warn("ApplyEffectAction : unknown mob effect {}", this.effect);
+ return;
+ }
+
+ LivingEntity entity = patch.getOriginal();
+ entity.addEffect(new MobEffectInstance(
+ mobEffect,
+ this.durationTicks,
+ this.amplifier,
+ this.ambient,
+ this.showParticles,
+ this.showIcon
+ ));
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityAction.java b/src/main/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityAction.java
new file mode 100644
index 0000000..ff3ee51
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityAction.java
@@ -0,0 +1,140 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.damagesource.DamageSource;
+import net.minecraft.world.damagesource.DamageSources;
+import net.minecraft.world.entity.LivingEntity;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.action.AnimationAction;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Damage the animating entity for {@link #amount} half-hearts using the named
+ * damage source. Server-side authoritative — {@code LivingEntity.hurt} is a
+ * no-op on the client.
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:damage_entity",
+ * "amount": 2.0,
+ * "source": "generic" }
+ * }
+ *
+ * The {@code source} field maps to a helper method on {@link DamageSources}
+ * (see the {@link SourceType} enum). Defaults to {@code generic}. In 1.20.1
+ * damage types are registry-driven {@link net.minecraft.resources.ResourceKey},
+ * so the direct JSON-to-ResourceKey path would require a {@code RegistryAccess}
+ * we don't have in a codec — using the helper names keeps authoring ergonomic
+ * and side-steps the registry plumbing. Mods that want a custom damage type
+ * can register an {@link AnimationAction} subclass that consumes their id
+ * directly.
+ */
+public record DamageEntityAction(
+ float amount,
+ SourceType source
+) implements AnimationAction {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("damage_entity");
+
+ /**
+ * Safe codec over {@link SourceType} : uppercases + looks up the enum
+ * constant, returning a {@link DataResult#error} for unknown names rather
+ * than throwing. {@code flatXmap} is used (over {@code xmap}) precisely to
+ * surface the error through the Codec pipeline.
+ */
+ private static final Codec SOURCE_TYPE_CODEC = Codec.STRING.flatXmap(
+ s -> {
+ try {
+ return DataResult.success(SourceType.valueOf(s.toUpperCase()));
+ } catch (IllegalArgumentException e) {
+ return DataResult.error(() -> "Unknown damage source type : " + s);
+ }
+ },
+ t -> DataResult.success(t.name().toLowerCase())
+ );
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("amount").forGetter(DamageEntityAction::amount),
+ SOURCE_TYPE_CODEC.optionalFieldOf("source", SourceType.GENERIC).forGetter(DamageEntityAction::source)
+ ).apply(i, DamageEntityAction::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void execute(
+ LivingEntityPatch> patch,
+ AssetAccessor extends DynamicAnimation> animation,
+ float prevElapsed,
+ float elapsed
+ ) {
+ LivingEntity entity = patch.getOriginal();
+ if (entity.level().isClientSide()) {
+ return;
+ }
+
+ DamageSource damageSource = resolveDamageSource(entity.damageSources(), this.source);
+ if (damageSource == null) {
+ TiedUpRigConstants.LOGGER.warn("DamageEntityAction : could not resolve damage source {}", this.source);
+ return;
+ }
+
+ entity.hurt(damageSource, this.amount);
+ }
+
+ private static DamageSource resolveDamageSource(DamageSources sources, SourceType type) {
+ return switch (type) {
+ case GENERIC -> sources.generic();
+ case MAGIC -> sources.magic();
+ case FALL -> sources.fall();
+ case IN_FIRE -> sources.inFire();
+ case ON_FIRE -> sources.onFire();
+ case LAVA -> sources.lava();
+ case DROWN -> sources.drown();
+ case STARVE -> sources.starve();
+ case CACTUS -> sources.cactus();
+ case CRAMMING -> sources.cramming();
+ case IN_WALL -> sources.inWall();
+ case WITHER -> sources.wither();
+ case FREEZE -> sources.freeze();
+ case DRY_OUT -> sources.dryOut();
+ case SWEET_BERRY_BUSH -> sources.sweetBerryBush();
+ };
+ }
+
+ /**
+ * Whitelist of vanilla damage sources addressable from JSON. Artist-facing
+ * names are lowercase ({@code "generic"}, {@code "magic"}, ...) — the codec
+ * uppercases before looking up the enum.
+ */
+ public enum SourceType {
+ GENERIC,
+ MAGIC,
+ FALL,
+ IN_FIRE,
+ ON_FIRE,
+ LAVA,
+ DROWN,
+ STARVE,
+ CACTUS,
+ CRAMMING,
+ IN_WALL,
+ WITHER,
+ FREEZE,
+ DRY_OUT,
+ SWEET_BERRY_BUSH
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundAction.java b/src/main/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundAction.java
new file mode 100644
index 0000000..90e53d5
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundAction.java
@@ -0,0 +1,90 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraftforge.registries.ForgeRegistries;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.action.AnimationAction;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Play a sound at the entity's position. Server-side authoritative — the
+ * {@code level.playSound(null, ...)} call broadcasts to all clients within
+ * the default spatial radius.
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:play_sound",
+ * "sound": "minecraft:entity.player.levelup",
+ * "volume": 0.8,
+ * "pitch": 1.1,
+ * "category": "neutral" }
+ * }
+ *
+ * {@code volume} defaults to {@code 1.0}, {@code pitch} defaults to
+ * {@code 1.0}, {@code category} defaults to {@code neutral}. Unknown sound
+ * ids are silently no-op — they log a WARN via the surrounding parse path
+ * and skip execution (safer than crashing the animation).
+ */
+public record PlaySoundAction(
+ ResourceLocation sound,
+ float volume,
+ float pitch,
+ SoundSource category
+) implements AnimationAction {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("play_sound");
+
+ private static final Codec SOURCE_CODEC = Codec.STRING.xmap(
+ s -> SoundSource.valueOf(s.toUpperCase()),
+ source -> source.getName().toUpperCase()
+ );
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ ResourceLocation.CODEC.fieldOf("sound").forGetter(PlaySoundAction::sound),
+ Codec.FLOAT.optionalFieldOf("volume", 1.0F).forGetter(PlaySoundAction::volume),
+ Codec.FLOAT.optionalFieldOf("pitch", 1.0F).forGetter(PlaySoundAction::pitch),
+ SOURCE_CODEC.optionalFieldOf("category", SoundSource.NEUTRAL).forGetter(PlaySoundAction::category)
+ ).apply(i, PlaySoundAction::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void execute(
+ LivingEntityPatch> patch,
+ AssetAccessor extends DynamicAnimation> animation,
+ float prevElapsed,
+ float elapsed
+ ) {
+ SoundEvent event = ForgeRegistries.SOUND_EVENTS.getValue(this.sound);
+ if (event == null) {
+ TiedUpRigConstants.LOGGER.warn("PlaySoundAction : unknown sound event {}", this.sound);
+ return;
+ }
+
+ LivingEntity entity = patch.getOriginal();
+ entity.level().playSound(
+ null,
+ entity.getX(), entity.getY(), entity.getZ(),
+ event,
+ this.category,
+ this.volume,
+ this.pitch
+ );
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleAction.java b/src/main/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleAction.java
new file mode 100644
index 0000000..e18e713
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleAction.java
@@ -0,0 +1,158 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.particles.ParticleType;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.phys.Vec3;
+import net.minecraftforge.registries.ForgeRegistries;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.action.AnimationAction;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.armature.Armature;
+import com.tiedup.remake.rig.armature.Joint;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Spawn a particle burst at the entity's root position or at a named joint.
+ * Client-side only — the {@code level.addParticle} API requires a ClientLevel.
+ * Actions declared on a server-side outer event are no-op.
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:spawn_particle",
+ * "particle": "minecraft:smoke",
+ * "at": "Root",
+ * "count": 5,
+ * "speed": 0.05,
+ * "offset_x": 0.0,
+ * "offset_y": 1.2,
+ * "offset_z": 0.0 }
+ * }
+ *
+ * {@code at} defaults to «root joint position = entity position» —
+ * if the specified joint does not exist in the armature, a WARN is logged and
+ * the particle spawns at the entity origin. {@code count} defaults to {@code 1},
+ * {@code speed} defaults to {@code 0.0}, offsets default to zero.
+ *
+ *
Design note : the particle type must implement {@link ParticleOptions}
+ * directly — vanilla «simple» particles like {@code minecraft:smoke}
+ * satisfy this because {@link ParticleType} extends {@link ParticleOptions}
+ * when the particle carries no extra data. Complex particles that require data
+ * parameters (block / item / dust color) cannot currently be authored through
+ * this action — that's a follow-up (would require a particle data sub-codec).
+ */
+public record SpawnParticleAction(
+ ResourceLocation particle,
+ String joint,
+ int count,
+ float speed,
+ float offsetX,
+ float offsetY,
+ float offsetZ
+) implements AnimationAction {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("spawn_particle");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ ResourceLocation.CODEC.fieldOf("particle").forGetter(SpawnParticleAction::particle),
+ Codec.STRING.optionalFieldOf("at", "").forGetter(SpawnParticleAction::joint),
+ Codec.INT.optionalFieldOf("count", 1).forGetter(SpawnParticleAction::count),
+ Codec.FLOAT.optionalFieldOf("speed", 0.0F).forGetter(SpawnParticleAction::speed),
+ Codec.FLOAT.optionalFieldOf("offset_x", 0.0F).forGetter(SpawnParticleAction::offsetX),
+ Codec.FLOAT.optionalFieldOf("offset_y", 0.0F).forGetter(SpawnParticleAction::offsetY),
+ Codec.FLOAT.optionalFieldOf("offset_z", 0.0F).forGetter(SpawnParticleAction::offsetZ)
+ ).apply(i, SpawnParticleAction::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void execute(
+ LivingEntityPatch> patch,
+ AssetAccessor extends DynamicAnimation> animation,
+ float prevElapsed,
+ float elapsed
+ ) {
+ LivingEntity entity = patch.getOriginal();
+ Level level = entity.level();
+
+ // Client-side guard : addParticle is a no-op on server but we short
+ // circuit early to avoid the registry lookup overhead.
+ if (!level.isClientSide()) {
+ return;
+ }
+
+ ParticleType> particleType = ForgeRegistries.PARTICLE_TYPES.getValue(this.particle);
+ if (particleType == null) {
+ TiedUpRigConstants.LOGGER.warn("SpawnParticleAction : unknown particle type {}", this.particle);
+ return;
+ }
+
+ if (!(particleType instanceof ParticleOptions options)) {
+ TiedUpRigConstants.LOGGER.warn(
+ "SpawnParticleAction : particle {} does not implement ParticleOptions (complex particles not yet supported)",
+ this.particle
+ );
+ return;
+ }
+
+ Vec3 origin = resolveOrigin(patch, entity);
+
+ for (int n = 0; n < this.count; n++) {
+ level.addParticle(
+ options,
+ origin.x + this.offsetX,
+ origin.y + this.offsetY,
+ origin.z + this.offsetZ,
+ 0.0, this.speed, 0.0
+ );
+ }
+ }
+
+ /**
+ * Resolve the spawn origin. If {@link #joint} is non-empty and matches an
+ * armature joint, we would ideally transform the joint's local position
+ * into world space — but the public {@link LivingEntityPatch} API does not
+ * yet expose a joint-world-position helper (tracked separately). For now
+ * we only validate the joint exists and fall back to the entity position.
+ */
+ private Vec3 resolveOrigin(LivingEntityPatch> patch, LivingEntity entity) {
+ Vec3 entityPos = new Vec3(entity.getX(), entity.getY(), entity.getZ());
+
+ if (this.joint == null || this.joint.isEmpty()) {
+ return entityPos;
+ }
+
+ Armature armature = patch.getArmature();
+ if (armature == null) {
+ return entityPos;
+ }
+
+ Joint target = armature.searchJointByName(this.joint);
+ if (target == null) {
+ TiedUpRigConstants.LOGGER.warn(
+ "SpawnParticleAction : unknown joint '{}' on armature '{}', falling back to entity position",
+ this.joint, armature
+ );
+ return entityPos;
+ }
+
+ // TODO (D2 follow-up) : apply joint's model-space transform to entityPos
+ // via patch.getModelMatrix(partialTick). Needs a partialTick plumb
+ // through AnimationAction.execute which today only forwards elapsed.
+ return entityPos;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
index a9fb4a8..b5c439e 100644
--- a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
+++ b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
@@ -28,6 +28,7 @@ import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
+import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents;
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordGetter;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordSetter;
@@ -94,18 +95,43 @@ public abstract class AnimationProperty {
/**
* Events that are fired in every tick.
+ *
+ * Phase 3 D2 — serializable from a datapack JSON
+ * {@code "tick_events"} block. Each entry is either a «time»
+ * event ({@code {"frame":0.15, "actions":[...]}}) or a
+ * «period» event
+ * ({@code {"start":0.0, "end":1.0, "actions":[...]}}). See
+ * {@link DataDrivenAnimationEvents#TICK_EVENTS_CODEC}.
*/
- public static final StaticAnimationProperty>> TICK_EVENTS = new StaticAnimationProperty>> ();
-
+ public static final StaticAnimationProperty>> TICK_EVENTS = new StaticAnimationProperty>> (
+ "tick_events",
+ DataDrivenAnimationEvents.TICK_EVENTS_CODEC
+ );
+
/**
* Events that are fired when the animation starts.
+ *
+ * Phase 3 D2 — serializable from a datapack JSON {@code "on_begin"}
+ * block. Each entry is an «action list»
+ * ({@code [{"type":"tiedup:play_sound", ...}, ...]}) or a full object
+ * ({@code {"actions":[...], "side":"SERVER"}}). See
+ * {@link DataDrivenAnimationEvents#BEGIN_END_EVENTS_CODEC}.
*/
- public static final StaticAnimationProperty>> ON_BEGIN_EVENTS = new StaticAnimationProperty>> ();
-
+ public static final StaticAnimationProperty>> ON_BEGIN_EVENTS = new StaticAnimationProperty>> (
+ "on_begin",
+ DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
+ );
+
/**
* Events that are fired when the animation ends.
+ *
+ * Phase 3 D2 — serializable from a datapack JSON {@code "on_end"}
+ * block. Same shape as {@link #ON_BEGIN_EVENTS}.
*/
- public static final StaticAnimationProperty>> ON_END_EVENTS = new StaticAnimationProperty>> ();
+ public static final StaticAnimationProperty>> ON_END_EVENTS = new StaticAnimationProperty>> (
+ "on_end",
+ DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
+ );
/**
* An event triggered when entity changes an item in hand.
diff --git a/src/test/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistryTest.java b/src/test/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistryTest.java
new file mode 100644
index 0000000..d6533c3
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/action/AnimationActionRegistryTest.java
@@ -0,0 +1,181 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundSource;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.tiedup.remake.rig.anim.action.impl.ApplyEffectAction;
+import com.tiedup.remake.rig.anim.action.impl.DamageEntityAction;
+import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
+import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
+
+/**
+ * Tests for {@link AnimationActionRegistry} and the dispatch codec exposed via
+ * {@link AnimationAction#CODEC}. Pure Java — no MC bootstrap required.
+ *
+ * The registry static-init registers the 4 core actions, so merely
+ * referencing {@link AnimationActionRegistry} here triggers the clinit. A
+ * {@code BeforeAll} hook forces class-loading in a deterministic order
+ * regardless of JUnit test ordering.
+ */
+class AnimationActionRegistryTest {
+
+ @BeforeAll
+ static void forceClinit() {
+ assertNotNull(PlaySoundAction.ID);
+ assertNotNull(SpawnParticleAction.ID);
+ assertNotNull(ApplyEffectAction.ID);
+ assertNotNull(DamageEntityAction.ID);
+ assertNotNull(AnimationAction.CODEC);
+ }
+
+ // ===== Registry population =====
+
+ @Test
+ void coreActions_registered() {
+ assertNotNull(AnimationActionRegistry.getCodec(PlaySoundAction.ID));
+ assertNotNull(AnimationActionRegistry.getCodec(SpawnParticleAction.ID));
+ assertNotNull(AnimationActionRegistry.getCodec(ApplyEffectAction.ID));
+ assertNotNull(AnimationActionRegistry.getCodec(DamageEntityAction.ID));
+ }
+
+ @Test
+ void types_returnsFourEntries_atMinimum() {
+ // At least 4 (there could be additional registrations by other tests,
+ // but we should have our 4 core).
+ assertTrue(AnimationActionRegistry.types().size() >= 4);
+ }
+
+ @Test
+ void getCodec_unknownType_returnsNull() {
+ assertNull(AnimationActionRegistry.getCodec(new ResourceLocation("foo", "bar")));
+ }
+
+ @Test
+ void register_duplicateId_throws() {
+ // Attempt to re-register PlaySoundAction id → must throw, never
+ // silently shadow. Uses an arbitrary no-op codec for the retry.
+ assertThrows(IllegalStateException.class, () ->
+ AnimationActionRegistry.register(PlaySoundAction.ID, PlaySoundAction.CODEC)
+ );
+ }
+
+ // ===== Dispatch codec round-trip =====
+
+ /**
+ * Encode a {@link PlaySoundAction} → JSON → decode → equals the original.
+ * This exercises the dispatch codec's serialize path (embed {@code type}
+ * discriminator + delegate to PlaySoundAction.CODEC).
+ */
+ @Test
+ void dispatchCodec_knownType_roundtrip_playSound() {
+ PlaySoundAction original = new PlaySoundAction(
+ new ResourceLocation("minecraft", "entity.player.levelup"),
+ 0.8F,
+ 1.1F,
+ SoundSource.NEUTRAL
+ );
+
+ DataResult encoded = AnimationAction.CODEC.encodeStart(JsonOps.INSTANCE, original);
+ assertTrue(encoded.result().isPresent(), "encode must succeed : " + encoded.error());
+
+ JsonObject json = encoded.result().get().getAsJsonObject();
+ assertEquals("tiedup:play_sound", json.get("type").getAsString());
+ assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
+
+ DataResult decoded =
+ AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(decoded.result().isPresent(), "decode must succeed : " + decoded.error());
+
+ AnimationAction back = decoded.result().get();
+ assertTrue(back instanceof PlaySoundAction);
+ assertEquals(original, back);
+ }
+
+ @Test
+ void dispatchCodec_unknownType_returnsError() {
+ JsonObject json = new JsonObject();
+ json.add("type", new JsonPrimitive("foo:unknown_action"));
+
+ DataResult decoded =
+ AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(decoded.result().isPresent(), "decode must fail for unknown type");
+ assertTrue(decoded.error().isPresent(), "error must be populated");
+ assertTrue(
+ decoded.error().get().message().contains("foo:unknown_action"),
+ "error message should mention the unknown type, got : " + decoded.error().get().message()
+ );
+ }
+
+ /**
+ * Missing {@code type} field → decode error.
+ */
+ @Test
+ void dispatchCodec_missingType_returnsError() {
+ JsonObject json = new JsonObject();
+ json.add("sound", new JsonPrimitive("minecraft:ambient.cave"));
+
+ DataResult decoded =
+ AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(decoded.result().isPresent(), "decode must fail without type field");
+ }
+
+ /**
+ * Round-trip all 4 core action types in a single test — guards against a
+ * silent registration drop.
+ */
+ @Test
+ void dispatchCodec_allCoreTypes_parsable() {
+ String[] types = {
+ "tiedup:play_sound",
+ "tiedup:spawn_particle",
+ "tiedup:apply_effect",
+ "tiedup:damage_entity"
+ };
+
+ for (String t : types) {
+ JsonObject json = new JsonObject();
+ json.add("type", new JsonPrimitive(t));
+ // Each action has at least one required field — add minimal set
+ switch (t) {
+ case "tiedup:play_sound" ->
+ json.add("sound", new JsonPrimitive("minecraft:entity.player.levelup"));
+ case "tiedup:spawn_particle" ->
+ json.add("particle", new JsonPrimitive("minecraft:smoke"));
+ case "tiedup:apply_effect" -> {
+ json.add("effect", new JsonPrimitive("minecraft:slowness"));
+ json.add("duration_ticks", new JsonPrimitive(60));
+ }
+ case "tiedup:damage_entity" ->
+ json.add("amount", new JsonPrimitive(1.0F));
+ default -> {}
+ }
+
+ DataResult decoded = AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(
+ decoded.result().isPresent(),
+ "decode of " + t + " must succeed : " + decoded.error().map(e -> e.message()).orElse("?")
+ );
+ }
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectActionTest.java b/src/test/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectActionTest.java
new file mode 100644
index 0000000..936645f
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/action/impl/ApplyEffectActionTest.java
@@ -0,0 +1,77 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Parse / default tests for {@link ApplyEffectAction}.
+ */
+class ApplyEffectActionTest {
+
+ @Test
+ void parse_fullObject_populates() {
+ JsonObject json = new JsonObject();
+ json.add("effect", new JsonPrimitive("minecraft:slowness"));
+ json.add("duration_ticks", new JsonPrimitive(120));
+ json.add("amplifier", new JsonPrimitive(2));
+ json.add("ambient", new JsonPrimitive(true));
+ json.add("show_particles", new JsonPrimitive(false));
+ json.add("show_icon", new JsonPrimitive(false));
+
+ DataResult parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
+
+ ApplyEffectAction action = parsed.result().get();
+ assertEquals(new ResourceLocation("minecraft", "slowness"), action.effect());
+ assertEquals(120, action.durationTicks());
+ assertEquals(2, action.amplifier());
+ assertTrue(action.ambient());
+ assertFalse(action.showParticles());
+ assertFalse(action.showIcon());
+ }
+
+ @Test
+ void parse_minimal_usesDefaults() {
+ JsonObject json = new JsonObject();
+ json.add("effect", new JsonPrimitive("minecraft:regeneration"));
+ json.add("duration_ticks", new JsonPrimitive(40));
+
+ DataResult parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent());
+
+ ApplyEffectAction action = parsed.result().get();
+ assertEquals(0, action.amplifier());
+ assertFalse(action.ambient());
+ assertTrue(action.showParticles());
+ assertTrue(action.showIcon());
+ }
+
+ @Test
+ void parse_missingRequired_fails() {
+ JsonObject json = new JsonObject();
+ json.add("effect", new JsonPrimitive("minecraft:glowing"));
+ // missing duration_ticks
+
+ DataResult parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(parsed.result().isPresent(), "parse without duration_ticks must fail");
+ }
+
+ @Test
+ void type_isStable() {
+ assertEquals(new ResourceLocation("tiedup", "apply_effect"), ApplyEffectAction.ID);
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityActionTest.java b/src/test/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityActionTest.java
new file mode 100644
index 0000000..639a2d1
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/action/impl/DamageEntityActionTest.java
@@ -0,0 +1,116 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Parse / default tests for {@link DamageEntityAction}.
+ */
+class DamageEntityActionTest {
+
+ @Test
+ void parse_fullObject_populates() {
+ JsonObject json = new JsonObject();
+ json.add("amount", new JsonPrimitive(2.5F));
+ json.add("source", new JsonPrimitive("magic"));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
+
+ DamageEntityAction action = parsed.result().get();
+ assertEquals(2.5F, action.amount(), 0.0001F);
+ assertEquals(DamageEntityAction.SourceType.MAGIC, action.source());
+ }
+
+ @Test
+ void parse_missingSource_defaultsToGeneric() {
+ JsonObject json = new JsonObject();
+ json.add("amount", new JsonPrimitive(1.0F));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent());
+
+ DamageEntityAction action = parsed.result().get();
+ assertEquals(DamageEntityAction.SourceType.GENERIC, action.source());
+ }
+
+ @Test
+ void parse_caseInsensitiveSource() {
+ JsonObject json = new JsonObject();
+ json.add("amount", new JsonPrimitive(1.0F));
+ json.add("source", new JsonPrimitive("In_Fire"));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent());
+ assertEquals(DamageEntityAction.SourceType.IN_FIRE, parsed.result().get().source());
+ }
+
+ /**
+ * Unknown source name + default GENERIC : because {@code source} is an
+ * {@code optionalFieldOf("source", GENERIC)}, Mojang's codec pipeline
+ * swallows the inner parse error and falls back to the default. Net
+ * effect : an artist typo silently becomes {@code GENERIC} damage rather
+ * than blocking the entire animation load. Acceptable behaviour — the
+ * error is still logged via the standard Codec partial-result path when
+ * the animation property parse runs through
+ * {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
+ * (see {@code resultOrPartial} in that method).
+ */
+ @Test
+ void parse_invalidSource_fallsBackToGeneric() {
+ JsonObject json = new JsonObject();
+ json.add("amount", new JsonPrimitive(1.0F));
+ json.add("source", new JsonPrimitive("unknown_source_xyz"));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(),
+ "optionalFieldOf(default) swallows inner parse error and returns default");
+ assertEquals(DamageEntityAction.SourceType.GENERIC, parsed.result().get().source());
+ }
+
+ @Test
+ void parse_missingAmount_fails() {
+ JsonObject json = new JsonObject();
+ json.add("source", new JsonPrimitive("generic"));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(parsed.result().isPresent(), "parse without amount must fail");
+ }
+
+ @Test
+ void type_isStable() {
+ assertEquals(new ResourceLocation("tiedup", "damage_entity"), DamageEntityAction.ID);
+ }
+
+ @Test
+ void allSourceTypes_haveResolvableName() {
+ // Guards against a sloppy enum constant added without being wired in
+ // resolveDamageSource. Parsing the enum name must succeed for every
+ // declared variant.
+ for (DamageEntityAction.SourceType type : DamageEntityAction.SourceType.values()) {
+ JsonObject json = new JsonObject();
+ json.add("amount", new JsonPrimitive(1.0F));
+ json.add("source", new JsonPrimitive(type.name().toLowerCase()));
+
+ DataResult parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(
+ parsed.result().isPresent(),
+ "source " + type + " must parse but got : " + parsed.error()
+ );
+ }
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundActionTest.java b/src/test/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundActionTest.java
new file mode 100644
index 0000000..828a078
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/action/impl/PlaySoundActionTest.java
@@ -0,0 +1,127 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundSource;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Parse / encode tests for {@link PlaySoundAction}. No MC bootstrap — the
+ * Codec / ResourceLocation / SoundSource types are available directly from
+ * the test classpath.
+ */
+class PlaySoundActionTest {
+
+ @Test
+ void parse_fullObject_roundtrip() {
+ JsonObject json = new JsonObject();
+ json.add("type", new JsonPrimitive("tiedup:play_sound"));
+ json.add("sound", new JsonPrimitive("minecraft:entity.player.levelup"));
+ json.add("volume", new JsonPrimitive(0.5F));
+ json.add("pitch", new JsonPrimitive(1.2F));
+ json.add("category", new JsonPrimitive("neutral"));
+
+ DataResult parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
+
+ PlaySoundAction action = parsed.result().get();
+ assertEquals(new ResourceLocation("minecraft", "entity.player.levelup"), action.sound());
+ assertEquals(0.5F, action.volume(), 0.0001F);
+ assertEquals(1.2F, action.pitch(), 0.0001F);
+ assertEquals(SoundSource.NEUTRAL, action.category());
+ assertEquals(new ResourceLocation("tiedup", "play_sound"), action.type());
+ }
+
+ @Test
+ void parse_missingOptionals_usesDefaults() {
+ JsonObject json = new JsonObject();
+ json.add("sound", new JsonPrimitive("minecraft:ui.button.click"));
+
+ DataResult parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse with only sound must succeed");
+
+ PlaySoundAction action = parsed.result().get();
+ assertEquals(1.0F, action.volume(), 0.0001F);
+ assertEquals(1.0F, action.pitch(), 0.0001F);
+ assertEquals(SoundSource.NEUTRAL, action.category());
+ }
+
+ @Test
+ void parse_missingRequiredSound_fails() {
+ JsonObject json = new JsonObject();
+ json.add("volume", new JsonPrimitive(1.0F));
+
+ DataResult parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(parsed.result().isPresent(), "parse without sound must fail");
+ }
+
+ /**
+ * Encode omits default values — that's Mojang's standard
+ * {@code optionalFieldOf(name, default)} semantics. So a record whose
+ * volume / pitch / category all match their defaults encodes down to
+ * only the {@code sound} field.
+ */
+ @Test
+ void encode_defaultValues_areOmitted() {
+ PlaySoundAction action = new PlaySoundAction(
+ new ResourceLocation("minecraft", "entity.player.levelup"),
+ 1.0F, // default volume
+ 1.0F, // default pitch
+ SoundSource.NEUTRAL // default category
+ );
+
+ DataResult encoded = PlaySoundAction.CODEC.encodeStart(JsonOps.INSTANCE, action);
+ assertTrue(encoded.result().isPresent());
+
+ JsonObject json = encoded.result().get().getAsJsonObject();
+ assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
+ }
+
+ /**
+ * Non-default values must appear in the encoded output.
+ */
+ @Test
+ void encode_nonDefaultValues_arePresent() {
+ PlaySoundAction action = new PlaySoundAction(
+ new ResourceLocation("minecraft", "entity.player.levelup"),
+ 0.8F, // non-default
+ 1.2F, // non-default
+ SoundSource.PLAYERS // non-default
+ );
+
+ DataResult encoded = PlaySoundAction.CODEC.encodeStart(JsonOps.INSTANCE, action);
+ assertTrue(encoded.result().isPresent());
+
+ JsonObject json = encoded.result().get().getAsJsonObject();
+ assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
+ assertNotNull(json.get("volume"));
+ assertEquals(0.8F, json.get("volume").getAsFloat(), 0.0001F);
+ assertNotNull(json.get("pitch"));
+ assertEquals(1.2F, json.get("pitch").getAsFloat(), 0.0001F);
+ }
+
+ @Test
+ void type_isStable() {
+ assertEquals(new ResourceLocation("tiedup", "play_sound"), PlaySoundAction.ID);
+ PlaySoundAction action = new PlaySoundAction(
+ new ResourceLocation("minecraft", "ambient.cave"),
+ 1.0F, 1.0F, SoundSource.AMBIENT
+ );
+ assertEquals(PlaySoundAction.ID, action.type());
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleActionTest.java b/src/test/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleActionTest.java
new file mode 100644
index 0000000..186f2ff
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/action/impl/SpawnParticleActionTest.java
@@ -0,0 +1,79 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.action.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Parse / default tests for {@link SpawnParticleAction}.
+ */
+class SpawnParticleActionTest {
+
+ @Test
+ void parse_fullObject_populates() {
+ JsonObject json = new JsonObject();
+ json.add("particle", new JsonPrimitive("minecraft:smoke"));
+ json.add("at", new JsonPrimitive("Head"));
+ json.add("count", new JsonPrimitive(5));
+ json.add("speed", new JsonPrimitive(0.05F));
+ json.add("offset_x", new JsonPrimitive(0.1F));
+ json.add("offset_y", new JsonPrimitive(1.2F));
+ json.add("offset_z", new JsonPrimitive(-0.3F));
+
+ DataResult parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
+
+ SpawnParticleAction action = parsed.result().get();
+ assertEquals(new ResourceLocation("minecraft", "smoke"), action.particle());
+ assertEquals("Head", action.joint());
+ assertEquals(5, action.count());
+ assertEquals(0.05F, action.speed(), 0.0001F);
+ assertEquals(0.1F, action.offsetX(), 0.0001F);
+ assertEquals(1.2F, action.offsetY(), 0.0001F);
+ assertEquals(-0.3F, action.offsetZ(), 0.0001F);
+ }
+
+ @Test
+ void parse_minimal_usesDefaults() {
+ JsonObject json = new JsonObject();
+ json.add("particle", new JsonPrimitive("minecraft:flame"));
+
+ DataResult parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertTrue(parsed.result().isPresent(), "parse must succeed with defaults");
+
+ SpawnParticleAction action = parsed.result().get();
+ assertEquals("", action.joint()); // "" == default to entity origin
+ assertEquals(1, action.count());
+ assertEquals(0.0F, action.speed(), 0.0001F);
+ assertEquals(0.0F, action.offsetX(), 0.0001F);
+ assertEquals(0.0F, action.offsetY(), 0.0001F);
+ assertEquals(0.0F, action.offsetZ(), 0.0001F);
+ }
+
+ @Test
+ void parse_missingParticle_fails() {
+ JsonObject json = new JsonObject();
+ json.add("count", new JsonPrimitive(3));
+
+ DataResult parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
+ assertFalse(parsed.result().isPresent(), "parse without particle must fail");
+ }
+
+ @Test
+ void type_isStable() {
+ assertEquals(new ResourceLocation("tiedup", "spawn_particle"), SpawnParticleAction.ID);
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/anim/property/AnimationEventCodecTest.java b/src/test/java/com/tiedup/remake/rig/anim/property/AnimationEventCodecTest.java
new file mode 100644
index 0000000..e275933
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/anim/property/AnimationEventCodecTest.java
@@ -0,0 +1,344 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.property;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.JsonOps;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.tiedup.remake.rig.anim.action.AnimationAction;
+import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents;
+import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.PeriodSerializedEvent;
+import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.SimpleSerializedEvent;
+import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.TimeSerializedEvent;
+import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
+import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
+import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
+
+/**
+ * Tests the Phase 3 D2 wiring : {@code on_begin}, {@code on_end} and
+ * {@code tick_events} properties must be accessible via
+ * {@link AnimationProperty#getSerializableProperty(String)} and must parse
+ * real datapack-shaped JSON blocks into runtime
+ * {@link com.tiedup.remake.rig.anim.property.AnimationEvent} instances.
+ *
+ * No MC bootstrap — Mojang Codec stack + Gson only.
+ */
+class AnimationEventCodecTest {
+
+ @BeforeAll
+ static void forceClinit() {
+ // Reference the fields explicitly so JUnit test ordering doesn't leave
+ // getSerializableProperty("on_begin") throwing IllegalStateException.
+ assertNotNull(StaticAnimationProperty.ON_BEGIN_EVENTS);
+ assertNotNull(StaticAnimationProperty.ON_END_EVENTS);
+ assertNotNull(StaticAnimationProperty.TICK_EVENTS);
+ // Also ensure the action registry clinit has run so dispatchCodec is
+ // populated.
+ assertNotNull(AnimationAction.CODEC);
+ assertNotNull(PlaySoundAction.ID);
+ }
+
+ // ===== Registration =====
+
+ @Test
+ void onBegin_registeredUnderName() {
+ AnimationProperty> prop = AnimationProperty.getSerializableProperty("on_begin");
+ assertNotNull(prop, "on_begin must be exposed via getSerializableProperty");
+ assertEquals(StaticAnimationProperty.ON_BEGIN_EVENTS, prop);
+ }
+
+ @Test
+ void onEnd_registeredUnderName() {
+ AnimationProperty> prop = AnimationProperty.getSerializableProperty("on_end");
+ assertNotNull(prop);
+ assertEquals(StaticAnimationProperty.ON_END_EVENTS, prop);
+ }
+
+ @Test
+ void tickEvents_registeredUnderName() {
+ AnimationProperty> prop = AnimationProperty.getSerializableProperty("tick_events");
+ assertNotNull(prop);
+ assertEquals(StaticAnimationProperty.TICK_EVENTS, prop);
+ }
+
+ // ===== on_begin / on_end parse =====
+
+ /**
+ * Bare action list shape :
+ *
[{"type":"tiedup:play_sound", "sound":"minecraft:ui.button.click"}]
+ * — the SUGAR_CODEC maps this to a single {@link SimpleSerializedEvent}
+ * with {@link AnimationEvent.Side#BOTH} as default.
+ */
+ @Test
+ void onBegin_bareActionList_parses() {
+ String jsonText = """
+ [
+ [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
+ assertEquals(1, events.size());
+ assertNotNull(events.get(0));
+ }
+
+ /**
+ * Full object shape :
+ * {"actions":[...], "side":"SERVER"}
+ */
+ @Test
+ void onBegin_fullObjectWithSide_parses() {
+ String jsonText = """
+ [
+ {
+ "actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}],
+ "side": "SERVER"
+ }
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
+ assertEquals(1, events.size());
+ }
+
+ @Test
+ void onEnd_multipleEntries_parses() {
+ String jsonText = """
+ [
+ [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}],
+ [{"type":"tiedup:apply_effect","effect":"minecraft:slowness","duration_ticks":40}]
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.ON_END_EVENTS.parseFrom(arr);
+ assertEquals(2, events.size());
+ }
+
+ /**
+ * The parseFrom on a purely lossy encoding path also tests that the
+ * on_begin codec does not reject multiple actions inside one entry.
+ */
+ @Test
+ void onBegin_multipleActionsPerEntry_parses() {
+ String jsonText = """
+ [
+ [
+ {"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"},
+ {"type":"tiedup:apply_effect","effect":"minecraft:slowness","duration_ticks":40,"amplifier":1}
+ ]
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
+ assertEquals(1, events.size());
+ }
+
+ // ===== tick_events parse =====
+
+ @Test
+ void tickEvents_timeEvent_parses() {
+ String jsonText = """
+ [
+ {
+ "frame": 0.15,
+ "actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
+ }
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
+ assertEquals(1, events.size());
+ assertTrue(
+ events.get(0) instanceof AnimationEvent.InTimeEvent,
+ "time-shape JSON must yield InTimeEvent, got : " + events.get(0).getClass().getSimpleName()
+ );
+ }
+
+ @Test
+ void tickEvents_periodEvent_parses() {
+ String jsonText = """
+ [
+ {
+ "start": 0.1,
+ "end": 0.8,
+ "actions": [{"type":"tiedup:spawn_particle","particle":"minecraft:smoke","count":3}]
+ }
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
+ assertEquals(1, events.size());
+ assertTrue(
+ events.get(0) instanceof AnimationEvent.InPeriodEvent,
+ "period-shape JSON must yield InPeriodEvent, got : " + events.get(0).getClass().getSimpleName()
+ );
+ }
+
+ @Test
+ void tickEvents_mixed_parses() {
+ String jsonText = """
+ [
+ {
+ "frame": 0.2,
+ "actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
+ },
+ {
+ "start": 0.5,
+ "end": 1.0,
+ "actions": [{"type":"tiedup:spawn_particle","particle":"minecraft:flame"}]
+ }
+ ]
+ """;
+ JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
+
+ List> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
+ assertEquals(2, events.size());
+ assertTrue(events.get(0) instanceof AnimationEvent.InTimeEvent);
+ assertTrue(events.get(1) instanceof AnimationEvent.InPeriodEvent);
+ }
+
+ // ===== Serialized event direct codec tests =====
+
+ @Test
+ void simpleSerializedEvent_codec_roundtrip() {
+ SimpleSerializedEvent ev = new SimpleSerializedEvent(
+ List.of(new PlaySoundAction(
+ new net.minecraft.resources.ResourceLocation("minecraft", "ui.button.click"),
+ 1.0F, 1.0F, net.minecraft.sounds.SoundSource.NEUTRAL
+ )),
+ AnimationEvent.Side.SERVER
+ );
+
+ DataResult enc =
+ SimpleSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
+ assertTrue(enc.result().isPresent(), "encode must succeed : " + enc.error());
+
+ DataResult dec =
+ SimpleSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
+ assertTrue(dec.result().isPresent(), "decode must succeed : " + dec.error());
+
+ SimpleSerializedEvent back = dec.result().get();
+ assertEquals(1, back.actions().size());
+ assertEquals(AnimationEvent.Side.SERVER, back.side());
+ }
+
+ @Test
+ void timeSerializedEvent_codec_roundtrip() {
+ TimeSerializedEvent ev = new TimeSerializedEvent(
+ 0.42F,
+ List.of(new PlaySoundAction(
+ new net.minecraft.resources.ResourceLocation("minecraft", "ambient.cave"),
+ 0.5F, 1.0F, net.minecraft.sounds.SoundSource.AMBIENT
+ )),
+ AnimationEvent.Side.CLIENT
+ );
+
+ DataResult enc =
+ TimeSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
+ assertTrue(enc.result().isPresent());
+
+ DataResult dec =
+ TimeSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
+ assertTrue(dec.result().isPresent());
+ assertEquals(0.42F, dec.result().get().frame(), 0.0001F);
+ }
+
+ @Test
+ void periodSerializedEvent_codec_roundtrip() {
+ PeriodSerializedEvent ev = new PeriodSerializedEvent(
+ 0.1F, 0.9F,
+ List.of(new PlaySoundAction(
+ new net.minecraft.resources.ResourceLocation("minecraft", "ambient.cave"),
+ 1.0F, 1.0F, net.minecraft.sounds.SoundSource.AMBIENT
+ )),
+ AnimationEvent.Side.BOTH
+ );
+
+ DataResult enc =
+ PeriodSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
+ assertTrue(enc.result().isPresent());
+
+ DataResult dec =
+ PeriodSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
+ assertTrue(dec.result().isPresent());
+ assertEquals(0.1F, dec.result().get().start(), 0.0001F);
+ assertEquals(0.9F, dec.result().get().end(), 0.0001F);
+ }
+
+ // ===== toRuntime conversion =====
+
+ @Test
+ void simpleSerializedEvent_toRuntime_producesNonNullSimpleEvent() {
+ SimpleSerializedEvent ev = new SimpleSerializedEvent(
+ List.of(), AnimationEvent.Side.BOTH
+ );
+ SimpleEvent> runtime = ev.toRuntime();
+ assertNotNull(runtime);
+ }
+
+ @Test
+ void timeSerializedEvent_toRuntime_producesInTimeEvent() {
+ TimeSerializedEvent ev = new TimeSerializedEvent(
+ 0.5F, List.of(), AnimationEvent.Side.BOTH
+ );
+ AnimationEvent, ?> runtime = ev.toRuntime();
+ assertTrue(runtime instanceof AnimationEvent.InTimeEvent);
+ }
+
+ @Test
+ void periodSerializedEvent_toRuntime_producesInPeriodEvent() {
+ PeriodSerializedEvent ev = new PeriodSerializedEvent(
+ 0.0F, 1.0F, List.of(), AnimationEvent.Side.BOTH
+ );
+ AnimationEvent, ?> runtime = ev.toRuntime();
+ assertTrue(runtime instanceof AnimationEvent.InPeriodEvent);
+ }
+
+ // ===== Edge cases =====
+
+ /**
+ * Empty list must parse to an empty runtime list (not a parse error).
+ */
+ @Test
+ void onBegin_emptyList_parsesToEmpty() {
+ JsonArray empty = new JsonArray();
+ List> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(empty);
+ assertNotNull(events);
+ assertTrue(events.isEmpty());
+ }
+
+ @Test
+ void tickEvents_emptyList_parsesToEmpty() {
+ JsonArray empty = new JsonArray();
+ List> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(empty);
+ assertNotNull(events);
+ assertTrue(events.isEmpty());
+ }
+
+ @Test
+ void beginEndEventsCodec_listOf_invokable() {
+ // Guard that the top-level codec is wired correctly — paranoia check.
+ assertNotNull(DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC);
+ assertNotNull(DataDrivenAnimationEvents.TICK_EVENTS_CODEC);
+ }
+}