diff --git a/README.md b/README.md index a790385..6978295 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ if (rules == null || rules.mayReach(from, to)) { /* send it */ } `mayReach(from, to)` and `maySpeak(player)` follow exactly the same rules as chat, and are safe from any thread. +`sharesCircle(viewerHas, to)` is for a viewer who isn't in game, such as someone on a web map. It's true only when they share a circle with `to`. World, world group and bypass don't count, and with no circles set up it's always false. The caller passes the viewer's permissions, read while they're offline, and anything it can't check should answer false. + ## Limits - Signs are readable by anyone who walks past, and a book or named item can be carried to another world. HouseRules only checks them when they're written. diff --git a/docs/decisions.md b/docs/decisions.md index afb2b83..6fe6026 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,5 +12,6 @@ Owner decisions for HouseRules, newest first. | 2026-11-07 | `/r` and `/reply` check reach again at the moment of the reply. | The other player may have moved to a world out of reach since the conversation started. | | 2026-11-07 | No `/sort` block in the defaults. | Portmanteau, the planned ChestSort replacement, keeps automatic sorting on by itself, and none of its commands turn it off. | | 2026-11-07 | `/hud share` from BlueMapHUD follows the same reach as chat, with no narrower rule for kids. | One rule for everything players send each other. | +| 2026-11-10 | A viewer who isn't in game, such as someone on the web map, sees only players who share a HouseRules circle with them (owner, D54). | Chat's world rule needs a world, and an away viewer has none. | | 2026-11-07 | Every list is in config, including circles, circle-only worlds, world groups, the dimension suffixes, message and reply commands, blocked commands and every message. With no circles, chat is vanilla. | Generic and publishable, with no server's names in the code. | - **BlueMinimap (2026-11-08, owner rename of BlueMapHUD):** 0.2.0 listens for `com.extantape.blueminimap.api.CoordinateShareEvent` when BlueMinimap is installed, and still for the old `com.extantape.bluemaphud.api` event when only BlueMapHUD 0.1.x is, for one release. Released together with BlueMinimap 0.2.0, which turns sharing off when HouseRules is older than 0.2, so no order of upgrades lets a share skip the circles. diff --git a/src/main/java/com/extantape/houserules/HouseRulesPlugin.java b/src/main/java/com/extantape/houserules/HouseRulesPlugin.java index 90ae697..9e47848 100644 --- a/src/main/java/com/extantape/houserules/HouseRulesPlugin.java +++ b/src/main/java/com/extantape/houserules/HouseRulesPlugin.java @@ -180,4 +180,15 @@ public boolean maySpeak(Player player) { State s = state; return s != null && s.settings().reach().maySpeak(Subject.of(player)); } + + @Override + public boolean sharesCircle(java.util.function.Predicate viewerHas, Player to) { + State s = state; + if (s == null || viewerHas == null || to == null) return false; + try { + return s.settings().reach().reachesWhileAway(viewerHas, Subject.of(to)); + } catch (RuntimeException e) { + return false; + } + } } diff --git a/src/main/java/com/extantape/houserules/api/ReachService.java b/src/main/java/com/extantape/houserules/api/ReachService.java index f3fce26..aa513dc 100644 --- a/src/main/java/com/extantape/houserules/api/ReachService.java +++ b/src/main/java/com/extantape/houserules/api/ReachService.java @@ -13,4 +13,11 @@ public interface ReachService { /** Whether the player may chat at all where they are. False in a circle-only world without a circle, or while chat is paused. */ boolean maySpeak(Player player); + + /** + * Whether a viewer who isn't in game, such as someone on the web map, may see {@code to}: only if they share a + * circle (owner, 2026-11-10, D54). {@code viewerHas} answers the viewer's permissions, read while they're offline; + * it should answer false for anything it can't check. Safe from any thread. + */ + boolean sharesCircle(java.util.function.Predicate viewerHas, Player to); } diff --git a/src/main/java/com/extantape/houserules/rules/Reach.java b/src/main/java/com/extantape/houserules/rules/Reach.java index a8c5b1c..7144189 100644 --- a/src/main/java/com/extantape/houserules/rules/Reach.java +++ b/src/main/java/com/extantape/houserules/rules/Reach.java @@ -105,4 +105,20 @@ public String sharedCircle(Subject from, Subject to) { } return null; } + + /** + * For a viewer who isn't in game, such as someone on the web map (owner, 2026-11-10, D54): only a shared circle + * counts. Worlds, world groups and bypass don't, because the viewer has no world. With no circles set up, nobody is + * reached: there's no circle to share. + * + * @param viewer what the viewer is allowed, read by the caller while they're offline + */ + public boolean reachesWhileAway(java.util.function.Predicate viewer, Subject to) { + if (!active()) return false; + for (String circle : circles) { + String node = CIRCLE_PERMISSION + circle; + if (viewer.test(node) && to.has(node)) return true; + } + return false; + } } diff --git a/src/test/java/com/extantape/houserules/rules/ReachTest.java b/src/test/java/com/extantape/houserules/rules/ReachTest.java index 38c0ee7..856abd3 100644 --- a/src/test/java/com/extantape/houserules/rules/ReachTest.java +++ b/src/test/java/com/extantape/houserules/rules/ReachTest.java @@ -84,4 +84,30 @@ void reachIsSymmetric() { player("n1", "forest"), player("n2", "hall"), player("n3", "meadow")); for (Subject a : all) for (Subject b : all) assertEquals(reach.mayReach(a, b), reach.mayReach(b, a), a.name() + "/" + b.name()); } + + // --- a viewer who isn't in game, such as the web map (D54) --- + + @Test + void awayViewersSeeOnlyTheirCircle() { + java.util.function.Predicate kidInRed = circle("red")::equals; + assertTrue(reach.reachesWhileAway(kidInRed, player("mum", "forest", circle("red")))); + assertFalse(reach.reachesWhileAway(kidInRed, player("stranger", "forest")), "the same world doesn't count"); + assertFalse(reach.reachesWhileAway(kidInRed, player("blue", "meadow", circle("blue")))); + assertFalse(reach.reachesWhileAway(kidInRed, player("admin", "forest", Reach.BYPASS)), "bypass doesn't count either"); + } + + @Test + void anAwayViewerWithNoCircleOrNoRulesSeesNobody() { + assertFalse(reach.reachesWhileAway(p -> false, player("mum", "forest", circle("red")))); + assertFalse(reach.reachesWhileAway(p -> true, player("stranger", "forest"))); + Reach off = new Reach(List.of(), Set.of(), Map.of(), List.of()); + assertFalse(off.reachesWhileAway(p -> true, player("anyone", "forest", circle("red"))), "no circles: nothing to share"); + } + + @Test + void anAwayViewersOwnPermissionsAreTheOnlyOnesAsked() { + java.util.List asked = new java.util.ArrayList<>(); + reach.reachesWhileAway(p -> { asked.add(p); return false; }, player("mum", "forest", circle("red"), circle("blue"))); + assertTrue(asked.stream().allMatch(p -> p.startsWith(Reach.CIRCLE_PERMISSION)), asked.toString()); + } }