From fbec5889d2c6ba12f85052ff5d2d9e9da52b1f67 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:32:21 +0000 Subject: [PATCH] Count only active guild members toward new nodes A member SimpleFactions marks inactive (four weeks offline) stays in the guild but no longer adds capacity for new nodes, meets the minimum member count to claim or place one, or raises node efficiency until they log in again. Nodes a guild already holds are kept on its full membership, so a member going inactive does not cost the guild a node. Co-Authored-By: Claude Opus 5.5 --- .../dowsing/managers/NodeManager.java | 15 +++- .../net/tfminecraft/dowsing/objects/Node.java | 11 +-- .../dowsing/utils/ActiveMembers.java | 31 +++++++ .../managers/NodeManagerEventsTest.java | 2 +- .../dowsing/objects/NodeBehaviorTest.java | 2 +- .../dowsing/utils/ActiveMembersTest.java | 80 +++++++++++++++++++ 6 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/tfminecraft/dowsing/utils/ActiveMembers.java create mode 100644 src/test/java/net/tfminecraft/dowsing/utils/ActiveMembersTest.java diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index a1db0ff..78717b9 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -208,11 +208,20 @@ public static void released(Guild g, Node n) { passCounts.computeIfPresent(g.getId(), (id, count) -> count - 1); } + /** Room for new nodes, from the guild's active members. */ public static Integer getNodeCapacity(Guild g) { + return capacityFor(g, net.tfminecraft.dowsing.utils.ActiveMembers.count(g)); + } + + /** Room to keep the nodes a guild already holds, from all its members, so going inactive does not cost a node. */ + public static Integer getHeldNodeCapacity(Guild g) { + return capacityFor(g, g == null || g.getMembers() == null ? 0 : g.getMembers().size()); + } + + private static Integer capacityFor(Guild g, int members) { int capacity = 1; if(g == null) return capacity; if(Cache.extraCapacity) { - int members = g.getMembers().size(); int added = (int) Math.floorDiv(members, Cache.membersPerCapacity); if(added > Cache.maxMemberCapacity) { added = Cache.maxMemberCapacity; @@ -450,8 +459,8 @@ private void tryCreateNode(Player p, Location loc, NodeBlock b, Cancellable e) { e.setCancelled(true); return; } - if(g.getMembers().size() < Cache.minMembersForNode && !b.isSpecial()){ - p.sendMessage("§cYou need at least "+Cache.minMembersForNode+" members in your guild to have a node!"); + if(net.tfminecraft.dowsing.utils.ActiveMembers.count(g) < Cache.minMembersForNode && !b.isSpecial()){ + p.sendMessage("§cYou need at least "+Cache.minMembersForNode+" active members in your guild to have a node!"); e.setCancelled(true); return; } diff --git a/src/main/java/net/tfminecraft/dowsing/objects/Node.java b/src/main/java/net/tfminecraft/dowsing/objects/Node.java index f9c2a96..c16bdbd 100644 --- a/src/main/java/net/tfminecraft/dowsing/objects/Node.java +++ b/src/main/java/net/tfminecraft/dowsing/objects/Node.java @@ -307,8 +307,9 @@ public boolean canHold(Guild g){ if(held(g)) return true; Player p = Members.player(g.getLeader()); if(p != null && p.isOnline() && Permissions.isAdmin(p)) return true; + // Holding counts every member, active or not; only new claims need active ones. if(g.getMembers().size() < Cache.minMembersForNode) return false; - if(NodeManager.getNodeAmount(g)-NodeManager.getNodeCapacity(g) > 0) return false; + if(NodeManager.getNodeAmount(g)-NodeManager.getHeldNodeCapacity(g) > 0) return false; return true; } @@ -328,8 +329,8 @@ public static boolean held(Guild g) { public boolean canClaim(Player p, Guild g){ if(Permissions.isAdmin(p)) return true; if(!isClaimable()) return false; - if(g.getMembers().size() < Cache.minMembersForNode){ - p.sendMessage("§cYou need at least "+Cache.minMembersForNode+" members in your guild to claim this node!"); + if(net.tfminecraft.dowsing.utils.ActiveMembers.count(g) < Cache.minMembersForNode){ + p.sendMessage("§cYou need at least "+Cache.minMembersForNode+" active members in your guild to claim this node!"); return false; } if(NodeManager.getNodeAmount(g)-NodeManager.getNodeCapacity(g) >= 0 && !block.isSpecial()) { @@ -584,14 +585,14 @@ public void deActivate() { public void growEfficiency() { // A held realm has no members, which would cap its nodes' efficiency at 0 until it is taken on. if(resolveGuild() == null || held(guild)) return; - int members = guild.getMembers().size(); + int members = net.tfminecraft.dowsing.utils.ActiveMembers.count(guild); double growth = Math.min(1.0, members*Cache.efficiencyGrowthPerMember); updateEfficiency(growth); } public double getMaxEfficiency() { if(resolveGuild() == null) return 0; - return Math.min(100.0, guild.getMembers().size()*Cache.maxEfficiencyPerMember); + return Math.min(100.0, net.tfminecraft.dowsing.utils.ActiveMembers.count(guild)*Cache.maxEfficiencyPerMember); } public void updateEfficiency(double eff) { diff --git a/src/main/java/net/tfminecraft/dowsing/utils/ActiveMembers.java b/src/main/java/net/tfminecraft/dowsing/utils/ActiveMembers.java new file mode 100644 index 0000000..615da84 --- /dev/null +++ b/src/main/java/net/tfminecraft/dowsing/utils/ActiveMembers.java @@ -0,0 +1,31 @@ +package net.tfminecraft.dowsing.utils; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.inactivity.InactivityService; + +/** + * The guild members who count toward nodes. SimpleFactions marks a member inactive after + * weeks offline; they stay in the guild but add no node capacity, do not meet the minimum + * member count and do not raise node efficiency until they log in again. + */ +public final class ActiveMembers { + private ActiveMembers() {} + + public static int count(Guild guild) { + if (guild == null || guild.getMembers() == null) return 0; + int active = 0; + for (String member : guild.getMembers()) { + if (!inactive(member)) active++; + } + return active; + } + + /** A member whose inactivity cannot be read counts, as before inactivity was checked. */ + static boolean inactive(String member) { + try { + return InactivityService.isMemberInactive(member); + } catch (LinkageError | RuntimeException unreadable) { + return false; + } + } +} diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java index 4dbfa0e..487a829 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java @@ -97,7 +97,7 @@ void placementRequiresGuildMembershipMinimumMembersAndAvailableCapacity() { verify(f.player).sendMessage(contains("need to have a guild")); f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(f.guild); Cache.minMembersForNode = 3; event = f.place(); f.manager.placeVanillaNode(event); - verify(event).setCancelled(true); verify(f.player).sendMessage(contains("at least 3 members")); + verify(event).setCancelled(true); verify(f.player).sendMessage(contains("at least 3 active members")); Cache.minMembersForNode = 1; NodeManager.nodes.add(f.nodeAt(f.remoteLocation())); event = f.place(); f.manager.placeVanillaNode(event); verify(event).setCancelled(true); verify(f.player).sendMessage(contains("filled its node capacity")); diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java index 4a76b36..d9e6511 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java @@ -138,7 +138,7 @@ void claimAndRetentionEnforceMembershipAndCapacityWithAdminAndSpecialExceptions( Cache.minMembersForNode = 3; assertFalse(node.canHold(null)); assertFalse(node.canHold(f.guild)); assertFalse(node.canClaim(player, f.guild)); - verify(player).sendMessage(contains("at least 3 members")); + verify(player).sendMessage(contains("at least 3 active members")); Cache.minMembersForNode = 1; assertTrue(node.canHold(f.guild)); assertTrue(node.canClaim(player, f.guild)); Node owned = f.owned(); NodeManager.nodes.add(owned); diff --git a/src/test/java/net/tfminecraft/dowsing/utils/ActiveMembersTest.java b/src/test/java/net/tfminecraft/dowsing/utils/ActiveMembersTest.java new file mode 100644 index 0000000..fd68d07 --- /dev/null +++ b/src/test/java/net/tfminecraft/dowsing/utils/ActiveMembersTest.java @@ -0,0 +1,80 @@ +package net.tfminecraft.dowsing.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.dowsing.Cache; +import net.tfminecraft.dowsing.managers.NodeManager; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.inactivity.InactivityService; + +class ActiveMembersTest { + private MockedStatic inactivity; + private Boolean previousExtra; + private Integer previousPer; + private int previousMax; + private Map previousBought; + + @BeforeEach void setUp() { + inactivity = mockStatic(InactivityService.class); + inactivity.when(() -> InactivityService.isMemberInactive(anyString())) + .thenAnswer(call -> call.getArgument(0, String.class).startsWith("away")); + previousExtra = Cache.extraCapacity; previousPer = Cache.membersPerCapacity; previousMax = Cache.maxMemberCapacity; + previousBought = new HashMap<>(NodeManager.extraCapacityByGuild); NodeManager.extraCapacityByGuild.clear(); + } + + @AfterEach void tearDown() { + inactivity.close(); + Cache.extraCapacity = previousExtra; Cache.membersPerCapacity = previousPer; Cache.maxMemberCapacity = previousMax; + NodeManager.extraCapacityByGuild.clear(); NodeManager.extraCapacityByGuild.putAll(previousBought); + } + + private static Guild guild(String... members) { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + when(guild.getMembers()).thenReturn(new ArrayList<>(List.of(members))); + return guild; + } + + @Test + void inactiveMembersAreLeftOutOfTheCount() { + assertEquals(2, ActiveMembers.count(guild("ann", "away1", "bob", "away2"))); + assertEquals(0, ActiveMembers.count(guild("away1"))); + assertEquals(0, ActiveMembers.count(null)); + Guild noRoster = mock(Guild.class); + assertEquals(0, ActiveMembers.count(noRoster)); + } + + @Test + void aMemberWhoseInactivityCannotBeReadStillCounts() { + inactivity.when(() -> InactivityService.isMemberInactive("broken")).thenThrow(new IllegalStateException("unreadable")); + inactivity.when(() -> InactivityService.isMemberInactive("old")).thenThrow(new NoSuchMethodError("older SimpleFactions")); + assertEquals(3, ActiveMembers.count(guild("broken", "old", "ann", "away1"))); + } + + @Test + void nodeCapacityGrowsOnlyWithActiveMembers() { + Cache.extraCapacity = true; Cache.membersPerCapacity = 2; Cache.maxMemberCapacity = 10; + assertEquals(2, NodeManager.getNodeCapacity(guild("ann", "bob", "away1", "away2", "away3"))); + assertEquals(3, NodeManager.getNodeCapacity(guild("ann", "bob", "cid", "dan", "away1"))); + } + + @Test + void nodesAlreadyHeldAreKeptOnEveryMember() { + Cache.extraCapacity = true; Cache.membersPerCapacity = 2; Cache.maxMemberCapacity = 10; + assertEquals(3, NodeManager.getHeldNodeCapacity(guild("ann", "bob", "away1", "away2", "away3"))); + assertEquals(1, NodeManager.getHeldNodeCapacity(null)); + assertEquals(1, NodeManager.getHeldNodeCapacity(mock(Guild.class))); + } +}