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