From f9d559583702b83f3fd4c0f1ef14e06ca579d0c2 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 14:00:56 +0200 Subject: [PATCH] feat(03-04): ajoute FallDamageGuard tracker thread-safe (Task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConcurrentHashMap current + exit + regionAtExit - shouldSuppressFallDamage: in-region FallDamage=false OU grace window - Pas de static mutable, injecté via constructeur - 7 tests pure-data couvrant entry/in-region/exit/grace/re-entry/override --- .../gravityflip/physics/FallDamageGuard.java | 85 ++++++++++++++++ .../physics/FallDamageGuardTest.java | 97 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java create mode 100644 src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java diff --git a/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java b/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java new file mode 100644 index 0000000..3b7c890 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java @@ -0,0 +1,85 @@ +package com.mythlane.gravityflip.physics; + +import com.mythlane.gravityflip.region.GravityFlipRegion; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID + * based on (a) current in-region membership, (b) post-exit grace window. + * + *

Pure-data service : no Hytale runtime dependency (no ECS, no PhysicsValues), injected + * via constructor into {@link GravityApplier} (populator) and + * {@link FallDamageSuppressorSystem} (consumer) — no static mutable state. + * + *

Precedence rule (multi-region) : when {@link #markInRegion} is called, the LAST + * call for a given UUID within a single tick wins. {@link GravityApplier} takes care of + * passing the FIRST matched region (iteration order of {@code enabledRegions}), so for an + * entity simultaneously in N regions the first-match region's {@code FallDamage} + * and {@code GracePeriodMs} drive suppression. + * + *

State machine : + *

+ */ +public final class FallDamageGuard { + + /** Entity currently inside a region (cleared on exit). */ + private final ConcurrentHashMap currentRegionByUuid = + new ConcurrentHashMap<>(); + + /** Timestamp (ms) at which the entity last exited a region. */ + private final ConcurrentHashMap exitTimestampByUuid = + new ConcurrentHashMap<>(); + + /** Region referenced at the moment of exit (used to read FallDamage + GracePeriodMs). */ + private final ConcurrentHashMap regionAtExitByUuid = + new ConcurrentHashMap<>(); + + public void markInRegion(UUID uuid, GravityFlipRegion region) { + if (uuid == null || region == null) return; + currentRegionByUuid.put(uuid, region); + // Re-entry ⇒ discard any stale grace entry (grace is a post-exit concept). + exitTimestampByUuid.remove(uuid); + regionAtExitByUuid.remove(uuid); + } + + public void markExit(UUID uuid, GravityFlipRegion region, long nowMs) { + if (uuid == null || region == null) return; + currentRegionByUuid.remove(uuid); + exitTimestampByUuid.put(uuid, nowMs); + regionAtExitByUuid.put(uuid, region); + } + + public boolean shouldSuppressFallDamage(UUID uuid, long nowMs) { + if (uuid == null) return false; + + GravityFlipRegion current = currentRegionByUuid.get(uuid); + if (current != null) { + return !current.isFallDamage(); + } + + Long exitMs = exitTimestampByUuid.get(uuid); + GravityFlipRegion exitRegion = regionAtExitByUuid.get(uuid); + if (exitMs == null || exitRegion == null) return false; + if (exitRegion.isFallDamage()) return false; // region allowed fall damage → no grace + return (nowMs - exitMs) <= exitRegion.getGracePeriodMs(); + } + + // ---- Test hooks / diagnostics (package-private) ---- + + int trackedInRegionCount() { return currentRegionByUuid.size(); } + int trackedGraceCount() { return exitTimestampByUuid.size(); } +} diff --git a/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java b/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java new file mode 100644 index 0000000..44c197f --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java @@ -0,0 +1,97 @@ +package com.mythlane.gravityflip.physics; + +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import com.mythlane.gravityflip.region.GravityFlipRegion; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-data tests for {@link FallDamageGuard} — no Hytale runtime dependency. + * Covers entry / in-region / exit / grace-window / re-entry / FallDamage=true override. + */ +class FallDamageGuardTest { + + @Test + void notInRegionReturnsFalse() { + FallDamageGuard guard = new FallDamageGuard(); + assertFalse(guard.shouldSuppressFallDamage(UUID.randomUUID(), 1000L)); + } + + @Test + void inRegionFallDamageTrueReturnsFalse() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion region = region(true, 2500); + guard.markInRegion(uuid, region); + assertFalse(guard.shouldSuppressFallDamage(uuid, 1000L)); + } + + @Test + void inRegionFallDamageFalseReturnsTrue() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion region = region(false, 2500); + guard.markInRegion(uuid, region); + assertTrue(guard.shouldSuppressFallDamage(uuid, 1000L)); + } + + @Test + void afterExitWithinGraceReturnsTrue() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion region = region(false, 2500); + guard.markInRegion(uuid, region); + long t0 = 1000L; + guard.markExit(uuid, region, t0); + assertTrue(guard.shouldSuppressFallDamage(uuid, t0 + 2499)); + } + + @Test + void afterExitBeyondGraceReturnsFalse() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion region = region(false, 2500); + guard.markInRegion(uuid, region); + long t0 = 1000L; + guard.markExit(uuid, region, t0); + assertFalse(guard.shouldSuppressFallDamage(uuid, t0 + 2501)); + } + + @Test + void reEntryAfterExitResetsTracking() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion region = region(false, 2500); + guard.markInRegion(uuid, region); + guard.markExit(uuid, region, 1000L); + guard.markInRegion(uuid, region); // re-enter + // In-region again with FallDamage=false → immediate suppression, grace reset. + assertTrue(guard.shouldSuppressFallDamage(uuid, 1500L)); + } + + @Test + void fallDamageTrueRegionNeverSuppressesEvenDuringGrace() { + FallDamageGuard guard = new FallDamageGuard(); + UUID uuid = UUID.randomUUID(); + GravityFlipRegion suppressed = region(false, 2500); + GravityFlipRegion allowed = region(true, 2500); + guard.markInRegion(uuid, suppressed); + guard.markExit(uuid, suppressed, 1000L); + // New region has FallDamage=true → override immediately. + guard.markInRegion(uuid, allowed); + assertFalse(guard.shouldSuppressFallDamage(uuid, 1500L)); + } + + private static GravityFlipRegion region(boolean fallDamage, int graceMs) { + GravityFlipRegion r = new GravityFlipRegion( + "t", new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true); + r.setFallDamage(fallDamage); + r.setGracePeriodMs(graceMs); + return r; + } +}