/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.TiedUpAnimationRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.client.ClientAnimator;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.PlayerPatch;
import com.tiedup.remake.rig.patch.TiedUpCapabilities;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.datadriven.AnimationBindings;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
/**
* Orchestrateur client-side — rebuild la map {@code livingAnimations} de
* l'animator RIG d'un {@link Player} en fonction des items bondage qu'il
* porte (P3-05).
*
*
Call sites (P3-06 & co)
*
* - {@code PacketSyncV2Equipment.handleOnClient} après
* {@code IV2BondageEquipment} deserialize pour une remote entity,
* - {@code LivingEquipmentChangeEvent} (filtré sur slots V2, ignorer
* vanilla armor),
* - {@code ClientPlayerNetworkEvent.LoggingIn} et
* {@code EntityJoinLevelEvent} (P3-20 rehydrate entrée de world /
* chunk load race).
*
*
* Workflow
*
* - Récupère le {@link PlayerPatch} via capability ; abort si absent
* (player pas encore patché, rare mais arrivable avant la fin du
* {@code onConstructed}).
* - Récupère le {@link ClientAnimator} ; abort si null (server side ou
* race avec {@code Animator} stripping durant chunk unload).
* - Appelle {@link ClientAnimator#resetLivingAnimations()} — les defaults
* EF (IDLE, WALK, etc.) sont re-poussés dans la map par le cycle
* {@code clear() + defaultLivingAnimations.forEach(addLivingAnimation)}
* implémenté dans {@link ClientAnimator}.
* - Lit {@link V2EquipmentHelper#getAllEquipped} — déjà dédupliqué au
* niveau capability ({@link com.tiedup.remake.v2.bondage.IV2BondageEquipment}
* contract), on applique néanmoins une dédup défensive par identity
* pour ne pas dépendre d'un invariant en amont qui pourrait changer.
* - Filtre les items non data-driven (V2 legacy sans
* {@link DataDrivenItemDefinition}) et trie par
* {@link DataDrivenItemDefinition#posePriority} ASC — ainsi le plus
* prioritaire itère EN DERNIER, et son {@code addLivingAnimation} écrase
* les lower-priority pour une même {@link LivingMotion} (sémantique
* {@code Map.put()} : dernier écrivain gagne).
* - Pour chaque item, si {@link DataDrivenItemDefinition#animations()}
* est non-null et non-vide, ajoute chaque binding via
* {@link ClientAnimator#addLivingAnimation}. Les IDs inconnus sont
* résolus via {@link TiedUpAnimationRegistry#resolveWithFallback}
* (fallback {@code EMPTY_ANIMATION} + WARN une fois par ID).
*
*
* Préconditions
*
* - Option B modder convention : les items bondage JSON NE bindent
* PAS {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. Le default
* EF IDLE re-injecté par {@code resetLivingAnimations} reste donc
* visible. Laisser un item binder IDLE est toléré (l'anim custom
* écrase) mais c'est un code smell — voir docs plan P3.
* - Appelé depuis le client main thread uniquement. L'animator
* n'est pas thread-safe.
* - Le package {@code v2.client} est strictement client-only malgré
* l'absence d'{@code @OnlyIn} sur {@link BondageStateHelpers} (pas
* d'imports client-only dedans). Cette classe-ci référence
* {@link ClientAnimator} → {@code @OnlyIn(Dist.CLIENT)} est requis
* pour éviter le class-load server-side.
*
*
* Design notes
*
* L'entrée publique {@link #rebuildBondageAnimations(Player)} est
* side-gated mais non testable sans MC runtime (player réel, capability
* réelle, animator réel). La logique pure est extraite dans deux helpers
* package-private :
*
* - {@link #extractSortedDefinitions} — dedup identity + filter +
* sort. Pur Java, testable sans aucun bootstrap.
* - {@link #applyDefinitions} — itère + appelle reset/add via des
* callbacks injectables ({@link Runnable} +
* {@link LivingAnimationAdder}). Testable avec un fake adder qui
* capture les appels dans une list.
*
*
* @see AnimationBindings
* @see TiedUpAnimationRegistry#resolveWithFallback(ResourceLocation)
*/
@OnlyIn(Dist.CLIENT)
public final class ClientRigEquipmentHandler {
private ClientRigEquipmentHandler() {
// utility class
}
/**
* Functional adapter autour de {@link ClientAnimator#addLivingAnimation}
* pour permettre l'injection en tests sans bootstrap MC.
*
* En production, l'implémentation est une méthode-ref
* {@code animator::addLivingAnimation}. En test, un fake qui capture les
* appels dans une collection (voir {@code ClientRigEquipmentHandlerTest}).
*/
@FunctionalInterface
interface LivingAnimationAdder {
void add(LivingMotion motion, AssetAccessor extends StaticAnimation> accessor);
}
/**
* Entry point publique — rebuild la map {@code livingAnimations} du
* {@link ClientAnimator} du player en fonction de ses items bondage V2
* équipés.
*
* No-op si :
*
* - {@code player == null}
* - pas de {@link PlayerPatch} capability attachée
* - pas de {@link ClientAnimator} (server side ou anim pipeline pas
* encore bootstrap)
*
*
* Null-safety : seuls {@code player}, {@code patch} et
* {@code animator} sont null-checkés ici. Les autres scénarios null
* (bindings null, stack empty dans l'equipment, id null) sont gérés
* inline dans {@link #applyDefinitions} et
* {@link #extractSortedDefinitions} — chacun skip silencieusement sans
* throw.
*
* Contract – ne JAMAIS appeler
* {@code animator.setCurrentMotionsAsDefault()} ailleurs dans le
* pipeline TiedUp. Cette méthode EF consolide les bindings custom
* courants dans la snapshot {@code defaultLivingAnimations} ; un
* {@code resetLivingAnimations()} ultérieur les restaurerait alors
* comme "defaults" après un unbind, et l'anim custom fuiterait au-delà
* de la durée de vie de l'item équipé. En prod EF,
* {@code setCurrentMotionsAsDefault} n'est appelé qu'une fois depuis
* {@code ClientAnimator.postInit()} pendant le bootstrap de
* l'animator, capturant les IDLE/WALK/RUN vanilla comme snapshot
* pérenne. Le rebuild TiedUp compte sur cet invariant : tout rebuild
* part d'un état EF vanilla stable.
*
* @param player le player dont on rebuild les anims ; null tolérée
*/
public static void rebuildBondageAnimations(Player player) {
if (player == null) return;
PlayerPatch> patch = TiedUpCapabilities.getPlayerPatch(player);
if (patch == null) return;
ClientAnimator animator = patch.getClientAnimator();
if (animator == null) return;
Map equipped = V2EquipmentHelper.getAllEquipped(player);
// La capability dédupe déjà par identity (cf V2BondageEquipment.getAllEquipped,
// IdentityHashMap seen+LinkedHashMap result) — on applique néanmoins notre
// propre dédup défensive pour ne pas dépendre d'un invariant qu'un refactor
// futur pourrait casser silencieusement.
List sortedDefs =
extractSortedDefinitions(equipped.values(), DataDrivenItemRegistry::get);
// Reset + restore des defaults EF (IDLE, WALK, RUN, etc.). Les bindings
// custom qu'on ajoute après prennent le pas sur ces defaults via la
// map livingAnimations (dernière put gagne pour une même LivingMotion).
//
// IMPORTANT : ne jamais appeler animator.setCurrentMotionsAsDefault()
// ailleurs dans le pipeline TiedUp — cela consoliderait les custom
// bindings dans les defaults, et un unbind ultérieur laisserait les
// anims en place (le reset restaurerait les ex-custom comme "defaults").
// Pour un rebuild propre, on compte sur le fait que defaultLivingAnimations
// reste la snapshot originale EF (IDLE/WALK/RUN vanilla, capturée une
// seule fois dans ClientAnimator.postInit() durant le bootstrap).
applyDefinitions(
animator::resetLivingAnimations,
animator::addLivingAnimation,
sortedDefs,
TiedUpAnimationRegistry::resolveWithFallback
);
TiedUpRigConstants.LOGGER.debug(
"[ClientRigEquipmentHandler] Rebuilt livingAnimations for player {} "
+ "({} data-driven items processed)",
player.getName().getString(),
sortedDefs.size()
);
}
/**
* Collecte les {@link DataDrivenItemDefinition} associées aux items
* d'entrée, dédupe par identity, et trie par {@code posePriority}
* ascendant.
*
* Pure fonction sans dépendance MC — testable unit. Le resolver
* permet de mocker {@code DataDrivenItemRegistry.get(stack)} en test.
* Un item dont le resolver retourne {@code null} (item non data-driven)
* est skip silencieusement.
*
* Tri ASC volontaire : dans {@link #applyDefinitions} la map
* {@code livingAnimations} suit la sémantique "dernier put gagne", donc
* pour qu'un item de priorité 20 batte un item de priorité 10 sur une
* même {@link LivingMotion}, on doit itérer le priorité 10 d'abord et
* le priorité 20 ensuite (il écrase).
*
* Sort déterministe :
*
* - Primary : {@code posePriority} ASC → highest-priority itère en
* dernier → gagne le conflit (last put wins dans
* {@code addLivingAnimation} map semantics).
* - Secondary : {@code id.toString()} lexicographique → tiebreaker
* stable indépendant de l'ordre de déclaration de
* {@link BodyRegionV2}. Deux items avec même {@code posePriority}
* résolvent sur leur {@link ResourceLocation} — un dev qui
* réordonne l'enum ne change plus silencieusement quelle anim
* gagne les conflits priority-tied.
*
*
* Note sur la généricité : l'API est paramétrée {@code }
* plutôt que fixée à {@link ItemStack} pour permettre un unit test sans
* bootstrap MC (le test passe des {@code Object} dummy, la prod passe
* des {@code ItemStack}). Aucune méthode d'instance n'est appelée sur
* le type — seule l'identité objet est utilisée, donc l'abstraction est
* safe.
*
* @param type des éléments (typiquement {@link ItemStack} en
* prod, {@link Object} en test)
* @param stacks iterable d'items (peut contenir des duplicats identity
* pour les multi-region items)
* @param resolver fonction {@code item → DataDrivenItemDefinition}
* (null pour items non data-driven)
* @return liste dédupliquée + triée ASC par posePriority ; non-null,
* possiblement vide
*/
static List extractSortedDefinitions(
Iterable stacks,
Function resolver
) {
// Dedup par identity — Collections.newSetFromMap(IdentityHashMap) est
// préféré à HashSet+equals() pour la sémantique "== vs equals()".
// Deux ItemStacks avec les mêmes NBT mais instances distinctes sont
// traités comme items distincts (convention V2 : un stack = une
// occurrence équipée).
Set unique = Collections.newSetFromMap(new IdentityHashMap<>());
List defs = new ArrayList<>();
for (T stack : stacks) {
if (stack == null) continue;
if (!unique.add(stack)) continue; // duplicate identity (multi-region item)
DataDrivenItemDefinition def = resolver.apply(stack);
if (def == null) continue; // V2 legacy item sans JSON definition
defs.add(def);
}
// Sort déterministe :
// 1. Primary : posePriority ASC → highest-priority itère en dernier →
// gagne le conflit (last put wins dans addLivingAnimation map
// semantics).
// 2. Secondary : id.toString() lexicographique → tiebreaker stable
// indépendant de BodyRegionV2 enum declaration order. Deux items
// avec même posePriority résolvent sur leur ResourceLocation.
defs.sort(
Comparator.comparingInt(DataDrivenItemDefinition::posePriority)
.thenComparing(d -> d.id().toString())
);
return defs;
}
/**
* Pousse les bindings de chaque définition dans l'animator via les
* callbacks fournis.
*
* Workflow strict :
*
* - Appelle {@code resetCallback} une fois — le contrat attendu est
* que cela vide la map {@code livingAnimations} et la repopule avec
* les defaults EF (comportement de
* {@link ClientAnimator#resetLivingAnimations}).
* - Pour chaque définition (déjà triée ASC par posePriority), si elle
* porte un {@link AnimationBindings} non-null et non-vide, itère
* les entries {@code livingMotions()} et appelle {@code adder} avec
* l'{@link AssetAccessor} résolu via {@code animResolver}.
*
*
* Si {@code animations()} est null (99% des items V2 legacy) ou
* {@link AnimationBindings#isEmpty()}, on skip — pas d'appel adder. Pas
* de warn : la majorité des items n'ont légitimement pas de binding.
*
* @param resetCallback callback pour reset la map animator (en prod:
* {@code animator::resetLivingAnimations})
* @param adder callback pour ajouter un binding
* {@code (motion, accessor)}
* @param sortedDefinitions liste triée ASC par posePriority
* @param animResolver résout un {@link ResourceLocation} en
* {@link AssetAccessor} — en prod
* {@link TiedUpAnimationRegistry#resolveWithFallback}
*/
static void applyDefinitions(
Runnable resetCallback,
LivingAnimationAdder adder,
List sortedDefinitions,
Function> animResolver
) {
resetCallback.run();
for (DataDrivenItemDefinition def : sortedDefinitions) {
AnimationBindings bindings = def.animations();
if (bindings == null || bindings.isEmpty()) continue;
for (Map.Entry entry
: bindings.livingMotions().entrySet()) {
AssetAccessor extends StaticAnimation> accessor =
animResolver.apply(entry.getValue());
adder.add(entry.getKey(), accessor);
}
}
}
}