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}, 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 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 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 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 : + * + *

+ * + *

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 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 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 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 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); + } +}