Skip to content

fix(visibility): enforce the role visibility matrix between all three roles - #199

Open
TheMeinerLP wants to merge 1 commit into
mainfrom
fix/role-visibility-matrix
Open

fix(visibility): enforce the role visibility matrix between all three roles#199
TheMeinerLP wants to merge 1 commit into
mainfrom
fix/role-visibility-matrix

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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

Chat: survivor and slender messages reach everyone, spectator chat stays among spectators — in every phase.

Root cause

Visibility was driven by two competing mechanisms at the same time: Minestom's viewable rule and manual viewer packets (updateNewViewer / updateOldViewer). The latter are purely packet based and never touch the viewer bitSet of the EntityView, so the two layers drifted apart:

  • GameStartListener hid the slender via updateOldViewer without deregistering anyone. The next rule evaluation saw isRegistered == true and skipped the spawn packet.
  • The manual toggle sent updateNewViewer first and re-evaluated the rule afterwards → duplicated spawn packets.
  • The automatic transition once the bar ran dry went through the manual layer only and was silently reverted by the next rule evaluation.

This change unifies everything onto one layer: per-viewer predicates exclusively, collected in the new VisibilityRules class.

Verified against Minestom 2026.07.22-26.2 (EntityView.java): the predicate receives the viewer and is evaluated whenever somebody enters range, so the matrix is natively expressible. addViewer / removeViewer stay unused on purpose, since they record players in manualViewers and would disable the rule for them permanently.

Violations fixed

Finding Before
Spectators could not see each other updateViewableRule(_ -> false)
Revived slender permanently visible the revive set neither the rule nor Tags.HIDDEN
Slender visible between team allocation and game start the rule was installed before Tags.HIDDEN existed
Spectator chat leaked to everyone fail-open: null instanceof GamePhase == false, plus a gap during the restart phase
Spectators took slender damage setHealth without a team/game mode filter — a dying spectator then triggered a second death message and a second SpectatorAddEvent
Spectators heard the teleport sound exactly when the slender turned invisible, no role filter
Slender and spectators received jumpscares including DARKNESS for 40 ticks on the slender

Cleanup

  • ViewRuleUpdater removed: isViewAble was dead code with an inverted name; isHidden and updateViewer were absorbed into VisibilityRules.
  • SlenderBarTrigger loses updateRuneFunction, PlayerChatListener loses phaseSupplier — both only fed the paths that are gone now.
  • The metadata broadcast to all players is dropped; Minestom pushes metadata to the entity's viewers anyway, and switchEntityType respawns on its own.

Tests

./gradlew build is green across all modules. One test per matrix cell plus regression tests:

  • VisibilityRulesTest — all six cells
  • SpectatorServiceTesttestJoinMakesPlayerInvisibleToOthers cemented the old bug and was replaced
  • SlenderReviveIntegrationTest.testRevivedSlenderIsHiddenFromEveryoneElse
  • PlayerChatListenerTest — game phase, restart phase and without an active phase
  • SlenderBarHelperTest, CygnusPlayerTickListenerTest

SlenderBarIntegrationTest.testAutoDepletionPlaysTeleportSound had to be adjusted: its own comment says "plays to nearby survivors", but it never tagged the player as a survivor and only passed because there was no role filter at all.

Deliberately out of scope

Fully documented in docs/plans/role-visibility-matrix.md. The largest remaining leak:

TeamHelper.updateTabList gives the slender the display name ⛧ name in red. This is broadcast to everyone via UPDATE_DISPLAY_NAME — every survivor and spectator immediately sees who the slender is in the tab list, and the same name sits under every chat message they send. Possibly intentional, so it is left untouched here.

Also open: page discoveries and death messages are broadcast unfiltered (real-time information for the slender); the corpse mannequin's particles bypass every viewable rule through sendGroupedPacket; finishGame resets no rules (harmless today, since one process serves exactly one round, but relevant as soon as a round reset is introduced).

Follow-up from the implementation: staminateam is now a package cycle, because TeamHelper reads the HIDDEN constant from SlenderBarHelper. Moving VISIBLE / HIDDEN into a neutral holder would resolve it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QQCb6kSTw5NnRF1Yc4hYXg

… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQCb6kSTw5NnRF1Yc4hYXg
@TheMeinerLP
TheMeinerLP force-pushed the fix/role-visibility-matrix branch from 9f6c7ec to 9321c37 Compare August 22, 2026 15:14
@github-actions

Copy link
Copy Markdown
Contributor

Test results

249 files  249 suites   1m 50s ⏱️
306 tests 301 ✅  5 💤 0 ❌
921 runs  906 ✅ 15 💤 0 ❌

Results for commit 9321c37.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant