From 99b9072b7887a01af71a3568e8bc0969f2b8e767 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 14:54:31 +0200 Subject: [PATCH] feat(03-05): ajoute RegionVisualizer (parsing hex/mode, throttling, low-level DebugUtils.add) (Task 2) - RegionVisualizer emet DisplayDebug cube par region via DebugUtils.add(World, DebugShape.Cube, Matrix4d, color, opacity, time, flags) (low-level => opacity custom respectee, vs addCube qui hardcode 0.8). - Mapping modes: Outline=FLAG_NO_SOLID, Faces=FLAG_NO_WIREFRAME, Both=FLAG_NONE, None=skip, unknown=fallback Outline. - parseColor(#RRGGBB) avec fallback COLOR_CYAN sur input invalide. - Throttling par region via ConcurrentHashMap + clock injecte. - Clamp defensive: VisualRefreshMs plancher 100ms (anti-DoS T-03-05-03), VisualOpacity clamp [0,1] (T-03-05-04). - TTL = refreshMs * 1.2 / 1000 (overlap 20% anti-flicker). - Toute emission wrappee dans world.execute(...) via WorldExecutor injectable. - Tests: parseColor valid/invalid, normalizeMode, flagsForMode, matrixFromBox non-cubique, throttling deterministe, skip None/disabled, clamp opacity/refresh. --- .../gravityflip/viz/RegionVisualizer.java | 214 +++++++++++++++++ .../gravityflip/viz/RegionVisualizerTest.java | 222 ++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java create mode 100644 src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java diff --git a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java new file mode 100644 index 0000000..41af469 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java @@ -0,0 +1,214 @@ +package com.mythlane.gravityflip.viz; + +import com.hypixel.hytale.math.matrix.Matrix4d; +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import com.hypixel.hytale.math.vector.Vector3f; +import com.hypixel.hytale.protocol.DebugShape; +import com.hypixel.hytale.server.core.modules.debug.DebugUtils; +import com.hypixel.hytale.server.core.universe.world.World; +import com.mythlane.gravityflip.region.GravityFlipRegion; +import com.mythlane.gravityflip.region.RegionSnapshot; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** + * Plan 03-05 : service qui émet des cubes debug (DisplayDebug / ClearDebugShapes) pour + * matérialiser chaque région de gravity-flip côté clients. Ne crée aucun scheduler ; + * {@link #visualize(World, RegionSnapshot)} est appelé à chaque tick par + * {@code RegionTickLoop}, avec throttling par région via {@code VisualRefreshMs}. + * + *

Mapping VisualMode → flags (cf. {@link DebugUtils}) : + *

+ * + *

Opacity caveat : {@link DebugUtils#addCube} hardcode {@code opacity=0.8} ; + * on passe donc par le low-level {@link DebugUtils#add(World, DebugShape, Matrix4d, Vector3f, float, float, int)} + * qui accepte l'opacity custom. TTL = {@code refreshMs * 1.2 / 1000} (overlap 20 % + * pour éviter le flicker entre deux émissions). + * + *

Threading : {@code lastEmitMs} est un {@link ConcurrentHashMap} car + * {@code RegionTickLoop} tourne sur un thread daemon séparé. Chaque appel + * {@link DebugUtils} est wrappé dans {@code world.execute(...)} pour satisfaire + * l'assert WorldThread (pattern identique à {@code RegionRegistry.refreshFor}). + * + *

Anti-DoS : {@code VisualRefreshMs} est clampé à un plancher pour + * empêcher une valeur pathologique (0 ou très petite) de saturer les clients + * avec des paquets {@code DisplayDebug}. + */ +public final class RegionVisualizer { + + /** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */ + static final int MIN_REFRESH_MS = 100; + + /** Testability: emitter injectable (prod = DebugUtils.add wrapper). */ + @FunctionalInterface + public interface DebugEmitter { + void emit(World world, DebugShape shape, Matrix4d matrix, + Vector3f color, float opacity, float time, int flags); + } + + /** Testability: executor injectable (prod = {@code world::execute}). */ + @FunctionalInterface + public interface WorldExecutor { + void execute(World world, Runnable r); + } + + private static final DebugEmitter DEFAULT_EMITTER = + (world, shape, matrix, color, opacity, time, flags) -> + DebugUtils.add(world, shape, matrix, color, opacity, time, flags); + + private static final WorldExecutor DEFAULT_EXECUTOR = + (world, r) -> world.execute(r); + + private final Consumer errorHandler; + private final DebugEmitter emitter; + private final WorldExecutor executor; + private final LongSupplier clock; + private final Map lastEmitMs = new ConcurrentHashMap<>(); + + public RegionVisualizer(Consumer errorHandler) { + this(errorHandler, DEFAULT_EMITTER, DEFAULT_EXECUTOR, System::currentTimeMillis); + } + + /** Package-private ctor pour tests (emitter, executor, clock injectés). */ + RegionVisualizer(Consumer errorHandler, + DebugEmitter emitter, + WorldExecutor executor, + LongSupplier clock) { + this.errorHandler = errorHandler == null ? t -> {} : errorHandler; + this.emitter = emitter; + this.executor = executor; + this.clock = clock; + } + + /** + * Émet un cube debug pour chaque région éligible du {@code snapshot}, en respectant + * mode / couleur / opacity / throttling. Ne propage aucune exception : tout throw + * est routé vers l'errorHandler (le tick ne doit pas mourir). + */ + public void visualize(World world, RegionSnapshot snapshot) { + if (snapshot == null) return; + try { + Map byRegion = snapshot.byRegion(); + if (byRegion == null) return; + for (GravityFlipRegion r : byRegion.keySet()) { + emitOne(world, r); + } + } catch (Throwable th) { + errorHandler.accept(th); + } + } + + private void emitOne(World world, GravityFlipRegion r) { + if (r == null || !r.isEnabled()) return; + String mode = normalizeMode(r.getVisualMode()); + if ("None".equals(mode)) return; + int flags = flagsForMode(mode); + + int refreshMs = Math.max(MIN_REFRESH_MS, r.getVisualRefreshMs()); + long now = clock.getAsLong(); + Long last = lastEmitMs.get(r.getName()); + if (last != null && (now - last) < refreshMs) return; + + Vector3f color = parseColor(r.getVisualColor()); + Matrix4d matrix = matrixFromBox(r.getBox()); + float ttlSeconds = refreshMs * 1.2f / 1000f; + float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0); + + lastEmitMs.put(r.getName(), now); + + try { + executor.execute(world, () -> { + try { + emitter.emit(world, DebugShape.Cube, matrix, color, opacity, ttlSeconds, flags); + } catch (Throwable th) { + errorHandler.accept(th); + } + }); + } catch (Throwable th) { + errorHandler.accept(th); + } + } + + /** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */ + public void clearAll(World world) { + if (world == null) return; + try { + executor.execute(world, () -> { + try { + DebugUtils.clear(world); + } catch (Throwable th) { + errorHandler.accept(th); + } + }); + } catch (Throwable th) { + errorHandler.accept(th); + } + } + + // ---------- helpers (package-private pour tests) ---------- + + /** Parse {@code #RRGGBB} → Vector3f(r/255, g/255, b/255) ; fallback COLOR_CYAN sur toute erreur. */ + static Vector3f parseColor(String hex) { + if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') { + return new Vector3f(DebugUtils.COLOR_CYAN); + } + try { + int r = Integer.parseInt(hex.substring(1, 3), 16); + int g = Integer.parseInt(hex.substring(3, 5), 16); + int b = Integer.parseInt(hex.substring(5, 7), 16); + return new Vector3f(r / 255.0f, g / 255.0f, b / 255.0f); + } catch (NumberFormatException e) { + return new Vector3f(DebugUtils.COLOR_CYAN); + } + } + + /** Normalise le mode : un mode inconnu → "Outline" ; null → "Outline". */ + static String normalizeMode(String mode) { + if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode)) { + return mode; + } + return "Outline"; + } + + /** Mapping VisualMode → flags DebugUtils. "None" n'est jamais passé ici (skip en amont). */ + static int flagsForMode(String mode) { + switch (normalizeMode(mode)) { + case "Faces": return DebugUtils.FLAG_NO_WIREFRAME; + case "Both": return DebugUtils.FLAG_NONE; + case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant. + case "Outline": + default: return DebugUtils.FLAG_NO_SOLID; + } + } + + /** Identity.translate(center).scale(sizeX, sizeY, sizeZ) pour une Box non-cubique. */ + static Matrix4d matrixFromBox(Box box) { + Vector3d min = box.min; + Vector3d max = box.max; + double cx = (min.x + max.x) * 0.5; + double cy = (min.y + max.y) * 0.5; + double cz = (min.z + max.z) * 0.5; + double sx = max.x - min.x; + double sy = max.y - min.y; + double sz = max.z - min.z; + Matrix4d m = new Matrix4d(); + m.identity(); + m.translate(cx, cy, cz); + m.scale(sx, sy, sz); + return m; + } + + static double clamp(double v, double lo, double hi) { + return v < lo ? lo : (v > hi ? hi : v); + } +} diff --git a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java new file mode 100644 index 0000000..0b4460b --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java @@ -0,0 +1,222 @@ +package com.mythlane.gravityflip.viz; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.math.matrix.Matrix4d; +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import com.hypixel.hytale.math.vector.Vector3f; +import com.hypixel.hytale.protocol.DebugShape; +import com.hypixel.hytale.server.core.modules.debug.DebugUtils; +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 org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests unitaires pour {@link RegionVisualizer}. Le {@code World} n'est jamais touché + * (pas mockable sous JDK 25) : le {@code WorldExecutor} injecté exécute la lambda + * inline sans ré-entrer dans World, et le {@code DebugEmitter} pousse dans une liste. + */ +class RegionVisualizerTest { + + private static final class Call { + final DebugShape shape; final Matrix4d matrix; final Vector3f color; + final float opacity; final float time; final int flags; + Call(DebugShape s, Matrix4d m, Vector3f c, float o, float t, int f) { + shape = s; matrix = m; color = c; opacity = o; time = t; flags = f; + } + } + + // ---------- parseColor ---------- + + @Test + void parseColor_validHex() { + Vector3f c = RegionVisualizer.parseColor("#FF8800"); + assertEquals(1.0f, c.x, 0.01f); + assertEquals(0.533f, c.y, 0.01f); + assertEquals(0.0f, c.z, 0.01f); + } + + @Test + void parseColor_invalidHex_fallsBackToCyan() { + Vector3f cyan = DebugUtils.COLOR_CYAN; + for (String bad : new String[]{null, "", "not-a-hex", "#ZZZZZZ", "#12345", "FF8800"}) { + Vector3f c = RegionVisualizer.parseColor(bad); + assertEquals(cyan.x, c.x, 0.0001f, "input=" + bad); + assertEquals(cyan.y, c.y, 0.0001f, "input=" + bad); + assertEquals(cyan.z, c.z, 0.0001f, "input=" + bad); + } + } + + // ---------- normalizeMode / flagsForMode ---------- + + @Test + void parseMode_unknown_fallsBackToOutline() { + assertEquals("Outline", RegionVisualizer.normalizeMode("Blah")); + assertEquals("Outline", RegionVisualizer.normalizeMode(null)); + assertEquals("Faces", RegionVisualizer.normalizeMode("Faces")); + assertEquals("Both", RegionVisualizer.normalizeMode("Both")); + assertEquals("None", RegionVisualizer.normalizeMode("None")); + } + + @Test + void flagsForMode_mapping() { + assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("Outline")); + assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces")); + assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both")); + // unknown → Outline + assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx")); + } + + // ---------- matrixFromBox ---------- + + @Test + void matrix_boxNonCubic() { + Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6)); + Matrix4d m = RegionVisualizer.matrixFromBox(b); + double[] d = m.getData(); + // column-major : scale en diag [0][5][10], translation en [12][13][14] + assertEquals(2.0, d[0], 1e-9); + assertEquals(4.0, d[5], 1e-9); + assertEquals(6.0, d[10], 1e-9); + assertEquals(1.0, d[12], 1e-9); // center x = 1 + assertEquals(2.0, d[13], 1e-9); // center y = 2 + assertEquals(3.0, d[14], 1e-9); // center z = 3 + } + + // ---------- visualize : throttling / modes / skip ---------- + + @Test + void visualize_throttlingSkipsSecondCallWithinWindow() { + List calls = new ArrayList<>(); + AtomicLong clock = new AtomicLong(1_000L); + RegionVisualizer viz = new RegionVisualizer( + t -> fail("errorHandler unexpectedly called: " + t), + (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)), + (w, r) -> r.run(), + clock::get); + + GravityFlipRegion r = region("z1", "#FF8800", "Outline", 1000, 0.5); + RegionSnapshot snap = snapshotOf(r); + + viz.visualize(null, snap); + assertEquals(1, calls.size(), "premier tick émet"); + + clock.set(1_500L); // +500ms < 1000 refreshMs + viz.visualize(null, snap); + assertEquals(1, calls.size(), "deuxième tick throttle"); + + clock.set(2_100L); // +1100ms >= 1000 + viz.visualize(null, snap); + assertEquals(2, calls.size(), "troisième tick ré-émet après refreshMs"); + } + + @Test + void visualize_skipsWhenModeNone() { + List calls = new ArrayList<>(); + RegionVisualizer viz = newViz(calls, 0L); + GravityFlipRegion r = region("z1", "#FF0000", "None", 1000, 0.5); + viz.visualize(null, snapshotOf(r)); + assertTrue(calls.isEmpty()); + } + + @Test + void visualize_skipsWhenDisabled() { + List calls = new ArrayList<>(); + RegionVisualizer viz = newViz(calls, 0L); + GravityFlipRegion r = region("z1", "#FF0000", "Outline", 1000, 0.5); + r.setEnabled(false); + viz.visualize(null, snapshotOf(r)); + assertTrue(calls.isEmpty()); + } + + @Test + void visualize_usesCorrectFlagsAndOpacityForFaces() { + List calls = new ArrayList<>(); + RegionVisualizer viz = newViz(calls, 0L); + GravityFlipRegion r = region("z1", "#00FF00", "Faces", 1000, 0.75); + viz.visualize(null, snapshotOf(r)); + assertEquals(1, calls.size()); + Call c = calls.get(0); + assertEquals(DebugShape.Cube, c.shape); + assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags); + assertEquals(0.75f, c.opacity, 1e-6); + // TTL = 1000 * 1.2 / 1000 = 1.2s + assertEquals(1.2f, c.time, 1e-3); + } + + @Test + void visualize_clampsOpacityOutOfRange() { + List calls = new ArrayList<>(); + RegionVisualizer viz = newViz(calls, 0L); + GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5); // > 1 → clamp à 1 + viz.visualize(null, snapshotOf(r)); + assertEquals(1.0f, calls.get(0).opacity, 1e-6); + } + + @Test + void visualize_clampsRefreshFloorBelowMin() { + // refreshMs = 10 < MIN_REFRESH_MS (100) → effectif = 100ms + List calls = new ArrayList<>(); + AtomicLong clock = new AtomicLong(0L); + RegionVisualizer viz = new RegionVisualizer( + t -> {}, + (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)), + (w, r) -> r.run(), + clock::get); + GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5); + viz.visualize(null, snapshotOf(r)); + clock.set(50L); // 50ms < 100 plancher + viz.visualize(null, snapshotOf(r)); + assertEquals(1, calls.size(), "plancher 100ms protège contre flood"); + clock.set(150L); + viz.visualize(null, snapshotOf(r)); + assertEquals(2, calls.size()); + } + + // ---------- helpers ---------- + + private static RegionVisualizer newViz(List calls, long now) { + AtomicLong clock = new AtomicLong(now); + return new RegionVisualizer( + t -> {}, + (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)), + (w, r) -> r.run(), + clock::get); + } + + private static GravityFlipRegion region(String name, String color, String mode, + int refreshMs, double opacity) { + GravityFlipRegion r = new GravityFlipRegion( + name, + new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), + true); + r.setVisualColor(color); + r.setVisualMode(mode); + r.setVisualRefreshMs(refreshMs); + r.setVisualOpacity(opacity); + return r; + } + + private static RegionSnapshot snapshotOf(GravityFlipRegion r) { + Map>> by = new LinkedHashMap<>(); + by.put(r, Collections.emptyList()); + Map>> frozen = Collections.unmodifiableMap(by); + return new RegionSnapshot() { + @Override public Map>> byRegion() { return frozen; } + @Override public long tickId() { return 1L; } + @Override public World world() { return null; } + }; + } +}