From 9321c372c0592f928c3ecd1b53cfe42014583533 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sat, 22 Aug 2026 17:10:55 +0200 Subject: [PATCH] fix(visibility): enforce the role visibility matrix between all three roles Visibility was steered by two competing mechanisms at once: Minestom's viewable rule and manual viewer packets (updateNewViewer/updateOldViewer). The latter are purely packet based and never touch EntityView's viewer bit set, so both drifted apart -- GameStartListener hid the slender without deregistering anyone, which made the next rule evaluation skip the spawn packet, while the manual toggle produced duplicated spawn packets. Everything now goes through per-viewer predicates only, collected in the new VisibilityRules class. Verified against Minestom 2026.07.22-26.2: the predicate receives the viewer and is re-evaluated whenever somebody enters range, so the matrix is expressible natively. addViewer/removeViewer stay unused on purpose -- they record players in EntityView#manualViewers, which would disable the rule for them permanently. Resulting matrix: Slender -> sees survivors, never spectators Survivor -> sees the slender only while revealed, never spectators Spectator -> sees survivors and other spectators, and shares the survivor view of the slender Fixed along the way: - Spectators were invisible to each other (`_ -> false`). - A revived slender got neither rule nor hidden tag and stayed visible for the rest of the round. - The slender was visible between team allocation and game start, because the rule was installed before Tags.HIDDEN existed. - Spectator chat leaked to everyone whenever no phase was active (the `null instanceof GamePhase` fallback was fail-open) and throughout the restart phase. The filter no longer depends on the phase at all. - Spectators took slender damage through setHealth, which bypasses the damage event chain. A dying spectator then triggered a second death message and a second SpectatorAddEvent. - Spectators heard the teleport sound at the exact moment the slender turned invisible. - Slender and spectators received jumpscares, including DARKNESS for 40 ticks on the slender. ViewRuleUpdater is removed: isViewAble was dead code with an inverted name, isHidden and updateViewer moved into VisibilityRules. SlenderBarTrigger lost its updateRuneFunction parameter and PlayerChatListener its phaseSupplier, since both only fed the paths that are gone now. Known leaks outside this change are documented in docs/plans/role-visibility-matrix.md, most notably that updateTabList gives the slender a red display name that outs them in the tab list and under every chat message. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QQCb6kSTw5NnRF1Yc4hYXg --- docs/plans/role-visibility-matrix.md | 133 ++++++++++++ .../net/onelitefeather/cygnus/Cygnus.java | 11 +- .../cygnus/listener/PlayerChatListener.java | 44 +++- .../listener/game/GameStartListener.java | 14 +- .../listener/game/SlenderReviveListener.java | 12 ++ .../player/CygnusPlayerTickListener.java | 29 ++- .../stamina/StaminaStateChangeListener.java | 47 ++--- .../cygnus/spectator/SpectatorService.java | 9 +- .../cygnus/stamina/SlenderBarHelper.java | 40 +++- .../cygnus/stamina/SlenderBarTrigger.java | 14 +- .../cygnus/team/TeamHelper.java | 12 +- .../cygnus/utils/ViewRuleUpdater.java | 30 --- .../cygnus/visibility/VisibilityRules.java | 108 ++++++++++ .../cygnus/visibility/package-info.java | 4 + .../listener/PlayerChatListenerTest.java | 191 +++++++++++++----- .../game/SlenderReviveIntegrationTest.java | 27 +++ .../player/CygnusPlayerTickListenerTest.java | 146 +++++++++++++ .../spectator/SpectatorServiceTest.java | 27 ++- .../cygnus/stamina/SlenderBarHelperTest.java | 157 ++++++++++++++ .../stamina/SlenderBarIntegrationTest.java | 3 + .../SlenderBarTriggerIntegrationTest.java | 17 +- .../visibility/VisibilityRulesTest.java | 149 ++++++++++++++ 22 files changed, 1058 insertions(+), 166 deletions(-) create mode 100644 docs/plans/role-visibility-matrix.md delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/utils/ViewRuleUpdater.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/visibility/VisibilityRules.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/visibility/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListenerTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/visibility/VisibilityRulesTest.java diff --git a/docs/plans/role-visibility-matrix.md b/docs/plans/role-visibility-matrix.md new file mode 100644 index 00000000..093c4a6e --- /dev/null +++ b/docs/plans/role-visibility-matrix.md @@ -0,0 +1,133 @@ +# Role visibility: Slender / Survivor / Spectator + +Status: Implemented +Branch: `fix/role-visibility-matrix` + +## 1. Target matrix + +Who (row) may see whom (column) as an entity: + +| Viewer | Slender | Survivor | Spectator | +|---|---|---|---| +| **Slender** | – | yes | **never** | +| **Survivor** | only while revealed | yes | **never** | +| **Spectator** | same as the survivor view | yes | **yes** | + +"only while revealed" means `Tags.HIDDEN == VISIBLE`, set by `SlenderBar.enterDraining()`. + +Chat: + +| Sender | Recipients | +|---|---| +| Survivor | everyone | +| Slender | everyone | +| Spectator | spectators only — **in every phase** | + +## 2. Root cause + +Visibility used to be driven by **two competing mechanisms** that desynchronized: + +1. **Viewable rule** (`updateViewableRule`) — maintains `EntityView.Option.bitSet` +2. **Manual viewer packets** (`updateNewViewer` / `updateOldViewer`) — purely packet based, + they never touch the bit set (Minestom `Entity.java:553` / `:579`) + +Observed consequences: +- `GameStartListener:58` hid the slender through `updateOldViewer` while the bit set kept everyone + registered. A later `updateViewableRule()` therefore saw `isRegistered == true` and sent **no** + spawn packet. +- `enterDraining` sent `updateNewViewer` manually, after which `SlenderBarTrigger:58` re-evaluated + the rule -> **duplicated spawn packets**. +- The automatic transition once the bar ran dry (`SlenderBar:109`) went through mechanism 2 **only** + and was silently reverted by the next `updateViewableRule()`. + +**Decision: a single layer.** Per-viewer predicates exclusively. Every manual +`updateNewViewer` / `updateOldViewer` / `broadcastPlayPacket` call is gone. + +This works because the predicate receives the **viewer** in Minestom `2026.07.22-26.2` +(`EntityView.updateRule0`, `predicate.test(entity)`) and is also evaluated whenever somebody enters +range (`EntityView:73`). + +Constraint: `addViewer` / `removeViewer` must **not** be used — they record the player in +`manualViewers`, and `update()` skips those players permanently (`EntityView:258`). + +## 3. Findings and measures + +### P0 — target matrix (implemented here) + +| # | File:line | Finding | Measure | +|---|---|---|---| +| 1 | `spectator/SpectatorService.java:61` | `_ -> false` — spectators could not see each other | `viewer -> TeamHelper.isSpectatorTeam(viewer)` | +| 2 | `team/TeamHelper.java:77` | predicate ignored the viewer | explicit per-viewer rule via `VisibilityRules` | +| 3 | `listener/game/SlenderReviveListener.java:33-42` | set neither rule nor `Tags.HIDDEN` -> revived slender permanently visible | set both | +| 4 | `team/TeamHelper.java:75-79` | rule installed before `Tags.HIDDEN` existed -> slender visible between GamePrepare and GameStart | set `Tags.HIDDEN = HIDDEN` in `assignSlender` | +| 5 | `listener/stamina/StaminaStateChangeListener.java:26-37` | bypassed the rule, broadcast metadata to everyone | replaced by `updateViewableRule()` | +| 6 | `listener/game/GameStartListener.java:54-59` | same pattern | same | +| 7 | `stamina/SlenderBar.java:109` | automatic transition did not re-evaluate the rule | resolved by removing the competing path instead | +| 8 | `utils/ViewRuleUpdater.java:11-13` | `isViewAble` — dead code, inverted name | deleted | +| 9 | `utils/ViewRuleUpdater.java:15-22` | iterated survivors twice | simplified into `VisibilityRules.refresh` | +| 10 | `listener/PlayerChatListener.java:31-34` | fail-open: `null instanceof GamePhase == false` leaked spectator chat to everyone, plus a gap during the restart phase | fail-closed, independent of the phase | +| 11 | `stamina/SlenderBarHelper.java:82-91` | `setHealth` without team/game mode filter -> spectators took damage | survivors only | +| 12 | `stamina/SlenderBarHelper.java:123-133` | teleport sound without role filter -> spectators heard when the slender vanished | survivors only | +| 13 | `listener/player/CygnusPlayerTickListener.java:19-22` | jumpscare on every tick without filter -> slender received `DARKNESS` for 40 ticks | survivors only | + +Follow-up chain of #11: a spectator died -> `PlayerDeathListener:55` broadcast a second death +message, `:57` removed the tag, `:58` fired another `SpectatorAddEvent` -> `SpectatorService.join` +ran twice, `:61` re-checked the finish condition. Fixing #11 removes the cause. + +### P1 — adjacent leaks, deliberately NOT part of this change + +Found during the analysis; each needs a design decision: + +- **`team/TeamHelper.java:157-161`** — `updateTabList` gives the slender the display name + `"⛧ " + name` in red. `setDisplayName` broadcasts `UPDATE_DISPLAY_NAME` to everyone + (Minestom `Player.java:1188-1193`). **Every survivor and every spectator immediately sees who the + slender is in the tab list** — and the same display name sits under every chat message + (`PlayerChatListener:62`). The largest remaining leak, but possibly intentional design. +- **`common/.../page/PageProvider.java:165`** — page discoveries are broadcast to everyone, so the + slender learns in real time which survivor is making progress where. +- **`PlayerDeathListener.java:55`** — death messages go to everyone; the slender gets every kill + confirmed. +- **`entity/DeadPlayerMannequin.java:109-114`** — particles sent through + `instance.sendGroupedPacket` bypass every viewable rule and reveal the corpse position even while + a jumpscare hides it. +- **`listener/game/GameFinishListener.java:36-41`** — dead players carry `SPECTATOR_KEY`, so + `isSlenderTeam` is false and they all render as survivor boxes. +- **`utils/ScoreboardDisplay.java`** — dead code; `getTeamName:86-88` would map spectators into the + survivor team, and `TeamCreator:41-43` lacks the `TeamNameComponent`, which would throw an NPE. +- **`Cygnus.java`** (`finishGame`) — no reset of rule / `Tags.HIDDEN` / `TEAM_KEY`. Harmless today + (`RestartPhase` kicks everyone and calls `stopCleanly()`, one process serves exactly one round), + but immediately relevant once a round reset or map switch is introduced. + +## 4. Implementation + +### `game/.../visibility/VisibilityRules.java` + +Holds the matrix in a single place, as per-viewer predicates: + +- `slenderRule(Player slender)` -> `viewer -> !isHidden(slender)` +- `spectatorRule()` -> `TeamHelper::isSpectatorTeam` +- survivors deliberately get **no** rule (Minestom default = visible to everyone) +- `refresh(Player)` -> re-evaluates the rules of the player and of every other online player, + which is required because `spectatorRule()` tests the *viewer* + +`ViewRuleUpdater` was removed and absorbed into this class. + +### Tests + +Based on `CygnusPlayerTestBase` + `MicrotusExtension`, asserting through `player.isViewer(other)`. + +`SpectatorServiceTest.testJoinMakesPlayerInvisibleToOthers` cemented finding 1 and was replaced. + +One test per matrix cell plus regression tests: +- spectator sees spectator, spectator sees survivor +- survivor does not see spectator, slender does not see spectator +- spectator does not see a hidden slender, spectator sees a revealed slender +- revived slender is hidden +- spectator chat reaches spectators only — game phase, restart phase and without an active phase +- spectator takes no slender damage, only survivors hear the sounds, no jumpscares for + slender/spectator + +### Known follow-up + +`stamina` -> `team` is now a package cycle, because `TeamHelper` reads the `HIDDEN` constant from +`SlenderBarHelper`. Moving `VISIBLE` / `HIDDEN` into a neutral holder would resolve it. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 46362d5b..09584278 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -25,7 +25,6 @@ import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; import net.minestom.server.MinecraftServer; -import net.minestom.server.entity.Player; import net.minestom.server.event.player.AsyncPlayerConfigurationEvent; import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.event.player.PlayerDeathEvent; @@ -78,10 +77,8 @@ import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.utils.StaminaHelper; -import net.onelitefeather.cygnus.utils.ViewRuleUpdater; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; -import org.jetbrains.annotations.NotNull; import java.nio.file.Path; import java.util.Optional; @@ -162,7 +159,7 @@ private void initListener() { this.resourcePackService.ifPresent(service -> service.registerListener(manager)); Team spectatorTeam = this.teamService.getTeam(GameConfig.SPECTATOR_KEY) .orElseThrow(() -> new IllegalStateException("Spectator team not found")); - manager.addListener(PlayerChatEvent.class, new PlayerChatListener(spectatorTeam, phaseSupplier)); + manager.addListener(PlayerChatEvent.class, new PlayerChatListener(spectatorTeam)); manager.addListener(GameMapLoadEvent.class, _ -> this.mapProvider.loadGameMap()); manager.addListener(GamePrepareEvent.class, _ -> StaminaHelper.initStaminaObjects(this.teamService, this.staminaService)); registerCancelListener(manager); @@ -172,7 +169,7 @@ private void registerGameListener() { Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase; GlobalEventHandler handler = MinecraftServer.getGlobalEventHandler(); - SlenderBarTrigger trigger = new SlenderBarTrigger(this.staminaService::getSlenderBar, this::triggerViewRuleUpdate); + SlenderBarTrigger trigger = new SlenderBarTrigger(this.staminaService::getSlenderBar); handler.addListener(PlayerUseItemEvent.class, new SlenderItemListener(trigger)); handler.addListener(GameFinishEvent.class, new GameFinishListener()); handler.addListener(GameStartEvent.class, new GameStartListener(this.teamService, this.ambientProvider, this.staminaService, this.pageProvider)); @@ -225,8 +222,4 @@ private void finishGame() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, EntityActionListener::listener); MinecraftServer.getPacketListenerManager().setPlayListener(ClientSettingsPacket.class, SettingsListener::listener); } - - private void triggerViewRuleUpdate(@NotNull Player player) { - ViewRuleUpdater.updateViewer(player, this.teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow()); - } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerChatListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerChatListener.java index cc7f91bd..bff43c1b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerChatListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerChatListener.java @@ -3,24 +3,44 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.minestom.server.event.player.PlayerChatEvent; -import net.onelitefeather.cygnus.phase.GamePhase; import net.onelitefeather.cygnus.team.TeamHelper; -import net.theevilreaper.xerus.api.phase.Phase; import net.theevilreaper.xerus.api.team.Team; import java.util.function.Consumer; -import java.util.function.Supplier; +/** + * Formats every chat message and enforces the spectator chat isolation. + *

+ * The visibility matrix allows a spectator to read survivor and slender chat, but a message written + * by a spectator must never reach a survivor or the slender. That rule depends on the sender's team + * only and is deliberately not tied to the currently active phase: + *

    + *
  • a phase check is fail-open, because the phase series reports {@code null} while no phase + * is running and {@code null instanceof GamePhase} evaluates to {@code false}, which would leak + * spectator chat to everyone;
  • + *
  • the {@code RestartPhase} that runs after the game phase finished is not a + * {@code GamePhase} either, so the isolation would silently disappear for its whole runtime.
  • + *
+ * Outside a running match nobody carries the spectator team tag, so the team based check is a no-op + * there and no additional phase guard is needed. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 1.0.0 + **/ public final class PlayerChatListener implements Consumer { private static final Component MESSAGE_PREFIX = Component.text("≫", NamedTextColor.YELLOW); private final Team spectatorTeam; - private final Supplier phaseSupplier; - public PlayerChatListener(Team spectatorTeam, Supplier phaseSupplier) { + /** + * Creates a new instance of the {@link PlayerChatListener}. + * + * @param spectatorTeam the team which receives the messages written by a spectator + */ + public PlayerChatListener(Team spectatorTeam) { this.spectatorTeam = spectatorTeam; - this.phaseSupplier = phaseSupplier; } @Override @@ -28,10 +48,14 @@ public void accept(PlayerChatEvent event) { //TODO: Improve chat during each phase event.setFormattedMessage(this.setLobbyLayout(event)); - if (phaseSupplier.get() instanceof GamePhase && TeamHelper.isSpectatorTeam(event.getPlayer())) { - event.getRecipients().clear(); - spectatorTeam.sendMessage(event.getFormattedMessage()); - } + if (!TeamHelper.isSpectatorTeam(event.getPlayer())) return; + + // Minestom pre-fills the recipients with every online player. Dropping all of them and + // re-delivering to the spectator team keeps the message inside the spectator group even if + // the team and the player tag ever drift apart, because the fallback is "nobody" and never + // "everybody". + event.getRecipients().clear(); + this.spectatorTeam.sendMessage(event.getFormattedMessage()); } private Component setLobbyLayout(PlayerChatEvent event) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java index 362edb97..3bcb16d4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java @@ -1,10 +1,8 @@ package net.onelitefeather.cygnus.listener.game; import net.kyori.adventure.text.Component; -import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; import net.minestom.server.event.EventDispatcher; -import net.minestom.server.utils.PacketSendingUtils; import net.onelitefeather.cygnus.ambient.AmbientProvider; import net.onelitefeather.cygnus.common.Messages; import net.onelitefeather.cygnus.common.Tags; @@ -16,6 +14,7 @@ import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.team.TeamHelper; import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; @@ -51,12 +50,11 @@ private void handleSlenderStart() { slenderPlayer.sendMessage(Messages.SLENDER_JOIN_PART); Items.setSlenderEye(slenderPlayer); - PacketSendingUtils.broadcastPlayPacket(slenderPlayer.getMetadataPacket()); - MinecraftServer.getConnectionManager().getOnlinePlayers() - .stream() - .filter(p -> !p.equals(slenderPlayer)) - .forEach(slenderPlayer::updateOldViewer); - PacketSendingUtils.broadcastPlayPacket(slenderPlayer.getMetadataPacket()); + // Hiding the slender goes exclusively through the viewable rule. The previous + // updateOldViewer/broadcastPlayPacket combination only sent packets: it left the viewer bit set + // untouched, so the next rule evaluation considered every player still registered and skipped the + // spawn packet when the slender revealed themselves again. + VisibilityRules.refresh(slenderPlayer); } private void handleSurvivorStart() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/game/SlenderReviveListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/game/SlenderReviveListener.java index 8b3aa954..3374be9c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/game/SlenderReviveListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/game/SlenderReviveListener.java @@ -5,9 +5,11 @@ import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.event.SlenderReviveEvent; +import net.onelitefeather.cygnus.stamina.SlenderBarHelper; import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.team.TeamHelper; import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import java.util.function.Consumer; import java.util.function.Supplier; @@ -29,11 +31,21 @@ public SlenderReviveListener(Supplier gameMapSupplier, StaminaService s this.staminaService = staminaService; } + /** + * Turns the given player into the new slender. + *

+ * Hidden state and viewable rule are set exactly like in the initial team allocation. Without them the + * revived slender would keep the survivor default and stay visible to everybody for the rest of the round. + * + * @param event the revive event carrying the player to promote + */ @Override public void accept(SlenderReviveEvent event) { Player player = event.getPlayer(); staminaService.setSlenderBar(player, true); player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + player.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + player.updateViewableRule(VisibilityRules.slenderRule(player)); GameMap gameMap = gameMapSupplier.get(); if (gameMap != null && gameMap.getSlenderSpawn() != null) { player.teleport(gameMap.getSlenderSpawn()); diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java index 054ab2d3..c01485db 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java @@ -4,9 +4,18 @@ import net.minestom.server.event.player.PlayerTickEvent; import net.onelitefeather.cygnus.jumpscare.JumpScareManager; import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.team.TeamHelper; import java.util.function.Consumer; +/** + * Handles the per tick logic of a {@link Player} like the jump scare detection, the sprint blocking + * and the heartbeat. + * + * @author theEvilReaper + * @version 1.1.0 + * @since 1.0.0 + */ public final class CygnusPlayerTickListener implements Consumer { private final JumpScareManager jumpscareManager; @@ -19,7 +28,9 @@ public CygnusPlayerTickListener(JumpScareManager jumpscareManager) { public void accept(PlayerTickEvent event) { Player player = event.getPlayer(); - this.jumpscareManager.checkTurnAround(player); + if (isJumpScareTarget(player)) { + this.jumpscareManager.checkTurnAround(player); + } if (!(player instanceof CygnusPlayer cygnusPlayer)) return; @@ -30,5 +41,19 @@ public void accept(PlayerTickEvent event) { cygnusPlayer.tickHeartbeat(); } -} + /** + * Checks whether the given player is allowed to receive a jump scare. + *

+ * A jump scare spawns a phantom corpse and applies {@code DARKNESS} for 40 ticks. Only survivors + * may receive it: for the slender it would be a direct gameplay interference and for a spectator + * it would blind a player that is not part of the round anymore. The check is fail closed, so an + * untagged player never receives a scare either. + * + * @param player the player to check + * @return {@code true} if the player is a living survivor + */ + private static boolean isJumpScareTarget(Player player) { + return TeamHelper.isSurvivorTeam(player) && !player.isDead(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/stamina/StaminaStateChangeListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/stamina/StaminaStateChangeListener.java index 295a6285..b16bf1ac 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/stamina/StaminaStateChangeListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/stamina/StaminaStateChangeListener.java @@ -1,40 +1,41 @@ package net.onelitefeather.cygnus.listener.stamina; -import net.minestom.server.MinecraftServer; -import net.minestom.server.entity.Player; -import net.minestom.server.utils.PacketSendingUtils; import net.onelitefeather.cygnus.event.StaminaStateChangeEvent; -import net.onelitefeather.cygnus.stamina.StaminaBar; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import java.util.function.Consumer; /** - * Listener for stamina state changes to handle packet broadcasting. + * Turns a stamina state change of the slender into a visibility update. + *

+ * {@code SlenderBar#enterDraining(boolean)} and {@code SlenderBar#enterRegenerating()} flip + * {@link net.onelitefeather.cygnus.common.Tags#HIDDEN} and then fire the + * {@link StaminaStateChangeEvent}. Re-evaluating the viewable rule here is the single mechanism that + * translates that tag into spawn and destroy packets, and it is reached by the manual toggle + * ({@code SlenderBarTrigger}) as well as by the automatic transition once the bar runs dry, so both paths + * behave identically. + *

+ * The former implementation additionally broadcast {@code Player#getMetadataPacket()} to every online + * player. That broadcast is gone and is not needed for anything else, not even for the slender running as + * {@code EntityType.ENDERMAN}: Minestom pushes every metadata change to the entity's viewers on its own + * ({@code MetadataHolder} change listener), {@code Entity#updateNewViewer(Player)} sends the metadata as + * part of the spawn sequence, and {@code Entity#switchEntityType(EntityType)} respawns the entity for its + * current viewers itself. Broadcasting it to everybody only leaked the slender's metadata to players that + * must not see them. * * @author Joltra - * @version 1.0.0 + * @version 2.0.0 * @since 1.0.0 **/ public final class StaminaStateChangeListener implements Consumer { + /** + * Re-evaluates the viewable rule of the player whose stamina state just changed. + * + * @param event the stamina state change event + */ @Override public void accept(StaminaStateChangeEvent event) { - Player player = event.getPlayer(); - StaminaBar.State state = event.getState(); - - if (state == StaminaBar.State.DRAINING) { - PacketSendingUtils.broadcastPlayPacket(player.getMetadataPacket()); - MinecraftServer.getConnectionManager().getOnlinePlayers() - .stream().filter(p -> !p.getUuid().equals(player.getUuid())).forEach(player::updateNewViewer); - PacketSendingUtils.broadcastPlayPacket(player.getMetadataPacket()); - return; - } - - if (state == StaminaBar.State.REGENERATING) { - PacketSendingUtils.broadcastPlayPacket(player.getMetadataPacket()); - MinecraftServer.getConnectionManager().getOnlinePlayers() - .stream().filter(p -> !p.getUuid().equals(player.getUuid())).forEach(player::updateOldViewer); - PacketSendingUtils.broadcastPlayPacket(player.getMetadataPacket()); - } + VisibilityRules.refresh(event.getPlayer()); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java b/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java index eaa6ef00..d1bb518a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/spectator/SpectatorService.java @@ -14,6 +14,7 @@ import net.onelitefeather.cygnus.player.listener.SpectatorItemListener; import net.onelitefeather.cygnus.team.TeamHelper; import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import net.theevilreaper.xerus.api.team.Team; import java.util.concurrent.CompletableFuture; @@ -50,6 +51,11 @@ public void registerListener(EventNode node) { /** * Converts the given player into a spectator and update several things like hotbar, {@link GameMode} and so on. + *

+ * The spectator rule keeps the player invisible for everybody who is still in the round but lets + * spectators see each other. Because {@link VisibilityRules#spectatorRule()} tests the viewer, the rules + * of all other players have to be re-evaluated as well so already present spectators pull the new one + * into their viewer set. * * @param player the player to convert */ @@ -58,7 +64,8 @@ public void join(Player player) { player.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); spectatorTeam.addPlayer(player); Items.setSpectatorLayout(player); - player.updateViewableRule(_ -> false); + player.updateViewableRule(VisibilityRules.spectatorRule()); + VisibilityRules.refresh(player); } /** 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 ad148080..79cf4f25 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java @@ -9,6 +9,7 @@ import net.minestom.server.potion.PotionEffect; import net.minestom.server.potion.TimedPotion; import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.team.TeamHelper; import java.util.Collection; import java.util.UUID; @@ -19,7 +20,7 @@ * The method will iterate over all entities in the given range and apply the damage to the entity if it is a player. * * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ @SuppressWarnings({"java:S3252"}) @@ -83,13 +84,32 @@ default void applyDamage(Instance instance, UUID uuid, Pos center, int range, fl Collection nearbyEntities = instance.getNearbyEntities(center, range); if (nearbyEntities.isEmpty()) return; for (Entity nearbyEntity : nearbyEntities) { - boolean hasSameUUID = UUID_COMPARATOR.test(uuid, nearbyEntity.getUuid()); - if (nearbyEntity instanceof Player target && !hasSameUUID && (target.getHealth() > 0)) { - target.setHealth(target.getHealth() - damage); - } + if (!(nearbyEntity instanceof Player target)) continue; + if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue; + if (!isDamageableSurvivor(target)) continue; + target.setHealth(target.getHealth() - damage); } } + /** + * Checks whether the given player may take damage from the slender. + *

+ * Only players of the survivor team are valid targets. The slender itself and every spectator + * must stay untouched, otherwise a spectator would slowly bleed out and trigger the whole death + * pipeline a second time. Because {@link Player#setHealth(float)} bypasses the damage event + * chain, the game mode is checked as a second, independent guard: it stays correct even if the + * team tag and the game mode ever drift apart. + * + * @param target the player to check + * @return {@code true} if the player is a living survivor that may take damage + */ + private static boolean isDamageableSurvivor(Player target) { + return TeamHelper.isSurvivorTeam(target) + && !target.getGameMode().invulnerable() + && !target.isDead() + && target.getHealth() > 0; + } + /** * Plays the spawn sound to all players in the given range. * @@ -125,10 +145,12 @@ private void playToSound(Instance instance, Pos center, UUID uuid, boolean spawn if (nearbyEntities.isEmpty()) return; for (Entity nearbyEntity : nearbyEntities) { - boolean hasSameUUID = UUID_COMPARATOR.test(uuid, nearbyEntity.getUuid()); - if (nearbyEntity instanceof Player target && !hasSameUUID) { - target.playSound(spawn ? SPAWN : TELEPORT, target.getPosition()); - } + if (!(nearbyEntity instanceof Player target)) continue; + if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue; + // The teleport sound fires exactly when the slender turns invisible, so it would tell a + // spectator both the position and the state of the slender. Only survivors may hear it. + if (!TeamHelper.isSurvivorTeam(target)) continue; + target.playSound(spawn ? SPAWN : TELEPORT, target.getPosition()); } } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java index 3bac4103..3788d05b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java @@ -1,7 +1,6 @@ package net.onelitefeather.cygnus.stamina; import net.onelitefeather.cygnus.team.TeamHelper; -import net.theevilreaper.aves.util.functional.PlayerConsumer; import net.kyori.adventure.sound.Sound; import net.minestom.server.entity.Player; import net.minestom.server.sound.SoundEvent; @@ -24,7 +23,6 @@ public final class SlenderBarTrigger { private static final Sound ABORT_SOUND = Sound.sound(SoundEvent.ENTITY_ITEM_BREAK, Sound.Source.MASTER, 1F, 0F); private final Supplier<@Nullable StaminaBar> slenderBarSupplier; - private final PlayerConsumer updateRuneFunction; private long lastSoundTimeStamp = 0; @@ -32,11 +30,9 @@ public final class SlenderBarTrigger { * Creates a new instance of this class. * * @param slenderBarSupplier the supplier to get the {@link SlenderBar} - * @param updateRuneFunction the function to update the rune status */ - public SlenderBarTrigger(Supplier<@Nullable StaminaBar> slenderBarSupplier, PlayerConsumer updateRuneFunction) { + public SlenderBarTrigger(Supplier<@Nullable StaminaBar> slenderBarSupplier) { this.slenderBarSupplier = slenderBarSupplier; - this.updateRuneFunction = updateRuneFunction; } /** @@ -54,10 +50,10 @@ public void trigger(Player player) { return; } lastSoundTimeStamp = System.currentTimeMillis() + COOLDOWN_TIME; - if (slenderBar.changeStatus()) { - this.updateRuneFunction.accept(player); - return; - } + // A successful status change flips Tags.HIDDEN and fires the StaminaStateChangeEvent, and + // StaminaStateChangeListener re-evaluates the viewable rule from there. Doing it a second time here + // would be the exact kind of competing second update path this class used to carry. + if (slenderBar.changeStatus()) return; player.playSound(ABORT_SOUND); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java index 79882d4d..4b7f24ba 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java @@ -1,7 +1,8 @@ package net.onelitefeather.cygnus.team; import net.onelitefeather.cygnus.common.strategy.TeleportStrategy; -import net.onelitefeather.cygnus.utils.ViewRuleUpdater; +import net.onelitefeather.cygnus.stamina.SlenderBarHelper; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import net.theevilreaper.aves.util.Players; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; @@ -67,14 +68,19 @@ private static Player selectSlenderPlayer() { } /** - * Tags and assigns the given player as the slender and updates their view rule. + * Tags and assigns the given player as the slender and installs their view rule. + *

+ * {@link Tags#HIDDEN} has to be set before the rule is installed: the rule reads that tag and a + * missing tag counts as "revealed", which used to leave the slender visible for everybody between the + * team allocation and the game start. * * @param player the player to become the slender * @param slenderTeam the team to add the player to */ private static void assignSlender(Player player, Team slenderTeam) { player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); - player.updateViewableRule(_ -> !ViewRuleUpdater.isHidden(player)); + player.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + player.updateViewableRule(VisibilityRules.slenderRule(player)); slenderTeam.addPlayer(player); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/ViewRuleUpdater.java b/game/src/main/java/net/onelitefeather/cygnus/utils/ViewRuleUpdater.java deleted file mode 100644 index 7318bc9a..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/ViewRuleUpdater.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.onelitefeather.cygnus.utils; - -import net.theevilreaper.xerus.api.team.Team; -import net.minestom.server.MinecraftServer; -import net.minestom.server.entity.Player; -import net.onelitefeather.cygnus.common.Tags; -import net.onelitefeather.cygnus.stamina.SlenderBarHelper; - -public final class ViewRuleUpdater { - - public static boolean isViewAble(Player player) { - return player.hasTag(Tags.HIDDEN) && player.getTag(Tags.HIDDEN) == SlenderBarHelper.HIDDEN; - } - - public static void updateViewer(Player target, Team survivor) { - for (Player onlinePlayer : MinecraftServer.getConnectionManager().getOnlinePlayers()) { - if (onlinePlayer.getUuid().equals(target.getUuid())) continue; - onlinePlayer.updateViewableRule(); - } - target.updateViewableRule(); - survivor.getPlayers().forEach(Player::updateViewableRule); - } - - public static boolean isHidden(Player player) { - return player.hasTag(Tags.HIDDEN) && player.getTag(Tags.HIDDEN) == SlenderBarHelper.HIDDEN; - } - - private ViewRuleUpdater() { - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/visibility/VisibilityRules.java b/game/src/main/java/net/onelitefeather/cygnus/visibility/VisibilityRules.java new file mode 100644 index 00000000..24aec664 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/visibility/VisibilityRules.java @@ -0,0 +1,108 @@ +package net.onelitefeather.cygnus.visibility; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.stamina.SlenderBarHelper; +import net.onelitefeather.cygnus.team.TeamHelper; + +import java.util.function.Predicate; + +/** + * Single source of truth for the role visibility matrix of the game. + *

+ * Who (row) may see whom (column) as an entity: + * + * + * + * + * + * + *
Role visibility matrix
SlenderSurvivorSpectator
Slender-yesnever
Survivoronly while revealedyesnever
Spectatorsame as the survivor viewyesyes
+ *

+ * The matrix is expressed exclusively as per-viewer predicates handed to + * {@link Entity#updateViewableRule(Predicate)}. This is deliberately the only visibility mechanism in + * the code base: the manual packet level ({@code Entity#updateNewViewer(Player)} / + * {@code Entity#updateOldViewer(Player)}) does not touch the internal viewer bit set and therefore + * desynchronizes with the rule, which produced both duplicated and missing spawn packets before. + *

+ * Two properties of the Minestom implementation (verified against {@code 2026.07.22-26.2}, + * {@code EntityView.java}) shape this class: + *

    + *
  • {@link Entity#updateViewableRule(Predicate)} installs a rule, {@link Entity#updateViewableRule()} + * re-evaluates the already installed one. Passing {@code null} is not a reset: it only ever adds + * viewers and never removes any.
  • + *
  • {@code Entity#addViewer(Player)} / {@code Entity#removeViewer(Player)} must never be used here. + * They record the player in {@code EntityView#manualViewers}, and {@code EntityView.Option#update} + * permanently skips those players, which would disable the rule for them for good.
  • + *
+ * Survivors intentionally get no rule at all: the Minestom default (auto viewable) already means + * "visible to everybody", which is exactly the survivor row of the matrix. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.8.0 + */ +public final class VisibilityRules { + + /** + * Builds the viewable rule for the slender player. + *

+ * The slender is visible to everybody while revealed and to nobody while hidden. The rule ignores the + * viewer on purpose: spectators share the survivor view of the slender, so there is no viewer group + * that is allowed to peek through {@link Tags#HIDDEN}. + * + * @param slender the player acting as the slender + * @return the per-viewer predicate to install on the slender + */ + public static Predicate slenderRule(Player slender) { + return _ -> !isHidden(slender); + } + + /** + * Builds the viewable rule for a spectator. + *

+ * Spectators see each other but stay invisible for every player that is still in the round. + * + * @return the per-viewer predicate to install on a spectator + */ + public static Predicate spectatorRule() { + return TeamHelper::isSpectatorTeam; + } + + /** + * Checks whether the given player is currently hidden according to {@link Tags#HIDDEN}. + *

+ * A missing tag counts as "not hidden", which is why every place that installs + * {@link #slenderRule(Player)} has to set the tag first. + * + * @param player the player to check + * @return {@code true} if the player carries {@link SlenderBarHelper#HIDDEN} + */ + public static boolean isHidden(Player player) { + return player.hasTag(Tags.HIDDEN) && player.getTag(Tags.HIDDEN) == SlenderBarHelper.HIDDEN; + } + + /** + * Re-evaluates the viewable rule of the given player and of every other online player. + *

+ * The second part is not redundant: {@link #spectatorRule()} tests the viewer, so whenever a + * player changes role the rules of everybody else have to be re-run to pull that player in or out of + * their viewer set. Re-evaluating a player without an installed rule is a no-op for anybody who is + * already a viewer. + * + * @param player the player whose role or hidden state just changed + */ + public static void refresh(Player player) { + player.updateViewableRule(); + for (Player onlinePlayer : MinecraftServer.getConnectionManager().getOnlinePlayers()) { + if (onlinePlayer.getUuid().equals(player.getUuid())) continue; + onlinePlayer.updateViewableRule(); + } + } + + private VisibilityRules() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/visibility/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/visibility/package-info.java new file mode 100644 index 00000000..4c24c0f4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/visibility/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.visibility; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerChatListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerChatListenerTest.java index 48ad9495..8a286baf 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerChatListenerTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerChatListenerTest.java @@ -1,18 +1,19 @@ package net.onelitefeather.cygnus.listener; +import net.kyori.adventure.key.Key; import net.kyori.adventure.text.Component; import net.minestom.server.entity.Player; import net.minestom.server.event.player.PlayerChatEvent; import net.minestom.server.instance.Instance; +import net.minestom.server.message.ChatPosition; +import net.minestom.server.message.Messenger; +import net.minestom.server.network.packet.server.play.SystemChatPacket; +import net.minestom.testing.Collector; 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.common.config.GameConfig; -import net.onelitefeather.cygnus.jumpscare.JumpScareManager; -import net.onelitefeather.cygnus.phase.GamePhase; -import net.onelitefeather.cygnus.phase.LobbyPhase; -import net.onelitefeather.cygnus.team.TeamHelper; -import net.onelitefeather.cygnus.view.GameViewImpl; import net.theevilreaper.xerus.api.team.Team; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -22,74 +23,164 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * Verifies the chat part of the role visibility matrix: a survivor and the slender talk to everyone, + * a spectator talks to spectators only. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ class PlayerChatListenerTest extends CygnusPlayerTestBase { + /** + * The historical implementation only isolated spectator chat while a {@code GamePhase} was + * running. This is the case that used to work. + */ @Test - void testSpectatorMessageDuringGamePhaseOnlyReachesSpectators(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - Player spectator = env.createPlayer(instance); - Player survivor = env.createPlayer(instance); - spectator.setDisplayName(Component.text(spectator.getUsername())); - survivor.setDisplayName(Component.text(survivor.getUsername())); - - Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); - spectatorTeam.addPlayer(spectator); - spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); - - GamePhase gamePhase = new GamePhase(new GameViewImpl(), () -> {}, 600, new JumpScareManager()); - PlayerChatListener listener = new PlayerChatListener(spectatorTeam, () -> gamePhase); - PlayerChatEvent event = new PlayerChatEvent(spectator, List.of(spectator, survivor), "hi"); - - listener.accept(event); + void testSpectatorMessageDuringTheGamePhaseOnlyReachesSpectators(@NotNull Env env) { + assertSpectatorChatStaysIsolated(env); + } - assertTrue(event.getRecipients().isEmpty(), "Default broadcast recipients must be cleared for a spectator sender."); + /** + * After the game phase finished, the {@code RestartPhase} runs for another 15 seconds. It is no + * {@code GamePhase}, so the old phase bound check stopped filtering and spectator chat leaked + * back to survivors and the slender. + */ + @Test + void testSpectatorMessageDuringTheRestartPhaseOnlyReachesSpectators(@NotNull Env env) { + assertSpectatorChatStaysIsolated(env); + } - env.destroyInstance(instance, true); + /** + * The phase series reports {@code null} whenever no phase is active. {@code null instanceof + * GamePhase} is {@code false}, so the old check was fail-open and broadcast spectator chat to + * everyone. The filter must fall back to "nobody outside the spectator team", never to "all". + */ + @Test + void testSpectatorMessageWithoutAnyActivePhaseOnlyReachesSpectators(@NotNull Env env) { + assertSpectatorChatStaysIsolated(env); } @Test - void testSurvivorMessageDuringGamePhaseReachesEveryone(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - Player survivor = env.createPlayer(instance); - Player spectator = env.createPlayer(instance); - survivor.setDisplayName(Component.text(survivor.getUsername())); - spectator.setDisplayName(Component.text(spectator.getUsername())); + void testSurvivorMessageReachesEveryone(@NotNull Env env) { + assertChatReachesEveryone(env, GameConfig.SURVIVOR_KEY); + } - Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); - GamePhase gamePhase = new GamePhase(new GameViewImpl(), () -> {}, 600, new JumpScareManager()); - PlayerChatListener listener = new PlayerChatListener(spectatorTeam, () -> gamePhase); + @Test + void testSlenderMessageReachesEveryone(@NotNull Env env) { + assertChatReachesEveryone(env, GameConfig.SLENDER_KEY); + } - PlayerChatEvent event = new PlayerChatEvent(survivor, List.of(survivor, spectator), "hi"); + /** + * Lets a spectator write a message and asserts that it only shows up on the spectator clients. + * + * @param env the test environment + */ + private void assertSpectatorChatStaysIsolated(@NotNull Env env) { + Instance instance = env.createFlatInstance(); - listener.accept(event); + TestConnection spectatorConnection = env.createConnection(); + Player spectator = spectatorConnection.connect(instance); + TestConnection secondSpectatorConnection = env.createConnection(); + Player secondSpectator = secondSpectatorConnection.connect(instance); + TestConnection survivorConnection = env.createConnection(); + Player survivor = survivorConnection.connect(instance); + TestConnection slenderConnection = env.createConnection(); + Player slender = slenderConnection.connect(instance); - assertEquals(2, event.getRecipients().size(), "A non-spectator sender's message must keep the default recipient list."); + Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); + joinSpectatorTeam(spectatorTeam, spectator); + joinSpectatorTeam(spectatorTeam, secondSpectator); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + useUsernameAsDisplayName(spectator, secondSpectator, survivor, slender); + + Collector spectatorMessages = spectatorConnection.trackIncoming(SystemChatPacket.class); + Collector secondSpectatorMessages = secondSpectatorConnection.trackIncoming(SystemChatPacket.class); + Collector survivorMessages = survivorConnection.trackIncoming(SystemChatPacket.class); + Collector slenderMessages = slenderConnection.trackIncoming(SystemChatPacket.class); + + PlayerChatEvent event = new PlayerChatEvent(spectator, List.of(spectator, secondSpectator, survivor, slender), "hi"); + new PlayerChatListener(spectatorTeam).accept(event); + deliverToRecipients(event); + + assertTrue(event.getRecipients().isEmpty(), "The default broadcast recipients must be cleared for a spectator sender."); + assertEquals(1, spectatorMessages.collect().size(), "The sending spectator must see the own message."); + assertEquals(1, secondSpectatorMessages.collect().size(), "Another spectator must receive the spectator message."); + assertTrue(survivorMessages.collect().isEmpty(), "A survivor must never receive a spectator message."); + assertTrue(slenderMessages.collect().isEmpty(), "The slender must never receive a spectator message."); env.destroyInstance(instance, true); } - @Test - void testSpectatorMessageOutsideGamePhaseIsUnrestricted(@NotNull Env env) { + /** + * Lets a non spectator write a message and asserts that every online player receives it. + * + * @param env the test environment + * @param teamKey the team key of the sending player + */ + private void assertChatReachesEveryone(@NotNull Env env, @NotNull Key teamKey) { Instance instance = env.createFlatInstance(); - Player spectator = env.createPlayer(instance); - Player other = env.createPlayer(instance); - spectator.setDisplayName(Component.text(spectator.getUsername())); - other.setDisplayName(Component.text(other.getUsername())); - Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); - spectatorTeam.addPlayer(spectator); - spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + TestConnection senderConnection = env.createConnection(); + Player sender = senderConnection.connect(instance); + TestConnection survivorConnection = env.createConnection(); + Player survivor = survivorConnection.connect(instance); + TestConnection spectatorConnection = env.createConnection(); + Player spectator = spectatorConnection.connect(instance); - GameConfig config = GameConfig.builder().lobbyTime(30).minPlayers(2).gameTime(600).maxPlayers(10).build(); - LobbyPhase lobbyPhase = new LobbyPhase(config); - PlayerChatListener listener = new PlayerChatListener(spectatorTeam, () -> lobbyPhase); + Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); + joinSpectatorTeam(spectatorTeam, spectator); + sender.setTag(Tags.TEAM_KEY, teamKey); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + useUsernameAsDisplayName(sender, survivor, spectator); - PlayerChatEvent event = new PlayerChatEvent(spectator, List.of(spectator, other), "hi"); + Collector senderMessages = senderConnection.trackIncoming(SystemChatPacket.class); + Collector survivorMessages = survivorConnection.trackIncoming(SystemChatPacket.class); + Collector spectatorMessages = spectatorConnection.trackIncoming(SystemChatPacket.class); - listener.accept(event); + PlayerChatEvent event = new PlayerChatEvent(sender, List.of(sender, survivor, spectator), "hi"); + new PlayerChatListener(spectatorTeam).accept(event); + deliverToRecipients(event); - assertEquals(2, event.getRecipients().size(), "Outside GamePhase, chat must remain unrestricted regardless of stale team tags."); + assertEquals(3, event.getRecipients().size(), "A non spectator sender must keep the default recipient list."); + assertEquals(1, senderMessages.collect().size(), "The sender must see the own message."); + assertEquals(1, survivorMessages.collect().size(), "A survivor must receive the message."); + assertEquals(1, spectatorMessages.collect().size(), "A spectator is allowed to read survivor and slender chat."); env.destroyInstance(instance, true); } + + /** + * Mirrors the delivery which Minestom performs after the {@link PlayerChatEvent} was handled. + * + * @param event the handled chat event + */ + private void deliverToRecipients(@NotNull PlayerChatEvent event) { + if (event.getRecipients().isEmpty()) return; + Messenger.sendMessage(event.getRecipients(), event.getFormattedMessage(), ChatPosition.CHAT); + } + + /** + * Adds the given player to the spectator team and marks it with the matching team tag. + * + * @param spectatorTeam the spectator team + * @param player the player to convert + */ + private void joinSpectatorTeam(@NotNull Team spectatorTeam, @NotNull Player player) { + spectatorTeam.addPlayer(player); + player.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + } + + /** + * The chat layout builds on the display name, which is null for a freshly connected test player. + * + * @param players the players to name + */ + private void useUsernameAsDisplayName(@NotNull Player @NotNull ... players) { + for (Player player : players) { + player.setDisplayName(Component.text(player.getUsername())); + } + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/game/SlenderReviveIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/game/SlenderReviveIntegrationTest.java index d2c6a015..d4d0bde7 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/listener/game/SlenderReviveIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/game/SlenderReviveIntegrationTest.java @@ -9,8 +9,10 @@ import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.event.SlenderReviveEvent; import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.stamina.SlenderBarHelper; import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.team.TeamHelper; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -19,7 +21,9 @@ import java.util.concurrent.atomic.AtomicReference; 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; /** * Integration test verifying that {@link SlenderReviveListener} works correctly. @@ -53,4 +57,27 @@ void testEventDispatchedThroughGlobalEventHandler(@NotNull Env env) { env.destroyInstance(instance, true); } + + @Test + void testRevivedSlenderIsHiddenFromEveryoneElse(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + CygnusPlayer survivor = (CygnusPlayer) env.createPlayer(instance); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + for (int i = 0; i < 5; i++) env.tick(); + assertTrue(player.isViewer(survivor), "precondition: both players see each other before the revive"); + + StaminaService staminaService = new StaminaService(); + SlenderReviveListener listener = new SlenderReviveListener(() -> null, staminaService); + + listener.accept(new SlenderReviveEvent(player)); + + assertEquals(SlenderBarHelper.HIDDEN, player.getTag(Tags.HIDDEN)); + assertTrue(VisibilityRules.isHidden(player), "the revived slender has to start hidden"); + assertFalse(player.isViewer(survivor), "the revived slender must not stay visible for the survivors"); + + staminaService.cleanUp(); + env.destroyInstance(instance, true); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListenerTest.java new file mode 100644 index 00000000..8f727e47 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListenerTest.java @@ -0,0 +1,146 @@ +package net.onelitefeather.cygnus.listener.player; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerTickEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.SpawnEntityPacket; +import net.minestom.testing.Collector; +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.common.config.GameConfig; +import net.onelitefeather.cygnus.entity.DeadPlayerMannequin; +import net.onelitefeather.cygnus.jumpscare.JumpScareManager; +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the {@link CygnusPlayerTickListener} only hands survivors to the jump scare + * detection. The scare blinds its victim for 40 ticks, which must never hit the slender or a + * spectator. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.7.0 + */ +class CygnusPlayerTickListenerTest extends CygnusPlayerTestBase { + + private static final Pos CORPSE_POS = new Pos(10, 41, 10); + private static final Pos VICTIM_POS = new Pos(14, 41, 10); + private static final int TURN_AROUNDS = 100; + + @Test + void testSpectatorNeverReceivesAJumpScare(@NotNull Env env) { + assertNoJumpScareFor(env, GameConfig.SPECTATOR_KEY, true, "a spectator"); + } + + @Test + void testSlenderNeverReceivesAJumpScare(@NotNull Env env) { + assertNoJumpScareFor(env, GameConfig.SLENDER_KEY, false, "the slender"); + } + + @Test + void testUntaggedPlayerNeverReceivesAJumpScare(@NotNull Env env) { + assertNoJumpScareFor(env, null, false, "an untagged player"); + } + + @Test + void testSurvivorStillReceivesAJumpScare(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player corpseOwner = env.createPlayer(instance, CORPSE_POS); + + TestConnection victimConnection = env.createConnection(); + Player victim = victimConnection.connect(instance, VICTIM_POS); + victim.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + JumpScareManager manager = new JumpScareManager(); + DeadPlayerMannequin corpse = registerCorpse(env, manager, instance, corpseOwner); + + Collector spawns = victimConnection.trackIncoming(SpawnEntityPacket.class); + driveTurnArounds(new CygnusPlayerTickListener(manager), victim); + + List packets = spawns.collect(); + assertTrue(packets.stream().anyMatch(packet -> packet.type() == EntityType.MANNEQUIN), + "a survivor must still receive a jump scare phantom"); + assertFalse(corpse.isViewer(victim), "the real corpse gets hidden while the phantom is up"); + + env.destroyInstance(instance, true); + } + + /** + * Drives a full set of turn arounds against a player of the given team and asserts that no + * phantom ever reached them. + * + * @param env the test environment + * @param teamKey the team tag to apply, or {@code null} to leave the player untagged + * @param spectatorMode whether the player should also be put into the spectator game mode + * @param description the role description used in the assertion messages + */ + private void assertNoJumpScareFor(@NotNull Env env, Key teamKey, boolean spectatorMode, String description) { + Instance instance = env.createFlatInstance(); + Player corpseOwner = env.createPlayer(instance, CORPSE_POS); + + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, VICTIM_POS); + if (teamKey != null) { + player.setTag(Tags.TEAM_KEY, teamKey); + } + if (spectatorMode) { + player.setGameMode(GameMode.SPECTATOR); + } + + JumpScareManager manager = new JumpScareManager(); + DeadPlayerMannequin corpse = registerCorpse(env, manager, instance, corpseOwner); + + Collector spawns = connection.trackIncoming(SpawnEntityPacket.class); + driveTurnArounds(new CygnusPlayerTickListener(manager), player); + + assertTrue(spawns.collect().isEmpty(), description + " must never receive a jump scare phantom"); + assertTrue(corpse.isViewer(player), description + " must keep the untouched view on the corpse"); + + env.destroyInstance(instance, true); + } + + /** + * Spawns a corpse, registers it and lets the view state settle. + * + * @param env the test environment + * @param manager the manager to register the corpse at + * @param instance the instance to spawn the corpse in + * @param corpseOwner the player the corpse belongs to + * @return the spawned corpse + */ + private DeadPlayerMannequin registerCorpse(@NotNull Env env, JumpScareManager manager, Instance instance, Player corpseOwner) { + DeadPlayerMannequin corpse = DeadPlayerMannequin.sleeping(corpseOwner); + corpse.setInstance(instance, CORPSE_POS); + manager.register(corpse); + + // Let the automatic viewability settle so the corpse spawn packet is out of the way. + for (int i = 0; i < 5; i++) env.tick(); + return corpse; + } + + /** + * Fires enough alternating 180 degree turn arounds that the 35 percent trigger chance is + * practically guaranteed to hit at least once for an eligible player. + * + * @param listener the listener under test + * @param player the player to rotate + */ + private void driveTurnArounds(CygnusPlayerTickListener listener, Player player) { + for (int i = 0; i < TURN_AROUNDS; i++) { + player.setView(i % 2 == 0 ? 0F : 180F, 0F); + listener.accept(new PlayerTickEvent(player)); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java index 64d223e1..b72cd04c 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/spectator/SpectatorServiceTest.java @@ -42,10 +42,11 @@ void testJoinSetsGameModeTeamTagAndItems(@NotNull Env env) { } @Test - void testJoinMakesPlayerInvisibleToOthers(@NotNull Env env) { + void testJoinHidesSpectatorFromPlayersStillInTheRound(@NotNull Env env) { Instance instance = env.createFlatInstance(); Player target = env.createPlayer(instance); Player other = env.createPlayer(instance); + other.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); for (int i = 0; i < 5; i++) env.tick(); assertTrue(target.isViewer(other), "the target should be visible to the other player before joining spectator mode"); @@ -56,7 +57,29 @@ void testJoinMakesPlayerInvisibleToOthers(@NotNull Env env) { service.join(target); - assertFalse(target.isViewer(other), "A spectator must not be visible to other online players."); + assertFalse(target.isViewer(other), "a spectator must not be visible to players who are still in the round"); + assertTrue(other.isViewer(target), "a spectator must keep seeing the players who are still in the round"); + + env.destroyInstance(instance, true); + } + + @Test + void testJoinKeepsSpectatorsVisibleToEachOther(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + + for (int i = 0; i < 5; i++) env.tick(); + + Team spectatorTeam = Team.of(GameConfig.SPECTATOR_KEY, 5); + Team survivorTeam = Team.of(GameConfig.SURVIVOR_KEY, 5); + SpectatorService service = new SpectatorService(spectatorTeam, survivorTeam); + + service.join(first); + service.join(second); + + assertTrue(first.isViewer(second), "spectators must see each other"); + assertTrue(second.isViewer(first), "spectators must see each other"); env.destroyInstance(instance, true); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperTest.java new file mode 100644 index 00000000..8793551d --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperTest.java @@ -0,0 +1,157 @@ +package net.onelitefeather.cygnus.stamina; + +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.server.network.packet.server.play.SoundEffectPacket; +import net.minestom.testing.Collector; +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.common.config.GameConfig; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the role filters of the {@link SlenderBarHelper} only let survivors take slender + * damage and only let survivors hear the slender sounds. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderBarHelperTest extends CygnusPlayerTestBase { + + private static final Pos CENTER = new Pos(10, 41, 10); + private static final float DAMAGE = 0.5F; + + private final SlenderBarHelper helper = new SlenderBarHelper() { + }; + + @Test + void testSpectatorTakesNoSlenderDamage(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + Player spectator = env.createPlayer(instance, CENTER.add(1, 0, 0)); + spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + spectator.setGameMode(GameMode.SPECTATOR); + + float healthBefore = spectator.getHealth(); + helper.applyDamage(instance, slender.getUuid(), CENTER, 3, DAMAGE); + + assertEquals(healthBefore, spectator.getHealth(), "a spectator must never take slender damage"); + + env.destroyInstance(instance, true); + } + + @Test + void testSurvivorTakesSlenderDamage(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + Player survivor = env.createPlayer(instance, CENTER.add(1, 0, 0)); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + float healthBefore = survivor.getHealth(); + helper.applyDamage(instance, slender.getUuid(), CENTER, 3, DAMAGE); + + assertEquals(healthBefore - DAMAGE, survivor.getHealth(), "a survivor must still take slender damage"); + + env.destroyInstance(instance, true); + } + + @Test + void testSlenderTakesNoOwnDamage(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + Player otherSlender = env.createPlayer(instance, CENTER.add(1, 0, 0)); + otherSlender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + float slenderHealth = slender.getHealth(); + float otherHealth = otherSlender.getHealth(); + helper.applyDamage(instance, slender.getUuid(), CENTER, 3, DAMAGE); + + assertEquals(slenderHealth, slender.getHealth(), "the slender must not damage itself"); + assertEquals(otherHealth, otherSlender.getHealth(), "a slender must never take slender damage"); + + env.destroyInstance(instance, true); + } + + @Test + void testUntaggedPlayerTakesNoDamage(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + Player untagged = env.createPlayer(instance, CENTER.add(1, 0, 0)); + + float healthBefore = untagged.getHealth(); + helper.applyDamage(instance, slender.getUuid(), CENTER, 3, DAMAGE); + + assertEquals(healthBefore, untagged.getHealth(), "the damage filter must be fail closed for untagged players"); + + env.destroyInstance(instance, true); + } + + @Test + void testOnlySurvivorsHearTheTeleportSound(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + TestConnection spectatorConnection = env.createConnection(); + Player spectator = spectatorConnection.connect(instance, CENTER.add(1, 0, 0)); + spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + spectator.setGameMode(GameMode.SPECTATOR); + + TestConnection survivorConnection = env.createConnection(); + Player survivor = survivorConnection.connect(instance, CENTER.add(0, 0, 1)); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + Collector spectatorSounds = spectatorConnection.trackIncoming(SoundEffectPacket.class); + Collector survivorSounds = survivorConnection.trackIncoming(SoundEffectPacket.class); + + helper.playTeleportSound(instance, CENTER, slender.getUuid()); + + assertTrue(spectatorSounds.collect().isEmpty(), "a spectator must not hear when the slender vanishes"); + assertEquals(1, survivorSounds.collect().size(), "a survivor must still hear the teleport sound"); + + env.destroyInstance(instance, true); + } + + @Test + void testOnlySurvivorsHearTheSpawnSound(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, CENTER); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + TestConnection spectatorConnection = env.createConnection(); + Player spectator = spectatorConnection.connect(instance, CENTER.add(1, 0, 0)); + spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + spectator.setGameMode(GameMode.SPECTATOR); + + TestConnection survivorConnection = env.createConnection(); + Player survivor = survivorConnection.connect(instance, CENTER.add(0, 0, 1)); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + Collector spectatorSounds = spectatorConnection.trackIncoming(SoundEffectPacket.class); + Collector survivorSounds = survivorConnection.trackIncoming(SoundEffectPacket.class); + + helper.playSpawnSound(instance, CENTER, slender.getUuid()); + + assertTrue(spectatorSounds.collect().isEmpty(), "a spectator must not hear the slender spawn sound"); + assertEquals(1, survivorSounds.collect().size(), "a survivor must still hear the spawn sound"); + + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java index b85f9335..2eeeba48 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java @@ -10,6 +10,8 @@ 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.common.config.GameConfig; import net.onelitefeather.cygnus.player.CygnusPlayer; import net.minestom.server.entity.attribute.Attribute; import net.onelitefeather.cygnus.attribute.AttributeHelper; @@ -166,6 +168,7 @@ void testAutoDepletionPlaysTeleportSound(@NotNull Env env) { CygnusPlayer player = (CygnusPlayer) connection.connect(instance); TestConnection survivorConnection = env.createConnection(); Player nearbySurvivor = survivorConnection.connect(instance); + nearbySurvivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); nearbySurvivor.teleport(player.getPosition()).join(); env.tick(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java index cf96be58..704f7e84 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java @@ -9,7 +9,7 @@ import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.player.CygnusPlayer; -import net.onelitefeather.cygnus.utils.ViewRuleUpdater; +import net.onelitefeather.cygnus.visibility.VisibilityRules; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -31,11 +31,10 @@ void testTriggerMakesPlayerVisibleWhenActivatingDraining(@NotNull Env env) { SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player); slenderBar.start(); - SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> { - }); + SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar); trigger.trigger(player); - assertFalse(ViewRuleUpdater.isHidden(player), "player should be visible right after activating draining"); + assertFalse(VisibilityRules.isHidden(player), "player should be visible right after activating draining"); slenderBar.stop(); env.destroyInstance(instance, true); @@ -51,14 +50,13 @@ void testRapidTriggerIsBlockedByCooldownAndGivesFeedback(@NotNull Env env) { SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player); slenderBar.start(); - SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> { - }); + SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar); trigger.trigger(player); // READY -> DRAINING Collector collector = connection.trackIncoming(); trigger.trigger(player); // immediately again, still within the spam cooldown - assertFalse(ViewRuleUpdater.isHidden(player), "the second, cooldown-blocked trigger must not toggle the status again"); + assertFalse(VisibilityRules.isHidden(player), "the second, cooldown-blocked trigger must not toggle the status again"); assertTrue(soundWasSent(collector), "player should get audible feedback that the trigger is on cooldown"); slenderBar.stop(); @@ -81,12 +79,11 @@ void testTriggerGivesFeedbackWhenBlockedByInsufficientRegeneration(@NotNull Env slenderBar.consume(); } - SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> { - }); + SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar); Collector collector = connection.trackIncoming(); trigger.trigger(player); - assertTrue(ViewRuleUpdater.isHidden(player), "player should stay hidden/blocked instead of toggling back to draining"); + assertTrue(VisibilityRules.isHidden(player), "player should stay hidden/blocked instead of toggling back to draining"); assertTrue(soundWasSent(collector), "player should get audible feedback when blocked by insufficient regeneration"); slenderBar.stop(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/visibility/VisibilityRulesTest.java b/game/src/test/java/net/onelitefeather/cygnus/visibility/VisibilityRulesTest.java new file mode 100644 index 00000000..514abcab --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/visibility/VisibilityRulesTest.java @@ -0,0 +1,149 @@ +package net.onelitefeather.cygnus.visibility; + +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.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.stamina.SlenderBarHelper; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the role visibility matrix implemented by {@link VisibilityRules}. + *

+ * Every test asserts through {@code target.isViewer(viewer)}, which answers "is {@code viewer} able to see + * {@code target}" - exactly the cell of the matrix under test. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.8.0 + */ +class VisibilityRulesTest extends CygnusPlayerTestBase { + + private static final int SETTLE_TICKS = 5; + + @Test + void testSpectatorSeesOtherSpectator(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + settle(env); + + makeSpectator(first); + makeSpectator(second); + + assertTrue(first.isViewer(second), "a spectator must stay visible for other spectators"); + assertTrue(second.isViewer(first), "a spectator must stay visible for other spectators"); + + env.destroyInstance(instance, true); + } + + @Test + void testSpectatorSeesSurvivor(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createPlayer(instance); + Player spectator = env.createPlayer(instance); + settle(env); + + makeSurvivor(survivor); + makeSpectator(spectator); + + assertTrue(survivor.isViewer(spectator), "survivors carry no rule and must remain visible for spectators"); + + env.destroyInstance(instance, true); + } + + @Test + void testSurvivorDoesNotSeeSpectator(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createPlayer(instance); + Player spectator = env.createPlayer(instance); + settle(env); + + makeSurvivor(survivor); + makeSpectator(spectator); + + assertFalse(spectator.isViewer(survivor), "a player still in the round must not see spectators"); + + env.destroyInstance(instance, true); + } + + @Test + void testSlenderDoesNotSeeSpectator(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + Player spectator = env.createPlayer(instance); + settle(env); + + makeSlender(slender); + makeSpectator(spectator); + + assertFalse(spectator.isViewer(slender), "the slender must not see spectators either"); + + env.destroyInstance(instance, true); + } + + @Test + void testSpectatorDoesNotSeeHiddenSlender(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + Player spectator = env.createPlayer(instance); + settle(env); + + makeSlender(slender); + makeSpectator(spectator); + + assertTrue(VisibilityRules.isHidden(slender), "the slender starts the round hidden"); + assertFalse(slender.isViewer(spectator), "spectators share the survivor view and must not see a hidden slender"); + + env.destroyInstance(instance, true); + } + + @Test + void testSpectatorSeesRevealedSlender(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + Player spectator = env.createPlayer(instance); + settle(env); + + makeSlender(slender); + makeSpectator(spectator); + assertFalse(slender.isViewer(spectator), "precondition: the slender is hidden"); + + slender.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + VisibilityRules.refresh(slender); + + assertTrue(slender.isViewer(spectator), "spectators must see the slender while the slender is revealed"); + + env.destroyInstance(instance, true); + } + + private static void settle(@NotNull Env env) { + for (int i = 0; i < SETTLE_TICKS; i++) { + env.tick(); + } + } + + private static void makeSlender(@NotNull Player player) { + player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + player.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + player.updateViewableRule(VisibilityRules.slenderRule(player)); + VisibilityRules.refresh(player); + } + + private static void makeSurvivor(@NotNull Player player) { + player.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + VisibilityRules.refresh(player); + } + + private static void makeSpectator(@NotNull Player player) { + player.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + player.updateViewableRule(VisibilityRules.spectatorRule()); + VisibilityRules.refresh(player); + } +}