diff --git a/README.md b/README.md index 4bc6b9d..be1f5f5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ commits to the fight, and defeats every monster the lure draws in. - **Lure raids** — an InteractibleFurniture lure opens a join window, then releases a paced wave; defeating the full wave clears the infestation. - **Committed parties** — a committed player who leaves the province takes - damage until they opt out of the fight. Losing the whole party + damage until they opt out of the fight. Rivers and lakes next to the + province's land still count as inside. Losing the whole party fails the lure, bystanders who stay are warned and damaged, and logged-out players get a grace period. A player still away when it ends no longer holds the lure, and that character dies when it next logs in. diff --git a/src/main/java/net/tfminecraft/infestations/cache/Cache.java b/src/main/java/net/tfminecraft/infestations/cache/Cache.java index b760e56..f2b682c 100644 --- a/src/main/java/net/tfminecraft/infestations/cache/Cache.java +++ b/src/main/java/net/tfminecraft/infestations/cache/Cache.java @@ -26,6 +26,7 @@ public final class Cache { public static int minPlayerDistance = 16; public static int logoutGraceSeconds = 300; public static double deserterDamage = 2.0; + public static int riverReach = 16; public static double hologramViewRange = 96; public static Set skipTerrains = new HashSet<>(); diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index b15aad3..0023c30 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -647,7 +647,7 @@ private void tickOutsideCommitted(Infestation infestation) { if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { continue; } - if (Provinces.at(player) == infestation.getProvinceId() || notAnswerable(infestation, player)) { + if (Provinces.within(player, infestation.getProvinceId()) || notAnswerable(infestation, player)) { continue; } actionBar(player, bar); @@ -674,7 +674,7 @@ public boolean leaveLure(Player player) { player.sendMessage(Messages.get("lure-left")); } found = true; - boolean inProvince = Provinces.at(player) == infestation.getProvinceId(); + boolean inProvince = Provinces.within(player, infestation.getProvinceId()); // Leaving cannot forgive a death owed for abandoning the fight before. keepOwedDeath(infestation, id); infestation.getCommitted().remove(id); @@ -804,7 +804,7 @@ private void failLure(Infestation infestation) { loc.getWorld().playSound(loc, Sound.ENTITY_WITHER_SPAWN, 0.4f, 1.4f); } for (Player player : Bukkit.getOnlinePlayers()) { - if (Provinces.at(player) == provinceId || committed.contains(player.getUniqueId())) { + if (Provinces.within(player, provinceId) || committed.contains(player.getUniqueId())) { player.sendMessage(Messages.get("lure-fail")); } } @@ -839,7 +839,7 @@ private void victory(Infestation infestation) { loc.getWorld().playSound(loc, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1.1f); } for (Player player : Bukkit.getOnlinePlayers()) { - if (Provinces.at(player) == provinceId) { + if (Provinces.within(player, provinceId)) { player.sendMessage(Messages.get("lure-victory")); } } @@ -1412,6 +1412,9 @@ public void onLeaveProvince(PlayerProvinceLeaveEvent event) { if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { return; } + if (Provinces.within(player, infestation.getProvinceId())) { + return; + } player.sendMessage(Messages.get("lure-outside")); } diff --git a/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java index fcb20be..6ddd81c 100644 --- a/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java @@ -46,6 +46,7 @@ public boolean loadSafe(File configFile) { Cache.minPlayerDistance = Math.max(0, config.getInt("min-player-distance", 16)); Cache.logoutGraceSeconds = Math.max(1, config.getInt("logout-grace-seconds", 300)); Cache.deserterDamage = Math.max(0.5, config.getDouble("deserter-damage", 2.0)); + Cache.riverReach = Math.clamp(config.getInt("river-reach", 16), 0, 64); Cache.hologramViewRange = Math.max(16, config.getDouble("hologram-view-range", 96)); Set skip = new HashSet<>(); diff --git a/src/main/java/net/tfminecraft/infestations/utils/Provinces.java b/src/main/java/net/tfminecraft/infestations/utils/Provinces.java index 4973154..c583c45 100644 --- a/src/main/java/net/tfminecraft/infestations/utils/Provinces.java +++ b/src/main/java/net/tfminecraft/infestations/utils/Provinces.java @@ -31,17 +31,50 @@ public static int at(Location location) { if (location == null) { return UNKNOWN; } - SimpleFactions sf = SimpleFactions.getInstance(); - if (sf == null) { - return UNKNOWN; - } - ProvinceGrid grid = sf.getProvinceGrid(); + ProvinceGrid grid = grid(); if (grid == null) { return UNKNOWN; } return grid.getAt(location.getBlockX(), location.getBlockZ()); } + /** + * Whether a player is inside a province for a lure: on its land, or in a river or lake + * ({@code WATER} province) with its land within {@link Cache#riverReach} blocks. Rivers are + * provinces of their own, so a river cutting through a province would otherwise count as + * leaving it. + */ + public static boolean within(Player player, int provinceId) { + int at = at(player); + if (at == provinceId) { + return true; + } + return isWater(at) && near(player.getLocation(), provinceId, Cache.riverReach); + } + + private static boolean near(Location location, int provinceId, int reach) { + ProvinceGrid grid = grid(); + if (grid == null) { + return false; + } + int x = location.getBlockX(); + int z = location.getBlockZ(); + int reachSquared = reach * reach; + for (int dx = -reach; dx <= reach; dx++) { + for (int dz = -reach; dz <= reach; dz++) { + if (dx * dx + dz * dz <= reachSquared && grid.getAt(x + dx, z + dz) == provinceId) { + return true; + } + } + } + return false; + } + + private static ProvinceGrid grid() { + SimpleFactions sf = SimpleFactions.getInstance(); + return sf == null ? null : sf.getProvinceGrid(); + } + public static Terrain terrain(int provinceId) { if (provinceId <= 0) { return null; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d11ea7b..073d0a9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -33,6 +33,10 @@ logout-grace-seconds: 300 # Hearts are 2 health. Applied each second to uncommitted players who stay # after a lure starts, and to committed players who leave the province. deserter-damage: 2 +# Rivers and lakes are provinces of their own. A player in one still counts +# as inside a lure's province while that province's land is within this many +# blocks (0 to 64). 0 counts every river as outside. +river-reach: 16 hologram-view-range: 96 skip-terrains: diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index 6ff000a..319b5e2 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -365,6 +365,34 @@ private void unregister() { Cache.skipTerrains = Set.of(); assertTrue(Provinces.validLand(7)); } } + @Test void riversWithinReachOfTheProvinceLandCountAsInside() { + int previousReach = Cache.riverReach; + try (var sf = mockStatic(SimpleFactions.class); var rest = mockStatic(RestServer.class)) { + Player player = mock(Player.class); when(player.getLocation()).thenReturn(new Location(null, 100, 64, 200)); + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + assertTrue(Provinces.within(player, 7)); assertFalse(Provinces.within(null, 7)); + rest.when(() -> RestServer.getProvince(player)).thenReturn(9); + assertFalse(Provinces.within(player, 7)); + SimpleFactions factions = mock(SimpleFactions.class); sf.when(SimpleFactions::getInstance).thenReturn(factions); + ProvinceManager manager = mock(ProvinceManager.class); when(factions.getProvinceManager()).thenReturn(manager); + Province river = mock(Province.class); when(manager.get(9)).thenReturn(river); + when(river.getTerrain()).thenReturn(Terrain.WATER); + assertFalse(Provinces.within(player, 7)); + ProvinceGrid grid = mock(ProvinceGrid.class); when(factions.getProvinceGrid()).thenReturn(grid); + when(grid.getAt(anyInt(), anyInt())).thenReturn(9); + Cache.riverReach = 16; + assertFalse(Provinces.within(player, 7)); + when(grid.getAt(100, 216)).thenReturn(7); + assertTrue(Provinces.within(player, 7)); + when(grid.getAt(100, 216)).thenReturn(9); when(grid.getAt(116, 216)).thenReturn(7); + assertFalse(Provinces.within(player, 7)); + when(grid.getAt(100, 201)).thenReturn(7); + Cache.riverReach = 0; assertFalse(Provinces.within(player, 7)); + Cache.riverReach = 1; assertTrue(Provinces.within(player, 7)); + when(river.getTerrain()).thenReturn(Terrain.SEA); + assertFalse(Provinces.within(player, 7)); + } finally { Cache.riverReach = previousReach; } + } @Test void terrainNamesUseStableLocale() { Locale previous = Locale.getDefault(); try (var sf = mockStatic(SimpleFactions.class)) { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 867f7e8..268a9d3 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -75,6 +75,7 @@ class InfestationManagerTest { private final Map tags = new IdentityHashMap<>(); private final Map kinds = new IdentityHashMap<>(); private final Map provinces = new IdentityHashMap<>(); + private final Map riverBanks = new IdentityHashMap<>(); private final Map online = new HashMap<>(); private final Map savedCache = new HashMap<>(); private Infestations previousPlugin; @@ -181,6 +182,11 @@ class InfestationManagerTest { groups.when(() -> GroupLoader.allowsTerrain(anyString(), anyInt())).thenReturn(true); provinceApi.when(() -> Provinces.at(any(Player.class))).thenAnswer(i -> provinces.getOrDefault(i.getArgument(0), -2)); provinceApi.when(() -> Provinces.at(any(Location.class))).thenReturn(1); + provinceApi.when(() -> Provinces.within(any(Player.class), anyInt())).thenAnswer(i -> { + int province = i.getArgument(1); + return provinces.getOrDefault(i.getArgument(0), -2) == province + || Objects.equals(riverBanks.get(i.getArgument(0)), province); + }); provinceApi.when(() -> Provinces.validLand(anyInt())).thenAnswer(i -> (int) i.getArgument(0) > 0); planner.when(() -> SpawnPlanner.clearOfPlayers(any(), anyDouble())).thenReturn(true); planner.when(() -> SpawnPlanner.anchor(any())).thenReturn(new Location(world, 5.5, 65, 5.5)); @@ -1183,8 +1189,23 @@ private static CharacterDeathEvent died(UUID owner, String character) { verify(p, never()).damage(anyDouble()); assertTrue(lure.getDeathOnLogin().contains(id)); } + @Test void riversWithinReachKeepOnlyCommittedPlayersInside() throws Exception { + Infestation active = lure(1, LurePhase.ACTIVE); + Player wading = player(10), bystander = player(10), farRiver = player(10); + riverBanks.put(wading, 1); riverBanks.put(bystander, 1); riverBanks.put(farRiver, 2); + active.getCommitted().add(wading.getUniqueId()); active.getCommitted().add(farRiver.getUniqueId()); + atTick(100); call("tickOutsideCommitted", active); call("warnNonJoiners", active); + verify(wading, never()).damage(anyDouble()); verify(wading, never()).sendMessage("lure-outside"); + verify(farRiver).damage(2.0); verify(farRiver).sendMessage("lure-outside"); + verify(bystander, never()).damage(anyDouble()); verify(bystander, never()).sendMessage("lure-nonjoiner"); + PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); + when(leave.getPlayer()).thenReturn(wading); when(leave.getProvinceId()).thenReturn(1); + manager.onLeaveProvince(leave); + verify(wading, never()).sendMessage("lure-outside"); + } + @Test void provinceEventsWarnOnlyRelevantParticipants() { - Player p = player(1); PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); + Player p = player(2); PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); when(leave.getPlayer()).thenReturn(p); when(leave.getProvinceId()).thenReturn(1); manager.onLeaveProvince(leave); Infestation state = infestation(1); manager.onLeaveProvince(leave); state.setPhase(LurePhase.JOINING); manager.onLeaveProvince(leave); diff --git a/src/test/java/net/tfminecraft/infestations/loader/LoadersTest.java b/src/test/java/net/tfminecraft/infestations/loader/LoadersTest.java index 83bc02a..66acc31 100644 --- a/src/test/java/net/tfminecraft/infestations/loader/LoadersTest.java +++ b/src/test/java/net/tfminecraft/infestations/loader/LoadersTest.java @@ -103,10 +103,17 @@ void emptyConfigurationRestoresDocumentedDefaultsWithoutAliasingConstantTables() assertEquals(16, Cache.minPlayerDistance); assertEquals(300, Cache.logoutGraceSeconds); assertEquals(2.0, Cache.deserterDamage); + assertEquals(16, Cache.riverReach); assertEquals(96.0, Cache.hologramViewRange); assertEquals(Set.of("water", "sea"), Cache.skipTerrains); } + @Test + void riverReachIsCappedSoTheGridScanStaysSmall() throws Exception { + new ConfigLoader().load(file("river-reach: 10000")); + assertEquals(64, Cache.riverReach); + } + @Test void completeConfigClampsUnsafeRangesAndNormalizesTerrainNames() throws Exception { assertTrue(new ConfigLoader().loadSafe(file(""" @@ -125,6 +132,7 @@ void completeConfigClampsUnsafeRangesAndNormalizesTerrainNames() throws Exceptio min-player-distance: -1 logout-grace-seconds: 0 deserter-damage: 0 + river-reach: -1 hologram-view-range: 0 skip-terrains: ['', 'FOREST', null] """))); @@ -144,6 +152,7 @@ void completeConfigClampsUnsafeRangesAndNormalizesTerrainNames() throws Exceptio assertEquals(0, Cache.minPlayerDistance); assertEquals(1, Cache.logoutGraceSeconds); assertEquals(0.5, Cache.deserterDamage); + assertEquals(0, Cache.riverReach); assertEquals(16.0, Cache.hologramViewRange); assertEquals(Set.of("forest"), Cache.skipTerrains); }