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<name, lastEmitMs> + 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.
This commit is contained in:
@@ -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<Call> 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<Call> 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<Call> 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<Call> 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<Call> 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<Call> 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<Call> 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<GravityFlipRegion, Collection<Ref<EntityStore>>> by = new LinkedHashMap<>();
|
||||
by.put(r, Collections.emptyList());
|
||||
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> frozen = Collections.unmodifiableMap(by);
|
||||
return new RegionSnapshot() {
|
||||
@Override public Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion() { return frozen; }
|
||||
@Override public long tickId() { return 1L; }
|
||||
@Override public World world() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user