package com.mythlane.gravityflip.physics; import com.hypixel.hytale.component.ComponentType; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.server.core.entity.UUIDComponent; import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.mythlane.gravityflip.region.GravityFlipRegion; import com.mythlane.gravityflip.region.RegionSnapshot; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; /** * Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every * entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via * {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)} * lambda — the ECS engine commits them on the main thread after the parallel pass. * *

Dedup: a thread-safe {@link Set}{@code } tracks entities flipped at the previous tick. * Entities that left an active region are restored to {@code invertedGravity=false} in a second * pass (trade-off documented in the plan — second O(N) scan accepted v1). */ public final class GravityApplier { // Lazy ComponentType holders — pattern identique à RegionRegistry.transform() // (évite static-init Hytale PluginBase pendant les tests). private static volatile ComponentType physicsType; private static volatile ComponentType uuidType; private static volatile ComponentType transformType; private static ComponentType physicsType() { ComponentType t = physicsType; if (t == null) { synchronized (GravityApplier.class) { t = physicsType; if (t == null) { t = PhysicsValues.getComponentType(); physicsType = t; } } } return t; } private static ComponentType uuidType() { ComponentType t = uuidType; if (t == null) { synchronized (GravityApplier.class) { t = uuidType; if (t == null) { t = UUIDComponent.getComponentType(); uuidType = t; } } } return t; } private static ComponentType transformType() { ComponentType t = transformType; if (t == null) { synchronized (GravityApplier.class) { t = transformType; if (t == null) { t = TransformComponent.getComponentType(); transformType = t; } } } return t; } // THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire. private final Set previouslyInverted = ConcurrentHashMap.newKeySet(); private final Consumer errorHandler; public GravityApplier(Consumer errorHandler) { this.errorHandler = errorHandler == null ? t -> {} : errorHandler; } /** Tick entry point. NO-OP si world ou snapshot est null. */ public void apply(World world, RegionSnapshot snapshot) { if (world == null || snapshot == null) return; Collection enabledRegions = snapshot.byRegion().keySet(); // Stratégie figée (cf. WARNING 1 résolu) : on accepte la duplication du containsPosition // (cf. must_haves trade-off) plutôt que de coupler à un nouveau contrat RegionSnapshot // qui exposerait des UUIDs préformés. Coût : 2 pass O(N) sur le store par tick. try { Store store = world.getEntityStore().getStore(); ComponentType PHYST = physicsType(); ComponentType UUIDT = uuidType(); ComponentType TT = transformType(); // PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON // via cmdBuf.replaceComponent ET enregistre l'UUID dans currentlyInRegion (thread-safe). // THREADING: lambda sur worker ECS → currentlyInRegion = ConcurrentHashMap.newKeySet ; toutes les // mutations PhysicsValues passent par cmdBuf (jamais replaceValues direct). Set currentlyInRegion = ConcurrentHashMap.newKeySet(); store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> { TransformComponent t; UUIDComponent uc; PhysicsValues v; try { t = chunk.getComponent(index, TT); uc = chunk.getComponent(index, UUIDT); v = chunk.getComponent(index, PHYST); } catch (Throwable ignored) { return; } if (t == null || uc == null || v == null) return; com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition(); double x = pos.x, y = pos.y, z = pos.z; boolean inAnyRegion = false; for (GravityFlipRegion r : enabledRegions) { if (r.asBox().containsPosition(x, y, z)) { inAnyRegion = true; break; } } if (!inAnyRegion) return; UUID u = uc.getUuid(); currentlyInRegion.add(u); if (!v.isInvertedGravity()) { Ref ref = chunk.getReferenceTo(index); cmdBuf.replaceComponent(ref, PHYST, new PhysicsValues(v.getMass(), v.getDragCoefficient(), true)); } }); // PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion, // re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf. // Trade-off perf accepté v1 — voir SUMMARY follow-up. Set toRestore = ConcurrentHashMap.newKeySet(); toRestore.addAll(previouslyInverted); toRestore.removeAll(currentlyInRegion); if (!toRestore.isEmpty()) { store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> { UUIDComponent uc; PhysicsValues v; try { uc = chunk.getComponent(index, UUIDT); v = chunk.getComponent(index, PHYST); } catch (Throwable ignored) { return; } if (uc == null || v == null) return; if (toRestore.contains(uc.getUuid()) && v.isInvertedGravity()) { Ref ref = chunk.getReferenceTo(index); cmdBuf.replaceComponent(ref, PHYST, new PhysicsValues(v.getMass(), v.getDragCoefficient(), false)); } }); } // Update tracker — ces ops sont sur le tick thread après la fin du pass parallel. // ConcurrentHashMap.newKeySet supporte clear()/addAll() concurrents safely. previouslyInverted.clear(); previouslyInverted.addAll(currentlyInRegion); } catch (Throwable th) { errorHandler.accept(th); } } /** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */ public static DiffResult diff(Set previous, Set current) { Set toFlip = new HashSet<>(current); toFlip.removeAll(previous); Set toRestore = new HashSet<>(previous); toRestore.removeAll(current); return new DiffResult(toFlip, toRestore); } public static final class DiffResult { public final Set toFlip; public final Set toRestore; DiffResult(Set f, Set r) { this.toFlip = f; this.toRestore = r; } } // ---- Test hooks (package-private) ---- /** Vue immuable du tracker — pour tests unitaires sémantiques (résolution WARNING 2). */ Set previouslyInvertedView() { return Collections.unmodifiableSet(previouslyInverted); } /** * Force une valeur du tracker hors runtime ECS — pour tester la sémantique sans dépendre de World/Store. * Package-private : NE PAS appeler depuis le code de production. */ void __updateTrackerForTest(Set newState) { previouslyInverted.clear(); previouslyInverted.addAll(newState); } }