From ef3f398c551fa4723aa6dfd5519e54abdd77ea56 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 10:31:43 +0200 Subject: [PATCH] feat(03-01): add GravityApplier with native PhysicsValues.invertedGravity toggle (Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service GravityApplier qui toggle PhysicsValues.invertedGravity via CommandBuffer.replaceComponent - Thread-safe tracker ConcurrentHashMap.newKeySet pour dedup entre ticks - Second pass O(N) pour restaurer la gravité à la sortie de région (trade-off v1 documenté) - Pure diff static helper + hooks package-private pour tests unitaires sans runtime Hytale - 6 tests verts (diff + tracker semantics) --- .../gravityflip/physics/GravityApplier.java | 188 ++++++++++++++++++ .../physics/GravityApplierDiffTest.java | 80 ++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java create mode 100644 src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java diff --git a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java new file mode 100644 index 0000000..8539594 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java @@ -0,0 +1,188 @@ +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); + } +} diff --git a/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java new file mode 100644 index 0000000..1e8306f --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java @@ -0,0 +1,80 @@ +package com.mythlane.gravityflip.physics; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pure-diff + tracker-semantics tests for {@link GravityApplier}. + * + *

Pas de mocks (pattern Phase 02-02 deviation #4). Pas de runtime Hytale requis — + * les tests 5 et 6 utilisent le hook package-private {@code __updateTrackerForTest} et + * la vue {@code previouslyInvertedView()} pour valider la sémantique du tracker sans + * toucher à {@code World} / {@code Store}. + */ +class GravityApplierDiffTest { + + @Test + void diffComputesToFlipAndToRestore() { + UUID a = UUID.randomUUID(), b = UUID.randomUUID(), c = UUID.randomUUID(), d = UUID.randomUUID(); + Set previous = new HashSet<>(Set.of(a, b, c)); + Set current = new HashSet<>(Set.of(b, c, d)); + GravityApplier.DiffResult res = GravityApplier.diff(previous, current); + assertEquals(Set.of(d), res.toFlip); + assertEquals(Set.of(a), res.toRestore); + } + + @Test + void diffEmptyPreviousFlipsAll() { + UUID x = UUID.randomUUID(), y = UUID.randomUUID(); + GravityApplier.DiffResult res = GravityApplier.diff(new HashSet<>(), new HashSet<>(Set.of(x, y))); + assertEquals(Set.of(x, y), res.toFlip); + assertTrue(res.toRestore.isEmpty()); + } + + @Test + void diffEmptyCurrentRestoresAll() { + UUID x = UUID.randomUUID(), y = UUID.randomUUID(); + GravityApplier.DiffResult res = GravityApplier.diff(new HashSet<>(Set.of(x, y)), new HashSet<>()); + assertTrue(res.toFlip.isEmpty()); + assertEquals(Set.of(x, y), res.toRestore); + } + + @Test + void diffNoChange() { + UUID a = UUID.randomUUID(); + GravityApplier.DiffResult res = GravityApplier.diff( + new HashSet<>(Set.of(a)), new HashSet<>(Set.of(a))); + assertTrue(res.toFlip.isEmpty()); + assertTrue(res.toRestore.isEmpty()); + } + + @Test + void trackerInitiallyEmpty() { + GravityApplier applier = new GravityApplier(t -> {}); + assertTrue(applier.previouslyInvertedView().isEmpty()); + } + + @Test + void trackerEvolvesViaPackagePrivateUpdate() { + GravityApplier applier = new GravityApplier(t -> {}); + UUID a = UUID.randomUUID(), b = UUID.randomUUID(), c = UUID.randomUUID(); + + applier.__updateTrackerForTest(new HashSet<>(Set.of(a, b))); + assertEquals(Set.of(a, b), applier.previouslyInvertedView()); + + applier.__updateTrackerForTest(new HashSet<>()); + assertTrue(applier.previouslyInvertedView().isEmpty()); + + applier.__updateTrackerForTest(new HashSet<>(Set.of(c))); + assertEquals(Set.of(c), applier.previouslyInvertedView()); + + // View is immutable. + Set view = applier.previouslyInvertedView(); + assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID())); + } +}