diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java index d45d0eee..1764491c 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java @@ -11,7 +11,7 @@ * and game-specific identifiers or timings. * * @author theEvilReaper - * @version 1.0.2 + * @version 1.1.0 * @since 1.0.0 **/ public final class Helper { @@ -69,6 +69,34 @@ public static int getRandomInt(int maximumValue) { return ThreadLocalRandom.current().nextInt(0, maximumValue); } + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static int clamp(int value, int min, int max) { + return Math.clamp(value, min, max); + } + + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static double clamp(double value, double min, double max) { + return Math.clamp(value, min, max); + } + /** * Adjusts the placement coordinates of a collectible page entity based on the * block face/direction it is attached to, ensuring it aligns correctly and remains visible. diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java new file mode 100644 index 00000000..33445809 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java @@ -0,0 +1,105 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Keeps one value per player, keyed by {@link Player#getUuid()}. + *

+ * {@code EquipmentScreenOverlay}, {@code TunnelVisionService}, {@code BloodSplatterService}, + * {@code SlenderGazeService} and {@code TunnelVisionCommand} each hand-rolled their own + * {@code Map} field for this, disagreeing along the way on {@link ConcurrentHashMap} versus + * {@link java.util.LinkedHashMap}. This type settles that: it is backed by a + * {@code ConcurrentHashMap}, because state that outlives a single tick has to survive being written + * from a scheduler task and cleared from a disconnect or death listener in the same round, and + * nothing in this project pins both of those to the same thread. Three of the five call sites this + * type replaces already reached for {@code ConcurrentHashMap} for exactly that reason; the other two + * used a {@code LinkedHashMap} only for its insertion order, which none of the five ever relied on. + * Correctness under a race a caller does not control beats an ordering guarantee nobody asked for. + *

+ * + * @param the kind of value tracked per player + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class PlayerState { + + private final Map values = new ConcurrentHashMap<>(); + + /** + * Stores a value for the given player, replacing whatever was tracked before. + * + * @param player the player to store a value for + * @param value the value to store + */ + public void put(Player player, V value) { + this.values.put(player.getUuid(), value); + } + + /** + * Reads the value tracked for the given player. + * + * @param player the player to read + * @return the tracked value, or {@code null} if none is tracked + */ + public @Nullable V get(Player player) { + return this.values.get(player.getUuid()); + } + + /** + * Reads the value tracked for the given player, computing and storing one first if none is + * tracked yet. + * + * @param player the player to read + * @param supplier supplies the value to store when none is tracked yet + * @return the tracked value, existing or freshly computed + */ + public V computeIfAbsent(Player player, Supplier supplier) { + return this.values.computeIfAbsent(player.getUuid(), _ -> supplier.get()); + } + + /** + * Stops tracking the given player. + * + * @param player the player to forget + * @return the value that was tracked for them, or {@code null} if none was + */ + public @Nullable V remove(Player player) { + return this.values.remove(player.getUuid()); + } + + /** + * The tracked values, without the players they belong to. + *

+ * The returned collection is a live view over the backing map: removing through its iterator + * also stops tracking that player, which is what lets a caller fade values out one by one while + * walking them, the way {@code BloodSplatterService} does. + *

+ * + * @return a live view over the tracked values + */ + public Collection values() { + return this.values.values(); + } + + /** + * @return {@code true} if no player is currently tracked + */ + public boolean isEmpty() { + return this.values.isEmpty(); + } + + /** + * Stops tracking every player. + */ + public void clear() { + this.values.clear(); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java new file mode 100644 index 00000000..3193a424 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java @@ -0,0 +1,79 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.timer.Task; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.TemporalUnit; + +/** + * Owns a single Minestom repeating scheduler {@link Task}. + *

+ * {@code AmbientProvider}, {@code TunnelVisionService}, {@code SlenderGazeService} and + * {@code BloodSplatterService} each hand-rolled the same {@code @Nullable Task} field with a + * guard-and-return {@code startTask()}/{@code stopTask()} pair. This type is that field, extracted + * once: start and stop are both idempotent, so a caller never has to remember whether it already + * called either of them. + *

+ *

+ * The action to run is constructor-injected rather than passed to {@link #start(long, TemporalUnit)}, + * because every one of the four services above ran exactly one action for the lifetime of the task + * and never swapped it out. + *

+ * + *

Usage:

+ *
{@code
+ * RepeatingTask task = new RepeatingTask(this::tick);
+ * task.start(1, ChronoUnit.SECONDS);
+ * // ...
+ * task.stop();
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RepeatingTask { + + private final Runnable action; + private @Nullable Task task; + + /** + * Creates a task that, once started, runs the given action on every repetition. + * + * @param action the action to run + */ + public RepeatingTask(Runnable action) { + this.action = action; + } + + /** + * Starts the task with the given period. Does nothing if the task is already running. + * + * @param period the amount of {@code unit}s between two runs + * @param unit the unit {@code period} is measured in + */ + public void start(long period, TemporalUnit unit) { + if (this.task != null) return; + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this.action) + .repeat(period, unit) + .schedule(); + } + + /** + * Stops the task. Does nothing if the task is not running. + */ + public void stop() { + if (this.task == null) return; + this.task.cancel(); + this.task = null; + } + + /** + * @return {@code true} if the task is currently running + */ + public boolean isRunning() { + return this.task != null; + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java new file mode 100644 index 00000000..1bb53b16 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link Helper#clamp(int, int, int)} and {@link Helper#clamp(double, double, double)}, + * which replace the hand-rolled {@code Math.min(hi, Math.max(lo, x))} scattered across the tunnel + * vision and slender gaze code. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class HelperTest { + + @Test + @DisplayName("An int within bounds is returned unchanged") + void intWithinBoundsIsUnchanged() { + assertEquals(5, Helper.clamp(5, 0, 10)); + } + + @Test + @DisplayName("An int below the lower bound is raised to it") + void intBelowLowerBoundIsRaised() { + assertEquals(0, Helper.clamp(-5, 0, 10)); + } + + @Test + @DisplayName("An int above the upper bound is lowered to it") + void intAboveUpperBoundIsLowered() { + assertEquals(10, Helper.clamp(15, 0, 10)); + } + + @Test + @DisplayName("A double within bounds is returned unchanged") + void doubleWithinBoundsIsUnchanged() { + assertEquals(0.5D, Helper.clamp(0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double below the lower bound is raised to it") + void doubleBelowLowerBoundIsRaised() { + assertEquals(0.0D, Helper.clamp(-0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double above the upper bound is lowered to it") + void doubleAboveUpperBoundIsLowered() { + assertEquals(1.0D, Helper.clamp(1.5D, 0.0D, 1.0D)); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java new file mode 100644 index 00000000..cb4fc585 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link PlayerState} tracks one value per player, keeps players apart, and forgets + * them cleanly. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class PlayerStateTest { + + @Test + void nothingIsTrackedForAFreshPlayer(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + assertNull(state.get(player)); + assertTrue(state.isEmpty()); + } + + @Test + void putThenGetReturnsTheStoredValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + state.put(player, "value"); + + assertEquals("value", state.get(player)); + assertFalse(state.isEmpty()); + } + + @Test + void playersAreKeptApart(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + + state.put(first, "first"); + state.put(second, "second"); + + assertEquals("first", state.get(first)); + assertEquals("second", state.get(second)); + } + + @Test + void removeForgetsThePlayerAndReturnsTheOldValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + assertEquals("value", state.remove(player)); + assertNull(state.get(player)); + assertNull(state.remove(player), "removing an untracked player must not throw"); + } + + @Test + void computeIfAbsentStoresAndReusesTheComputedValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + StringBuilder first = state.computeIfAbsent(player, StringBuilder::new); + StringBuilder second = state.computeIfAbsent(player, StringBuilder::new); + + assertEquals(first, second, "a second call must not overwrite the already-tracked value"); + } + + @Test + void clearForgetsEveryPlayer(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + state.put(first, "first"); + state.put(second, "second"); + + state.clear(); + + assertTrue(state.isEmpty()); + } + + @Test + void removingThroughValuesForgetsThePlayerToo(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + Iterator values = state.values().iterator(); + values.next(); + values.remove(); + + assertTrue(state.isEmpty(), "the map backing values() must be live, the way BloodSplatterService needs it"); + assertNull(state.get(player)); + } + + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createPlayer(instance); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java new file mode 100644 index 00000000..429d5368 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java @@ -0,0 +1,99 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link RepeatingTask} owns exactly one scheduler task no matter how many times + * start and stop are called. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class RepeatingTaskIntegrationTest { + + @Test + void notRunningBeforeStart() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + assertFalse(task.isRunning()); + } + + @Test + void runsOnceStarted(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + assertTrue(task.isRunning()); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertTrue(ticks.get() > 0, "the action should have run at least once by now"); + } + + @Test + void startIsIdempotent(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + int afterFirstBatch = ticks.get(); + + // Stopping cancels the single task this class is meant to own. If start() had scheduled a + // second task on the repeated call, stop() would only ever reach one of them and the other + // would keep running forever, still incrementing the counter below. + task.stop(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterFirstBatch, ticks.get(), "a leaked second task would still be ticking"); + } + + @Test + void stopStopsTheTask(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + task.stop(); + assertFalse(task.isRunning()); + int afterStop = ticks.get(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterStop, ticks.get(), "no more runs should happen after stop()"); + } + + @Test + void stopIsIdempotent() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + task.stop(); + + assertFalse(task.isRunning(), "stopping a task that never ran must not throw"); + } +} diff --git a/docs/instance-switch-fix-guide.md b/docs/instance-switch-fix-guide.md new file mode 100644 index 00000000..75a8b29d --- /dev/null +++ b/docs/instance-switch-fix-guide.md @@ -0,0 +1,477 @@ +# Fix-Guide: Instanz-Wechsel Lobby → Game-Map + +> **Scope:** Der Übergang von der Lobby-Instanz in die Game-Map-Instanz am Ende der +> `WaitingPhase` — `WaitingPhase`, `Cygnus#initPhases`, `GameMapProvider#switchToGameMap`, +> `TeamHelper#teleportTeams`, `TeleportStrategy`. +> Der Chunk-Sichtbarkeits-Workaround (`InstanceSwitchChunkPlayer`) ist **nicht** die Ursache — +> er greift korrekt, kommt aber gar nicht erst zum Zug. Siehe [Nicht die Ursache](#nicht-die-ursache). + +## Wie du dieses Dokument benutzt + +Jeder Fix hat eine **Sprungmarke**. In IntelliJ: + +| Was | Shortcut | Eingabe | +|---|---|---| +| Klasse öffnen | `Ctrl+N` (`Cmd+O`) | `WaitingPhase` | +| Datei öffnen | `Ctrl+Shift+N` | `WaitingPhase.java` | +| Zu Zeile springen | `Ctrl+G` (`Cmd+L`) | `48` | +| Alle Aufrufer finden | `Alt+F7` auf dem Symbol | — | +| Zurück zum Ausgangspunkt | `Ctrl+Alt+←` | — | + +Die Reproduktion liegt bereits im Repo: +`game/src/test/java/net/onelitefeather/cygnus/map/GameMapSwitchOrderIntegrationTest.java`. +Sie ist **heute grün**, weil sie das *kaputte* Verhalten festschreibt. Nach den Fixes muss sie +umgedreht werden — siehe [Tests](#tests). + +--- + +## 1. Das Gesamtbild + +Der Wechsel besteht aus **drei** Schritten, die zwingend in dieser Reihenfolge laufen müssen: + +1. Provider auf die Game-Map umschalten (`activeInstance = gameInstance`) +2. Spieler in die neue Instanz bewegen (`setInstance`) +3. Alte Lobby-Instanz abbauen (`unregisterInstance`) + +Der Code führt sie in der Reihenfolge **2 → 3 → 1** aus. Schritt 1 und 3 stecken zusammen in +`GameMapProvider#switchToGameMap`, und der wird eine Sekunde *nach* dem Teleport gefeuert. + +```mermaid +sequenceDiagram + participant LP as LobbyPhase + participant WP as WaitingPhase + participant TH as TeamHelper + participant MP as GameMapProvider + participant IM as InstanceManager + + LP->>LP: onUpdate (ticks == 0)
GamePrepareEvent → Teams zugewiesen + LP-->>WP: finish → advance() + Note over WP: currentTicks = 3, DOWN + WP->>WP: onUpdate (ticks == 2) — nichts + rect rgb(255, 224, 224) + WP->>TH: onUpdate (ticks == 1)
teleportLogic.apply() + TH->>MP: getActiveInstance().get() + MP-->>TH: ⚠️ LOBBY-Instanz (noch nicht umgeschaltet) + TH->>TH: slender.setInstance(lobby, slenderSpawn) + TH-->>WP: 💥 IllegalArgumentException + end + Note over WP: Scheduler-Task stirbt.
onFinish() wird NIE erreicht. + WP--xMP: switchToGameMap() läuft nie + WP--xIM: advance() läuft nie → keine GamePhase +``` + +**Ergebnis auf dem Server:** Der Countdown läuft auf 0, dann passiert nichts mehr. Keine GamePhase, +kein `GameStartEvent`, kein Slender-Item, keine Pages. Die Spieler stehen in der Lobby, bis der +Prozess neu gestartet wird. + +--- + +## 2. Die Fehlerkette im Detail + +### 2.1 Der Auslöser: `getActiveInstance()` ist ein Live-Supplier + +`AbstractMapProvider#getActiveInstance()` (Aves 1.16.1) gibt `() -> this.activeInstance` zurück — +also **kein Snapshot**, sondern ein Blick auf das Feld zum Aufrufzeitpunkt. Der Aufruf in +`Cygnus#initPhases` (Zeile 189) fragt es genau in dem Moment ab, in dem es noch die Lobby ist. + +```java +// Cygnus.java:187-198 +VoidConsumer instanceSwitch = gameMapProvider::switchToGameMap; // → onFinish, ticks == 0 +VoidConsumer teamInitializer = () -> { + Instance activeInstance = gameMapProvider.getActiveInstance().get(); // ⚠️ noch die Lobby + ... + TeamHelper.teleportTeams(this.teamService, gameMapProvider.getGameMap(), activeInstance); +}; +``` + +Und in `WaitingPhase` läuft `teleportLogic` **vor** `instanceSwitch`: + +```java +// WaitingPhase.java:42-52 +@Override +protected void onFinish() { // ticks == 0 + this.instanceSwitch.apply(); // ← Schritt 1 + 3 + this.gameView.addPlayers(...); +} + +@Override +public void onUpdate() { + if (getCurrentTicks() == 1) { // ← eine Sekunde FRÜHER + this.teleportLogic.apply(); // ← Schritt 2 + } +} +``` + +`TimedPhase#onUpdate0` zählt herunter und ruft `finish()` erst im *nächsten* Durchlauf, nachdem +`currentTicks` bereits 0 ist. Zwischen `teleportLogic` und `instanceSwitch` liegen damit **zwei +Sekunden**. + +### 2.2 Der harte Abbruch: der Slender + +`TeamHelper#teleportTeams` behandelt Slender und Survivor unterschiedlich: + +```java +// TeamHelper.java:165 — ohne Guard +slenderTeam.getPlayers().forEach(player -> updateInstance(player, gameInstance, gameMap.getSlenderSpawn())); + +// TeamHelper.java:241 +private static void updateInstance(Player player, Instance instance, Pos position) { + player.setInstance(instance, position); +} +``` + +Minestom lehnt das ab, weil Ziel- und Ist-Instanz identisch sind: + +```java +// net.minestom.server.entity.Player:620-622 (Minestom 2026.07.22-26.2) +public CompletableFuture setInstance(Instance instance, Pos spawnPosition) { + final Instance currentInstance = this.instance; + Check.argCondition(currentInstance == instance, "Instance should be different than the current one"); +``` + +→ `IllegalArgumentException: Instance should be different than the current one` + +Die Exception fliegt in einem Minestom-Scheduler-Task. `SchedulerImpl` (Zeile 103-106) fängt sie und +antwortet mit `TaskSchedule.stop()`: + +```java +} catch (Throwable t) { + MinecraftServer.getExceptionManager().handleException(new RuntimeException("Exception in scheduled task", t)); + schedule = TaskSchedule.stop(); +} +``` + +Der Repeat-Task der `WaitingPhase` wird also **abgeschaltet**, nicht bloß übersprungen. `onFinish()` +und damit `super.finish()` → `finishedCallback` → `LinearPhaseSeries#advance()` laufen nie. Die +Phasenkette steht. + +### 2.3 Der stille Fehler: die Survivor + +`TeleportStrategy` hat den Guard, der `updateInstance` fehlt: + +```java +// TeleportStrategy.java:33-37 (ROUND_ROBIN_RANDOM), 51-55 (SINGLE) +if (player.getInstance() != null && player.getInstance().equals(instance)) { + player.teleport(spawnPos); // ← greift hier, weil instance == Lobby +} else { + player.setInstance(instance, spawnPos); +} +``` + +Die Survivor werfen deshalb *nicht*, sondern werden auf die **Arena-Koordinaten innerhalb der +Lobby-Welt** teleportiert. Je nach Lobby-Map ist das Void, eine Wand oder ein zufälliger Punkt. +Dieser Zweig ist der Grund, warum das Symptom manchmal als „Spieler fallen ins Nichts" statt als +„nichts passiert" berichtet wird — es hängt davon ab, ob der Slender-Teleport zuerst dran ist. + +### 2.4 Die zweite Sperre: die Lobby lässt sich nicht abbauen + +Selbst wenn 2.2 wegfällt (etwa weil der Slender-Guard nachgerüstet wird), schlägt der nächste +Schritt fehl: + +```java +// GameMapProvider.java:56-64 +public void switchToGameMap() { + if (this.activeInstance != null) { + MinecraftServer.getInstanceManager().unregisterInstance(this.activeInstance); // 💥 + ... +``` + +```java +// net.minestom.server.instance.InstanceManager:113-115 +public void unregisterInstance(Instance instance) { + long onlinePlayers = instance.getPlayers().stream().filter(Player::isOnline).count(); + Check.stateCondition(onlinePlayers > 0, "You cannot unregister an instance with players inside."); +``` + +→ `IllegalStateException: You cannot unregister an instance with players inside.` + +Und weil der Check **vor** `this.activeInstance = null` steht, bleibt der Provider danach auf der +Lobby stehen. Alles, was später `getActiveInstance()` fragt (`PlayerLoginListener`, +`teleportToSpawn`), bekommt weiterhin die Lobby. + +--- + +## 3. Die Fixes + +Drei Änderungen, die zusammen die Reihenfolge **1 → 2 → 3** herstellen. + +### Fix 1 — `switchToGameMap` in Umschalten und Abbauen trennen + +**Sprungmarke:** `GameMapProvider.java:56` + +Das Umschalten der Referenz und das Abbauen der alten Instanz sind zwei Ereignisse mit dem Teleport +dazwischen. Sie dürfen nicht in einer Methode stecken. + +```java +/** + * Switches the provider over to the game map. + * + *

This only moves the active references; the lobby instance stays registered so the players + * can still be moved out of it. Call {@link #releasePreviousInstance()} once they are gone.

+ * + * @throws IllegalStateException if the game map has not been loaded yet + */ +public void switchToGameMap() { + if (this.gameInstance == null || this.gameMap == null) { + throw new IllegalStateException("The game map has not been loaded yet"); + } + this.previousInstance = this.activeInstance; + this.activeInstance = this.gameInstance; + this.activeMap = this.gameMap; +} + +/** + * Unregisters the instance the provider was on before the last switch. + * + *

Minestom refuses to unregister an instance that still holds online players, so this must + * run after every player has been moved into the new instance. Calling it more than once, or + * without a previous switch, does nothing.

+ */ +public void releasePreviousInstance() { + if (this.previousInstance == null) return; + MinecraftServer.getInstanceManager().unregisterInstance(this.previousInstance); + this.previousInstance = null; +} +``` + +Dazu das Feld neben `gameInstance`: + +```java +private @Nullable InstanceContainer previousInstance; +``` + +> **Warum kein `null`-Check statt `throw`:** Wenn `loadGameMap()` nie lief, ist `gameInstance` null. +> Das jetzige `switchToGameMap` würde `activeInstance` dann auf `null` setzen und jeden späteren +> `getActiveInstance()`-Aufruf vergiften. Lauter Fehlschlag ist hier besser als stille Korruption. + +### Fix 2 — Reihenfolge in `WaitingPhase` korrigieren + +**Sprungmarke:** `WaitingPhase.java:42` + +`onUpdate` bleibt der Ort für den Teleport, aber der Instanz-Wechsel muss davor liegen — und der +Abbau der Lobby danach. + +```java +@Override +public void onStart() { + super.onStart(); + EventDispatcher.call(new GamePreLaunchEvent()); + this.instanceSwitch.apply(); // Schritt 1: Provider zeigt ab jetzt auf die Game-Map +} + +@Override +protected void onFinish() { + this.gameView.addPlayers(new HashSet<>(MinecraftServer.getConnectionManager().getOnlinePlayers())); +} + +@Override +public void onUpdate() { + if (getCurrentTicks() == 1) { + this.teleportLogic.apply(); // Schritt 2 + 3 + } +} +``` + +Das Umschalten in `onStart()` zu legen hat einen zweiten Vorteil: Zwischen Umschalten und Teleport +liegen jetzt zwei Sekunden, in denen die Game-Instanz bereits registriert und ihr Chunk-Loader +aktiv ist. + +### Fix 3 — Lobby nach dem Teleport freigeben + +**Sprungmarke:** `Cygnus.java:185` (`initPhases`) + +Der `teamInitializer` ist die einzige Stelle, die weiß, wann alle Spieler umgezogen sind. + +```java +private void initPhases() { + GameMapProvider gameMapProvider = ((GameMapProvider) this.mapProvider); + VoidConsumer instanceSwitch = gameMapProvider::switchToGameMap; + VoidConsumer teamInitializer = () -> { + Instance activeInstance = gameMapProvider.getActiveInstance().get(); + if (activeInstance == null) { + throw new IllegalStateException("Active instance not available for team teleport"); + } + TeamHelper.teleportTeams( + this.teamService, + gameMapProvider.getGameMap(), + activeInstance + ); + gameMapProvider.releasePreviousInstance(); // Schritt 3 + }; + ... +} +``` + +> **Achtung, asynchron:** `setInstance` liefert ein `CompletableFuture`, das erst fertig ist, wenn +> die Ziel-Chunks geladen sind. `teleportTeams` ignoriert die Futures heute. Solange +> `releasePreviousInstance()` direkt danach läuft, kann ein Spieler theoretisch noch als „in der +> Lobby" zählen. Sauber wäre, dass `teleportTeams` die Futures sammelt und +> `releasePreviousInstance()` an `CompletableFuture.allOf(...).thenRun(...)` hängt — siehe +> [Offene Punkte](#offene-punkte). + +### Fix 4 — Guard für den Slender-Teleport + +**Sprungmarke:** `TeamHelper.java:241` + +Auch mit korrekter Reihenfolge bleibt `updateInstance` eine Falle: jeder künftige Aufruf mit der +aktuellen Instanz des Spielers wirft. Der Guard, den `TeleportStrategy` schon hat, gehört hierher. + +```java +/** + * Moves the player into the given instance, or teleports within it if the player is already there. + * + * @param player the player to update + * @param instance the new instance + * @param position the new position + */ +private static void updateInstance(Player player, Instance instance, Pos position) { + if (instance.equals(player.getInstance())) { + player.teleport(position); + return; + } + player.setInstance(instance, position); +} +``` + +> Alternativ ließe sich der Guard einmalig in `TeleportStrategy` zentralisieren und von beiden +> Pfaden nutzen. Das ist die schönere Variante, aber ein größerer Eingriff — für den Fix reicht +> die Symmetrie oben. + +--- + +## 4. Tests + +Die Reproduktion liegt in +`game/src/test/java/net/onelitefeather/cygnus/map/GameMapSwitchOrderIntegrationTest.java` und ist +**vor** den Fixes grün (🔴 = fängt den Bug): + +| Test | Was er heute festschreibt | +|---|---| +| `testSlenderTeleportIntoTheLobbyThrows` 🔴 | `teleportTeams` mit der noch-Lobby wirft `IllegalArgumentException` | +| `testSurvivorsNeverLeaveTheLobby` 🔴 | Survivor landen auf Arena-Koordinaten *in der Lobby-Instanz* | +| `testSwitchWithPlayersInsideTheLobbyThrows` 🔴 | `switchToGameMap()` wirft, solange Spieler in der Lobby stehen | + +**Nach den Fixes umdrehen.** Aus dem ersten Test wird: + +```java +@Test +void testTeleportAfterSwitchMovesEveryoneIntoTheGameInstance(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root); + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + provider.loadGameMap(); + + Player slender = env.createPlayer(lobbyInstance, LOBBY_SPAWN); + Player survivor = env.createPlayer(lobbyInstance, LOBBY_SPAWN); + TeamService teamService = createTeamService(); + teamService.getTeam(GameConfig.SLENDER_KEY).orElseThrow().addPlayer(slender); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(survivor); + + provider.switchToGameMap(); // Schritt 1 + InstanceContainer gameInstance = (InstanceContainer) provider.getActiveInstance().get(); + assertNotSame(lobbyInstance, gameInstance); + + TeamHelper.teleportTeams(teamService, provider.getGameMap(), gameInstance); // Schritt 2 + assertSame(gameInstance, slender.getInstance()); + assertSame(gameInstance, survivor.getInstance()); + assertEquals(SLENDER_SPAWN, slender.getPosition()); + + assertDoesNotThrow(provider::releasePreviousInstance); // Schritt 3 + assertFalse(MinecraftServer.getInstanceManager().getInstances().contains(lobbyInstance)); + + provider.close(); + env.destroyInstance(gameInstance, true); +} +``` + +Ergänzend absichern (🛡️): + +| Test | Zweck | +|---|---| +| `testSwitchWithoutLoadedGameMapThrows` 🛡️ | `switchToGameMap()` ohne `loadGameMap()` wirft, statt `activeInstance` auf `null` zu setzen | +| `testReleaseIsRepeatable` 🛡️ | `releasePreviousInstance()` zweimal hintereinander tut beim zweiten Mal nichts | +| `testUpdateInstanceWithinSameInstanceTeleports` 🛡️ | `teleportTeams` auf die bereits aktive Instanz wirft nicht mehr (Fix 4) | + +Ausführen: + +```bash +./gradlew :game:test --tests "net.onelitefeather.cygnus.map.GameMapSwitchOrderIntegrationTest" +./gradlew :game:test --tests "net.onelitefeather.cygnus.map.GameMapProviderIntegrationTest" +``` + +`GameMapProviderIntegrationTest#testGameInstanceUsesOwnFalcoChunkLoader` ruft `switchToGameMap()` +ohne Spieler auf und bleibt von den Fixes unberührt — er ist die Absicherung, dass der reine +Referenz-Wechsel weiter funktioniert. + +--- + +## 5. Nicht die Ursache + +### `InstanceSwitchChunkPlayer` + +Der Workaround für [MC-310041](https://bugs.mojang.com/browse/MC/issues/MC-310041) (Chunks bleiben +nach einem Instanz-Wechsel unsichtbar) arbeitet korrekt — `InstanceSwitchChunkPlayerIntegrationTest` +ist grün. Er kommt beim Lobby→Game-Wechsel derzeit nur gar nicht zum Einsatz, weil der Wechsel +vorher abbricht. **Nach** den Fixes wird er zum ersten Mal wirklich benutzt; wenn dann schwarze +Chunks auftauchen, ist *das* der Ort zum Nachschauen, nicht vorher. + +### Die Chunk-Loader + +`FalcoAnvilLoader` hält Region-Dateien offen und wird erst im Shutdown-Task geschlossen +(`Cygnus.java:113`). `releasePreviousInstance()` schließt den Lobby-Loader **nicht** — das ist +gewollt, `GameMapProvider#close()` räumt beide zusammen ab. Kein Handlungsbedarf, aber gut zu +wissen, dass die Lobby-Region-Dateien bis zum Serverende geöffnet bleiben. + +--- + +## 6. Weitere Befunde am Rand + +Beim Nachvollziehen der Phasenkette aufgefallen, **nicht** Teil dieses Fixes: + +### `GamePhase`s `finishedCallback` wird überschrieben + +`Cygnus.java:202` übergibt `this::finishGame` an den `GamePhase`-Konstruktor, der es via +`setFinishedCallback` speichert. `LinearPhaseSeries#startCurrentPhase` überschreibt es unmittelbar +danach: + +```java +// LinearPhaseSeries.java:115-118 (Xerus 1.12.1) +public void startCurrentPhase() { + currentPhase.setFinishedCallback(this::advance); // ← überschreibt finishGame + currentPhase.start(); +} +``` + +Folge: `Cygnus#finishGame()` läuft nie. Damit bleiben `pageProvider.cleanUp()`, +`staminaService.cleanUp()`, `ambientProvider.stopTask()`, `jumpscareManager.cleanUp()` und das +Zurücksetzen des `ClientEntityActionPacket`-Listeners aus. Bei einem Single-Match-Server, der +danach ohnehin per `RestartPhase` stoppt, fällt das nicht auf — es wird relevant, sobald der Server +mehrere Runden spielen soll. Der übliche Weg wäre, das Cleanup an `GameFinishEvent` zu hängen statt +an den Callback. + +### `LobbyPhase#onUpdate` hängt am exakten Tick-Wert + +`GameMapLoadEvent` wird nur bei exakt `getCurrentTicks() == FORCE_START_TIME - 1` (also 10) +gefeuert. Wird dieser Wert je übersprungen, bleibt `gameInstance` `null` und der Wechsel hat kein +Ziel. Aktuell ist das abgesichert: `GameConfigBuilder#lobbyTime` wirft bei +`lobbyTime <= FORCE_START_TIME`, der Default ist 30, `GameConfigReader` geht über den Builder, und +`setForceStarted(true)` setzt auf exakt 11 — der Countdown läuft also immer durch die 10. Trotzdem +ist die Kopplung „Feature funktioniert, solange niemand die Konstante anfasst" fragil; ein +`if (getCurrentTicks() <= FORCE_START_TIME - 1)` mit dem idempotenten `loadGameMap()` dahinter wäre +robuster. Mit Fix 1 fällt ein verpasster Load immerhin laut auf, statt `activeInstance` still auf +`null` zu setzen. + +--- + +## Offene Punkte + +- **Futures von `teleportTeams`:** Die Methode ignoriert die `CompletableFuture`s von + `setInstance`. Für `releasePreviousInstance()` wäre `CompletableFuture.allOf(...)` die korrekte + Bedingung. Solange alle Ziel-Chunks bereits geladen sind, gibt Minestom + `AsyncUtils.VOID_FUTURE` (bereits fertig) zurück und das Problem tritt nicht auf — verlassen + sollte man sich darauf nicht. +- **Spieler, die während der `WaitingPhase` verbinden:** `PlayerLoginListener` kickt alles, was + nicht in der `LobbyPhase` ist, also greift der Fall nicht. Sollte das gelockert werden, braucht + `releasePreviousInstance()` einen erneuten Leer-Check. +- **Zentraler Instanz-Wechsel-Helfer:** `TeleportStrategy` (2×), `TeamHelper#updateInstance` und + `AbstractMapProvider#teleportToSpawn` implementieren dieselbe „bin ich schon da?"-Logik jeweils + eigenständig — bzw. eben nicht. Ein gemeinsamer Helfer in `common` würde Fix 4 überflüssig machen. diff --git a/docs/slender-visibility-fix-guide.md b/docs/slender-visibility-fix-guide.md new file mode 100644 index 00000000..b12cfb2a --- /dev/null +++ b/docs/slender-visibility-fix-guide.md @@ -0,0 +1,721 @@ +# Fix-Guide: Slender-Sichtbarkeit + +> **Symptom:** Man läuft aus der View-Distance und wieder hinein — und sieht den Slender, obwohl er +> unsichtbar sein sollte. +> +> **Kurzdiagnose:** Kein Regressions-Commit. Der Sichtbarkeitspfad ist seit dem Initial-Commit +> `c65313e` falsch verdrahtet und nur kosmetisch verändert worden. Was sich wie eine wiederkehrende +> Regression anfühlt, ist ein latenter Konstruktionsfehler, der bei jeder Änderung an Bewegung, +> Chunk-Handling oder Instanzwechsel anders zutage tritt. +> +> **Stand 06.08.2026:** Alle sechs Befunde sind unverändert offen und durch +> `SlenderVisibilityIntegrationTest` reproduziert. Das Spectator-System (`d29578b`, seit 2.7.0) hat +> einen **siebten** Sichtbarkeits-Leak hinzugefügt — siehe [Abschnitt 2b](#2b-das-spectator-system-seit-270). + +## Wie du dieses Dokument benutzt + +Navigation wie im [Sprint-Guide](sprint-stamina-fix-guide.md): `Ctrl+N` für Klassen, `Ctrl+G` für +Zeilen, `Alt+F7` für Aufrufer. Alle Zeilenangaben sind gegen den aktuellen `main`-Stand geprüft, alle +Minestom-Zitate gegen `minestom-2026.07.12-26.2-sources.jar`. + +**Lies zuerst [Abschnitt 1](#1-das-kernmissverständnis).** Ohne diesen einen Punkt wirkt der +restliche Code zufällig — und genau deshalb waren die bisherigen Fixversuche wirkungslos. + +--- + +## 1. Das Kernmissverständnis + +Minestom übergibt dem `viewableRule`-Prädikat **den Kandidaten-Viewer**, nicht die Entity, der die +Regel gehört. Verifiziert in `EntityView.java:68-77`: + +```java +if (!entity.isViewer(player) && + player.getVehicle() != entity && + entity.viewEngine.viewableOption.predicate(player) && // ← player, nicht entity + player.viewEngine.viewerOption.predicate(entity)) { + + entity.viewEngine.viewableOption.register(player); + ... +} +``` + +Die einzige jemals installierte Regel ist `TeamHelper.java:107-111`: + +```java +private static void assignSlender(Player player, Team slenderTeam) { + player.setTag(Tags.TEAM_ID, SLENDER_TEAM_ID); + player.updateViewableRule(ViewRuleUpdater::viewableRuleForSlender); + slenderTeam.addPlayer(player); +} +``` + +Das Prädikat bekommt also **den Survivor** übergeben und liest `Tags.HIDDEN` **des Survivors**: + +```java +// ViewRuleUpdater.java:42-44 +public static boolean viewableRuleForSlender(Player player) { + return player.hasTag(Tags.HIDDEN) && player.getTag(Tags.HIDDEN) == SlenderBarHelper.HIDDEN; +} +``` + +**Konsequenz:** Von 9 Schreibstellen auf `Tags.HIDDEN` zielen nur 2 auf einen Survivor. Die anderen +7 schreiben auf den Slender und werden **von niemandem gelesen**. + +```mermaid +flowchart LR + subgraph writes["setTag(Tags.HIDDEN) — 9 Stellen"] + direction TB + S1["GameStartListener:40 → Slender"] + S2["SlenderBar:66, 93, 104, 116 → Slender"] + S3["SlenderBarTrigger:72 → Slender"] + S4["ViewRuleUpdater:28/30 → Slender"] + V1["GameStartListener:49 → Survivor"] + V2["ViewRuleUpdater:36/38 → Survivor"] + end + + RULE{{"viewableRuleForSlender(player)
liest den Tag des VIEWERS"}} + + subgraph cand["Viewer-Kandidaten — jeder entscheidet über seinen eigenen Tag"] + direction TB + A["lebender Survivor
im Team → Tag wird gepflegt"] + B["Spectator seit 2.7.0
Team verlassen → Tag eingefroren"] + end + + S1 -.->|nie gelesen| X["🗑️ toter Code"] + S2 -.->|nie gelesen| X + S3 -.->|nie gelesen| X + S4 -.->|nie gelesen| X + V1 ==>|wirksam| RULE + V2 ==>|wirksam| RULE + + RULE ==> A + RULE ==> B + V2 -.->|"erreicht ihn nie:
iteriert nur survivorTeam"| B + + style X fill:#eee,stroke:#999 + style RULE fill:#ffe0e0,stroke:#c00 + style B fill:#ffe0e0,stroke:#c00 +``` + +**Das erklärt, warum jeder bisherige Fix an `SlenderBar.changeStatus()` folgenlos blieb** — z. B. +Commit `2233076 "Fix showing of slender after full drain"`. Er ändert einen Tag, den die Regel nie +ansieht. + +Zwei weitere Stolperfallen im selben File: + +- `isViewAble` (`:11-13`) und `isHidden` (`:42-44`) sind **Zeile für Zeile identisch**, + tragen aber gegensätzliche Bedeutungen („ist versteckt" vs. „darf sehen"). +- Die Namen sind **vertauscht**: `showSlender` wird auf **Survivors** angewandt (`:20`), + `showSurvivor` auf den **Slender** (`:21`). + +--- + +## 2. Warum dein Symptom auftritt + +Es gibt **vier Ausgänge** aus dem DRAINING-Zustand, aber nur **einer** räumt die Sichtbarkeit auf: + +```mermaid +stateDiagram-v2 + direction LR + READY --> DRAINING: changeStatus() ✅ + REGENERATING --> DRAINING: changeStatus() ✅ + DRAINING --> REGENERATING: changeStatus()
manueller 2. Druck ✅ + DRAINING --> REGENERATING: handleDraining() Timeout
SlenderBar.java:64 ❌ + REGENERATING --> READY: handleRegeneration()
SlenderBar.java:80 ❌ + + note right of DRAINING + ✅ = läuft über SlenderBarTrigger + → changeVisibilityStatus + updateViewer + ❌ = umgeht den Trigger komplett + end note +``` + +Der Timeout-Pfad ist der **Normalfall**: man drückt das Auge einmal und lässt die 16 Sekunden +auslaufen. Genau dieser Pfad räumt nicht auf. + +```mermaid +sequenceDiagram + autonumber + participant SU as Survivor + participant SB as SlenderBar + participant L as StaminaStateChangeListener + participant MS as Minestom EntityView + + Note over SU: Augendruck → Survivor-Tag = HIDDEN(1)
Prädikat true → Slender registriert + gespawnt + + SB->>SB: handleDraining() Timeout (SlenderBar.java:64) + Note over SB: state = REGENERATING
setTag(HIDDEN) auf den SLENDER → wirkungslos
❌ kein updateViewableRule()
❌ Survivor-Tag bleibt 1 + + SB->>L: StaminaStateChangeEvent(REGENERATING) + L->>MS: forEach(player::updateOldViewer) + Note over MS: sendet NUR destroyPacketCache
bitSet bleibt unberührt + + Note over SU,MS: 🔴 Client hat Entity zerstört,
Server führt Survivor weiter als Viewer,
Prädikat sagt weiterhin true + + SU->>MS: läuft >5 Chunks weg + MS->>MS: handleAutoViewRemoval → unregister + Note over MS: JETZT erst ist die Registrierung echt weg + + SU->>MS: läuft zurück + MS->>MS: !isViewer(survivor) == true → Prädikat prüfen + Note over MS: Survivor-Tag ist IMMER NOCH 1 → true + MS->>SU: register() + SpawnEntityPacket + Note over SU: 👁️ Slender sichtbar +``` + +**Warum ausgerechnet die View-Distance?** Solange du in Reichweite bleibst, blockiert +`!entity.isViewer(player)` (`EntityView.java:71`) den Respawn — der Server hält dich ja noch für +einen Viewer. Erst das echte Austragen beim Chunk-Austritt macht den Weg für den falschen +Wiedereinstieg frei. Die Reichweite ist `ServerFlag.java:18` +`ENTITY_VIEW_DISTANCE = 5` Chunks (80 Blöcke). + +Der Zustand hält **bis zum nächsten Augendruck** und betrifft **alle Survivor gleichzeitig**, weil +`ViewRuleUpdater.java:20` das gesamte Team taggt, unabhängig von der Entfernung. + +### Folgeschaden: die Polarität kippt dauerhaft + +`showSurvivor` und `showSlender` sind beides **relative Toggles** (`:26-40`) — sie kippen den +Ist-Wert, statt den Soll-Wert aus dem Zustand abzuleiten. Nach dem übersprungenen Timeout stehen die +Survivor-Tags auf 1, also macht der nächste Augendruck sie auf 0 → **der Angriffsmodus versteckt den +Slender**, der Regenerationsmodus zeigt ihn. Ab da ist alles 180° verdreht. + +Verschärfend: der vermeintliche Schutz in `SlenderBar.java:88` + +```java +if (state == State.REGENERATING && this.time <= 10) return false; +``` + +kann nie greifen — `time` ist `private final int time` (`:28`), im Konstruktor auf `MAX_TIME = 16` +gesetzt (`:35`) und nie verändert. Gemeint war `currentTime`. `changeStatus()` liefert damit +**immer** `true`. + +--- + +## 2b. Das Spectator-System (seit 2.7.0) {#2b-das-spectator-system-seit-270} + +Commit `d29578b` hat einen **eigenständigen, zweiten Leak** hinzugefügt. Er ist unabhängig von Fix 2 +— er trifft sogar den manuellen Doppeldruck, also genau den einen Pfad, der bis dahin korrekt +aufräumte. + +Beim Tod verliert der Spieler das Survivor-Team und seine `TEAM_ID`, aber **nicht** `Tags.HIDDEN`. +Da das Prädikat den Tag des Viewers liest, entscheidet dieser eingefrorene Tag ab jetzt dauerhaft, +ob er den Slender sieht — und `ViewRuleUpdater.updateViewer` fasst ihn nie wieder an, weil es über +`survivor.getPlayers()` iteriert (`:20`, `:23`), das er gerade verlassen hat. + +```mermaid +flowchart TB + DEATH(["Survivor stirbt — PlayerDeathListener:44-52"]) + + DEATH --> R1["survivorTeam.removePlayer
→ raus aus dem Pool von updateViewer"] + DEATH --> R2["removeTag(TEAM_ID)"] + DEATH --> R3["SpectatorAddEvent → SpectatorService.join:55-61
setTag(TEAM_ID, SPECTATOR) · GameMode
updateViewableRule('_ → false')"] + DEATH --> R4["❌ Tags.HIDDEN wird nirgends geräumt"] + + R1 --> FROZEN + R4 --> FROZEN{{"Tag friert auf dem Wert
zum Todeszeitpunkt ein"}} + + FROZEN -->|"Tag = HIDDEN(1)
Slender war gerade sichtbar"| C1["👁️ sieht den Slender für immer —
auch wenn der unsichtbar sein soll"] + FROZEN -->|"Tag = VISIBLE(0)
Slender war gerade unsichtbar"| C2["🚫 sieht den Slender nie wieder —
auch nicht im Angriffsmodus"] + + style R4 fill:#ffe0e0,stroke:#c00 + style FROZEN fill:#ffe0e0,stroke:#c00 + style C1 fill:#ffe0e0,stroke:#c00 + style C2 fill:#ffe0e0,stroke:#c00 +``` + +Beide Ausgänge sind Defekte: der eine verrät die Slender-Position an einen Toten, der andere macht +das Zuschauen sinnlos. Reproduziert in `SlenderSpectatorVisibilityIntegrationTest`. + +### Drei Besitzer, ein Regel-Slot pro Entity + +`updateViewableRule` hat pro Entity genau einen Slot — der letzte Aufruf **überschreibt** den +vorherigen. Inzwischen greifen drei Systeme darauf zu: + +```mermaid +flowchart LR + subgraph owners["updateViewableRule — ein Slot pro Entity"] + direction TB + O1["TeamHelper:109
ViewRuleUpdater::viewableRuleForSlender
❌ tag-basiert, relativ getoggelt"] + O2["SpectatorService:60
'_ → false'
✅ absolut, aus dem Zustand"] + O3["JumpScareManager:282/304/306
'viewer → !hiddenFrom.contains(uuid)'
✅ absolut, mit updateViewableRule(null)-Reset"] + end + + O2 -.->|"überschreibt die Slender-Regel,
falls der Slender selbst stirbt"| O1 + + style O1 fill:#ffe0e0,stroke:#c00 + style O2 fill:#e0ffe0,stroke:#0a0 + style O3 fill:#e0ffe0,stroke:#0a0 +``` + +Zwei Dinge fallen daran auf: + +- **`JumpScareManager` macht bereits genau das, was Fix 1 fordert** — eine absolute Regel, direkt aus + dem Zustand abgeleitet, plus sauberer Reset über `updateViewableRule(null)` (`:304`). Das Muster + muss also nicht erfunden, sondern nur auf den Slender übertragen werden. +- `PlayerDeathListener:52` feuert `SpectatorAddEvent` für **jeden** Toten, ohne Team-Prüfung. Stirbt + der Slender, überschreibt `SpectatorService.join` seine Sichtbarkeitsregel mit `_ -> false`. Nach + Fix 1 muss dieser Pfad die Slender-Regel entweder ausnehmen oder bewusst ersetzen. + +--- + +## 3. Ausgangslage: es gibt bereits einen halben Fix + +**Wichtig, bevor du anfängst:** Auf `origin/fix/visibility-system` (19.07.2026, theEvilReaper) liegt +ein ungemergter Branch, der die **Architektur bereits richtig löst**. Er: + +- löscht `ViewRuleUpdater.java` **komplett**, +- führt `SlenderVisibilityChangeEvent(player, hidden)` ein, +- und leitet die Regel direkt aus dem Zustand ab statt aus einem Tag: + +```java +// SlenderVisibilityChangeListener.java (auf dem Branch) +public void accept(SlenderVisibilityChangeEvent event) { + Player slender = event.getPlayer(); + boolean hidden = event.isHidden(); + slender.updateViewableRule(viewer -> !hidden); +} +``` + +Das ist genau der richtige Ansatz: **absolut, aus dem Zustand, ohne Tag-Umweg.** Gefeuert wird es an +drei Stellen — `SlenderBarTrigger` (manueller Druck), `TeamHelper.assignSlender` (Rundenstart) und +`SlenderReviveListener` (Rollenwechsel). Ein Test ist dabei. + +**Aber er behebt dein Symptom nicht.** `SlenderBar.java` ist **nicht** unter den 9 geänderten +Dateien — der Auto-Timeout-Pfad feuert das neue Event also nicht, und `changeVisibilityStatus` +toggelt weiterhin relativ. Auch `StaminaStateChangeListener` bleibt unangetastet. + +### Empfehlung + +| Weg | Vorgehen | +|---|---| +| **A (empfohlen)** | Branch mergen/rebasen, dann Fix 2, 3, 5 und 7 aus diesem Guide ergänzen | +| **B** | In `main` von Hand, Fix 1–7 der Reihe nach — nutze den Branch als Vorlage für Fix 1 | + +Der Guide ist so geschrieben, dass beide Wege funktionieren. + +> **Stand 06.08.2026 zu Weg A:** Der Branch liegt inzwischen **48 Commits** hinter `main` und +> merged nicht mehr konfliktfrei. Betroffen sind `Cygnus.java` (Imports + Listener-Registrierung) +> und `GameStartListener.java`. Beides ist überschaubar, aber einplanen. Neu hinzugekommen ist +> außerdem das Spectator-System, das der Branch nicht kennt — Fix 7 ist in beiden Wegen nötig. + +--- + +## 4. Die Fixes + +| # | Ort | Was | Behebt | +|---|---|---|---| +| [1](#fix-1) | `ViewRuleUpdater` → Event | Regel aus dem Zustand statt aus Tags | Wurzel | +| [2](#fix-2) | `SlenderBar.java:64, 80` | Timeout an den zentralen Pfad anschließen | **dein Symptom** | +| [3](#fix-3) | `StaminaStateChangeListener` | rohe Paket-Sender entfernen | Client/Server-Divergenz | +| [4](#fix-4) | `SlenderReviveListener` | Rollenwechsel vollständig | neuer Slender dauerhaft sichtbar | +| [5](#fix-5) | `StaminaBar` / `StaminaService` | `onStop()` + Cleanup | Runde 2 | +| [6](#fix-6) | `SlenderBar.java:88` | `this.time` → `currentTime` | toter Guard | +| [7](#fix-7) | `PlayerDeathListener` / `SpectatorService` | Spectator aus dem Tag-Modell lösen | Spectator-Leak | + +--- + +### Fix 1 — Sichtbarkeit aus dem Zustand ableiten, nicht aus einem Tag {#fix-1} + +**Springe zu:** `ViewRuleUpdater.java` (ganze Datei), `TeamHelper.java:109` + +#### Warum nicht einfach den Tag reparieren + +Naheliegend wäre, `Tags.HIDDEN` konsequent auf den Survivors zu pflegen. Das ist der falsche Weg: +Ein gespiegelter Zustand muss bei **jedem** Übergang synchron gehalten werden — und genau dieses +Synchronhalten ist das, was hier seit dem Initial-Commit schiefgeht. Vier Übergänge, von denen zwei +den Sync überspringen, sind kein Sync-Problem, sondern ein Designproblem. + +Der Ausweg: Das Prädikat wird bei **jedem** Viewer-Add ohnehin neu ausgewertet +(`EntityView.java:73`). Es kann den Zustand also direkt lesen. Dann gibt es nichts mehr zu +synchronisieren. + +#### Der Fix + +Übernimm das Muster vom Branch — Event plus Listener: + +```java +// event/SlenderVisibilityChangeEvent.java +public record ... // Player + boolean hidden, wie auf dem Branch + +// listener/game/SlenderVisibilityChangeListener.java +public void accept(SlenderVisibilityChangeEvent event) { + event.getPlayer().updateViewableRule(viewer -> !event.isHidden()); +} +``` + +Und lösche `ViewRuleUpdater` samt allen 7 toten `setTag(Tags.HIDDEN, …)`-Aufrufen auf dem Slender +(`GameStartListener:40`, `SlenderBar:66/93/104/116`, `SlenderBarTrigger:72`). + +> **Warum `updateViewableRule(Predicate)` und nicht `removeViewer`?** +> `Entity.removeViewer(player)` (`Entity.java:536`) ist hier **untauglich**: es delegiert an +> `EntityView.manualRemove` (`:126-135`), das für Auto-Viewer `false` liefert und ein kompletter +> No-Op ist — der Spieler steht gar nicht in `manualViewers`. Nur `updateViewableRule(Predicate)` +> (`Entity.java:492`) und `setAutoViewable(false)` (`:488`) ändern `bitSet` **und** Pakete. + +#### Gegenprobe +`grep -rn "Tags.HIDDEN" game/src/main/` → nach dem Fix keine Treffer mehr (oder nur noch dort, wo +der Tag eine andere, klar benannte Bedeutung hat). + +--- + +### Fix 2 — Der Auto-Timeout muss durch denselben Pfad wie der manuelle Druck {#fix-2} + +**Springe zu:** `SlenderBar.java:56-85` + +**Das ist der Fix für dein gemeldetes Symptom.** Ohne ihn bleibt der Bug bestehen, auch mit Fix 1 +und auch mit dem gemergten Branch. + +#### Was aktuell dasteht + +```java +private void handleDraining() { + if (currentTime >= 0) { + // ... drain + return; + } + state = State.REGENERATING; + colorState = StaminaColors.REGENERATING; + player.setTag(Tags.HIDDEN, HIDDEN); // ← wirkungslos + EventDispatcher.call(new StaminaStateChangeEvent(player, state)); + // ... Effekte, Speed, Sprint +} // ← kein Sichtbarkeits-Update +``` + +Zum Vergleich der **manuelle** Pfad, der es richtig macht — `SlenderBarTrigger.java:58-61`: + +```java +if (slenderBar.changeStatus()) { + this.changeVisibilityStatus(player); + this.updateRuneFunction.accept(player); // → ViewRuleUpdater.updateViewer +} +``` + +**Die Asymmetrie zwischen diesen beiden Ausgängen aus DRAINING ist der Defekt.** Dasselbe gilt für +`handleRegeneration()` (`:75-85`): der Übergang REGENERATING → READY in Zeile 80-83 aktualisiert +ebenfalls nichts. + +#### Der Fix + +Feuere aus **beiden** automatischen Übergängen dasselbe Sichtbarkeits-Event wie der manuelle Pfad: + +```java +private void handleDraining() { + if (currentTime >= 0) { + // ... unverändert + return; + } + state = State.REGENERATING; + colorState = StaminaColors.REGENERATING; + EventDispatcher.call(new StaminaStateChangeEvent(player, state)); + EventDispatcher.call(new SlenderVisibilityChangeEvent(player, true)); // ← unsichtbar + // ... Effekte unverändert +} +``` + +Sauberer, falls du etwas mehr umbauen willst: **einen einzigen privaten Übergangspunkt** in +`SlenderBar` einführen, durch den *alle* State-Wechsel laufen — +`changeStatus()`, der Timeout und der READY-Übergang. Dann kann kein künftiger Pfad den +Sichtbarkeitsteil mehr vergessen. Das ist die strukturelle Variante von Fix 2 und der Grund, warum +dieser Bug wiederkommen wird, solange es vier getrennte Ausgänge gibt. + +--- + +### Fix 3 — `updateNewViewer`/`updateOldViewer` sind keine Sichtbarkeits-API {#fix-3} + +**Springe zu:** `StaminaStateChangeListener.java:25-38`, `GameStartListener.java:52-57` + +#### Warum das falsch ist + +Beide Methoden sind in Minestom `@ApiStatus.Internal` (`Entity.java:548` bzw. `:574`) und sind +**reine Paket-Sender**: + +```java +// Entity.java:575-578 +@ApiStatus.Internal +public void updateOldViewer(Player player) { + leashedEntities.forEach(entity -> player.sendPacket(new AttachEntityPacket(entity.getEntityId(), -1))); + player.sendPacket(destroyPacketCache); +} +``` + +Der autoritative Zustand liegt in `EntityView.Option.bitSet`, geändert **nur** durch +`register`/`unregister` (`EntityView.java:206-213`). Solange `isRegistered == true` gilt, fließen +weiterhin **alle** `sendPacketToViewers`-Pakete (Metadata, Equipment, Bewegung) an die Survivors — +`getViewers()` iteriert allein über den bitSet, das Prädikat gatet dort nichts. + +Zusätzlich gehen diese Pakete an **alle** `getOnlinePlayers()`, während `updateViewableRule()` nur +5 Chunks weit reicht. Spieler außerhalb behalten eine eingefrorene Geist-Entity. + +#### Der Fix + +Beide Blöcke ersatzlos streichen. Nach Fix 1 + 2 erledigt das Sichtbarkeits-Event alles — es ändert +bitSet **und** verschickt die Pakete. Der `broadcastPlayPacket(getMetadataPacket())`-Teil kann +bleiben, wenn ihr die Metadaten wirklich broadcasten wollt; die `updateNewViewer`/`updateOldViewer`- +Schleifen müssen weg. + +> **Nebenbefund `GameStartListener.java:52-57`:** In Runde 1 ist diese Schleife toter Code — Zeile 49 +> setzt die Survivors auf `VISIBLE`, das Prädikat ist damit false, der Slender ist gar nicht +> registriert. Der echte Defekt dort ist das **fehlende Sichtbarkeits-Update nach Zeile 40**: der +> Rundenstart verlässt sich darauf, dass zufällig noch kein Survivor im bitSet steht. + +--- + +### Fix 4 — `SlenderReviveListener` vollzieht den Rollenwechsel nicht {#fix-4} + +**Springe zu:** `SlenderReviveListener.java:32-41` + +```java +staminaService.setSlenderBar(player, true); +player.setTag(Tags.TEAM_ID, TeamHelper.SLENDER_TEAM_ID); +``` + +Kein `updateViewableRule(...)`, kein Sichtbarkeits-Event. Der **neue Slender hat gar keine Regel** → +`predicate == null` → `EntityView.java:197-200` liefert bedingungslos `true` → **dauerhaft für alle +sichtbar**. + +Gleichzeitig behält der alte Slender seine Regel: `Entity.removeFromInstance` (`:936`) fasst +`Option.predicate` nicht an. + +**Fix:** dieselbe Sequenz durchlaufen wie `TeamHelper.assignSlender` (`TeamHelper.java:107-111`) — +Regel installieren und Sichtbarkeits-Event feuern — **und** den alten Slender abräumen. Der Branch +aus Abschnitt 3 macht den ersten Teil bereits (`SlenderReviveListener.java:37`). + +Der zugehörige Test `SlenderReviveIntegrationTest.java:49-51` prüft heute nur TEAM_ID, Position und +`assertNotNull(getSlenderBar())` — er zementiert den unvollständigen Rollenwechsel, statt ihn zu +fangen. + +--- + +### Fix 5 — Kein Cleanup über Rundengrenzen {#fix-5} + +**Springe zu:** `StaminaBar.java:57-62`, `StaminaService.java:82-94` + +`StaminaBar.stop()` hat kein `onStop()`-Gegenstück zu `onStart()` (`SlenderBar.java:40-44`), und +`StaminaService.cleanUp()` löscht weder Tags noch die ViewableRule. Ein `removeTag(Tags.HIDDEN)` +existiert **projektweit nirgends** — das einzige `removeTag` ist `PlayerDeathListener.java:50` für +`TEAM_ID`. + +Konsequenz: Ist in Runde 2 ein anderer Spieler Slender, während alte Tags noch stehen, registriert +die Regel sofort alle nahen Spieler — **der Slender ist sichtbar, noch bevor das Auge je gedrückt +wurde**. + +**Fix:** `onStop()` als abstrakte Pflichtmethode in `StaminaBar` einführen, aus `stop()` aufrufen; +`SlenderBar.onStop()` setzt Regel (`updateViewableRule((Predicate) null)`), Effekte, +Speed-Basis und Sprint zurück. Das ist derselbe Fix wie B4 im +[Sprint-Guide](sprint-stamina-fix-guide.md) — einmal bauen, beide Bereiche profitieren. + +--- + +### Fix 6 — Toter Guard in `changeStatus()` {#fix-6} + +**Springe zu:** `SlenderBar.java:88` + +```java +if (state == State.REGENERATING && this.time <= 10) return false; +``` + +`this.time` → `this.currentTime`, und die `10` als benannte Konstante (z. B. +`REACTIVATION_THRESHOLD`). Solange die Bedingung konstant false ist, liefert `changeStatus()` immer +`true` und der Slender kann den Angriffsmodus mit leerer Bar sofort neu aktivieren. + +--- + +### Fix 7 — Der Spectator hängt weiter am Tag-Modell {#fix-7} + +**Springe zu:** `PlayerDeathListener.java:44-52`, `SpectatorService.java:55-61` + +Siehe [Abschnitt 2b](#2b-das-spectator-system-seit-270) für die Herleitung. Der Fix hängt davon ab, +ob Fix 1 schon steht: + +**Nach Fix 1** löst sich der Leak weitgehend von selbst: Die Regel wird dann aus dem Slender-Zustand +abgeleitet und gilt für jeden Viewer gleich — ein Spectator hat keinen eigenen Tag mehr, der +einfrieren könnte. Zu tun bleibt: + +- In `SlenderVisibilityChangeListener` festlegen, ob Spectators den Slender **immer** sehen sollen + (üblich für Zuschauermodi) oder der Slender-Sichtbarkeit folgen. Das ist eine Design-, keine + Bugfrage — entscheidet es bewusst: + ```java + slender.updateViewableRule(viewer -> !hidden || TeamHelper.isSpectatorTeam(viewer)); + ``` +- `PlayerDeathListener:52` feuert `SpectatorAddEvent` ohne Team-Prüfung. Stirbt der Slender, + überschreibt `SpectatorService.join:60` seine Regel mit `_ -> false`. Entweder den Slender dort + ausnehmen oder den Rollenwechsel bewusst durchführen (dann greift auch Fix 4). + +**Vor Fix 1** (falls ihr Fix 7 vorziehen wollt) reicht ein `player.removeTag(Tags.HIDDEN)` in +`PlayerDeathListener` **nicht** — dann fällt der Spectator auf „kein Tag" und sieht den Slender nie +mehr. Ihr müsstet den Tag stattdessen aktiv auf `HIDDEN` halten, was das Sync-Problem aus +[Fix 1](#fix-1) nur verschiebt. **Empfehlung: Fix 1 zuerst.** + +--- + +## 5. Tests + +Für den Sichtbarkeitspfad existierte **kein einziger Test**; `GameViewIntegrationTest` ist +`@Disabled("Investigate why this test is broken")`. Inzwischen liegen zwei Reproduktionsklassen im +Repo — sie sind **rot** und dokumentieren den Ist-Zustand: + +| Klasse | Deckt ab | +|---|---| +| `SlenderVisibilityIntegrationTest` | Fix 2 (Auto-Timeout, Polarität, View-Distance-Zyklus) | +| `SlenderSpectatorVisibilityIntegrationTest` | Fix 7 (eingefrorener Spectator-Tag, beide Richtungen) | + +### Die entscheidende Assertion + +Der Grund, warum dieser Bug so lange überlebt hat: Man sieht ihn nicht an den Paketen, sondern nur +am **Serverzustand**. Deshalb ist die wichtigste Zeile in allen folgenden Tests: + +```java +assertFalse(slender.isViewer(survivor)); +``` + +Genau diese Assertion trennt die beiden auseinandergelaufenen Kanäle — Client hat destroy bekommen, +Server führt den Viewer weiter — und hätte Fix 2 und Fix 3 sofort aufgedeckt. Ein Test, der nur auf +`DestroyEntitiesPacket` prüft, wäre **grün** und hätte nichts gefangen. + +> **⚠️ Falle: der Test muss den Trigger-Pfad mitspielen.** Ruft ein Test nur +> `slenderBar.changeStatus()` auf, überspringt er `SlenderBarTrigger.changeVisibilityStatus` **und** +> `ViewRuleUpdater.updateViewer`. Dann bekommt der Survivor nie den `HIDDEN`-Tag, der Slender wird +> nie sichtbar — und `assertFalse(slender.isViewer(survivor))` ist **vacuously true**. Der Test ist +> grün, ohne irgendetwas zu prüfen. +> +> Deshalb hat jede der Klassen unten eine **Vorbedingungs-Assertion** +> (`assertTrue(slender.isViewer(survivor))` nach dem Augendruck), die genau das absichert, und eine +> `pressEye`-Hilfsmethode, die den Produktionspfad nachbildet. Streicht die beiden nicht weg. + +### Die beiden Testklassen + +Beide liegen bereits im Repo und sind **rot** — sie sind der Nachweis, nicht der Vorschlag. +Statt sie hier in voller Länge abzudrucken, hier nur der Teil, auf den es ankommt: der +nachgebildete Produktionspfad. + +`game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java` + +```java +/** Replays what GameStartListener does at round start. */ +private void startRound(Player slender, Player survivor) { + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + slender.updateViewableRule(ViewRuleUpdater::viewableRuleForSlender); +} + +/** Replays an eye press: SlenderBarTrigger.trigger + ViewRuleUpdater.updateViewer. */ +private void pressEye(SlenderBar bar, Player slender, Player survivor) { + if (!bar.changeStatus()) return; + // SlenderBarTrigger.changeVisibilityStatus + Byte value = slender.getTag(Tags.HIDDEN); + byte current = value != null ? value : SlenderBarHelper.VISIBLE; + slender.setTag(Tags.HIDDEN, current == SlenderBarHelper.VISIBLE + ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE); + // ViewRuleUpdater.updateViewer(slender, survivorTeam) fuer ein Ein-Survivor-Team + survivor.updateViewableRule(); + ViewRuleUpdater.showSlender(survivor); + ViewRuleUpdater.showSurvivor(slender); + slender.updateViewableRule(); + survivor.updateViewableRule(); +} +``` + +Darauf setzen vier Tests auf — Zustand nach dem jeweiligen Übergang, gemessen an +`slender.isViewer(survivor)`: + +| Test | Erwartung | Heute | +|---|---|---| +| `testEyePressMakesSlenderVisible` | Vorbedingung: Augendruck macht sichtbar | ✅ grün | +| `testAutoTimeoutUnregistersViewer` | nach Timeout kein Viewer mehr | 🔴 `expected false, was true` | +| `testManualToggleStaysCorrect` | manueller Doppeldruck räumt auf | ✅ grün — der einzige korrekte Pfad | +| `testPolarityStableAcrossCycles` | Zyklus 2 macht wieder sichtbar | 🔴 `expected true, was false` | +| `testViewDistanceCycleDoesNotRespawn` | 0 `SpawnEntityPacket` beim Wiedereintritt | 🔴 `expected 0, was 1` | + +`game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java` + +Hier kommt eine `die()`-Hilfsmethode dazu, die `PlayerDeathListener` + `SpectatorService.join` +nachbildet — und deren Auffälligkeit gerade ist, was **nicht** darin steht: + +```java +private void die(Player player, List survivorTeam) { + survivorTeam.remove(player); // raus aus dem Pool von updateViewer + player.removeTag(Tags.TEAM_ID); + player.setGameMode(GameMode.SPECTATOR); + player.setTag(Tags.TEAM_ID, TeamHelper.SPECTATOR_TEAM_ID); + player.updateViewableRule(_ -> false); + // kein removeTag(Tags.HIDDEN) - genau das ist Fix 7 +} +``` + +| Test | Erwartung | Heute | +|---|---|---| +| `testDeathLeavesHiddenTagBehind` | dokumentiert den eingefrorenen Tag | ✅ grün (Ist-Zustand) | +| `testManualToggleDoesNotReachSpectator` | Spectator wird mit ausgetragen | 🔴 `expected false, was true` | +| `testSpectatorSeesSlenderDuringAttack` | Spectator sieht den Angriffsmodus | 🔴 `expected true, was false` | + +Beide Spectator-Tests prüfen **vor** der eigentlichen Assertion einen lebenden Kontroll-Survivor. +Der ist grün — was beweist, dass der Leak am Spectator-Status hängt und nicht am allgemeinen +Sichtbarkeitsdefekt aus Fix 2. + +### Weitere fehlende Fälle + +Nach Priorität, wenn du die Abdeckung ausbauen willst: + +1. **Rundenstart:** nach `GameStartEvent` muss `slender.getViewers()` leer sein — auch in einer + zweiten Runde mit denselben Spielern (fängt Fix 5). +2. **`SlenderReviveEvent`:** nach dem Revive muss der neue Slender eine Regel haben und für + Survivors unsichtbar sein (fängt Fix 4). Erweitere `SlenderReviveIntegrationTest.java:49`. +3. **Instanzwechsel:** `setInstance` darf die Regel nicht verlieren — relevant wegen + `TeamHelper.teleportTeams` und `InstanceSwitchChunkPlayer`. +4. **Später hinzukommender Spieler:** wer mitten in der Runde joint, darf einen unsichtbaren Slender + nicht sehen. +5. **Tod des Slenders:** `PlayerDeathListener:52` feuert `SpectatorAddEvent` ohne Team-Prüfung — + `SpectatorService.join:60` würde die Slender-Regel mit `_ -> false` überschreiben (siehe + [Fix 7](#fix-7)). + +--- + +## 6. Verifikation + +```bash +./gradlew :game:test --tests '*Visibility*' --tests '*Slender*' +``` + +Checkliste: + +- [ ] `SlenderVisibilityIntegrationTest` grün, insbesondere `testAutoTimeoutUnregistersViewer` +- [ ] `SlenderSpectatorVisibilityIntegrationTest` grün (Fix 7) +- [ ] Die Vorbedingungs-Assertions sind noch da — sonst sind die Tests vacuously grün +- [ ] `SlenderReviveIntegrationTest` weiterhin grün +- [ ] `grep -rn "updateNewViewer\|updateOldViewer" game/src/main/` → keine Treffer mehr +- [ ] `grep -rn "Tags.HIDDEN" game/src/main/` → keine Treffer mehr (oder klar benannte Restnutzung) +- [ ] `Alt+F7` auf `SlenderVisibilityChangeEvent` → gefeuert aus **allen** State-Übergängen: + `changeStatus()` ×3, Timeout, READY-Übergang, Rundenstart, Revive +- [ ] `grep -rn "updateViewableRule" game/src/main/` → jeder Treffer gehört zu genau einem der drei + Besitzer aus [Abschnitt 2b](#2b-das-spectator-system-seit-270); keiner überschreibt einen anderen + +Manuell im Spiel — das ist die Sequenz, die den Bug erzeugt hat: + +1. Runde starten, als Slender das Auge **einmal** drücken → sichtbar +2. **Nicht** erneut drücken, die ~16 s auslaufen lassen → Slender verschwindet +3. Als Survivor **über 96 Blöcke** weglaufen und zurückkommen → Slender muss **unsichtbar bleiben** +4. Auge erneut drücken → Slender muss **sichtbar** werden (nicht umgekehrt — das prüft die Polarität) +5. Runde zu Ende spielen, neue Runde mit anderem Slender → zu Rundenbeginn unsichtbar + +Und die Spectator-Sequenz für Fix 7 — beide Richtungen, sie schlagen unterschiedlich fehl: + +6. Auge drücken (Slender **sichtbar**), dann einen Survivor töten → als Spectator zuschauen: + der Slender darf nach dem Zurückschalten **nicht** weiter sichtbar bleiben +7. Umgekehrt: einen Survivor töten, während der Slender **unsichtbar** ist → als Spectator muss man + den Slender beim nächsten Augendruck **sehen** können + +--- + +## Zusammenhang mit dem Sprint-Guide + +Vier Befunde teilen sich dieselben Zeilen mit dem [Sprint-Guide](sprint-stamina-fix-guide.md): + +| Gemeinsame Wurzel | Sprint-Seite | Sichtbarkeits-Seite | +|---|---|---| +| `SlenderBar.java:64-72` umgeht den Trigger | Effekte/Speed ohne Trigger-Pfad | Fix 2 — **dein Hauptsymptom** | +| `Tags.HIDDEN` relativ getoggelt | — | Fix 1, Polaritätsumkehr | +| `StaminaBar.stop()` ohne `onStop()` | Effekte/Attribute überleben Cleanup | Fix 5 — Regel und Tags überleben | +| `SlenderReviveListener` unvollständig | keine saubere Bar-Übergabe | Fix 4 — neuer Slender sichtbar | + +**Wenn du beide Guides abarbeitest, bau `onStop()` (Fix 5 hier / B4 dort) nur einmal** — es ist +dieselbe Methode. Gleiches gilt für den toten Guard in `SlenderBar.java:88`. diff --git a/docs/sprint-stamina-fix-guide.md b/docs/sprint-stamina-fix-guide.md new file mode 100644 index 00000000..bae1cce9 --- /dev/null +++ b/docs/sprint-stamina-fix-guide.md @@ -0,0 +1,931 @@ +# Fix-Guide: FoodBar & Sprinten (Survivor-Seite) + +> **Scope:** Nur der Survivor-Sprint-Pfad — `FoodBar`, `PlayerStartSprintingListener`, +> `CygnusPlayer.setSprinting`, `CygnusEntityActionListener`. +> Der Slender-Pfad (`SlenderBar`, `SlenderReviveListener`) hat eigene Befunde und ist +> **bewusst nicht** Teil dieses Guides — siehe Abschnitt [Bewusst ausgeklammert](#bewusst-ausgeklammert). + +## Wie du dieses Dokument benutzt + +Jeder Fix hat eine **Sprungmarke**. In IntelliJ: + +| Was | Shortcut | Eingabe | +|---|---|---| +| Klasse öffnen | `Ctrl+N` (`Cmd+O`) | `FoodBar` | +| Datei öffnen | `Ctrl+Shift+N` | `FoodBar.java` | +| Zu Zeile springen | `Ctrl+G` (`Cmd+L`) | `78` | +| Alle Aufrufer finden | `Alt+F7` auf dem Symbol | — | +| Zurück zum Ausgangspunkt | `Ctrl+Alt+←` | — | + +Empfohlener Ablauf pro Fix: **Test zuerst schreiben** (Abschnitt [Tests](#tests)) → Test läuft rot → +Fix anwenden → Test läuft grün. Die Tests sind so gebaut, dass sie den Bug *vorher* wirklich fangen; +bei jedem Test steht dabei, ob er vor dem Fix rot ist (🔴) oder reine Absicherung (🛡️). + +--- + +## 1. Das Gesamtbild + +Der Sprint-Zustand eines Survivors lebt an **vier** Orten gleichzeitig. Das ist die Wurzel fast aller +Symptome: niemand besitzt den Zustand, jeder schreibt darauf. + +```mermaid +flowchart TB + subgraph client["Client (autoritativ für Sprint-Input)"] + C["Spieler drückt Sprint-Taste"] + end + + subgraph server["Server"] + PKT["CygnusEntityActionListener
ClientEntityActionPacket"] + EV["PlayerStartSprintingEvent"] + LIS["PlayerStartSprintingListener"] + FB["FoodBar
state + currentSpeedCount"] + CP["CygnusPlayer
blockedSprinting"] + ATTR["MOVEMENT_SPEED
AttributeModifier"] + META["entityMeta.setSprinting"] + XP["player.setExp
(die sichtbare Leiste)"] + end + + C -->|"START_SPRINTING"| PKT + PKT --> EV --> LIS + LIS -->|"canConsume()"| FB + LIS -->|"liest"| CP + PKT -->|"setSprinting()"| CP + CP --> ATTR + CP --> META + FB -->|"Tick 1000ms"| XP + FB -->|"schreibt"| CP + + style FB fill:#ffe0e0,stroke:#c00 + style CP fill:#ffe0e0,stroke:#c00 +``` + +**Rot markiert = geteilter Zustand ohne Besitzer.** `blockedSprinting` wird von der `FoodBar` +geschrieben und vom Listener gelesen; `FoodBar.state` wird vom Listener geschrieben (über +`canConsume()`!) und von der Bar gelesen. Beide Richtungen — daher die Regressionen. + +### Der Zustandsautomat der FoodBar + +**Ist-Zustand** (`FoodBar.java`): + +```mermaid +stateDiagram-v2 + [*] --> READY: Konstruktor
currentSpeedCount = 20 + + READY --> DRAINING: canConsume()
⚠️ mutiert beim Fragen + + DRAINING --> DRAINING: consume() alle 1000ms
count -= 2 + DRAINING --> REGENERATING: count <= 0
setBlockedSprinting(true) + DRAINING --> REGENERATING: switchToRegenerating()
(Sprint losgelassen) + + REGENERATING --> REGENERATING: consume() alle 1000ms
count += 1 + REGENERATING --> DRAINING: canConsume() && count > 7
⚠️ auch wenn Sprint abgelehnt wurde + REGENERATING --> READY: count == 20
setBlockedSprinting(false) + + note right of REGENERATING + ⚠️ Der Rücksprung nach DRAINING + passiert auch, wenn das Event + bereits gecancelt wurde. + Das ist Bug #1. + end note +``` + +Die beiden ⚠️-Kanten sind das Problem: **`canConsume()` heißt wie eine Frage, ist aber ein +Zustandswechsel.** Der Aufrufer kann nicht folgenlos „nur mal nachsehen". + +--- + +## 2. Der Hauptbug im Ablauf + +Das ist die Sequenz hinter „die Stamina resettet nicht sauber" und „ich bin ewig gesperrt": + +```mermaid +sequenceDiagram + autonumber + participant P as Spieler (Client) + participant L as PlayerStartSprintingListener + participant CP as CygnusPlayer + participant FB as FoodBar + + Note over FB: Zustand: REGENERATING
count = 8, blockedSprinting = true + + P->>L: START_SPRINTING (Taste gehalten) + L->>CP: hasBlockedSprinting() + CP-->>L: true + L->>L: event.setCancelled(true) + Note over L: ⛔ hier fehlt das return + + L->>FB: canConsume() + Note over FB: REGENERATING && 8 > 7
→ state = DRAINING ✏️ + FB-->>L: true + Note over L: kein setCancelled mehr —
aber Event ist schon gecancelt + + Note over FB: Bar tickt jetzt als DRAINING:
count 8 → 6 → 4 → 2 → 0 + + FB->>CP: setBlockedSprinting(true) + Note over CP,FB: Der Spieler war die ganze Zeit
gesperrt und hat nie sprinten können —
trotzdem ist die Bar leer und
die 20s-Sperre beginnt von vorn. +``` + +**Warum du das im Spiel als „kaputt" erlebst:** Du wartest auf Regeneration, hältst die Sprint-Taste +gedrückt (was jeder tut, wenn man wegrennen will), und die Bar fällt wieder auf null — ohne dass du +jemals schneller geworden bist. Gefühlt: „die Stamina resettet nicht" oder „ich bin dauerhaft gesperrt". + +**Wichtige Einschränkung — damit du beim Testen nicht in die Irre läufst:** Kurzes *Antippen* der +Taste heilt sich selbst. Das folgende `STOP_SPRINTING`-Packet läuft über +`PlayerStopSprintingListener` → `switchToRegenerating()` und macht den Übergang rückgängig. Der +Schaden entsteht nur beim **gehaltenen** Sprint-Input. Es ist also kein harter Deadlock, sondern ein +reproduzierbarer Frust-Loop — deshalb ist der Bug bisher durch alle manuellen Tests gerutscht. + +--- + +## 3. Die Fixes + +Reihenfolge ist beabsichtigt: Fix 1 ist der eigentliche Bug, Fix 2–3 verhindern die nächste +Regression an derselben Stelle, Fix 4–5 sind Härtung. + +| # | Ort | Was | Aufwand | +|---|---|---|---| +| [1](#fix-1) | `FoodBar` + `PlayerStartSprintingListener` | Query/Command trennen, `return` ergänzen | mittel | +| [2](#fix-2) | `FoodBar.handleFoodRegeneration` | Grenzwert + Clamping + Off-by-one | klein | +| [3](#fix-3) | `CygnusPlayer.setSprinting` | Attribut-Key-Kollision, Modifier im gesperrten Zweig | mittel | +| [4](#fix-4) | `StaminaService.getFoodBar` | Null-Sicherheit | klein | +| [5](#fix-5) | `CygnusEntityActionListener` | Entprellung | klein | + +--- + +### Fix 1 — `canConsume()` ist ein Mutator und läuft nach dem Cancel weiter {#fix-1} + +**Springe zu:** `PlayerStartSprintingListener.java:29-36` und `FoodBar.java:78-89` + +#### Was aktuell dasteht + +`PlayerStartSprintingListener.java:29` +```java +if (cygnusPlayer.hasBlockedSprinting()) { + event.setCancelled(true); +} + +FoodBar staminaBarRef = staminaFunction.apply(player); +if (!staminaBarRef.canConsume()) { + event.setCancelled(true); +} +``` + +`FoodBar.java:78` +```java +public boolean canConsume() { + if (state == State.READY) { + state = State.DRAINING; // ← Seiteneffekt in einer "can"-Methode + return true; + } + if (state == State.REGENERATING && currentSpeedCount > 7D) { + state = State.DRAINING; // ← und hier nochmal + return true; + } + return false; +} +``` + +#### Warum das falsch ist + +Zwei getrennte Fehler, die sich gegenseitig verstärken: + +1. **Fehlendes `return`.** Nach `setCancelled(true)` läuft die Methode weiter. Der Sprint ist bereits + abgelehnt, aber die Zustandsmaschine wird trotzdem noch angefasst. +2. **`canConsume()` verletzt Command-Query-Separation.** Eine Methode, deren Name eine Frage stellt, + muss beantwortbar sein, ohne die Welt zu verändern. Sonst ist jeder Aufrufer verpflichtet, den + Nebeneffekt zu kennen — und genau das geht beim nächsten Refactoring verloren. + +Nur *einer* der beiden Fehler zu beheben reicht nicht: Mit `return`, aber weiterhin mutierendem +`canConsume()`, ist der nächste Aufrufer wieder in derselben Falle. Ohne `return`, aber mit sauberer +Query, würde die Ablehnung schweigend ignoriert. + +#### Der Fix + +**Schritt 1** — `FoodBar.java:78`, `canConsume()` in Frage + Kommando aufteilen: + +```java +/** Schwelle, ab der ein Wiedereinstieg ins Sprinten während der Regeneration erlaubt ist. */ +private static final float REGEN_RESUME_THRESHOLD = 7.0F; + +/** + * Prüft, ob der Spieler das Sprinten beginnen darf. + *

+ * Diese Methode ist frei von Seiteneffekten und darf beliebig oft aufgerufen werden. + * Der eigentliche Zustandswechsel erfolgt über {@link #startDraining()}. + * + * @return {@code true}, wenn Sprinten erlaubt ist, sonst {@code false} + */ +public boolean canConsume() { + return state == State.READY + || (state == State.REGENERATING && currentSpeedCount > REGEN_RESUME_THRESHOLD); +} + +/** + * Wechselt in den Verbrauchsmodus. Nur aufrufen, wenn der Sprint tatsächlich startet. + */ +public void startDraining() { + if (!canConsume()) return; + this.state = State.DRAINING; +} +``` + +**Schritt 2** — `PlayerStartSprintingListener.java:29`, den ganzen Block ersetzen: + +```java +FoodBar foodBar = staminaFunction.apply(player); +if (foodBar == null) return; // siehe Fix 4 + +if (cygnusPlayer.hasBlockedSprinting() || !foodBar.canConsume()) { + event.setCancelled(true); + return; // ← der eigentliche Fix +} + +foodBar.startDraining(); +``` + +Die Umstellung auf `||` ist kein Stil-Detail: sie macht strukturell unmöglich, dass zwischen +„abgelehnt" und „Zustand ändern" noch Code steht. Der Zustand wird jetzt **nur** auf dem Pfad +angefasst, auf dem der Sprint tatsächlich beginnt. + +#### Gegenprobe nach dem Fix +`Alt+F7` auf `startDraining()` — es darf **genau einen** Aufrufer geben, und der muss hinter dem +`return` liegen. + +--- + +### Fix 2 — Regeneration: Off-by-one, Float-Gleichheit, fehlendes Clamping {#fix-2} + +**Springe zu:** `FoodBar.java:57-68` + +#### Was aktuell dasteht + +```java +private void handleFoodRegeneration() { + if (this.currentSpeedCount == MAX_FOOD) { // Float == int + state = State.READY; + player.setBlockedSprinting(false); + return; + } + + if (this.currentSpeedCount < MAX_FOOD) { + ++this.currentSpeedCount; + player.setExp(normalize(this.currentSpeedCount)); + } +} +``` + +#### Warum das falsch ist + +Drei Probleme, alle in vier Zeilen: + +1. **Off-by-one.** Geprüft wird *vor* dem Hochzählen. Der Tick, der `currentSpeedCount` auf 20 + bringt, entsperrt noch nicht — erst der nächste. Die Erholung dauert **21 statt 20 Sekunden**, + und in dieser einen Sekunde steht die Leiste sichtbar voll, während du noch gesperrt bist. Das + ist der Teil, der sich im Spiel wie „resettet nicht sauber" anfühlt. +2. **Float-Gleichheit als einzige Ausstiegsbedingung.** `currentSpeedCount` ist ein `float`. Heute + sind alle Schritte ganzzahlig (−2 / +1), also trifft `== 20` zufällig. Ändert jemand `FOOD_TAKE` + auf `2.5` oder die Regenerationsrate auf `0.5`, wird die Bedingung nie wahr → **permanenter + Sprint-Lock**, ohne dass irgendwo ein Fehler auftaucht. Das ist eine gestellte Falle für den + nächsten Balancing-Commit. +3. **Kein Clamping.** Nichts hindert `currentSpeedCount` daran, über `MAX_FOOD` hinauszulaufen, + wenn ein zusätzlicher Regenerationspfad dazukommt. + +#### Der Fix + +```java +private void handleFoodRegeneration() { + if (this.currentSpeedCount < MAX_FOOD) { + this.currentSpeedCount = Math.min(this.currentSpeedCount + FOOD_REGEN, MAX_FOOD); + this.player.setExp(normalize(this.currentSpeedCount)); + } + + if (this.currentSpeedCount >= MAX_FOOD) { + this.state = State.READY; + this.player.setBlockedSprinting(false); + } +} +``` + +Dazu oben bei den Konstanten (`FoodBar.java:11`) die bisher implizite `++` benennen: + +```java +private static final float FOOD_REGEN = 1.0F; +``` + +Erst zählen, dann prüfen — damit entsperrt derselbe Tick, der die Leiste vollmacht. `>=` statt `==` +macht den Ausstieg unabhängig von der Schrittweite, `Math.min` deckelt sauber. + +> **Sonderfall, den du beim Testen sehen wirst:** `switchToRegenerating()` (`FoodBar.java:95`) setzt +> auch aus `READY` heraus auf `REGENERATING` — jedes Loslassen der Sprint-Taste bei voller Bar tut +> das. Mit obigem Fix heilt sich das im selben Tick (`count >= MAX_FOOD` → sofort zurück auf +> `READY`). Vorher brauchte es dafür einen extra Tick. Kein eigener Fix nötig, aber gut zu wissen, +> damit du den Zustandswechsel im Debugger nicht für einen Bug hältst. + +--- + +### Fix 3 — Attribut-Key kollidiert mit Minestom, Modifier bleibt im gesperrten Zweig kleben {#fix-3} + +**Springe zu:** `CygnusPlayer.java:20-24` und `CygnusPlayer.java:55-72` + +#### Was aktuell dasteht + +```java +private static final AttributeModifier SPEED_MODIFIER_SPRINTING = + new AttributeModifier(Key.key("minecraft:sprinting"), 0.25, AttributeOperation.ADD_MULTIPLIED_TOTAL); + +private static final AttributeModifier DISABLED_SPRINT_MODIFIER = + new AttributeModifier(Key.key("minecraft:sprinting"), 0.0, AttributeOperation.ADD_MULTIPLIED_TOTAL); + +@Override +public void setSprinting(boolean sprinting) { + if (blockedSprinting) { + this.entityMeta.setSprinting(false); + this.sendSpringPackets(); + return; // ← Attribut wird hier NICHT angefasst + } + // ... +} +``` + +#### Warum das falsch ist + +**Problem A — Key-Kollision mit Minestom.** Verifiziert in +`minestom-ce-extensions-1.2.0-sources.jar`: + +```java +// LivingEntity.java:52 +private static final AttributeModifier SPRINTING_SPEED_MODIFIER = + new AttributeModifier(Key.key("sprinting"), 0.3, AttributeOperation.ADD_MULTIPLIED_TOTAL); + +// LivingEntity.java:58 +public static final Set PROTECTED_MODIFIERS = Set.of(SPRINTING_SPEED_MODIFIER.id()); + +// AttributeInstance.java:119 +public void clearModifiers() { + this.modifiers.values().removeIf(modifier -> !LivingEntity.PROTECTED_MODIFIERS.contains(modifier.id())); +} +``` + +`Key.key("sprinting")` löst zum Default-Namespace auf und ist damit **identisch** mit Cygnus' +`Key.key("minecraft:sprinting")`. Folge: Cygnus' Modifier steht in Minestoms `PROTECTED_MODIFIERS` +und **überlebt jedes `clearModifiers()`**. Ein Reset-Pfad, der aufräumen will, lässt ausgerechnet den +Sprint-Modifier stehen. + +**Problem B — der gesperrte Zweig korrigiert das Attribut nicht.** Wer im Moment des Sperrens einen +Modifier-Zustand hat, behält ihn. Heute rettet nur die Aufrufreihenfolge in `FoodBar.java:47-48` +(`setSprinting(false)` *vor* `setBlockedSprinting(true)`), dass das nicht auffällt. Jeder andere +Pfad, der `setBlockedSprinting(true)` setzt, während der Spieler sprintet, lässt den +25%-Boost +aktiv — der Spieler ist „gesperrt" und rennt trotzdem. + +**Problem C — `DISABLED_SPRINT_MODIFIER` ist ein No-Op.** `ADD_MULTIPLIED_TOTAL` mit `0.0` +multipliziert mit 1.0, ändert also nichts. Er existiert nur, um über die Key-Kollision den anderen +Modifier zu verdrängen — ein Trick, der die Kollision aus Problem A voraussetzt. + +#### Der Fix + +```java +private static final AttributeModifier SPEED_MODIFIER_SPRINTING = + new AttributeModifier(Key.key("cygnus:sprinting"), 0.25, AttributeOperation.ADD_MULTIPLIED_TOTAL); +// DISABLED_SPRINT_MODIFIER ersatzlos löschen — er war ein No-Op. + +@Override +public void setSprinting(boolean sprinting) { + boolean effective = sprinting && !this.blockedSprinting; + + if (effective) { + this.getAttribute(Attribute.MOVEMENT_SPEED).addModifier(SPEED_MODIFIER_SPRINTING); + } else { + this.getAttribute(Attribute.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER_SPRINTING); + } + + this.entityMeta.setSprinting(effective); + this.sendSpringPackets(); +} +``` + +Der frühe `return` verschwindet: `blockedSprinting` wird jetzt zu einem *Filter auf den Wert*, nicht +zu einem Sprung an der Aufräumarbeit vorbei. Damit ist der Modifier auf **jedem** Pfad konsistent — +die Reihenfolgeabhängigkeit aus Problem B ist strukturell weg. + +> ⚠️ **Nebenwirkung, die du kennen musst:** Mit eigenem Key überschreibt Cygnus' Modifier Minestoms +> 0.3-Modifier nicht mehr. Das ist hier unkritisch, weil `CygnusPlayer.setSprinting` **kein** +> `super.setSprinting()` aufruft und Minestoms Modifier damit nie gesetzt wird (siehe +> `LivingEntity.java:111-119`). Solltest du später `super.setSprinting()` ergänzen, addieren sich +> beide Modifier (0.3 + 0.25) statt sich zu ersetzen. Behalte das im Kopf — der Test in +> [`CygnusPlayerSprintIntegrationTest`](#test-3) fängt genau diesen Fall ab. + +--- + +### Fix 4 — `getFoodBar()` kann `null` liefern {#fix-4} + +**Springe zu:** `StaminaService.java:118` und `PlayerStartSprintingListener.java:33` + +```java +public FoodBar getFoodBar(Player player) { + return (FoodBar) this.staminaBars.get(player.getUuid()); +} +``` + +`HashMap.get` liefert `null`, wenn kein Eintrag existiert — die Methode ist aber nicht als +`@Nullable` markiert, und beide Sprint-Listener dereferenzieren das Ergebnis ungeprüft. Erreichbar +ist das real: `Cygnus.finishGame()` (`Cygnus.java:194-200`) ruft `staminaService.cleanUp()` **vor** +dem Rückbau des Packet-Listeners. Wer in diesem Fenster die Sprint-Taste drückt, bekommt eine NPE. + +**Fix:** +```java +public @Nullable FoodBar getFoodBar(Player player) { + return (FoodBar) this.staminaBars.get(player.getUuid()); +} +``` +Plus `if (foodBar == null) return;` in beiden Listenern (in Fix 1 schon enthalten) und in +`PlayerStopSprintingListener.java:26`. + +Wenn du an der Ursache statt am Symptom ansetzen willst: in `Cygnus.finishGame()` die beiden Zeilen +tauschen, sodass der Packet-Listener zurückgebaut wird, *bevor* `cleanUp()` läuft. Beides zu machen +ist hier richtig — Reihenfolge fixt diesen Fall, Null-Check schützt vor dem nächsten. + +--- + +### Fix 5 — Keine Entprellung im Packet-Listener {#fix-5} + +**Springe zu:** `CygnusEntityActionListener.java:18-32` + +Cygnus ersetzt Minestoms Default-Listener (`Cygnus.java:170`), übernimmt aber dessen Entprellung +nicht. Minestom prüft `oldState != sprinting` bevor es dispatcht; Cygnus feuert bei **jedem** +`START_SPRINTING`-Packet ein neues Event. Zusammen mit Fix 1 war das der Verstärker — jedes +wiederholte Packet zog erneut am Zustandsautomaten. + +Ergänze in `CygnusEntityActionListener.listener`: + +```java +case START_SPRINTING -> { + if (player.isSprinting()) return; // schon im Zustand → nichts zu tun + // ... bestehender Code +} +case STOP_SPRINTING -> { + if (!player.isSprinting()) return; + // ... bestehender Code +} +``` + +Verwandt, aber separat: `CygnusPlayerTickListener.java:26-29` sendet, solange gesperrt, **jeden +Tick** ein volles `EntityAttributesPacket` + Metadata — 20 Pakete/s pro gesperrtem Spieler. Für sich +harmlos, aber unnötig; sende nur bei tatsächlicher Änderung. + +--- + +## 4. Tests {#tests} + +Das ist der Teil, der die Regressionen künftig verhindert. Drei Dateien, aufsteigend im Umfang. + +### Warum diese Tests so aussehen + +Vier Prinzipien, an denen sich die bestehenden Tests messen lassen müssen: + +1. **Verhalten testen, nicht Konstruktion.** `StaminaFactoryTest` und `StaminaServiceTest` prüfen + heute nur, dass Objekte entstehen und Getter etwas zurückgeben. Kein einziger Test hat je eine + `StaminaBar` getickt — deshalb konnte der tote Guard in `SlenderBar.java:88` seit dem + Initial-Commit überleben. +2. **Den Task umgehen, `consume()` direkt aufrufen.** `start()` hängt die Bar an den echten + Scheduler — ein Test, der auf echte Sekunden wartet, ist langsam und flaky. `consume()` ist + `public`; ruf es in einer Schleife auf. Damit ist jeder Tick deterministisch und der Test läuft + in Millisekunden. +3. **Invarianten prüfen, nicht nur Beispiele.** Der wertvollste Test unten ist nicht „nach 10 Ticks + ist die Bar leer", sondern „`blockedSprinting == true` **genau dann wenn** die Bar im gesperrten + Zustand ist". Ein Beispieltest fängt einen Bug, ein Invariantentest fängt eine ganze Bug-Klasse. +4. **Den Bug erst rot sehen.** Schreib den Test, lass ihn gegen den *unveränderten* Code laufen und + überzeug dich, dass er fehlschlägt. Ein Test, den du nie rot gesehen hast, könnte auch einfach + nichts prüfen. + +Zum Zugriff auf `state`: Das Feld ist `protected` in `StaminaBar` und liegt im Package +`net.onelitefeather.cygnus.stamina`. Tests im **selben Package** kommen ohne Getter und ohne +Reflection dran. `currentSpeedCount` ist `private` — bewusst nicht angefasst: der Test soll den Wert +über echte `consume()`-Aufrufe erreichen, nicht hineinschreiben. Das hält ihn ehrlich. + +--- + +### Test 1 — Zustandsautomat der FoodBar + +`game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java` + +```java +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the state machine of the {@link FoodBar}. + *

+ * The tests call {@link FoodBar#consume()} directly instead of starting the scheduler task, + * which makes every tick deterministic and keeps the suite fast. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.4.0 + */ +class FoodBarIntegrationTest extends CygnusPlayerTestBase { + + /** Ticks needed to drain a full bar: 20 food / 2 per tick. */ + private static final int TICKS_TO_DRAIN = 10; + /** Ticks needed to regenerate from empty: 20 food / 1 per tick. */ + private static final int TICKS_TO_REGENERATE = 20; + + @Test + @DisplayName("Eine frische Bar ist READY und nicht gesperrt") + void testInitialState(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + FoodBar foodBar = new FoodBar(player); + + assertEquals(StaminaBar.State.READY, foodBar.state); + assertFalse(player.hasBlockedSprinting()); + assertTrue(foodBar.canConsume(), "Eine volle Bar muss Sprinten erlauben"); + + env.destroyInstance(instance, true); + } + + /** + * 🔴 Rot vor Fix 1: {@code canConsume()} wechselt heute beim blossen Fragen nach DRAINING. + *

+ * Das ist der Kern von Command-Query-Separation: eine Frage darf man beliebig oft + * stellen, ohne dass sich etwas aendert. + */ + @Test + @DisplayName("canConsume() ist frei von Seiteneffekten") + void testCanConsumeDoesNotMutate(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + FoodBar foodBar = new FoodBar(player); + + // Dreimal fragen darf den Zustand nicht bewegen. + assertTrue(foodBar.canConsume()); + assertTrue(foodBar.canConsume()); + assertTrue(foodBar.canConsume()); + + assertEquals(StaminaBar.State.READY, foodBar.state, + "canConsume() darf den Zustand nicht veraendern - dafuer ist startDraining() da"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Voller Zyklus: READY -> DRAINING -> REGENERATING -> READY") + void testFullCycle(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + FoodBar foodBar = new FoodBar(player); + + foodBar.startDraining(); + assertEquals(StaminaBar.State.DRAINING, foodBar.state); + + // Leerlaufen lassen. + for (int i = 0; i < TICKS_TO_DRAIN; i++) { + foodBar.consume(); + } + + assertEquals(StaminaBar.State.REGENERATING, foodBar.state, + "Bei leerer Bar muss die Regeneration beginnen"); + assertTrue(player.hasBlockedSprinting(), + "Bei leerer Bar muss der Sprint gesperrt sein"); + assertFalse(foodBar.canConsume(), + "Direkt nach dem Leerlaufen darf nicht wieder gesprintet werden"); + + // 🔴 Rot vor Fix 2: heute braucht es 21 Ticks, weil vor dem Hochzaehlen geprueft wird. + for (int i = 0; i < TICKS_TO_REGENERATE; i++) { + foodBar.consume(); + } + + assertEquals(StaminaBar.State.READY, foodBar.state, + "Nach " + TICKS_TO_REGENERATE + " Ticks muss die Bar wieder READY sein"); + assertFalse(player.hasBlockedSprinting(), + "Nach voller Regeneration muss die Sperre aufgehoben sein"); + + env.destroyInstance(instance, true); + } + + /** + * 🛡️ Absicherung: dokumentiert die Wiedereinstiegs-Schwelle von 7 als bewusste + * Design-Entscheidung, damit ein spaeterer Balancing-Commit sie nicht versehentlich kippt. + */ + @Test + @DisplayName("Wiedereinstieg erst oberhalb der Schwelle") + void testResumeThreshold(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + FoodBar foodBar = new FoodBar(player); + + foodBar.startDraining(); + for (int i = 0; i < TICKS_TO_DRAIN; i++) { + foodBar.consume(); + } + + // Bei 7 (Schwelle nicht ueberschritten) noch gesperrt ... + for (int i = 0; i < 7; i++) { + foodBar.consume(); + } + assertFalse(foodBar.canConsume(), "Bei genau 7 darf noch nicht gesprintet werden"); + + // ... bei 8 erlaubt. + foodBar.consume(); + assertTrue(foodBar.canConsume(), "Oberhalb von 7 ist Sprinten wieder erlaubt"); + + env.destroyInstance(instance, true); + } + + /** + * Der wertvollste Test dieser Datei: eine Invariante ueber den kompletten Verlauf + * statt einzelner Beispiel-Zeitpunkte. Er faengt jede kuenftige Aenderung, die + * blockedSprinting und den Bar-Zustand auseinanderlaufen laesst. + */ + @Test + @DisplayName("Invariante: gesperrt genau dann, wenn die Bar erschoepft regeneriert") + void testBlockedInvariantHoldsThroughout(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + FoodBar foodBar = new FoodBar(player); + + foodBar.startDraining(); + + for (int tick = 0; tick < TICKS_TO_DRAIN + TICKS_TO_REGENERATE + 5; tick++) { + foodBar.consume(); + + boolean blocked = player.hasBlockedSprinting(); + boolean exhausted = foodBar.state == StaminaBar.State.REGENERATING; + + assertEquals(exhausted, blocked, + "Tick " + tick + ": blockedSprinting (" + blocked + + ") muss dem Bar-Zustand (" + foodBar.state + ") entsprechen"); + } + + env.destroyInstance(instance, true); + } +} +``` + +--- + +### Test 2 — Der eigentliche Regressionstest für Fix 1 + +`game/src/test/java/net/onelitefeather/cygnus/listener/game/PlayerStartSprintingListenerIntegrationTest.java` + +Dieser Test hätte den Bug gefangen. Er ist der wichtigste der drei. + +```java +package net.onelitefeather.cygnus.listener.game; + +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.movement.PlayerStartSprintingEvent; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.stamina.FoodBar; +import net.onelitefeather.cygnus.stamina.StaminaBar; +import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.team.TeamHelper; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Regression tests for {@link PlayerStartSprintingListener}. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.4.0 + */ +class PlayerStartSprintingListenerIntegrationTest extends CygnusPlayerTestBase { + + /** + * 🔴 Rot vor Fix 1 - das ist der gemeldete Bug. + *

+ * Ein gesperrter Spieler haelt die Sprint-Taste gedrueckt. Das Event muss abgelehnt + * werden UND die Bar darf sich nicht bewegen. Heute schiebt canConsume() sie zurueck + * nach DRAINING, wodurch die Regeneration von vorn beginnt. + */ + @Test + @DisplayName("Gesperrter Spieler: Event abgelehnt und Bar unveraendert") + void testBlockedPlayerDoesNotMutateBar(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID); + + StaminaService staminaService = new StaminaService(); + staminaService.createStaminaBars(java.util.Set.of(player)); + FoodBar foodBar = staminaService.getFoodBar(player); + assertNotNull(foodBar); + + // Bar leerlaufen lassen -> Spieler ist gesperrt und regeneriert. + foodBar.startDraining(); + for (int i = 0; i < 10; i++) { + foodBar.consume(); + } + assertTrue(player.hasBlockedSprinting(), "Vorbedingung: Spieler muss gesperrt sein"); + + // Bis ueber die Wiedereinstiegs-Schwelle regenerieren (count = 8). + for (int i = 0; i < 8; i++) { + foodBar.consume(); + } + assertTrue(player.hasBlockedSprinting(), + "Vorbedingung: Sperre besteht bis zur vollen Bar"); + + StaminaBar.State stateBefore = foodBar.state; + + var listener = new PlayerStartSprintingListener(staminaService::getFoodBar); + var event = new PlayerStartSprintingEvent(player); + listener.accept(event); + + assertTrue(event.isCancelled(), + "Ein gesperrter Spieler darf nicht sprinten duerfen"); + assertEquals(stateBefore, foodBar.state, + "Ein abgelehnter Sprint darf die Stamina nicht anfassen"); + + staminaService.cleanUp(); + env.destroyInstance(instance, true); + } + + /** + * 🔴 Rot vor Fix 1: wiederholte Packets ziehen heute mehrfach am Zustandsautomaten. + */ + @Test + @DisplayName("Wiederholte Sprint-Events aendern den Zustand nicht mehrfach") + void testRepeatedEventsAreIdempotent(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID); + + StaminaService staminaService = new StaminaService(); + staminaService.createStaminaBars(java.util.Set.of(player)); + FoodBar foodBar = staminaService.getFoodBar(player); + assertNotNull(foodBar); + + foodBar.startDraining(); + for (int i = 0; i < 10; i++) { + foodBar.consume(); + } + + var listener = new PlayerStartSprintingListener(staminaService::getFoodBar); + for (int i = 0; i < 20; i++) { + listener.accept(new PlayerStartSprintingEvent(player)); + } + + assertEquals(StaminaBar.State.REGENERATING, foodBar.state, + "20 abgelehnte Sprint-Versuche duerfen die Regeneration nicht abbrechen"); + + staminaService.cleanUp(); + env.destroyInstance(instance, true); + } + + /** + * 🛡️ Absicherung fuer Fix 4: kein NPE, wenn keine Bar registriert ist. + */ + @Test + @DisplayName("Spieler ohne registrierte FoodBar wirft keine NPE") + void testMissingFoodBarIsHandled(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + player.setTag(Tags.TEAM_ID, TeamHelper.SURVIVOR_TEAM_ID); + + StaminaService staminaService = new StaminaService(); // bewusst leer + var listener = new PlayerStartSprintingListener(staminaService::getFoodBar); + + assertDoesNotThrow(() -> listener.accept(new PlayerStartSprintingEvent(player))); + + env.destroyInstance(instance, true); + } +} +``` + +--- + +### Test 3 — Attribut-Konsistenz {#test-3} + +`game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerSprintIntegrationTest.java` + +```java +package net.onelitefeather.cygnus.player; + +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the sprint attribute handling of {@link CygnusPlayer}. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.4.0 + */ +class CygnusPlayerSprintIntegrationTest extends CygnusPlayerTestBase { + + /** + * 🔴 Rot vor Fix 3: Cygnus verwendet denselben Key wie Minestoms + * geschuetzter SPRINTING_SPEED_MODIFIER. Dadurch ueberlebt der Cygnus-Modifier + * jedes clearModifiers() - ein Reset-Pfad kann ihn nicht mehr entfernen. + */ + @Test + @DisplayName("Sprint-Modifier kollidiert nicht mit Minestoms geschuetztem Key") + void testModifierKeyDoesNotCollide(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + + player.setSprinting(true); + + boolean usesProtectedKey = player.getAttribute(Attribute.MOVEMENT_SPEED) + .getModifiers().stream() + .anyMatch(modifier -> LivingEntity.PROTECTED_MODIFIERS.contains(modifier.id())); + + assertFalse(usesProtectedKey, + "Cygnus darf keinen Modifier-Key aus LivingEntity.PROTECTED_MODIFIERS verwenden, " + + "sonst ueberlebt er jedes clearModifiers()"); + + env.destroyInstance(instance, true); + } + + /** + * 🔴 Rot vor Fix 3: der gesperrte Zweig kehrt heute frueh zurueck, ohne den + * Modifier zu entfernen. Wer beim Sperren gerade sprintet, behaelt den Boost. + */ + @Test + @DisplayName("Sperren entfernt den Sprint-Boost unabhaengig von der Aufrufreihenfolge") + void testBlockingRemovesSpeedModifier(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + + player.setSprinting(true); + double sprintingSpeed = player.getAttribute(Attribute.MOVEMENT_SPEED).getValue(); + + // Bewusst die "falsche" Reihenfolge: erst sperren, dann setSprinting. + player.setBlockedSprinting(true); + player.setSprinting(true); + + double blockedSpeed = player.getAttribute(Attribute.MOVEMENT_SPEED).getValue(); + + assertTrue(blockedSpeed < sprintingSpeed, + "Ein gesperrter Spieler darf keinen Sprint-Boost behalten " + + "(gesperrt: " + blockedSpeed + ", sprintend: " + sprintingSpeed + ")"); + assertFalse(player.isSprinting(), + "Ein gesperrter Spieler darf nicht als sprintend markiert sein"); + + env.destroyInstance(instance, true); + } +} +``` + +--- + +## 5. Verifikation + +```bash +./gradlew :game:test --tests '*FoodBar*' --tests '*Sprint*' +``` + +Checkliste nach allen Fixes: + +- [ ] Alle drei neuen Testklassen grün +- [ ] `StaminaServiceTest`, `StaminaFactoryTest`, `StaminaColorsTest` weiterhin grün +- [ ] `Alt+F7` auf `FoodBar.startDraining()` → genau ein Aufrufer, hinter dem `return` +- [ ] `Alt+F7` auf `canConsume()` → nur noch Abfragen, keine Stellen die auf den Nebeneffekt bauen +- [ ] Grep nach `minecraft:sprinting` im Projekt → keine Treffer mehr + +Manuell im Spiel: + +1. Als Survivor bis zur Erschöpfung sprinten → Sperre setzt ein, Leiste leer +2. **Sprint-Taste gedrückt halten** während der Regeneration → die Leiste muss weiter steigen und + darf nicht zurückfallen (das war der Bug) +3. Bei voller Leiste → Sperre löst sich **im selben Moment**, in dem die Leiste voll ist, nicht eine + Sekunde später +4. Nach dem Entsperren sprinten → spürbarer Geschwindigkeitsgewinn + +--- + +## Bewusst ausgeklammert + +Diese Befunde sind real, gehören aber zum Slender-Pfad und sind hier **nicht** behandelt: + +| Befund | Ort | Wirkung | +|---|---|---| +| Toter Guard: `this.time <= 10` auf finaler Konstante `16` | `SlenderBar.java:88` | Slender reaktiviert den Angriffsmodus mit leerer Stamina | +| `SlenderRevive` stoppt die alte `FoodBar` nicht | `SlenderReviveListener.java:34` | Zwei Bars schreiben gegeneinander auf `blockedSprinting` | +| `StaminaBar.stop()` hat kein `onStop()` | `StaminaBar.java:57` | Potions, Speed-Basis und `Tags.HIDDEN` überleben den Cleanup | +| `Tags.HIDDEN` wird relativ statt absolut geschrieben | `SlenderBarTrigger.java:69` | gehört zur separaten Sichtbarkeits-Analyse | + +Der erste ist ein Einzeiler mit hoher Wirkung, falls du ihn direkt mitnehmen willst: +`this.time` → `this.currentTime` (und die Schwelle als benannte Konstante). diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md new file mode 100644 index 00000000..4f6a0f32 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -0,0 +1,209 @@ +# Tunnel vision for survivors + +## Goal + +A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a +heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player, +continuous rather than on/off, and driven entirely by the server. + +## Why not a shader + +The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work. + +A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob +vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be +switched on for one player from the server, and none carries an intensity parameter. The only way +to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden +enderman, which takes over the camera and makes the game unplayable. + +That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` +plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom +ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`). + +So the effect is rendered as the `camera_overlay` of an item worn on the head — the mechanism +behind the carved pumpkin, and the one thing in vanilla that draws a texture across the whole +screen and scales it with the viewport. Behind an interface that a post-effect renderer can slot +into once 26.3 and Minestom support land; the gameplay side does not change when that happens. + +Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), +[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3). + +## Intensity + +`TunnelVisionIntensity` turns two inputs into a value in `[0, 1]`. It has no Minestom dependency +beyond positions, so it is testable without a server. + +**Stamina.** With `s = currentSpeedCount / 20`: + +``` +stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2 +``` + +Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far +more dramatic than crossing the halfway mark. + +**Slender.** With `d` the distance between survivor and Slender: + +``` +proximity = clamp((25 - d) / (25 - 6), 0, 1) +view = 0.6 + 0.4 * max(0, dot(survivorLookDirection, directionToSlender)) +slender = proximity * view +``` + +The effect starts at 25 blocks and peaks at 6. Looking straight at him is worse than having him +behind you, but never by more than a factor of 1.67 — he is frightening either way. + +**Combination:** + +``` +combined = 1 - (1 - stamina) * (1 - slender) +``` + +Both sources add up noticeably but saturate cleanly at 1.0 instead of clamping hard, so neither +one can hide the other. + +**No line-of-sight raycast.** A wall between survivor and Slender does not dampen the effect. It +would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better +atmosphere anyway. + +## Stages and pulse + +The continuous value is quantised to 16 stages, which double as the frames of the heartbeat. +Minecraft cannot animate an overlay texture — `.mcmeta` animation covers block, item, particle, +painting and effect textures only — so the animation is the server walking through the frames. Two mechanisms sit on top, in this order: + +1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 16)` and afterwards + only moves when `combined * 16` is more than 0.6 stages away from it. Distance and stamina both + jitter constantly; without this the overlay flickers at every stage boundary. +2. **Pulse on top of the stabilised stage.** + +``` +depth = (16 / 16) * combined // one stage per 16, i.e. a fixed share of the scale +frequency = 1.0 + 1.5 * combined // Hz +display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 16) +``` + +The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low +intensity — a depth that does not scale would make stage 1 flicker between 0 and 1. + +The pulse only ever opens the view back up, never past the base stage. A symmetric pulse would be +clipped away exactly where it matters most: at full intensity the base stage is already the +maximum, so everything above it is lost and the heartbeat disappears. + +The order matters: hysteresis applies to the base value, the pulse is added afterwards. Reversed, +the hysteresis would damp out exactly the pulsing it is there to allow. + +Stage 0 is not a texture. It clears the overlay. + +**Service tick: 100 ms.** The heartbeat reaches 2.5 Hz, and sampling it at 4 Hz — a 250 ms tick — +aliases it into something jerky. 100 ms samples it ten times per second, which is smooth and still +a tiny packet per survivor. + +## Pack assets + +In `cygnus-pack`, namespace `cygnus`: + +``` +pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_16.png +pack/assets/cygnus/equipment/empty.json +``` + +Each texture is 1024×576 — 16:9, because the client stretches a camera overlay across the screen +rather than fitting it. The darkening closes in from all four edges rather than as a circle from +the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle +framing the screen, to 2 at stage 16, where a plain ellipse reads as a tunnel. Textures are +generated by `tools/generate_overlay.py`, which also produces the blood splatter. + +**How it reaches the screen.** The server puts an item in the player's head slot carrying +`equippable{slot:head, camera_overlay:"cygnus:gui/tunnel_vision/stage_N"}`. Three details keep the +carrier out of the way: + +- `asset_id` points at `cygnus:empty`, an equipment model with no layers. Without it Minecraft + draws the item itself on the player's head. +- `swappable`, `dispensable` and `damage_on_hurt` are all off, so nobody strips the overlay by + accident and it is not treated as armour. +- The equip sound is `minecraft:intentionally_empty`; the default would click on every stage + change, ten times a second. + +**The position needs no calibration.** This is the whole reason for the mechanism: the client +scales the overlay to the viewport, so it fits every resolution and GUI scale on its own. A font +glyph cannot — its size is fixed in the pack, so it has to be calibrated against one resolution +and drifts on every other. + +## Components + +New package `net.onelitefeather.cygnus.tunnelvision`: + +- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it. +- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure. +- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a + post-effect renderer slots into on 26.3. +- `OverlayTunnelVisionRenderer` — the implementation described above; it contributes a texture to + the shared `ScreenOverlay` rather than dressing the player itself. +- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one + scheduler task. +- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the + vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing; + `intensity` runs the real heartbeat. + +One task for everyone rather than one per player as `StaminaBar` does: the Slender position is +read once per tick instead of once per survivor, and cleanup happens in one place. + +## Wiring + +`Cygnus` creates the service and the command. The service then listens for the round's lifecycle +itself, the way `SpectatorService` and `ResourcePackService` already do, rather than being called +from the existing listeners: + +| Event | What happens | +| --- | --- | +| `GameStartEvent` | starts drawing for the survivor team | +| `PlayerDeathEvent` | removes the player (transition to spectator) | +| `PlayerDisconnectEvent` | removes the player | +| `GameFinishEvent` | full cleanup | + +This keeps `GameStartListener`, `PlayerDeathListener` and `PlayerQuitListener` — and their tests — +untouched: none of them has anything the service needs beyond the moment itself. + +Two changes to existing code: + +- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The + service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs + game logic off a display detail. +- **The service only exists when the resource pack is active.** `Cygnus` creates it only if + `resourcePackService` is present, reusing the `Optional` already in place. Without the pack the + textures do not exist and players would get a fullscreen missing-texture checkerboard. + +## Failure modes + +The service keeps running in all of these; none of them throws. + +| Situation | Behaviour | +| --- | --- | +| No Slender (disconnected, not yet assigned) | stamina share only | +| Slender in a different instance | slender share is 0 | +| No `FoodBar` registered for a player | stamina share is 0 | +| Stage drops to 0 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head | +| Player dies or becomes a spectator | explicit `clear()`, same reason | + +## Tests + +- `TunnelVisionIntensityTest` — plain JUnit: edge values (full stamina at long range gives 0, + empty stamina at close range gives 1), monotonicity in both inputs, and the view factor. +- `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity, + hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds. +- `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and + `clear()` drops only its own layer rather than wiping the screen out from under the blood + splatter. +- `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood + wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving + empties the slot, and an unchanged overlay is not re-sent. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no + Slender or one in another instance, and the four lifecycle events. +- `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and + clears on `off`. +- `FoodBarTest` — a fresh bar reports a full share. + +The pack side cannot be tested automatically. Glyph sizing and the look of the vignette are +verified in-game against a snapshot build of `cygnus-pack`; that is an explicit step in the +implementation plan, not an afterthought. diff --git a/docs/superpowers/specs/2026-08-11-blood-splatter-design.md b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md new file mode 100644 index 00000000..f26bd1a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md @@ -0,0 +1,104 @@ +# Blood splatter on damage + +## Goal + +Taking a hit throws blood across the screen: it appears at once, from the side the hit came from, +and fades away within about a second. It says nothing about how the player is doing — that is the +tunnel vision's job — it only says *you were just hit, from over there*. + +## Sharing the screen with the tunnel vision + +Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the +player's head — the only mechanism in vanilla that scales a texture to the viewport instead of +being calibrated against one resolution. + +A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the +head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`, +`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the +screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards. + +The alternative was pre-rendering every combination of splatter frame and vignette stage, so both +stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every +change to either effect would force re-rendering all of them. + +Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty +equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the +texture actually changes — an equipment update goes out to every viewer, not just the wearer. + +## Trigger + +Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never +raises Minestom's `EntityDamageEvent`, so a listener on it would never fire. + +`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position +and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and +`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not +used yet but is the natural handle for anything that should scale with how hard the hit was. + +## Direction + +`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim +rather than from the world: + +``` +alignment = dot(victimLookDirection, directionToSource) +alignment > 0.5 -> FRONT +alignment < -0.5 -> BACK +cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT +``` + +A hit from the east lands on the left for a player looking south and on the right for one looking +north. From the exact same spot the direction is meaningless, so it falls back to FRONT. + +## Frames + +Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated +hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera +overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2 +seconds. A fresh hit restarts the sequence rather than queueing behind the old one. + +The task that drives the fade starts with the first splatter and stops once nothing is bleeding +any more, rather than spinning over an empty map between hits. + +Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a +power-law radius — many specks, few real blotches — weighted towards the side the hit came from, +then blurred and thresholded so they melt into shapes with ragged edges instead of reading as +confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along +the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like +it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto. + +## Wiring + +`Cygnus` creates the service and `/blood`, and the service listens for itself: + +| Event | What happens | +| --- | --- | +| `PlayerDamagedEvent` | throws a splatter from the direction of the source | +| `PlayerDisconnectEvent` | drops the player's splatter | + +Like the tunnel vision, it is only registered when a resource pack is configured — without the +pack the textures are missing and players would get a fullscreen missing-texture checkerboard. + +`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the +drawings can be judged without waiting to be hit. + +## Failure modes + +| Situation | Behaviour | +| --- | --- | +| Hit while a splatter is still fading | the old one is replaced, the sequence restarts | +| Hit from the victim's own position | falls back to `FRONT` | +| Player leaves mid-fade | the splatter is dropped with them | +| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over | + +## Tests + +- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides + rather than the world, and the degenerate same-spot case. +- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and + cleans up, a second hit restarts, the damage event triggers it, players are independent. +- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the + player who dealt it. +- `BloodCommandTest` — every side can be requested, and the bare command picks one. +- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns + afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 09584278..6758fe2d 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -33,12 +33,17 @@ import net.minestom.server.event.player.PlayerSpawnEvent; import net.minestom.server.event.player.PlayerUseItemEvent; import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; import net.minestom.server.listener.EntityActionListener; import net.minestom.server.listener.common.SettingsListener; import net.minestom.server.network.packet.client.common.ClientSettingsPacket; import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; +import net.onelitefeather.cygnus.blood.BloodSplatterService; +import net.onelitefeather.cygnus.command.BloodCommand; +import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.command.StartCommand; +import net.onelitefeather.cygnus.command.TunnelVisionCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.config.GameConfig; @@ -47,6 +52,7 @@ import net.onelitefeather.cygnus.common.page.PageProvider; import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent; import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.gaze.SlenderGazeService; import net.onelitefeather.cygnus.event.SlenderReviveEvent; import net.onelitefeather.cygnus.event.StaminaStateChangeEvent; import net.onelitefeather.cygnus.jumpscare.JumpScareManager; @@ -76,12 +82,22 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.stamina.FoodBar; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.OverlayProperties; +import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; +import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; /** @@ -103,6 +119,11 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final JumpScareManager jumpscareManager; private final SpectatorService spectatorService; private final Optional resourcePackService; + private final ScreenOverlay screenOverlay; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final BloodSplatterService bloodSplatterService; + private final TunnelVisionService tunnelVisionService; + private final SlenderGazeService slenderGazeService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -126,6 +147,14 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.screenOverlay = new EquipmentScreenOverlay(); + this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); + this.bloodSplatterService = new BloodSplatterService( + this.screenOverlay, + bound -> ThreadLocalRandom.current().nextInt(bound) + ); + this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, this::remainingStamina); + this.slenderGazeService = new SlenderGazeService(this.screenOverlay, this::currentSlender); this.initPhases(); this.initCommands(); this.initListener(); @@ -136,6 +165,42 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); + manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer)); + manager.register(new BloodCommand(this.bloodSplatterService)); + manager.register(new GlitchCommand(this.slenderGazeService)); + } + + /** + * Reads a survivor's remaining stamina for the tunnel vision. + * + * @param player the survivor to read + * @return the remaining share, or a full bar while the player has none yet + */ + private double remainingStamina(Player player) { + FoodBar bar = this.staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + + /** + * Looks up the player currently playing the slender. + * + * @return the slender, or {@code null} while the role is unassigned + */ + private @Nullable Player currentSlender() { + return this.teamService.getTeam(GameConfig.SLENDER_KEY) + .flatMap(team -> team.getPlayers().stream().findFirst()) + .orElse(null); + } + + /** + * Collects the players that are currently survivors. + * + * @return the survivor team's players + */ + private Set currentSurvivors() { + return this.teamService.getTeam(GameConfig.SURVIVOR_KEY) + .map(team -> Set.copyOf(team.getPlayers())) + .orElseGet(Set::of); } private void initListener() { @@ -188,6 +253,14 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientSettingsPacket.class, CygnusSettingsListener::listener); spectatorService.registerListener(handler); + + // Without the pack the vignette font does not exist and survivors would stare at an + // empty box, so the effect stays off wherever the pack is not delivered. + if (OverlayProperties.enabled()) { + this.tunnelVisionService.registerListener(handler, this::currentSurvivors); + this.slenderGazeService.registerListener(handler, this::currentSurvivors); + this.bloodSplatterService.registerListener(handler); + } } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java new file mode 100644 index 00000000..6408208d --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; + +/** + * The side of the screen a splatter is thrown from, seen from the victim rather than from the + * world — being hit from the east means something different depending on where you are looking. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum BloodDirection { + + FRONT, + RIGHT, + BACK, + LEFT; + + /** Above this alignment with the view direction a hit counts as coming from straight ahead. */ + private static final double FORWARD_THRESHOLD = 0.5D; + + /** Below this distance the direction to the source carries no meaning any more. */ + private static final double DISTANCE_EPSILON = 1.0E-6D; + + /** + * Works out which side a hit came from. + * + * @param victim the victim's position, whose yaw and pitch supply the view direction + * @param source where the damage came from + * @return the side to throw the splatter from + */ + public static BloodDirection between(Pos victim, Point source) { + double distance = victim.distance(source); + if (distance < DISTANCE_EPSILON) return FRONT; + + Vec towardsSource = new Vec( + source.x() - victim.x(), + source.y() - victim.y(), + source.z() - victim.z() + ).div(distance); + Vec facing = victim.direction(); + + double alignment = facing.dot(towardsSource); + if (alignment > FORWARD_THRESHOLD) return FRONT; + if (alignment < -FORWARD_THRESHOLD) return BACK; + + // The cross product points up when the source sits on the side the victim's left hand is + // on, which for a player looking south is the east. + return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java new file mode 100644 index 00000000..cec37114 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java @@ -0,0 +1,169 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; + +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Locale; +import java.util.function.IntUnaryOperator; + +/** + * Throws a splatter of blood across the screen when a player is hit and fades it out again. + *

+ * The textures are laid out as direction × variant × frame. The direction aims the splatter at the + * side the hit came from, the variant keeps repeated hits from looking mechanical, and the frames + * are the fade — Minecraft cannot animate a camera overlay, so the server steps through them. + *

+ * + * @author TheMeinerLP + * @version 2.1.0 + * @since 2.7.0 + */ +public final class BloodSplatterService { + + /** How many drawings exist per direction. */ + static final int VARIANTS = 2; + + /** How many frames a splatter fades over. */ + static final int FRAMES = 12; + + /** + * How long a single frame stays on screen. Twelve frames at this rate keep the splatter alive + * for the same 1.2 seconds as six did at twice the interval, but it runs down the screen + * smoothly rather than in visible steps. + */ + static final int FRAME_MILLIS = 100; + + /** Where the splatter textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/blood/"; + + /** The keys, indexed {@code [direction][variant][frame]}. */ + private static final Key[][][] TEXTURES = buildTextures(); + + private final ScreenOverlay overlay; + private final IntUnaryOperator variantPicker; + private final PlayerState active = new PlayerState<>(); + + /** Fades every active splatter forward by one frame. Runs only while someone is bleeding. */ + final RepeatingTask fadeTask = new RepeatingTask(this::tick); + + /** + * Creates a new service. + * + * @param overlay the overlay that owns the player's screen + * @param variantPicker picks a variant below the given bound + */ + public BloodSplatterService(ScreenOverlay overlay, IntUnaryOperator variantPicker) { + this.overlay = overlay; + this.variantPicker = variantPicker; + } + + /** + * Listens for hits and for players leaving. + * + * @param node the node to register on + */ + public void registerListener(EventNode node) { + node.addListener(PlayerDamagedEvent.class, event -> this.splatter( + event.getPlayer(), + BloodDirection.between(event.getPlayer().getPosition(), event.getSource()) + )); + node.addListener(PlayerDisconnectEvent.class, event -> this.clear(event.getPlayer())); + } + + /** + * Throws a fresh splatter, replacing whatever is still fading. + * + * @param player the player who was hit + * @param direction the side the hit came from + */ + public void splatter(Player player, BloodDirection direction) { + Splatter splatter = new Splatter(player, direction, this.variantPicker.applyAsInt(VARIANTS)); + this.active.put(player, splatter); + this.draw(splatter); + this.fadeTask.start(FRAME_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Takes the splatter off a player's screen. + * + * @param player the player to clear + */ + public void clear(Player player) { + if (this.active.remove(player) == null) return; + this.overlay.set(player, OverlayLayer.BLOOD, null); + } + + /** + * Advances every splatter by one frame and drops the ones that have faded out. + */ + void tick() { + Iterator splatters = this.active.values().iterator(); + while (splatters.hasNext()) { + Splatter splatter = splatters.next(); + splatter.frame++; + + if (splatter.frame >= FRAMES) { + splatters.remove(); + this.overlay.set(splatter.player, OverlayLayer.BLOOD, null); + continue; + } + this.draw(splatter); + } + + // Nothing is bleeding; the task would only spin over an empty map until the next hit. + if (this.active.isEmpty()) this.fadeTask.stop(); + } + + /** + * Puts a splatter's current frame on its player's screen. + * + * @param splatter the splatter to draw + */ + private void draw(Splatter splatter) { + this.overlay.set(splatter.player, OverlayLayer.BLOOD, + TEXTURES[splatter.direction.ordinal()][splatter.variant][splatter.frame]); + } + + /** + * Builds the texture key for every cell of the direction × variant × frame grid. + * + * @return the keys, indexed {@code [direction][variant][frame]} + */ + private static Key[][][] buildTextures() { + return OverlayTextureKeys.cube( + TEXTURE_PATH, + BloodDirection.values().length, VARIANTS, FRAMES, + direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT), + OverlayTextureKeys.ONE_BASED, + OverlayTextureKeys.ONE_BASED + ); + } + + /** + * One player's running splatter. + */ + private static final class Splatter { + + private final Player player; + private final BloodDirection direction; + private final int variant; + private int frame; + + private Splatter(Player player, BloodDirection direction, int variant) { + this.player = player; + this.direction = direction; + this.variant = variant; + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java new file mode 100644 index 00000000..86addd53 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.blood; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java new file mode 100644 index 00000000..ce198835 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java @@ -0,0 +1,48 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentEnum; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.blood.BloodDirection; +import net.onelitefeather.cygnus.blood.BloodSplatterService; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Throws a blood splatter on demand, so the drawings can be judged without waiting to be hit. + *

+ * {@code /blood} picks a side at random, {@code /blood front|right|back|left} asks for one. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class BloodCommand extends Command { + + /** + * Creates the command. + * + * @param service the service that throws the splatter + */ + public BloodCommand(BloodSplatterService service) { + super("blood"); + + var direction = ArgumentType.Enum("side", BloodDirection.class) + .setFormat(ArgumentEnum.Format.LOWER_CASED); + + this.setDefaultExecutor((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can bleed."); + if (player == null) return; + BloodDirection[] sides = BloodDirection.values(); + service.splatter(player, sides[ThreadLocalRandom.current().nextInt(sides.length)]); + }); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can bleed."); + if (player == null) return; + service.splatter(player, context.get(direction)); + }, direction); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java new file mode 100644 index 00000000..8ef1d271 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java @@ -0,0 +1,48 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import org.jetbrains.annotations.Nullable; + +/** + * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this + * package draws on a screen and only a player has one. + *

+ * {@code TunnelVisionCommand}, {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an + * identical {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the + * error string sent back to the console. This type is that method, extracted once. + *

+ *

+ * A static helper was chosen over an abstract base command on purpose. The narrowing check is the + * only thing the three commands share — their constructors take different services, their default + * executors print different usage lines, and {@code TunnelVisionCommand} alone runs a per-player + * preview loop. An abstract base class would force every subclass into one constructor shape and + * one inheritance chain to get a single one-line check, coupling command shape to something none of + * them actually have in common. A stateless static method carries the shared behaviour without + * dragging the unrelated parts of any one command onto the other two, which keeps each command free + * to change its syntax, its executor and its scheduling independently. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class CommandSenders { + + private CommandSenders() { + } + + /** + * Narrows the given sender down to a player, telling them why not if it cannot. + * + * @param sender the sender to narrow + * @param reason the rest of the sentence after {@code "Only players "}, e.g. {@code "can bleed."} + * @return the player, or {@code null} if the sender has no screen to draw on + */ + public static @Nullable Player asPlayer(CommandSender sender, String reason) { + if (sender instanceof Player player) return player; + sender.sendMessage(Messages.withMiniPrefix("Only players " + reason)); + return null; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java new file mode 100644 index 00000000..199d25b1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java @@ -0,0 +1,49 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.gaze.SlenderGaze; +import net.onelitefeather.cygnus.gaze.SlenderGazeService; + +/** + * Puts the slender's glitch on screen without him being there, so the drawings can be judged from + * the lobby. + *

+ * {@code /glitch <1-4>} holds one level, {@code /glitch off} takes it away. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class GlitchCommand extends Command { + + /** + * Creates the command. + * + * @param service the service that draws the tearing + */ + public GlitchCommand(SlenderGazeService service) { + super("glitch"); + + var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS); + + this.setDefaultExecutor((sender, context) -> sender.sendMessage( + Messages.withMiniPrefix("Usage: /glitch <1-" + SlenderGaze.LEVELS + "> | off") + )); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + if (player == null) return; + service.show(player, context.get(level) - 1); + }, level); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + if (player == null) return; + service.hide(player); + }, ArgumentType.Literal("off")); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java new file mode 100644 index 00000000..5f8ad9f3 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -0,0 +1,106 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; + +import java.time.temporal.ChronoUnit; + +/** + * Puts the tunnel vision on screen without a running round, so the glyph sizes in the resource + * pack can be judged from the lobby. + *

+ * {@code /tunnelvision stage <0-16>} freezes a single stage, which is what the font's + * {@code height} and {@code ascent} are calibrated against. {@code /tunnelvision intensity + * <0.0-1.0>} runs the same heartbeat the game uses, to judge how the pulse feels. Both are ended + * by {@code /tunnelvision off}. + *

+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 2.7.0 + */ +public final class TunnelVisionCommand extends Command { + + private final TunnelVisionRenderer renderer; + private final PlayerState previews; + + /** + * Creates the command. + * + * @param renderer the renderer that draws the preview + */ + public TunnelVisionCommand(TunnelVisionRenderer renderer) { + super("tunnelvision"); + this.renderer = renderer; + this.previews = new PlayerState<>(); + + var stage = ArgumentType.Integer("level").between(0, TunnelVisionStage.MAX_STAGE); + var intensity = ArgumentType.Double("amount").between(0.0D, 1.0D); + + this.setDefaultExecutor((sender, context) -> sender.sendMessage( + Messages.withMiniPrefix("Usage: /tunnelvision stage <0-16> | intensity <0.0-1.0> | off") + )); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + if (player == null) return; + this.stopPreview(player); + this.renderer.render(player, context.get(stage)); + }, ArgumentType.Literal("stage"), stage); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + if (player == null) return; + this.startPreview(player, context.get(intensity)); + }, ArgumentType.Literal("intensity"), intensity); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + if (player == null) return; + this.stopPreview(player); + this.renderer.clear(player); + }, ArgumentType.Literal("off")); + } + + /** + * Draws a constant intensity with its heartbeat running until the preview is stopped. + * + * @param player the player to draw for + * @param intensity the intensity to hold + */ + private void startPreview(Player player, double intensity) { + this.stopPreview(player); + + TunnelVisionStage stage = new TunnelVisionStage(); + // The task alone would only draw from its first repetition onward, so the initial stage is + // rendered here, the same way BloodSplatterService and SlenderGazeService draw their first + // frame before ever starting their own repeating task. + this.renderer.render(player, stage.update(intensity)); + + RepeatingTask task = new RepeatingTask(() -> { + if (!player.isOnline()) { + this.stopPreview(player); + return; + } + this.renderer.render(player, stage.update(intensity)); + }); + this.previews.put(player, task); + task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Ends a running preview, leaving whatever is on screen untouched. + * + * @param player the player whose preview to end + */ + private void stopPreview(Player player) { + RepeatingTask task = this.previews.remove(player); + if (task != null) task.stop(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java new file mode 100644 index 00000000..15fab507 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java @@ -0,0 +1,65 @@ +package net.onelitefeather.cygnus.event; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Player; +import net.minestom.server.event.trait.PlayerEvent; + +/** + * Called when a player takes damage from the game. + *

+ * Cygnus applies damage by setting health directly, which never raises Minestom's + * {@code EntityDamageEvent}. This event fills that gap for everything that needs to react to a + * hit — the blood splatter above all — and carries where the hit came from, so the reaction can + * be aimed. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@SuppressWarnings("java:S6206") +public final class PlayerDamagedEvent implements PlayerEvent { + + private final Player player; + private final Point source; + private final float amount; + + /** + * Creates a new instance of the {@link PlayerDamagedEvent}. + * + * @param player the player who was hit + * @param source where the damage came from + * @param amount how much health was taken + */ + public PlayerDamagedEvent(Player player, Point source, float amount) { + this.player = player; + this.source = source; + this.amount = amount; + } + + /** + * {@inheritDoc} + */ + @Override + public Player getPlayer() { + return this.player; + } + + /** + * Returns where the damage came from. + * + * @return the position of the source + */ + public Point getSource() { + return this.source; + } + + /** + * Returns how much health the hit took. + * + * @return the damage amount + */ + public float getAmount() { + return this.amount; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java new file mode 100644 index 00000000..01fbb31a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java @@ -0,0 +1,69 @@ +package net.onelitefeather.cygnus.gaze; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * Works out how badly the sight of the slender tears a survivor's view apart. + *

+ * This is about seeing him, not about him being there: standing behind a survivor does nothing at + * all, however close he is. Only once he is inside their field of view does the picture start to + * come apart, and it gets worse the nearer he is. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class SlenderGaze { + + /** Nothing to draw: he is out of range, or out of sight. */ + public static final int NONE = -1; + + /** How many degrees of tearing there are between just visible and right in front. */ + public static final int LEVELS = 4; + + /** Beyond this distance he is too far away to unsettle anything. */ + private static final double RANGE = 32.0D; + + /** The distance at which the tearing is at its worst. */ + private static final double CLOSE = 6.0D; + + /** + * How far off the view direction he may stand and still count as seen. Roughly the horizontal + * field of view of a default client — the effect belongs on the screen he is on. + */ + private static final double FIELD_OF_VIEW = 0.55D; + + /** Below this distance the direction to him carries no meaning any more. */ + private static final double DISTANCE_EPSILON = 1.0E-6D; + + private SlenderGaze() { + } + + /** + * Works out the tearing a survivor gets from where the slender stands. + * + * @param survivor the survivor's position, whose yaw and pitch supply the view direction + * @param slender the slender's position + * @return a level between {@code 0} and {@code LEVELS - 1}, or {@link #NONE} + */ + public static int levelOf(Pos survivor, Pos slender) { + double distance = survivor.distance(slender); + if (distance > RANGE) return NONE; + if (distance < DISTANCE_EPSILON) return LEVELS - 1; + + Vec towardsSlender = new Vec( + slender.x() - survivor.x(), + slender.y() - survivor.y(), + slender.z() - survivor.z() + ).div(distance); + + if (survivor.direction().dot(towardsSlender) < FIELD_OF_VIEW) return NONE; + + double nearness = (RANGE - distance) / (RANGE - CLOSE); + double clamped = Helper.clamp(nearness, 0.0D, 1.0D); + return (int) Math.round(clamped * (LEVELS - 1)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java new file mode 100644 index 00000000..a21eda61 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java @@ -0,0 +1,208 @@ +package net.onelitefeather.cygnus.gaze; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.common.util.Helper; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Tears a survivor's picture apart while the slender stands in their view. + *

+ * This replaces what the tunnel vision used to do when he came near, and it asks a different + * question: not how close he is, but whether they can see him. Standing behind a survivor does + * nothing at all. + *

+ *

+ * A real colour-space shift would need a post-processing shader, and on Minecraft 26.2 those + * cannot be switched on for a single player, so this is a camera overlay like the others — the + * colour is laid over the world rather than the world being recalculated. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class SlenderGazeService { + + /** Where the glitch textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/glitch/level_"; + + /** How many frames the tearing runs through. */ + static final int FRAMES = 4; + + /** How long a frame stays on screen. */ + static final int TICK_MILLIS = 100; + + private static final Key[][] TEXTURES = OverlayTextureKeys.table( + TEXTURE_PATH, SlenderGaze.LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + private final ScreenOverlay overlay; + private final Supplier<@Nullable Player> slender; + private final PlayerState survivors = new PlayerState<>(); + private final RepeatingTask task = new RepeatingTask(this::tick); + + private int frame; + + /** + * Creates a new service. + * + * @param overlay the overlay that owns the players' screens + * @param slender supplies the current slender, or {@code null} while there is none + */ + public SlenderGazeService(ScreenOverlay overlay, Supplier<@Nullable Player> slender) { + this.overlay = overlay; + this.slender = slender; + } + + /** + * Hooks the service into the round's lifecycle. + *

+ * Mirrors {@code TunnelVisionService}: the service listens for itself rather than being called + * from {@code GameStartListener} and friends, because — unlike {@code AmbientProvider}, which + * has no per-player state to speak of — it has to drop an individual survivor's tracking the + * moment they die or disconnect, not only when the whole round ends. Folding that into the + * round's start and finish hooks would mean widening their signatures for every service that + * needs it; listening for itself keeps this self-contained instead. + *

+ * + * @param node the node to register on + * @param survivors supplies the survivors of the starting round + */ + public void registerListener(EventNode node, Supplier> survivors) { + node.addListener(GameStartEvent.class, event -> { + this.startTask(); + for (Player survivor : survivors.get()) { + this.track(survivor); + } + }); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> { + this.clearAll(); + this.stopTask(); + }); + } + + /** + * Starts the update task. Does nothing if it is already running. + */ + public void startTask() { + this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Stops the update task. Does nothing if it is not running. Leaves whatever is on a tracked + * survivor's screen where it is — pair with {@link #clearAll()} where every screen needs wiping + * too. + */ + public void stopTask() { + this.task.stop(); + } + + /** + * Starts drawing for a survivor. + * + * @param survivor the survivor to draw for + */ + public void track(Player survivor) { + this.survivors.put(survivor, survivor); + } + + /** + * Stops drawing for a survivor and clears what is left on their screen. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player) == null) return; + this.overlay.set(player, OverlayLayer.GLITCH, null); + } + + /** + * Clears every tracked survivor's screen and forgets all of them. + */ + public void clearAll() { + for (Player survivor : this.survivors.values()) { + this.overlay.set(survivor, OverlayLayer.GLITCH, null); + } + this.survivors.clear(); + } + + /** + * Puts one level on a player's screen and leaves it there, for judging the drawings without a + * slender to walk in front of. + *

+ * This sits on the service rather than a separate type because it draws from the very texture + * table {@link #tick()} already builds; splitting it out would mean either rebuilding that table + * a second time or exposing it, trading one seam for a worse one over two lines of + * {@code GlitchCommand} preview code. + *

+ * + * @param player the player to draw for + * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1} + */ + public void show(Player player, int level) { + int clamped = Helper.clamp(level, 0, SlenderGaze.LEVELS - 1); + this.overlay.set(player, OverlayLayer.GLITCH, TEXTURES[clamped][this.frame % FRAMES]); + } + + /** + * Takes the tearing off a player's screen. + * + * @param player the player to clear + */ + public void hide(Player player) { + this.overlay.set(player, OverlayLayer.GLITCH, null); + } + + /** + * Advances the tearing by one frame and redraws every survivor. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + Player currentSlender = this.slender.get(); + this.frame++; + + for (Player survivor : this.survivors.values()) { + int level = this.levelFor(survivor, currentSlender); + if (level == SlenderGaze.NONE) { + this.overlay.set(survivor, OverlayLayer.GLITCH, null); + continue; + } + this.overlay.set(survivor, OverlayLayer.GLITCH, TEXTURES[level][this.frame % FRAMES]); + } + } + + /** + * Works out the tearing one survivor gets. + * + * @param survivor the survivor to look at + * @param slender the current slender, may be {@code null} + * @return the level, or {@link SlenderGaze#NONE} + */ + private int levelFor(Player survivor, @Nullable Player slender) { + if (slender == null) return SlenderGaze.NONE; + + Instance instance = slender.getInstance(); + if (instance == null || !instance.equals(survivor.getInstance())) return SlenderGaze.NONE; + + return SlenderGaze.levelOf(survivor.getPosition(), slender.getPosition()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java new file mode 100644 index 00000000..2ce2129c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.gaze; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java new file mode 100644 index 00000000..244d80f9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java @@ -0,0 +1,142 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.item.component.Equippable; +import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import org.jetbrains.annotations.Nullable; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Puts the overlay on screen as the {@code camera_overlay} of an item worn on the head. + *

+ * This is the one mechanism in vanilla that draws a texture across the whole screen and scales it + * with the viewport — the same one the carved pumpkin uses. A font glyph cannot do that: its size + * is fixed in the pack, so it has to be calibrated against a resolution and drifts on every other. + *

+ *

+ * A player has one head, so only one layer can be shown at a time. The topmost one wins, which + * means a splatter of blood takes the screen for as long as it lasts and the tunnel vision comes + * back underneath it afterwards. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class EquipmentScreenOverlay implements ScreenOverlay { + + /** + * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of + * that — so the material only has to exist. + */ + private static final Material CARRIER = Material.PAPER; + + /** + * An equipment model with no layers, from the resource pack. Without an asset id Minecraft + * falls back to drawing the item itself on the player's head. + */ + private static final String EMPTY_ASSET = "cygnus:empty"; + + /** Vanilla's silent sound; the default equip sound would click on every stage change. */ + private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null); + + private final PlayerState> layers = new PlayerState<>(); + private final PlayerState shown = new PlayerState<>(); + + /** + * {@inheritDoc} + */ + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = this.layers + .computeIfAbsent(player, () -> new EnumMap<>(OverlayLayer.class)); + + if (texture == null) { + current.remove(layer); + } else { + current.put(layer, texture); + } + + this.apply(player, current); + } + + /** + * {@inheritDoc} + */ + @Override + public void clear(Player player) { + this.layers.remove(player); + this.shown.remove(player); + player.setHelmet(ItemStack.AIR); + } + + /** + * Works out which layer is on top and puts it on the player's head. + * + * @param player the player to draw for + * @param current the layers currently set for them + */ + private void apply(Player player, Map current) { + Key topmost = this.topmost(current); + + if (topmost == null) { + if (this.shown.remove(player) == null) return; + player.setHelmet(ItemStack.AIR); + return; + } + + // The overlay is refreshed many times a second; re-sending an unchanged item would put an + // equipment update on the wire for every viewer each time. + if (topmost.equals(this.shown.get(player))) return; + + this.shown.put(player, topmost); + player.setHelmet(carrierFor(topmost)); + } + + /** + * Picks the layer that is drawn on top of the others. + * + * @param current the layers currently set + * @return the texture to show, or {@code null} if nothing is set + */ + private @Nullable Key topmost(Map current) { + Key topmost = null; + // Declaration order of OverlayLayer is drawing order, so the last hit wins. + for (OverlayLayer layer : OverlayLayer.values()) { + Key texture = current.get(layer); + if (texture != null) topmost = texture; + } + return topmost; + } + + /** + * Builds the item that carries a given overlay texture. + * + * @param texture the texture to show + * @return the item to put in the head slot + */ + private static ItemStack carrierFor(Key texture) { + Equippable equippable = new Equippable( + EquipmentSlot.HELMET, + SILENT, + EMPTY_ASSET, + texture.asString(), + null, + false, + false, + false, + false, + false, + SILENT + ); + return ItemStack.builder(CARRIER).set(DataComponents.EQUIPPABLE, equippable).build(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java new file mode 100644 index 00000000..656ed319 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java @@ -0,0 +1,21 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * The full-screen layers a player can have on their HUD at once, in drawing order — later + * constants are drawn on top of earlier ones. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum OverlayLayer { + + /** The narrowing view, driven by how much stamina a survivor has left. */ + TUNNEL_VISION, + + /** The tearing that comes over a survivor while the slender is in their view. */ + GLITCH, + + /** The splatter that flashes up when the player is hit; sits closest to the eye. */ + BLOOD +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java new file mode 100644 index 00000000..76581c82 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * Decides whether the full-screen overlays — the tunnel vision and the blood splatter — run. + *

+ * They used to be tied to the ResourcePack feature, on the grounds that without the pack their + * textures are missing and a player would get a fullscreen checkerboard. That was too blunt: a + * server can be run without handing out a pack while the people testing it have the pack enabled + * locally, and in that setup the effects silently never started. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class OverlayProperties { + + static final String ENABLED_PROPERTY = "cygnus.overlays"; + + private OverlayProperties() { + } + + /** + * Tells whether the overlays should run. + *

+ * On unless the property says {@code false}. Anything unreadable leaves them on: the effects + * are part of the game, and a typo in a start script should not quietly remove them. + *

+ * + * @return whether to register the overlay services + */ + public static boolean enabled() { + return !"false".equalsIgnoreCase(System.getProperty(ENABLED_PROPERTY, "true").trim()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java new file mode 100644 index 00000000..fe97ff2e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java @@ -0,0 +1,112 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; + +import java.util.function.IntFunction; + +/** + * Builds the {@code cygnus:} {@link Key}s a full-screen overlay draws from the resource pack. + *

+ * {@code OverlayTunnelVisionRenderer}, {@code SlenderGazeService} and {@code BloodSplatterService} + * each hand-rolled their own {@code buildTextures()}, one per axis count: a flat table indexed by + * stage, a two-dimensional one indexed by level and frame, and a three-dimensional one (flattened + * into a single array) indexed by direction, variant and frame. All three follow the same shape once + * written out: the texture path, followed by every axis's label joined with {@code _}. This type is + * that shape, extracted once for every table rank the three renderers need. + *

+ *

+ * An axis's labels come from an {@link IntFunction}, not a plain 1-based count, because the blood + * splatter's outermost axis is a {@code BloodDirection} name such as {@code left} rather than a + * number. {@link #ONE_BASED} covers the common case of a numbered axis. + *

+ * + *

Usage:

+ *
{@code
+ * Key[] stages = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 16, OverlayTextureKeys.ONE_BASED);
+ * Key[][] glitch = OverlayTextureKeys.table(
+ *         "gui/glitch/level_", LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * Key[][][] blood = OverlayTextureKeys.cube(
+ *         "gui/blood/", DIRECTIONS, VARIANTS, FRAMES,
+ *         direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
+ *         OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class OverlayTextureKeys { + + /** The namespace every overlay texture lives under. */ + public static final String NAMESPACE = "cygnus"; + + /** Labels an axis {@code 1}, {@code 2}, {@code 3}, ... from its zero-based index. */ + public static final IntFunction ONE_BASED = index -> Integer.toString(index + 1); + + private OverlayTextureKeys() { + // Prevent instantiation of utility class + } + + /** + * Builds a flat table of texture keys, one per index. + * + * @param path the texture path every key is built from, without a trailing separator + * @param size how many keys to build + * @param label labels each index + * @return the keys, indexed the same way + */ + public static Key[] flat(String path, int size, IntFunction label) { + Key[] keys = new Key[size]; + for (int i = 0; i < size; i++) { + keys[i] = Key.key(NAMESPACE, path + label.apply(i)); + } + return keys; + } + + /** + * Builds a two-dimensional table of texture keys, one per row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param rows how many rows to build + * @param columns how many columns to build + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [row][column]} + */ + public static Key[][] table(String path, int rows, int columns, IntFunction rowLabel, + IntFunction columnLabel) { + Key[][] keys = new Key[rows][columns]; + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[row][column] = Key.key(NAMESPACE, path + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + return keys; + } + + /** + * Builds a three-dimensional table of texture keys, one per plane, row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param planes how many planes to build + * @param rows how many rows to build + * @param columns how many columns to build + * @param planeLabel labels each plane + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [plane][row][column]} + */ + public static Key[][][] cube(String path, int planes, int rows, int columns, IntFunction planeLabel, + IntFunction rowLabel, IntFunction columnLabel) { + Key[][][] keys = new Key[planes][rows][columns]; + for (int plane = 0; plane < planes; plane++) { + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[plane][row][column] = Key.key(NAMESPACE, + path + planeLabel.apply(plane) + "_" + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + } + return keys; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java new file mode 100644 index 00000000..de9ca55b --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +/** + * Owns the full-screen overlay of a player and decides what ends up on it. + *

+ * The effects hand over a texture for their layer rather than drawing themselves, because a player + * only has one screen to give: whichever effect drew last would otherwise wipe the other. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public interface ScreenOverlay { + + /** + * Sets or removes what a layer contributes to the player's screen. + * + * @param player the player to draw for + * @param layer the layer to change + * @param texture the overlay texture to show, or {@code null} to drop the layer + */ + void set(Player player, OverlayLayer layer, @Nullable Key texture); + + /** + * Drops every layer and clears the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java new file mode 100644 index 00000000..8ec288a2 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.overlay; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java index d2a89e7e..38cf62d5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java @@ -99,6 +99,19 @@ private float normalize(float current) { return Math.max(0.0f, current / MAX_FOOD); } + /** + * Returns the remaining stamina as a share of a full bar. + *

+ * This is what drives the survivor's tunnel vision. The bar mirrors the same value into the + * experience bar, but reading it back from there would tie game logic to a display detail. + *

+ * + * @return the remaining stamina between {@code 0.0f} and {@code 1.0f} + */ + public float remainingShare() { + return normalize(this.currentSpeedCount); + } + /** * Returns an indication state if the bar could be consumed. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java index 79cf4f25..74164bcd 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java @@ -3,12 +3,14 @@ import net.kyori.adventure.sound.Sound; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Entity; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.minestom.server.potion.TimedPotion; import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; import net.onelitefeather.cygnus.team.TeamHelper; import java.util.Collection; @@ -88,6 +90,9 @@ default void applyDamage(Instance instance, UUID uuid, Pos center, int range, fl if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue; if (!isDamageableSurvivor(target)) continue; target.setHealth(target.getHealth() - damage); + // Setting health never raises Minestom's own damage event, so anything reacting to + // a hit — the blood splatter above all — would otherwise never hear about it. + EventDispatcher.call(new PlayerDamagedEvent(target, center, damage)); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java new file mode 100644 index 00000000..7916d1af --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java @@ -0,0 +1,61 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; + +/** + * Contributes the tunnel vision to the shared screen overlay. + *

+ * Each stage is a camera overlay texture from the resource pack. Minecraft cannot animate one, so + * the heartbeat is the server walking through the stages, one texture per frame. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class OverlayTunnelVisionRenderer implements TunnelVisionRenderer { + + /** Where the stage textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/tunnel_vision/stage_"; + + private static final Key[] TEXTURES = + OverlayTextureKeys.flat(TEXTURE_PATH, TunnelVisionStage.MAX_STAGE, OverlayTextureKeys.ONE_BASED); + + private final ScreenOverlay overlay; + + /** + * Creates a renderer drawing into the given overlay. + * + * @param overlay the overlay that owns the player's screen + */ + public OverlayTunnelVisionRenderer(ScreenOverlay overlay) { + this.overlay = overlay; + } + + /** + * {@inheritDoc} + */ + @Override + public void render(Player player, int stage) { + if (stage <= 0) { + this.clear(player); + return; + } + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TEXTURES[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]); + } + + /** + * {@inheritDoc} + *

+ * Only this layer is dropped. Clearing the screen would take the blood splatter with it. + *

+ */ + @Override + public void clear(Player player) { + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java new file mode 100644 index 00000000..ee58c9be --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java @@ -0,0 +1,45 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * Turns a draining stamina bar into an intensity in {@code [0, 1]} that drives how far the + * survivor's view narrows. + *

+ * The slender used to feed into this intensity as well; he now speaks through + * {@code gaze.SlenderGazeService} instead, which tears the view independently rather than adding + * to this gauge. + *

+ *

+ * The calculation is deliberately free of any server state so it can be exercised without a + * running instance. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionIntensity { + + /** Share of the stamina bar below which the view starts to narrow. */ + private static final double STAMINA_THRESHOLD = 0.5D; + + private TunnelVisionIntensity() { + } + + /** + * Calculates the share contributed by the survivor's stamina. + *

+ * Nothing happens above half a bar; below it the curve accelerates quadratically, so the last + * few percent feel far more dramatic than crossing the halfway mark. + *

+ * + * @param normalizedStamina the remaining stamina as a share of a full bar + * @return the intensity share in {@code [0, 1]} + */ + public static double fromStamina(double normalizedStamina) { + if (normalizedStamina >= STAMINA_THRESHOLD) return 0.0D; + double drained = (STAMINA_THRESHOLD - normalizedStamina) / STAMINA_THRESHOLD; + return Helper.clamp(drained * drained, 0.0D, 1.0D); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java new file mode 100644 index 00000000..1e9a4955 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; + +/** + * Displays a tunnel vision stage to a survivor. + *

+ * This is the seam between the game logic and the way the effect reaches the screen. Minecraft + * 26.2 offers no per-player post-processing effect, so the only implementation today draws the + * vignette as a HUD overlay. Once {@code /posteffect} is available a second implementation can + * take its place without the calculation or the service noticing. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public interface TunnelVisionRenderer { + + /** + * Shows the given stage to the player. + * + * @param player the player to draw for + * @param stage the stage between {@code 0} and {@link TunnelVisionStage#MAX_STAGE}, where + * {@code 0} means no overlay + */ + void render(Player player, int stage); + + /** + * Removes the overlay from the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java new file mode 100644 index 00000000..91991ea9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java @@ -0,0 +1,161 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; + +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; + +/** + * Drives the tunnel vision of every survivor from a single repeating task. + *

+ * One task rather than one per player, as {@code StaminaBar} does it, so there is a single place to + * clean up. + *

+ *

+ * The stamina arrives as a function rather than as a service: it only needs a number, so the + * dependency does not have to be a live object the service keeps in sync. + *

+ *

+ * The slender used to feed into this as well. He now speaks through {@code SlenderGazeService} + * instead, which asks whether a survivor can see him rather than how near he is. + *

+ *

+ * Unlike {@code AmbientProvider}, this service is not merely started and stopped by name from + * {@code GameStartListener} and {@code Cygnus.finishGame()} — it still exposes {@link #startTask()} + * and {@link #stopTask()} for exactly that purpose, but it also has to react the moment a single + * survivor dies or disconnects, or the vignette they last saw keeps showing on a screen nobody is + * playing through any more. Neither of those listeners knows about individual players today, and + * teaching them to would spread a tunnel-vision concern into files that otherwise have nothing to do + * with it. Registering here, scoped to this service's own node, keeps that mapping local to the one + * class that needs it — {@code BloodSplatterService} and {@code SlenderGazeService} register + * themselves for the same reason. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionService { + + private final TunnelVisionRenderer renderer; + private final ToDoubleFunction stamina; + private final PlayerState survivors = new PlayerState<>(); + private final RepeatingTask task = new RepeatingTask(this::tick); + + /** + * Creates a new service. + * + * @param renderer the renderer that puts a stage on the screen + * @param stamina supplies a survivor's remaining stamina as a share of a full bar + */ + public TunnelVisionService(TunnelVisionRenderer renderer, ToDoubleFunction stamina) { + this.renderer = renderer; + this.stamina = stamina; + } + + /** + * Starts the update task. Does nothing if it is already running. + */ + public void startTask() { + this.task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Stops the update task. Does nothing if it is not running. + */ + public void stopTask() { + this.task.stop(); + } + + /** + * Starts drawing for a survivor, with a fresh stage. + *

+ * This is bookkeeping only: it does not touch the update task, so {@link #registerListener} can + * compose it with {@link #startTask()} instead of the two always happening together. + *

+ * + * @param survivor the survivor to draw for + */ + public void track(Player survivor) { + this.survivors.put(survivor, new Tracked(survivor, new TunnelVisionStage())); + } + + /** + * Hooks the service into the round's lifecycle. + *

+ * See the class documentation for why this service registers itself rather than being called by + * name the way {@code AmbientProvider} is. + *

+ * + * @param node the node to register on + * @param survivors supplies the survivors of the starting round + */ + public void registerListener(EventNode node, Supplier> survivors) { + node.addListener(GameStartEvent.class, event -> { + this.startTask(); + for (Player survivor : survivors.get()) { + this.track(survivor); + } + }); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> { + this.clearAll(); + this.stopTask(); + }); + } + + /** + * Stops drawing for a survivor and clears whatever is still on their screen — on death, on + * the way into the spectator team, or on quit. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player) == null) return; + this.renderer.clear(player); + } + + /** + * Clears every survivor's screen and stops tracking all of them, without touching the update + * task — pair with {@link #stopTask()} to end a round the way {@link #registerListener} does. + */ + public void clearAll() { + for (Tracked tracked : this.survivors.values()) { + this.renderer.clear(tracked.player()); + } + this.survivors.clear(); + } + + /** + * Updates every tracked survivor once. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + for (Tracked tracked : this.survivors.values()) { + Player survivor = tracked.player(); + double intensity = TunnelVisionIntensity.fromStamina(this.stamina.applyAsDouble(survivor)); + this.renderer.render(survivor, tracked.stage().update(intensity)); + } + } + + /** + * Pairs a survivor with the overlay state that belongs to them. + * + * @param player the survivor + * @param stage their stage state, carrying hysteresis and heartbeat + */ + private record Tracked(Player player, TunnelVisionStage stage) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java new file mode 100644 index 00000000..8db40ff6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -0,0 +1,79 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * Holds the overlay state of a single survivor: which of the discrete stages is currently shown, + * and where the heartbeat that modulates it stands. + *

+ * Two mechanisms sit between the continuous intensity and the rendered stage. Hysteresis keeps the + * quantised base stage still while distance and stamina jitter around a boundary, and the pulse is + * added on top of the stabilised value — reversed, the hysteresis would damp out exactly the + * pulsing it exists to allow. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionStage { + + /** + * Number of stages the overlay is quantised to; stage {@code 0} means no overlay. + *

+ * These double as the frames of the heartbeat: Minecraft cannot animate an overlay texture, so + * the animation is the server walking through the stages. Thirty-two of them make the view + * close smoothly; at sixteen the steps were visible as the tunnel narrowed. + *

+ */ + public static final int MAX_STAGE = 32; + + /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ + public static final int TICK_MILLIS = 100; + + /** Distance in stages the intensity has to travel before the base stage follows. */ + private static final double HYSTERESIS = 0.6D; + + /** + * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it + * stays equally visible whatever {@link #MAX_STAGE} is. + */ + private static final double PULSE_DEPTH = MAX_STAGE / 16.0D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param combined the combined intensity from {@link TunnelVisionIntensity} + * @return the stage to render, between {@code 0} and {@link #MAX_STAGE} + */ + public int update(double combined) { + double exactStage = combined * MAX_STAGE; + if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { + this.baseStage = (int) Math.round(exactStage); + } + + this.elapsedSeconds += TICK_SECONDS; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * combined; + double depth = PULSE_DEPTH * combined; + // The heartbeat only ever opens the view up, never beyond the base stage: at full + // intensity the base stage is the maximum, and a symmetric pulse would be clipped away + // exactly where it matters most. + double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); + + int rendered = (int) Math.round(this.baseStage + pulse); + return Helper.clamp(rendered, 0, MAX_STAGE); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java new file mode 100644 index 00000000..1b7817dd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java @@ -0,0 +1,57 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies from which side the blood is thrown across the screen. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodDirectionTest { + + /** A victim in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos VICTIM = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A hit from straight ahead lands in front") + void hitFromAheadIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from behind lands in the back") + void hitFromBehindIsBack() { + assertEquals(BloodDirection.BACK, BloodDirection.between(VICTIM, new Pos(0, 40, -6))); + } + + @Test + @DisplayName("Looking south, a hit from the east lands on the left") + void hitFromEastIsLeft() { + assertEquals(BloodDirection.LEFT, BloodDirection.between(VICTIM, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Looking south, a hit from the west lands on the right") + void hitFromWestIsRight() { + assertEquals(BloodDirection.RIGHT, BloodDirection.between(VICTIM, new Pos(-6, 40, 0))); + } + + @Test + @DisplayName("The victim's own facing decides, not the world") + void facingDecides() { + Pos turned = new Pos(0, 40, 0, 180, 0); + assertEquals(BloodDirection.BACK, BloodDirection.between(turned, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from the exact same spot still picks a side") + void hitFromTheSameSpotIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 0))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java new file mode 100644 index 00000000..9b9d9308 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java @@ -0,0 +1,220 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the splatter that flashes up when a player is hit and fades out on its own. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodSplatterServiceTest extends CygnusPlayerTestBase { + + /** Always picks the first variant, so the expected code points are predictable. */ + private static final java.util.function.IntUnaryOperator FIRST_VARIANT = bound -> 0; + + @Test + @DisplayName("A hit puts the first frame on screen right away") + void hitShowsTheFirstFrame(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The direction of the hit picks a different set of frames") + void directionPicksItsOwnFrames(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.LEFT); + + assertEquals(textureOf(BloodDirection.LEFT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + assertNotEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The splatter fades frame by frame and disappears") + void splatterFadesAway(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.tick(); + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(player, OverlayLayer.BLOOD), "the second frame follows"); + + for (int remaining = 1; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertNull(overlay.of(player, OverlayLayer.BLOOD), "the splatter has to clean up after itself"); + } + + @Test + @DisplayName("A second hit restarts the splatter") + void secondHitRestarts(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + service.tick(); + service.tick(); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD), "a fresh hit starts over"); + } + + @Test + @DisplayName("Being hit is announced by the damage event") + void damageEventTriggersTheSplatter(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new PlayerDamagedEvent(player, new Pos(0, 40, 6), 1.0F)); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "only the blood layer belongs to this service"); + assertTrue(overlay.of(player, OverlayLayer.BLOOD) != null, "a hit has to show blood"); + } + + @Test + @DisplayName("Clearing takes the splatter off the screen") + void clearingRemovesTheSplatter(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.clear(player); + + assertNull(overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The fade task only runs while something is bleeding") + void fadeTaskTracksActiveSplatters(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + assertFalse(service.fadeTask.isRunning(), "nothing is bleeding yet"); + + service.splatter(player, BloodDirection.FRONT); + assertTrue(service.fadeTask.isRunning(), "a hit has to keep the fade task alive"); + + for (int remaining = 0; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertFalse(service.fadeTask.isRunning(), "the task stops itself once nothing is bleeding any more"); + } + + @Test + @DisplayName("Two players bleed independently") + void playersAreIndependent(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(4, 40, 0)); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(first, BloodDirection.FRONT); + service.tick(); + service.splatter(second, BloodDirection.BACK); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(first, OverlayLayer.BLOOD)); + assertEquals(textureOf(BloodDirection.BACK, 0, 0), overlay.of(second, OverlayLayer.BLOOD)); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Works out the texture a direction, variant and frame map to. + * + * @param direction the direction of the hit + * @param variant the variant index + * @param frame the frame index + * @return the texture key + */ + private Key textureOf(BloodDirection direction, int variant, int frame) { + return Key.key("cygnus", "%s%s_%d_%d".formatted( + BloodSplatterService.TEXTURE_PATH, + direction.name().toLowerCase(java.util.Locale.ROOT), + variant + 1, + frame + 1 + )); + } + + /** + * Records what the service contributes, standing in for the title-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map> layers = new HashMap<>(); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = + this.layers.computeIfAbsent(player.getUuid(), key -> new EnumMap<>(OverlayLayer.class)); + if (texture == null) { + current.remove(layer); + return; + } + current.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @param layer the layer to look up + * @return the glyph currently set, or {@code null} if there is none + */ + private @Nullable Key of(Player player, OverlayLayer layer) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).get(layer); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java new file mode 100644 index 00000000..06cc1cf5 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java @@ -0,0 +1,131 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.blood.BloodDirection; +import net.onelitefeather.cygnus.blood.BloodSplatterService; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies the command used to throw a splatter without waiting to be hit. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("The command throws a splatter from the requested side") + void splatterIsThrownFromTheRequestedSide(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "blood left"); + + assertNotNull(overlay.blood(), "the command has to put blood on screen"); + } + + @Test + @DisplayName("Without a side the command picks one itself") + void splatterWorksWithoutASide(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "blood"); + + assertNotNull(overlay.blood(), "the bare command still has to show something"); + } + + @Test + @DisplayName("Every side of the splatter can be requested") + void everySideCanBeRequested(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + for (BloodDirection direction : BloodDirection.values()) { + overlay.forget(); + MinecraftServer.getCommandManager().execute(player, "blood " + direction.name().toLowerCase()); + assertNotNull(overlay.blood(), "no splatter for " + direction); + } + } + + /** + * Registers the command under test against the given overlay. The environment is shared across + * the tests in this class, so any command left over from an earlier one — still drawing into + * that test's overlay — has to go first. + * + * @param overlay the overlay the service draws into + */ + private void register(RecordingOverlay overlay) { + Command previous = MinecraftServer.getCommandManager().getCommand("blood"); + if (previous != null) MinecraftServer.getCommandManager().unregister(previous); + MinecraftServer.getCommandManager().register(new BloodCommand(new BloodSplatterService(overlay, bound -> 0))); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Records what the service contributes, standing in for the title-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map layers = new EnumMap<>(OverlayLayer.class); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + if (texture == null) { + this.layers.remove(layer); + return; + } + this.layers.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.clear(); + } + + /** + * @return the texture currently on the blood layer, or {@code null} if there is none + */ + private @Nullable Key blood() { + return this.layers.get(OverlayLayer.BLOOD); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.layers.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java new file mode 100644 index 00000000..772756b1 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java @@ -0,0 +1,132 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.gaze.SlenderGaze; +import net.onelitefeather.cygnus.gaze.SlenderGazeService; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the command used to preview the slender's glitch without him being there. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class GlitchCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A requested level is drawn right away") + void levelIsDrawnOnRequest(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "glitch 2"); + + assertNotNull(overlay.glitch(), "the command has to put the glitch on screen"); + } + + @Test + @DisplayName("Switching the preview off clears the screen") + void offClearsTheScreen(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + MinecraftServer.getCommandManager().execute(player, "glitch 2"); + + MinecraftServer.getCommandManager().execute(player, "glitch off"); + + assertNull(overlay.glitch(), "the preview must disappear"); + } + + @Test + @DisplayName("Every level of the tearing can be requested") + void everyLevelCanBeRequested(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + for (int level = 1; level <= SlenderGaze.LEVELS; level++) { + overlay.forget(); + MinecraftServer.getCommandManager().execute(player, "glitch " + level); + assertNotNull(overlay.glitch(), "no glitch for level " + level); + } + } + + /** + * Registers the command under test against the given overlay. The environment is shared across + * the tests in this class, so any command left over from an earlier one — still drawing into + * that test's overlay — has to go first. + * + * @param overlay the overlay the service draws into + */ + private void register(RecordingOverlay overlay) { + Command previous = MinecraftServer.getCommandManager().getCommand("glitch"); + if (previous != null) MinecraftServer.getCommandManager().unregister(previous); + MinecraftServer.getCommandManager().register(new GlitchCommand(new SlenderGazeService(overlay, () -> null))); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Records what the service contributes, standing in for the title-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map layers = new EnumMap<>(OverlayLayer.class); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + if (texture == null) { + this.layers.remove(layer); + return; + } + this.layers.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.clear(); + } + + /** + * @return the texture currently on the glitch layer, or {@code null} if there is none + */ + private @Nullable Key glitch() { + return this.layers.get(OverlayLayer.GLITCH); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.layers.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java new file mode 100644 index 00000000..2716638c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -0,0 +1,110 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.component.Equippable; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the command used to eyeball the vignette while the round has not started yet. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class TunnelVisionCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A requested stage is drawn right away") + void stageIsDrawnOnRequest(Env env) { + Player player = spawn(env); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + assertEquals(textureOf(5), cameraOverlay(player), "the command must draw the requested stage"); + } + + @Test + @DisplayName("Switching the preview off clears the screen") + void offClearsTheScreen(Env env) { + Player player = spawn(env); + register(); + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); + + assertTrue(player.getHelmet().isAir(), "the preview must disappear"); + } + + @Test + @DisplayName("A previewed intensity starts at its stage") + void intensityStartsDrawing(Env env) { + Player player = spawn(env); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); + + assertEquals( + textureOf(TunnelVisionStage.MAX_STAGE), + cameraOverlay(player), + "full intensity starts at the tightest stage" + ); + } + + /** + * Registers the command under test. The environment is shared across the tests in this class, + * so a second registration would be rejected. + */ + private void register() { + if (MinecraftServer.getCommandManager().getCommand("tunnelvision") != null) return; + MinecraftServer.getCommandManager().register( + new TunnelVisionCommand(new OverlayTunnelVisionRenderer(new EquipmentScreenOverlay()))); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Reads the camera overlay the player is currently wearing. + * + * @param player the player to read + * @return the overlay texture as a string + */ + private String cameraOverlay(Player player) { + Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); + assertNotNull(equippable, "nothing is carrying an overlay"); + return equippable.cameraOverlay(); + } + + /** + * Builds the texture expected for a stage. + * + * @param stage the stage + * @return the texture as a string + */ + private String textureOf(int stage) { + return "cygnus:gui/tunnel_vision/stage_" + stage; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java new file mode 100644 index 00000000..3186ee9b --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java @@ -0,0 +1,252 @@ +package net.onelitefeather.cygnus.gaze; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the tearing a survivor gets while the slender stands in their view. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderGazeServiceTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("Seeing the slender tears the survivor's view") + void seeingHimTearsTheView(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH), "he is right in front of them"); + } + + @Test + @DisplayName("With him behind them there is nothing to see") + void behindThemNothingHappens(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, -5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH), "the effect is about seeing him"); + } + + @Test + @DisplayName("Looking away takes it off again") + void lookingAwayClearsIt(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + survivor.teleport(new Pos(0, 40, 0, 180, 0)); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The tearing runs on while he stays in view") + void tearingKeepsMoving(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + Key first = overlay.of(survivor, OverlayLayer.GLITCH); + service.tick(); + + assertNotEquals(first, overlay.of(survivor, OverlayLayer.GLITCH), + "a still picture is not a glitch"); + } + + @Test + @DisplayName("Without a slender nothing happens at all") + void withoutASlenderNothingHappens(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player survivor = connect(env, env.createFlatInstance(), new Pos(0, 40, 0, 0, 0)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> null); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A removed survivor gets their view back") + void removedSurvivorIsCleared(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + service.remove(survivor); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void clearAllWipesEveryone(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player first = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player second = connect(env, instance, new Pos(4, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(first); + service.track(second); + service.tick(); + + service.clearAll(); + + assertNull(overlay.of(first, OverlayLayer.GLITCH)); + assertNull(overlay.of(second, OverlayLayer.GLITCH)); + + service.tick(); + assertNull(overlay.of(first, OverlayLayer.GLITCH), "clearAll must stop the drawing as well"); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartTracksSurvivors(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathRemovesTheSurvivor(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishClearsEveryone(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + /** + * Connects a player at the given position. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place them + * @return the connected player + */ + private Player connect(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service contributes, standing in for the equipment-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map> layers = new HashMap<>(); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = + this.layers.computeIfAbsent(player.getUuid(), key -> new EnumMap<>(OverlayLayer.class)); + if (texture == null) { + current.remove(layer); + return; + } + current.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @param layer the layer to look up + * @return the texture currently set, or {@code null} if there is none + */ + private @Nullable Key of(Player player, OverlayLayer layer) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).get(layer); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java new file mode 100644 index 00000000..dadbbdb3 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java @@ -0,0 +1,72 @@ +package net.onelitefeather.cygnus.gaze; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies when the sight of the slender starts to tear a survivor's view apart. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderGazeTest { + + /** A survivor in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos SURVIVOR = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A slender straight ahead and close tears the view apart") + void closeAndAheadIsStrongest() { + assertEquals(SlenderGaze.LEVELS - 1, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 4))); + } + + @Test + @DisplayName("A slender ahead but far off barely registers") + void farAheadIsWeak() { + int level = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 28)); + assertTrue(level >= 0 && level < SlenderGaze.LEVELS - 1, "expected a weak level, got " + level); + } + + @Test + @DisplayName("Out of range there is nothing, however clear the line") + void beyondRangeIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 80))); + } + + @Test + @DisplayName("Standing behind a survivor does nothing, however close") + void behindIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, -4)), + "the effect is about seeing him, not about him being there"); + } + + @Test + @DisplayName("Just outside the corner of the eye does nothing either") + void besideIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Turning towards him brings it on") + void turningTowardsHimBringsItOn() { + Pos turned = new Pos(0, 40, 0, -90, 0); + assertTrue(SlenderGaze.levelOf(turned, new Pos(6, 40, 0)) > SlenderGaze.NONE, + "he is in front of the survivor now"); + } + + @Test + @DisplayName("Closing in never weakens the effect") + void levelIsMonotonic() { + int previous = SlenderGaze.NONE; + for (int distance = 40; distance >= 1; distance--) { + int current = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, distance)); + assertTrue(current >= previous, "the tearing eased off at distance " + distance); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java new file mode 100644 index 00000000..d9366eaf --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java @@ -0,0 +1,140 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.component.Equippable; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the overlay reaches the client as a camera overlay on the player's head, which is + * the only way to have it scale with the screen rather than with a font size. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class EquipmentScreenOverlayTest extends CygnusPlayerTestBase { + + private static final Key TUNNEL = Key.key("cygnus", "gui/tunnel_vision/stage_7"); + private static final Key BLOOD = Key.key("cygnus", "gui/blood/left_1_1"); + + private final EquipmentScreenOverlay overlay = new EquipmentScreenOverlay(); + + @Test + @DisplayName("A layer becomes a camera overlay on the player's head") + void layerBecomesCameraOverlay(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The blood takes the screen while it is up") + void bloodWinsOverTheTunnelVision(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + assertEquals(BLOOD.asString(), equippable(player).cameraOverlay(), + "only one camera overlay exists, and a hit is what matters most"); + } + + @Test + @DisplayName("Once the blood is gone the tunnel vision comes back") + void tunnelVisionReturnsAfterTheBlood(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.set(player, OverlayLayer.BLOOD, null); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The last layer leaving empties the head slot") + void lastLayerEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null); + + assertTrue(player.getHelmet().isAir(), "an item left behind would keep the overlay up"); + } + + @Test + @DisplayName("Clearing empties the head slot and forgets the layers") + void clearingEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.clear(player); + + assertTrue(player.getHelmet().isAir()); + } + + @Test + @DisplayName("The carrier item cannot be taken off or seen") + void carrierItemStaysPutAndInvisible(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + Equippable equippable = equippable(player); + assertFalse(equippable.swappable(), "right-clicking must not strip the overlay"); + assertFalse(equippable.dispensable(), "a dispenser must not hand out overlays"); + assertFalse(equippable.damageOnHurt(), "the carrier is not armour"); + assertNotNull(equippable.assetId(), "without an asset the item is drawn on the player's head"); + } + + @Test + @DisplayName("Setting the same layer twice does not churn the slot") + void repeatedSetKeepsTheSameItem(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + ItemStack first = player.getHelmet(); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(first, player.getHelmet(), "an unchanged overlay must not be re-sent"); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Reads the equippable component off the player's head slot. + * + * @param player the player to read + * @return the component + */ + private Equippable equippable(Player player) { + Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); + assertNotNull(equippable, "nothing is carrying an overlay"); + return equippable; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java new file mode 100644 index 00000000..23210808 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java @@ -0,0 +1,50 @@ +package net.onelitefeather.cygnus.overlay; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the switch that decides whether the full-screen overlays run. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayPropertiesTest { + + @AfterEach + void clearProperty() { + System.clearProperty(OverlayProperties.ENABLED_PROPERTY); + } + + @Test + @DisplayName("The overlays run unless somebody says otherwise") + void enabledByDefault() { + assertTrue(OverlayProperties.enabled(), "the effects are part of the game, not an extra"); + } + + @Test + @DisplayName("They can be switched off") + void canBeSwitchedOff() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "false"); + assertFalse(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Switching them on explicitly works too") + void canBeSwitchedOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "true"); + assertTrue(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Anything unreadable leaves them on rather than silently off") + void nonsenseLeavesThemOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "perhaps"); + assertTrue(OverlayProperties.enabled(), "a typo must not take the effects out of the game"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java new file mode 100644 index 00000000..a05927c9 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that {@link OverlayTextureKeys} reproduces the {@code cygnus:} key conventions the + * tunnel vision, glitch and blood renderers built by hand. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayTextureKeysTest { + + @Test + @DisplayName("A flat table matches the tunnel vision's stage_ convention") + void flatMatchesTunnelVisionConvention() { + Key[] keys = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 3, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_1"), keys[0]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_2"), keys[1]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_3"), keys[2]); + } + + @Test + @DisplayName("A two-dimensional table matches the glitch's level__ convention") + void tableMatchesGlitchConvention() { + Key[][] keys = OverlayTextureKeys.table( + "gui/glitch/level_", 2, 2, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/glitch/level_1_1"), keys[0][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_1_2"), keys[0][1]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_1"), keys[1][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_2"), keys[1][1]); + } + + @Test + @DisplayName("A three-dimensional table matches the blood's __ convention") + void cubeMatchesBloodConvention() { + String[] directions = {"left", "right"}; + + Key[][][] keys = OverlayTextureKeys.cube( + "gui/blood/", 2, 2, 2, + index -> directions[index], OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/blood/left_1_1"), keys[0][0][0]); + assertEquals(Key.key("cygnus", "gui/blood/left_2_1"), keys[0][1][0]); + assertEquals(Key.key("cygnus", "gui/blood/right_1_2"), keys[1][0][1]); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java new file mode 100644 index 00000000..1fe45c52 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -0,0 +1,32 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the stamina share other systems read off the survivor's bar. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class FoodBarTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A fresh bar reports a full share") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java new file mode 100644 index 00000000..3784ab4c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java @@ -0,0 +1,68 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventFilter; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that damage dealt by the slender is announced, since setting health directly never + * raises Minestom's own damage event. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderBarHelperDamageTest extends CygnusPlayerTestBase { + + private static final float DAMAGE = 0.5F; + private static final int RANGE = 3; + + private final SlenderBarHelper helper = new SlenderBarHelper() { + }; + + @Test + @DisplayName("A damaged player is announced together with where the hit came from") + void damageIsAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + attacker.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + Player victim = env.createConnection().connect(instance, new Pos(1, 40, 0)); + victim.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + Pos center = new Pos(0, 40, 0); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, victim); + + this.helper.applyDamage(instance, attacker.getUuid(), center, RANGE, DAMAGE); + + collector.assertSingle(event -> { + assertEquals(victim, event.getPlayer(), "the victim has to be the one that was hit"); + assertEquals(center, event.getSource(), "the source is what aims the splatter"); + assertEquals(DAMAGE, event.getAmount(), "the amount travels along for anything that scales with it"); + }); + } + + @Test + @DisplayName("The player dealing the damage is left out") + void attackerIsNotAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + attacker.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, attacker); + + this.helper.applyDamage(instance, attacker.getUuid(), new Pos(0, 40, 0), RANGE, DAMAGE); + + collector.assertEmpty(); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java new file mode 100644 index 00000000..a954ed00 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java @@ -0,0 +1,226 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.event.StaminaStateChangeEvent; +import net.onelitefeather.cygnus.listener.stamina.StaminaStateChangeListener; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.visibility.VisibilityRules; +import net.theevilreaper.xerus.api.team.Team; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the spectator system introduced in 2.7.0 interacts with the slender + * visibility rule. + *

+ * A dead survivor leaves the survivor team, so + * {@link VisibilityRules#refresh(Player)} no longer reaches it through the team + * iteration. It is still covered by the online-player pass and by the slender's own + * re-evaluation, so a spectator must keep following the slender's visibility in both + * directions — it must see the slender during an attack and lose sight of it afterwards. + *

+ * Independently, {@code SpectatorService.join} installs {@code _ -> false} on the + * spectator itself, which must keep it invisible to everybody else. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class SlenderSpectatorVisibilityIntegrationTest extends CygnusPlayerTestBase { + + /** + * Builds a survivor team holding the given players. + * + * @param survivors the players to put into the team + * @return the populated survivor team + */ + private Team createSurvivorTeam(Player @NotNull ... survivors) { + Team team = Team.of(GameConfig.SURVIVOR_KEY, 10); + for (Player survivor : survivors) { + team.addPlayer(survivor); + } + return team; + } + + /** + * Replays {@code TeamHelper.assignSlender} plus the round start tagging, ending in the + * intended state: rule installed, slender hidden. + * + * @param slender the player acting as the slender + * @param survivorTeam the survivor team + */ + private void startRound(@NotNull Player slender, @NotNull Team survivorTeam) { + // The single production path from a stamina state change to a visibility update. + MinecraftServer.getGlobalEventHandler() + .addListener(StaminaStateChangeEvent.class, new StaminaStateChangeListener()); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(VisibilityRules.slenderRule(slender)); + survivorTeam.getPlayers().forEach(survivor -> { + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + }); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(); + } + + /** + * Replays an eye press: {@code SlenderBarTrigger.trigger} plus the real + * {@link VisibilityRules#refresh(Player)}. + * + * @param bar the slender bar to toggle + * @param slender the player acting as the slender + */ + private void pressEye(@NotNull SlenderBar bar, @NotNull Player slender) { + // SlenderBar flips Tags.HIDDEN itself and fires the StaminaStateChangeEvent; + // StaminaStateChangeListener re-evaluates the viewable rule from there. + bar.changeStatus(); + } + + /** + * Replays {@code PlayerDeathListener} followed by {@code SpectatorService.join}: the + * player leaves the survivor team, loses its team key, and gains the spectator key + * plus its own view rule. + * + * @param player the dying player + * @param survivorTeam the team the player leaves + */ + private void die(@NotNull Player player, @NotNull Team survivorTeam) { + // PlayerDeathListener + survivorTeam.removePlayer(player); + player.removeTag(Tags.TEAM_KEY); + // SpectatorService.join + player.setGameMode(GameMode.SPECTATOR); + player.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + player.updateViewableRule(_ -> false); + } + + /** + * Dying while the slender is invisible must not lock the spectator out of ever seeing + * the slender again — otherwise spectating is pointless. + */ + @Test + @DisplayName("Spectator sieht den Slender im Angriffsmodus") + void testSpectatorSeesSlenderDuringAttack(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Der Survivor stirbt, bevor der Slender je sichtbar war. + die(dying, survivorTeam); + env.tick(); + + // Jetzt der Angriffsmodus - der Spectator muss den Slender sehen. + pressEye(slenderBar, slender); + env.tick(); + + assertTrue(slender.isViewer(alive), + "Kontrolle: der lebende Survivor sieht den Slender im Angriffsmodus"); + assertTrue(slender.isViewer(dying), + "Ein Spectator muss den Slender im Angriffsmodus sehen - " + + "sonst schaut er einem unsichtbaren Spiel zu"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * Mirror case: a spectator that saw the slender during an attack must lose sight of it + * again when the slender goes back to hidden, otherwise the slender's position is + * leaked to a dead player. + */ + @Test + @DisplayName("Manueller Doppeldruck traegt auch den Spectator aus") + void testManualToggleAlsoUnregistersSpectator(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Augendruck: Slender wird sichtbar. + pressEye(slenderBar, slender); + env.tick(); + assertTrue(slender.isViewer(dying), "Vorbedingung: Slender muss sichtbar sein"); + + // Der Survivor stirbt, waehrend der Slender sichtbar ist. + die(dying, survivorTeam); + env.tick(); + + // Manueller Doppeldruck zurueck in den Regenerationsmodus. + pressEye(slenderBar, slender); + env.tick(); + + assertFalse(slender.isViewer(alive), + "Kontrolle: fuer den lebenden Survivor raeumt der manuelle Pfad korrekt auf"); + assertFalse(slender.isViewer(dying), + "Auch der Spectator darf nach dem Zurueckschalten kein Viewer mehr sein - " + + "sonst verraet der unsichtbare Slender einem Toten seine Position"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * {@code SpectatorService.join} installs {@code _ -> false} on the spectator, so nobody + * may keep it as a registered viewer. + */ + @Test + @DisplayName("Spectator ist fuer andere unsichtbar") + void testSpectatorIsHiddenFromOthers(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + env.tick(); + + assertTrue(dying.isViewer(alive), "Vorbedingung: lebende Survivor sehen sich gegenseitig"); + + die(dying, survivorTeam); + env.tick(); + + assertFalse(dying.isViewer(alive), + "Nach dem Wechsel in den Spectator-Modus darf ihn kein Survivor mehr sehen"); + assertFalse(dying.isViewer(slender), + "Auch der Slender darf den Spectator nicht mehr sehen"); + + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java new file mode 100644 index 00000000..3bd32a04 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java @@ -0,0 +1,316 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.event.StaminaStateChangeEvent; +import net.onelitefeather.cygnus.listener.stamina.StaminaStateChangeListener; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.visibility.VisibilityRules; +import net.theevilreaper.xerus.api.team.Team; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the slender's server-side viewer registration stays in sync with the + * visual state across all transitions of the {@link SlenderBar}. + *

+ * The viewable rule installed by {@code TeamHelper.assignSlender} reads + * {@link VisibilityRules#isHidden(Player)} of the slender and ignores the + * candidate viewer, so a transition only takes effect once someone re-evaluates the + * rule via {@link VisibilityRules#refresh(Player)}. These tests check that + * every exit out of {@code DRAINING} does exactly that. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.4.0 + */ +class SlenderVisibilityIntegrationTest extends CygnusPlayerTestBase { + + /** Ticks to run the bar dry: MAX_TIME 16 / TIME_STEP 0.5. */ + private static final int TICKS_TO_TIMEOUT = 34; + + /** + * Ticks to regenerate from empty back to MIN_TIME_TO_REACTIVATE: 10 / TIME_STEP 0.5, plus one + * for the overshoot DRAINING leaves behind. Below that {@code SlenderBar.changeStatus()} + * refuses a new attack. + */ + private static final int TICKS_TO_REACTIVATE = 21; + + /** + * Builds a survivor team holding the given players, mirroring the team the production + * code hands to {@link VisibilityRules#refresh(Player)}. + * + * @param survivors the players to put into the team + * @return the populated survivor team + */ + private Team createSurvivorTeam(Player @NotNull ... survivors) { + Team team = Team.of(GameConfig.SURVIVOR_KEY, 10); + for (Player survivor : survivors) { + team.addPlayer(survivor); + } + return team; + } + + /** + * Replays {@code TeamHelper.assignSlender} followed by + * {@code GameStartListener.handleSlenderStart} / {@code handleSurvivorStart}. + *

+ * The closing {@code updateViewableRule()} is not part of the production + * path — production sets the tag without re-evaluating. It is added here so the + * remaining tests start from the intended state; the missing call itself is covered + * by {@link #testRoundStartHidesSlender(Env)}. + * + * @param slender the player acting as the slender + * @param survivorTeam the survivor team + */ + private void startRound(@NotNull Player slender, @NotNull Team survivorTeam) { + // The single production path from a stamina state change to a visibility update. + MinecraftServer.getGlobalEventHandler() + .addListener(StaminaStateChangeEvent.class, new StaminaStateChangeListener()); + assignSlender(slender); + survivorTeam.getPlayers().forEach(survivor -> { + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + }); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(); + } + + /** + * Replays {@code TeamHelper.assignSlender} verbatim, including the order in which the + * rule is installed relative to the {@link Tags#HIDDEN} tag. + * + * @param slender the player to become the slender + */ + private void assignSlender(@NotNull Player slender) { + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(VisibilityRules.slenderRule(slender)); + } + + /** + * Replays an eye press: {@code SlenderBarTrigger.trigger}, i.e. changeStatus plus + * changeVisibilityStatus plus the real {@link VisibilityRules#refresh}. + * + * @param bar the slender bar to toggle + * @param slender the player acting as the slender + */ + private void pressEye(@NotNull SlenderBar bar, @NotNull Player slender) { + // SlenderBar flips Tags.HIDDEN itself and fires the StaminaStateChangeEvent; + // StaminaStateChangeListener re-evaluates the viewable rule from there. + bar.changeStatus(); + } + + /** + * Sanity check: without this the remaining assertions could pass vacuously. + * The eye press must make the slender a registered viewer of the survivor. + */ + @Test + @DisplayName("Vorbedingung: Augendruck macht den Slender sichtbar") + void testEyePressMakesSlenderVisible(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + assertFalse(slender.isViewer(survivor), "Vor dem Augendruck darf niemand den Slender sehen"); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + pressEye(slenderBar, slender); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Nach dem Augendruck muss der Survivor registrierter Viewer sein"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * The round start installs the rule before the HIDDEN tag exists, so the rule + * evaluates to visible and registers every nearby player. Setting the tag afterwards + * without re-evaluating leaves that registration in place. + */ + @Test + @DisplayName("Rundenstart laesst den Slender nicht registriert zurueck") + void testRoundStartHidesSlender(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + + // Exact production order: TeamHelper.assignSlender, then GameStartListener. + assignSlender(slender); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Zu Rundenbeginn darf der Slender fuer keinen Survivor registriert sein - " + + "die Regel wird installiert, bevor der HIDDEN-Tag existiert, und " + + "danach nicht erneut ausgewertet"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Auto-Timeout traegt den Viewer serverseitig aus") + void testAutoTimeoutUnregistersViewer(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender); + env.tick(); + assertTrue(slender.isViewer(survivor), "Vorbedingung: Slender muss sichtbar sein"); + + // Bar auslaufen lassen - der Pfad, der den Trigger umgeht. + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem Auto-Timeout darf der Survivor kein registrierter Viewer mehr sein - " + + "sonst wird der Slender beim Wiedereintritt in die View-Distance neu gespawnt"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Manueller Doppeldruck bleibt korrekt") + void testManualToggleStaysCorrect(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender); // READY -> DRAINING + env.tick(); + pressEye(slenderBar, slender); // DRAINING -> REGENERATING + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem manuellen Zurueckschalten darf kein Viewer registriert bleiben"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Polaritaet bleibt ueber mehrere Zyklen stabil") + void testPolarityStableAcrossCycles(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Zyklus 1: Angriff, dann auslaufen lassen. + pressEye(slenderBar, slender); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Die Bar muss erst wieder ueber MIN_TIME_TO_REACTIVATE steigen, sonst + // verweigert changeStatus() den zweiten Angriff. + for (int i = 0; i < TICKS_TO_REACTIVATE; i++) { + slenderBar.consume(); + } + + // Zyklus 2: erneuter Angriff - der Slender MUSS jetzt sichtbar sein. + pressEye(slenderBar, slender); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Im Angriffsmodus muss der Slender sichtbar sein - " + + "ist er es nicht, ist die Sichtbarkeits-Polaritaet verdreht"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("View-Distance-Zyklus spawnt den Slender nicht neu") + void testViewDistanceCycleDoesNotRespawn(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Ab hier auf Spawn-Pakete horchen - VOR dem Teleport starten. + var spawnCollector = survivorConnection.trackIncoming( + net.minestom.server.network.packet.server.play.SpawnEntityPacket.class); + + // Raus: 6 Chunks weit (> ENTITY_VIEW_DISTANCE = 5). + survivor.teleport(new Pos(6 * 16, 40, 0)).join(); + env.tick(); + + // Und wieder rein. + survivor.teleport(new Pos(5, 40, 5)).join(); + env.tick(); + + assertEquals(0, spawnCollector.collect().size(), + "Beim Wiedereintritt in die View-Distance darf der unsichtbare Slender " + + "nicht neu gespawnt werden"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java new file mode 100644 index 00000000..edac3d84 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java @@ -0,0 +1,134 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies which texture the tunnel vision contributes to the shared screen overlay. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A stage is contributed as its overlay texture") + void stageIsContributedAsTexture(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 3); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"), + overlay.of(OverlayLayer.TUNNEL_VISION), + "the texture must match the stage" + ); + } + + @Test + @DisplayName("Clearing drops only the tunnel vision layer") + void clearingDropsOnlyItsOwnLayer(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay); + renderer.render(player, 4); + + renderer.clear(player); + + assertNull(overlay.of(OverlayLayer.TUNNEL_VISION), "the layer must be gone"); + assertFalse(overlay.wasWiped(), "wiping the screen would take the blood splatter with it"); + } + + @Test + @DisplayName("Stage zero drops the layer instead of drawing an empty texture") + void zeroStageDropsTheLayer(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 0); + + assertNull(overlay.of(OverlayLayer.TUNNEL_VISION)); + } + + @Test + @DisplayName("The tightest stage has a texture of its own") + void tightestStageHasItsOwnTexture(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE), + overlay.of(OverlayLayer.TUNNEL_VISION) + ); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Records what a renderer contributes, standing in for the equipment-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map layers = new EnumMap<>(OverlayLayer.class); + private boolean wiped; + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + if (texture == null) { + this.layers.remove(layer); + return; + } + this.layers.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.wiped = true; + this.layers.clear(); + } + + /** + * @param layer the layer to look up + * @return the texture currently set for the layer, or {@code null} if there is none + */ + private @Nullable Key of(OverlayLayer layer) { + return this.layers.get(layer); + } + + /** + * @return whether the whole screen was cleared rather than a single layer + */ + private boolean wasWiped() { + return this.wiped; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..d976aaea --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..f01d9c5b --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,235 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation. + *

+ * The slender no longer feeds into this — he speaks through {@code SlenderGazeService} — so what + * is left here is the stamina and the lifecycle. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionServiceTest extends CygnusPlayerTestBase { + + private static final double FULL_STAMINA = 1.0D; + private static final double NO_STAMINA = 0.0D; + + @Test + @DisplayName("An exhausted survivor sees the tightest stage") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void clearAllClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(first); + service.track(second); + service.tick(); + + service.clearAll(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "clearing must stop the drawing as well"); + } + + @Test + @DisplayName("Starting and stopping the task is idempotent") + void startAndStopTaskAreIdempotent(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + + service.startTask(); + service.startTask(); + service.stopTask(); + service.stopTask(); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..407e80bc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,100 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionStageTest { + + /** Enough updates to cover several periods of the slowest heartbeat. */ + private static final int SAMPLES = 60; + + @Test + @DisplayName("Without any threat the overlay stays off") + void calmIntensityStaysOff() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(0, stage.update(0.0D)); + } + + @Test + @DisplayName("Full intensity pulses across the top of the scale") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + // Stated as a share of the scale rather than as a stage count, so raising the number of + // stages does not turn this into a test of one particular pulse depth. + assertTrue(lowest < highest, "the pulse does not open up again"); + assertTrue(lowest >= TunnelVisionStage.MAX_STAGE - TunnelVisionStage.MAX_STAGE / 4, + "the pulse swings the view too far open at full intensity"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.51D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.53D), + "the stage should follow a real change"); + } + + @Test + @DisplayName("The stage never leaves its bounds") + void stageStaysWithinBounds() { + TunnelVisionStage stage = new TunnelVisionStage(); + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D); + assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current); + } + } + + /** + * Feeds a constant intensity for a while and reports the highest stage seen, which is the + * stage the pulse starts from. + * + * @param stage the stage state to drive + * @param combined the constant intensity to feed + * @return the highest stage observed + */ + private int highestOver(TunnelVisionStage stage, double combined) { + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + highest = Math.max(highest, stage.update(combined)); + } + return highest; + } +}