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: + *

+ * 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); + } +}