feat(03-01): add GravityApplier with native PhysicsValues.invertedGravity toggle (Task 1)

- Service GravityApplier qui toggle PhysicsValues.invertedGravity via CommandBuffer.replaceComponent
- Thread-safe tracker ConcurrentHashMap.newKeySet<UUID> 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)
This commit is contained in:
2026-04-23 10:31:43 +02:00
parent 562f60d343
commit ef3f398c55
2 changed files with 268 additions and 0 deletions
@@ -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}.
*
* <p>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<UUID> previous = new HashSet<>(Set.of(a, b, c));
Set<UUID> 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<UUID> view = applier.previouslyInvertedView();
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
}
}