From 516b528c2552a0757d127cdcb3fc34fb20b5893b Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:36:25 +0000 Subject: [PATCH 1/2] Inactive members give no prestige and give up their offices A member marked inactive (21 days offline) stays in their guild but now counts for nothing until they log in again: - Prestige: the Members line counts only active members. - Counts: new county claims, levy caps, a guild's member share in stability, and movement strength use active members only. Counties a realm already holds are still kept on its full membership. - Offices: inactive members lose council seats and candidacies, cannot vote, stand for leader, succeed the leader, or lead a movement or cause, and an appointed Spymaster gives up the post. The realm leader stays leader, and guild leaders keep their guilds. The inactivity check now reads the day count before the online lookup, so active members cost a map read. Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/army/Military.java | 5 +- .../espionage/EspionageService.java | 8 +- .../simplefactions/government/Council.java | 8 +- .../government/election/Election.java | 2 + .../government/movement/Movement.java | 13 +- .../government/movement/cause/Cause.java | 1 + .../simplefactions/guild/Guild.java | 10 +- .../inactivity/InactivityRules.java | 5 +- .../inactivity/InactivityService.java | 18 +- .../managers/inventory/TierTitleCreator.java | 6 +- .../managers/inventory/TierTitleView.java | 2 +- .../simplefactions/objects/Faction.java | 11 +- .../simplefactions/tiers/Title.java | 7 +- .../commitment/LevySnapshotCalculator.java | 2 +- .../espionage/InactiveSpymasterTest.java | 52 +++++ .../inactivity/InactiveMembersTest.java | 186 ++++++++++++++++++ .../inventory/ArmyMenusCoverageTest.java | 6 +- 17 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/InactiveSpymasterTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/inactivity/InactiveMembersTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/army/Military.java b/src/main/java/net/tfminecraft/simplefactions/army/Military.java index f404d190..449bd705 100644 --- a/src/main/java/net/tfminecraft/simplefactions/army/Military.java +++ b/src/main/java/net/tfminecraft/simplefactions/army/Military.java @@ -271,12 +271,13 @@ public List getLevies() { subjectLevies = subject.getMilitary().getLevies(); } int total = 0; + int activeMembers = net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(subject.getMembers()).size(); for(Regiment r : subject.getMilitary().getRegiments()) { if (r.isEquipment()) continue; int count = r.getCurrentSlots(); count = (int) Math.round(count * (subject.getModifier(FactionModifiers.LEVY).getAmount()/100)); - if(total+count >= subject.getMembers().size()) { - count = subject.getMembers().size() - total; + if(total+count >= activeMembers) { + count = activeMembers - total; } count = Math.max(0, count); total += count; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 1aacd4c1..fe4eb46f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -51,9 +51,15 @@ public static boolean eligible(Faction faction, String member) { return member != null && (faction.isMember(member) || faction.isLeader(member)); } + /** An inactive Spymaster gives up the post; the leader keeps the default holding. */ + private static boolean serving(Faction faction, String member) { + return eligible(faction, member) + && (faction.isLeader(member) || !net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(member)); + } + /** The leader's default holding follows the leadership; deliberate appointments stay. */ private static boolean validHolder(Faction faction, SpecialPositionAssignment holder) { - return holder != null && eligible(faction, holder.memberKey()) && !deadCharacter(holder) + return holder != null && serving(faction, holder.memberKey()) && !deadCharacter(holder) && !(holder.automatic && !faction.isLeader(holder.memberKey())); } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Council.java b/src/main/java/net/tfminecraft/simplefactions/government/Council.java index c570fec3..2cc93962 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Council.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Council.java @@ -131,6 +131,7 @@ public boolean toggleRefuse(String name) { public boolean canBeMember(String name, boolean ignoreSize, boolean ignoreRefuses) { if(members.contains(name)) return false; if(f.getLeader().equals(name)) return false; + if(net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(name)) return false; if(!ignoreSize && getCurrentSize() >= getMaxSize()) return false; if(!ignoreRefuses && refuses.contains(name)) return false; if(f.getOrCreateMainGuild().isMember(name)) return true; @@ -150,6 +151,9 @@ public boolean canRemainMember(String name) { if (f.getLeader().equals(name)) { return false; } + if (net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(name)) { + return false; + } if (f.getOrCreateMainGuild().isMember(name)) { return true; } @@ -271,7 +275,9 @@ private void cleanupCouncil() { } } for(String refuser : new ArrayList<>(refuses)) { - if(!canBeMember(refuser, true, true)) { + // An inactive member keeps their refusal for when they come back. + if(!canBeMember(refuser, true, true) + && !net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(refuser)) { refuses.remove(refuser); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java index 1a99b50e..72991c86 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java @@ -245,6 +245,8 @@ public boolean canBeCandidate(Candidate type, String player, boolean includeAlre // Must be faction member (any guild) if (!gov.getFaction().isMember(player)) return false; + if (net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(player)) + return false; Guild g = gov.getFaction().getGuild(player); if (g != null && !g.isBase() && g.isLeader(player)) { return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java index e9b0aced..7f742ad0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java @@ -196,6 +196,7 @@ public void tick() { } checkSupporters(); checkForeignBackers(); + if (hasLeader() && net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(leader)) leader = null; if(causes.size() == 0) { f.getGovernment().endMovement(this); } @@ -562,15 +563,17 @@ public List getAllSupportingFactions() { public double getPower() { double base = 0; - double memberCount = supporters.getAllMembers().size(); + // Inactive members count on neither side of the share. + double memberCount = net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(supporters.getAllMembers()).size(); for(Cause cause : causes) { - memberCount += cause.getPool().getAllMembers().size(); + memberCount += net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(cause.getPool().getAllMembers()).size(); } for(Faction backer : foreignBackers) { - memberCount += (backer.getMembers().size() + backer.getVassalMembers().size())/2.0; //backers count half + memberCount += (backer.getActiveMembers().size() + backer.getActiveVassalMembers().size())/2.0; //backers count half } - if((f.getMembers().size()+f.getVassalMembers().size()) > 0) { - base += (100.0* (memberCount/(f.getMembers().size()+f.getVassalMembers().size()))); + int realmMembers = f.getActiveMembers().size() + f.getActiveVassalMembers().size(); + if(realmMembers > 0) { + base += (100.0* (memberCount/realmMembers)); } if(f.getTotalTradePower() > 0) { base += (30.0* (getTotalTradePower()/f.getTotalTradePower())); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java index d4349307..ab7fe785 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java @@ -81,6 +81,7 @@ public void leave(Object o) { public boolean canBeLeader(String p) { if(p == null || !pool.getAllMembers().contains(p)) return false; + if(net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(p)) return false; Member relation = movement.getFaction().getRelationToFaction(p); if (relation == null) return false; return switch (relation) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index a7d0e6aa..bb4f2a8e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -869,12 +869,18 @@ public double getRepressFavourCost() { return Formatter.formatDouble(getStabilityEffect()*0.5); } + /** Members not marked inactive. */ + public List getActiveMembers() { + return net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(members); + } + public double getMemberPercentage() { - int totalMembers = host.getMembers().size() + host.getVassalMembers().size(); + int totalMembers = net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(host.getMembers()).size() + + net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(host.getVassalMembers()).size(); if (totalMembers <= 0) { return 0.0; } - return (double) members.size() / (double) totalMembers; + return (double) getActiveMembers().size() / (double) totalMembers; } public double getStabilityEffect() { diff --git a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java index a448bc18..4185729d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java @@ -5,7 +5,10 @@ /** * Inactivity is a mark, not a kick. After {@link #DAYS_UNTIL_INACTIVE} offline - * days a member stays in the guild and is shown as inactive. A guild whose + * days a member stays in the guild and is shown as inactive. An inactive + * member adds no prestige, does not count toward member-based limits and + * gives up council seats, candidacies, movement leadership and the Spymaster + * post. A realm leader stays leader, and a guild leader keeps the guild. A guild whose * real members are all inactive loses 1% of upgrade output every * {@link #DECAY_INTERVAL_MILLIS}. A faction in the same state loses 1% prestige * on that cadence and, while it holds more land than its prestige pays for, diff --git a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java index a699480b..4b25af29 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java @@ -110,8 +110,19 @@ public static int prestigePercent(Faction faction) { public static boolean isMemberInactive(String name) { if (!InactivityRules.isTrackedMember(name)) return false; - if (isOnline(name)) return false; - return InactivityRules.isInactive(FactionCleanup.daysOffline(name)); + // The day count is a map read; only a member already past the threshold needs the online lookup. + if (!InactivityRules.isInactive(FactionCleanup.daysOffline(name))) return false; + return !isOnline(name); + } + + /** The members who still count. Inactive members stay in their guild but add nothing to counts. */ + public static List activeOnly(java.util.Collection members) { + List active = new ArrayList<>(); + if (members == null) return active; + for (String member : members) { + if (!isMemberInactive(member)) active.add(member); + } + return active; } public static boolean isFullyInactive(List members) { @@ -143,7 +154,8 @@ public static void onLogin(Player player) { Guild guild = faction.getGuild(name); boolean outputCleared = guild != null && clearClock(guildClocks, guild.getId()); boolean prestigeCleared = clearClock(factionClocks, faction.getId()); - if (prestigeCleared) faction.updatePrestige(); + // The returning member counts toward prestige again, with or without a penalty to clear. + faction.updatePrestige(); if (outputCleared || prestigeCleared) { recomputeEarliest(); save(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java index 9329217e..6f6b5d9f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java @@ -160,9 +160,9 @@ public ItemStack createTitleItem(Player p, Faction f, Title t, List pro } private static void addCountyLimitLore(Faction f, Tier county, List lore) { - lore.add(StringFormatter.formatHex("#e07373County limit reached: #f5f5f5" + f.getTitles(county).size() + "/" + f.getMembers().size() - + " #e07373(one per member)")); - lore.add(StringFormatter.formatHex("#767a77Recruit more members to hold another county.")); + lore.add(StringFormatter.formatHex("#e07373County limit reached: #f5f5f5" + f.getTitles(county).size() + "/" + f.getActiveMembers().size() + + " #e07373(one per active member)")); + lore.add(StringFormatter.formatHex("#767a77Recruit members, or bring inactive ones back, to hold another county.")); } public int getNewTitleCost(Faction f, Tier t) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java index 5e942f50..e8c75ff0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java @@ -196,7 +196,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(tier.getId().equalsIgnoreCase("county")) { current = f.getUntitledProvinces().size(); if(Title.countyLimitReached(f, tier)) { - p.sendMessage("§cYou need more members to form more counties!"); + p.sendMessage("§cYou need more active members to form more counties!"); return; } } else { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 475ea7fa..6f05f503 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -599,6 +599,13 @@ public void setName(String name) { public List getMembers() { return guildHandler.getAllMembers(); } + /** Members not marked inactive. Inactive members stay in the realm but count for nothing. */ + public List getActiveMembers() { + return net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(getMembers()); + } + public List getActiveVassalMembers() { + return net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(getVassalMembers()); + } public List getVassalMembers() { List members = new ArrayList<>(); for(Faction vassal : RelationManager.getSubjects(this)) { @@ -644,6 +651,7 @@ public boolean isMember(org.bukkit.entity.Player player) { public boolean canBecomeLeader(String member) { if(isLeader(member)) return false; if(!isMember(member)) return false; + if(net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(member)) return false; for(Guild g : guildHandler.getGuilds()) { if(g.isLeader(member)) return false; } @@ -779,7 +787,7 @@ public void updatePrestige() { // Two halves: headcount, where each account is one member split across its // characters by recent play, plus what each counted member's online time is worth. - var headcount = net.tfminecraft.simplefactions.prestige.MemberShare.count(guildHandler.getAllMembers()); + var headcount = net.tfminecraft.simplefactions.prestige.MemberShare.count(getActiveMembers()); double members = Math.pow(headcount.heads()+4, 1.8)+5 + net.tfminecraft.simplefactions.prestige.MemberPlaytime.totalFor(headcount.counted()); @@ -994,6 +1002,7 @@ public void ping() { government.ping(); } public boolean canVote(String p) { + if(net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(p)) return false; if(isMember(p)) return true; if(hasFactionRule(Rules.VASSAL_VOTING_RIGHTS)) { if(getVassalMembers().contains(p)) return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java b/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java index ebf3bff7..d05415ee 100644 --- a/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java +++ b/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java @@ -127,9 +127,12 @@ public boolean canBeHeld(Faction f, List ownedProvinces, List ow return true; } - /** A realm holds at most one county per member; at the limit it can't claim or form another. */ + /** + * A realm forms at most one county per active member; at the limit it can't claim or form another. + * Counties it already holds are kept while it has one per member, active or not. + */ public static boolean countyLimitReached(Faction f, Tier tier) { - return tier.getTier() == 2 && f.getTitles(tier).size() >= f.getMembers().size(); + return tier.getTier() == 2 && f.getTitles(tier).size() >= f.getActiveMembers().size(); } public boolean countyLimitReached(Faction f) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java b/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java index e8258fc2..425fbf38 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java @@ -44,7 +44,7 @@ public static int directLevyContribution(Faction source) { return 0; } int total = 0; - int memberCap = source.getMembers() != null ? source.getMembers().size() : 0; + int memberCap = source.getMembers() != null ? net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(source.getMembers()).size() : 0; double levyPercent = source.getModifier(FactionModifiers.LEVY).getAmount(); for (Regiment regiment : source.getMilitary().getRegiments()) { if (regiment.isLevy() || regiment.isEquipment()) { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/InactiveSpymasterTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/InactiveSpymasterTest.java new file mode 100644 index 00000000..1fec5577 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/InactiveSpymasterTest.java @@ -0,0 +1,52 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.UUID; +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.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.objects.Faction; + +class InactiveSpymasterTest { + private MockedStatic<InactivityService> inactivity; + + @BeforeEach void setUp() { + inactivity = mockStatic(InactivityService.class); + inactivity.when(() -> InactivityService.isMemberInactive(anyString())).thenReturn(true); + } + + @AfterEach void tearDown() { + inactivity.close(); + } + + private Faction faction(String spy, boolean leader) { + var faction = mock(Faction.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getEspionage()).thenReturn(new EspionageState()); + var holder = new SpecialPositionAssignment(); + EspionageTestFixtures.bind(holder, spy); + holder.playerId = UUID.randomUUID(); + when(faction.isMember(EspionageTestFixtures.key(spy))).thenReturn(true); + when(faction.isLeader(EspionageTestFixtures.key(spy))).thenReturn(leader); + faction.getEspionage().appoint(holder, 50); + return faction; + } + + @Test void anInactiveSpymasterNoLongerGuardsTheRealm() { + assertFalse(EspionageService.hasSpymaster(faction("Spy", false))); + } + + @Test void anInactiveLeaderKeepsTheirOwnSpymasterPost() { + assertTrue(EspionageService.hasSpymaster(faction("Leader", true))); + } + + @Test void anActiveSpymasterStillGuardsTheRealm() { + inactivity.when(() -> InactivityService.isMemberInactive(anyString())).thenReturn(false); + assertTrue(EspionageService.hasSpymaster(faction("Spy", false))); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactiveMembersTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactiveMembersTest.java new file mode 100644 index 00000000..98a6a13c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactiveMembersTest.java @@ -0,0 +1,186 @@ +package net.tfminecraft.simplefactions.inactivity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** An inactive member stays in the realm but counts for nothing and holds no office but the realm's leadership. */ +class InactiveMembersTest { + private PersistenceFilesFixture disk; + private FactionDomainFixture fixture; + private Field historyField; + private Object originalHistory; + + @BeforeEach + void setup() throws Exception { + disk = new PersistenceFilesFixture(); + Files.createDirectories(disk.root.resolve("Data")); + fixture = new FactionDomainFixture(); + when(fixture.ui.plugin.getLogger()).thenReturn(mock(Logger.class)); + historyField = FactionCleanup.class.getDeclaredField("offlineDays"); + historyField.setAccessible(true); + originalHistory = historyField.get(null); + history(Map.of()); + } + + @AfterEach + void cleanup() throws Exception { + try { + if (historyField != null) historyField.set(null, originalHistory); + if (fixture != null) fixture.close(); + } finally { + if (disk != null) disk.close(); + } + } + + private void history(Map<String, Integer> days) throws Exception { + disk.write("Cache/logins.json", JsonUtil.GSON.toJson(days)); + historyField.set(null, null); + } + + private void away(String... members) throws Exception { + Map<String, Integer> days = new HashMap<>(); + for (String member : members) days.put(member, InactivityRules.DAYS_UNTIL_INACTIVE); + history(days); + } + + private Faction realm(String leader, String... members) { + Faction faction = fixture.saved("home", leader); + for (String member : members) faction.addMember(member); + return faction; + } + + @Test + void onlyActiveMembersAreKept() throws Exception { + away("Bob"); + assertEquals(List.of(), InactivityService.activeOnly(null)); + assertEquals( + List.of("Alice", "dummy_bot"), + InactivityService.activeOnly(Arrays.asList("Alice", "Bob", "dummy_bot"))); + } + + @Test + void anInactiveMemberAddsNoPrestigeUntilTheyReturn() throws Exception { + Faction faction = realm("Alice", "Bob", "Carl"); + faction.updatePrestige(); + double active = faction.getPrestige(); + + away("Bob", "Carl"); + faction.updatePrestige(); + assertEquals(List.of("Alice"), faction.getActiveMembers()); + assertTrue(faction.getPrestige() < active); + + history(Map.of()); + faction.updatePrestige(); + assertEquals(active, faction.getPrestige()); + } + + @Test + void anInactiveMemberCannotSitOnTheCouncilVoteStandOrSucceed() throws Exception { + Faction faction = realm("Alice", "Bob", "Carl"); + var council = faction.getGovernment().getCouncil(); + council.addMemberForce("Bob"); + away("Bob"); + + council.toggleRefuse("Carl"); + history(Map.of("Bob", InactivityRules.DAYS_UNTIL_INACTIVE, "Carl", InactivityRules.DAYS_UNTIL_INACTIVE)); + council.replace(); + assertFalse(council.isMember("Bob")); + assertTrue(council.refuses("Carl")); + away("Bob"); + council.toggleRefuse("Carl"); + assertFalse(council.canRemainMember("Bob")); + assertTrue(council.canRemainMember("Carl")); + assertFalse(council.canBeMember("Bob", true, true)); + assertTrue(council.canBeMember("Carl", true, true)); + assertFalse(faction.canVote("Bob")); + assertTrue(faction.canVote("Carl")); + assertFalse(faction.canBecomeLeader("Bob")); + assertTrue(faction.canBecomeLeader("Carl")); + var election = faction.getGovernment().getElection(); + assertFalse(election.canBeCandidate(Candidate.LEADER, "Bob", false)); + assertTrue(election.canBeCandidate(Candidate.LEADER, "Carl", false)); + } + + @Test + void anInactiveLeaderStaysLeader() throws Exception { + Faction faction = realm("Alice", "Bob"); + away("Alice"); + faction.getGovernment().replace(); + assertEquals("Alice", faction.getLeader()); + } + + @Test + void anInactiveMovementLeaderLosesTheLeadAndInactiveSupportersAddNoPower() throws Exception { + Faction faction = realm("Alice", "Bob", "Carl", "Dana"); + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + Movement movement = new Movement(faction, "Bob", proposal); + faction.getGovernment().getMovements().add(movement); + var cause = movement.getCauses().get(0); + cause.getPool().addCitizen("Carl"); + double power = movement.getPower(); + assertTrue(cause.canBeLeader("Bob")); + + away("Bob"); + assertFalse(cause.canBeLeader("Bob")); + assertTrue(movement.getPower() < power); + movement.tick(); + assertNull(movement.getLeader()); + assertNull(cause.getLeader()); + assertTrue(cause.getPool().getCitizens().contains("Bob")); + } + + @Test + void aBranchCountsOnlyItsActiveMembersTowardItsShareOfTheRealm() throws Exception { + Faction faction = realm("Alice", "Dana"); + Guild branch = fixture.guild(faction, "smiths", "Bob"); + branch.addMember("Carl"); + assertEquals(0.5, branch.getMemberPercentage()); + + away("Carl"); + assertEquals(List.of("Bob"), branch.getActiveMembers()); + assertEquals(1 / 3.0, branch.getMemberPercentage(), 1e-9); + away("Alice", "Bob", "Carl", "Dana"); + assertEquals(0.0, branch.getMemberPercentage()); + } + + @Test + void newCountiesNeedAnActiveMemberEach() throws Exception { + Tier county = TierLoader.getByString("county"); + Faction faction = mock(Faction.class); + List<Title> held = new ArrayList<>(List.of(mock(Title.class))); + when(faction.getTitles(county)).thenReturn(held); + when(faction.getMembers()).thenReturn(List.of("Alice", "Bob")); + when(faction.getActiveMembers()).thenReturn(List.of("Alice", "Bob")); + assertFalse(Title.countyLimitReached(faction, county)); + + when(faction.getActiveMembers()).thenReturn(List.of("Alice")); + assertTrue(Title.countyLimitReached(faction, county)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java index b7f6ffd2..298547be 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java @@ -369,7 +369,7 @@ void newCountiesAreLimitedByMembersAndNewHigherTitlesByTheLiege() { faction.addProvince(11); titles.titleTypeView(null, player, faction, county, true, 0); titles.click(fixture.ui.click(player, 52), top(), player); - verify(player).sendMessage("§cYou need more members to form more counties!"); + verify(player).sendMessage("§cYou need more active members to form more counties!"); assertFalse(TitleManager.isFormingTitle.containsKey(player)); faction.addMember("Citizen"); Faction liege = fixture.saved("liege", "Liege"); @@ -397,10 +397,10 @@ void countyButtonsSayWhenTheOneCountyPerMemberLimitBlocksThem() { List<ItemStack> rendered = titles.creator.getSortedTitleItems(player, faction, List.of(home, east, foreign)); - String limit = "County limit reached: 1/1 (one per member)"; + String limit = "County limit reached: 1/1 (one per active member)"; assertEquals(Material.RED_CONCRETE, rendered.get(1).getType()); assertTrue(lore(rendered.get(1)).contains("Unavailable\n" + limit)); - assertTrue(lore(rendered.get(1)).contains("Recruit more members to hold another county.")); + assertTrue(lore(rendered.get(1)).contains("Recruit members, or bring inactive ones back, to hold another county.")); assertFalse(lore(rendered.get(0)).contains(limit)); assertFalse(lore(rendered.get(2)).contains(limit)); ItemStack create = titles.creator.createNewTitleItem(faction, county); From 09cbffcec01a928bb972446cca72320d97b27694 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:17:17 +0000 Subject: [PATCH 2/2] Mark members inactive after four weeks offline The inactivity threshold moves from 21 to 28 offline days, as requested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../inactivity/InactivityRules.java | 2 +- .../inactivity/InactivityCoverageTest.java | 22 +++++++++---------- .../inactivity/InactivityRulesTest.java | 6 ++--- .../utils/FactionCleanupCoverageTest.java | 10 ++++----- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java index 4185729d..bc30f116 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java @@ -15,7 +15,7 @@ * sheds provinces in claim order. The capital is last, and it does go. */ public final class InactivityRules { - public static final int DAYS_UNTIL_INACTIVE = 21; + public static final int DAYS_UNTIL_INACTIVE = 28; public static final long DECAY_INTERVAL_MILLIS = 4L * 60L * 60L * 1000L; public static final int DECAY_STEP_PERCENT = 1; public static final int DECAY_CAP_PERCENT = 100; diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java index b02cce62..7e8b7eba 100644 --- a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java @@ -183,7 +183,7 @@ void missingNullAndPartialSavedStatesUseDefaultsAndClampPersistedPenalties() thr @Test void inactivityMembershipSkipsPlaceholdersAndUsesCurrentOnlinePresence() throws Exception { - history(Map.of("Alice", 21, "Bob", 20, "Cara", 35)); + history(Map.of("Alice", 28, "Bob", 20, "Cara", 35)); assertFalse(InactivityService.isMemberInactive(null)); assertFalse(InactivityService.isMemberInactive(" ")); assertFalse(InactivityService.isMemberInactive("DUMMY_guard")); @@ -211,7 +211,7 @@ void armingAndDueTicksApplyOneStepWithoutKickingMembersOrChangingTheirBanks() th Guild guild = fixture.guild(faction, "merchants", "Bob"); net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(base.getBank(), 37.0); net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 19.0); - history(Map.of("Alice", 21, "Bob", 22)); + history(Map.of("Alice", 28, "Bob", 29)); InactivityService.armAll(null, 1000); InactivityService.armAll(Arrays.asList(null, faction), 1000); assertEquals( @@ -242,7 +242,7 @@ void armingAndDueTicksApplyOneStepWithoutKickingMembersOrChangingTheirBanks() th void dueSweepArmsUntrackedInactiveGroupsAndSkipsGroupsStillWaiting() throws Exception { Faction armed = fixture.saved("armed", "Alice"); Faction fresh = fixture.saved("fresh", "Bob"); - history(Map.of("Alice", 21, "Bob", 22)); + history(Map.of("Alice", 28, "Bob", 29)); state(armed.getOrCreateMainGuild().getId(), 0, armed.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(null); @@ -289,7 +289,7 @@ void loginClearsOnlyTheReturningMembersGuildAndItsFactionAndPersistsActivity() t Faction faction = fixture.saved("home", "Alice"); Guild guild = fixture.guild(faction, "merchants", "Bob"); Guild base = faction.getOrCreateMainGuild(); - history(Map.of("Alice", 22, "Bob", 30, "Stranger", 21, "Cara", 20)); + history(Map.of("Alice", 29, "Bob", 30, "Stranger", 28, "Cara", 20)); state(guild.getId(), 35, faction.getId(), 40, 1); var snapshot = saved(); InactivityService.SavedClock baseClock = new InactivityService.SavedClock(); @@ -323,7 +323,7 @@ void loginClearsOnlyTheReturningMembersGuildAndItsFactionAndPersistsActivity() t Player stranger = fixture.player("Stranger"); InactivityService.onLogin(stranger); verify(stranger).sendMessage("§aYou are active again."); - history(Map.of("Bob", 21)); + history(Map.of("Bob", 28)); InactivityService.onLogin(bob); verify(bob).sendMessage("§aYou are active again."); } @@ -343,7 +343,7 @@ void provinceDecayPreservesTheCapitalUntilLastAndSavesTheNewClaims(boolean capit faction.setCapital(1, false, false); Guild guild = fixture.guild(faction, "merchants", "Bob"); if (!capitalOnly) guild.setCapital(2, false); - history(Map.of("Alice", 21, "Bob", 21)); + history(Map.of("Alice", 28, "Bob", 28)); state(guild.getId(), 0, faction.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(List.of(faction)); @@ -369,7 +369,7 @@ void affordableAndCivilWarLockedProvincesAreRetainedDuringPenaltyTicks() throws Faction faction = fixture.saved("home", "Alice"); fixture.provinceData.put(1, new Province(1, "PLAINS", 1)); faction.addProvince(1); - history(Map.of("Alice", 21)); + history(Map.of("Alice", 28)); state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(List.of(faction)); @@ -392,7 +392,7 @@ void affordableAndCivilWarLockedProvincesAreRetainedDuringPenaltyTicks() throws void persistenceAndEconomyFailuresAreLoggedWithoutEscapingTheLifecycle() throws Exception { Faction faction = fixture.saved("home", "Alice"); Guild guild = faction.getOrCreateMainGuild(); - history(Map.of("Alice", 21)); + history(Map.of("Alice", 28)); state(guild.getId(), 10, faction.getId(), 20, 1); InactivityService.load(); doThrow(new IllegalStateException("province copy unavailable")) @@ -446,7 +446,7 @@ void emptyOrNullSavedHistoryIsPreservedUntilAValidExplicitReload(String contents void failedAtomicSaveKeepsTheCompletePreviousHistoryAndCanBeRetried() throws Exception { Faction faction = fixture.saved("home", "Alice"); Guild guild = faction.getOrCreateMainGuild(); - history(Map.of("Alice", 21)); + history(Map.of("Alice", 28)); state(guild.getId(), 10, faction.getId(), 20, 1); InactivityService.load(); var file = disk.root.resolve("Cache/inactivity.json"); @@ -475,7 +475,7 @@ void failedAtomicSaveKeepsTheCompletePreviousHistoryAndCanBeRetried() throws Exc void oneFailingGuildLogDoesNotPreventTheNextFactionFromAdvancing() throws Exception { Faction failing = fixture.saved("failing", "Alice"); Faction next = fixture.saved("next", "Bob"); - history(Map.of("Alice", 21, "Bob", 21)); + history(Map.of("Alice", 28, "Bob", 28)); InactivityService.armAll(List.of(failing, next), 0); String failedGuildName = failing.getOrCreateMainGuild().getName(); Logger logger = fixture.ui.plugin.getLogger(); @@ -493,7 +493,7 @@ void oneFailingGuildLogDoesNotPreventTheNextFactionFromAdvancing() throws Except @Test void duePenaltiesCanBeLoggedDuringShutdownWithoutALivePlugin() throws Exception { Faction faction = fixture.saved("home", "Alice"); - history(Map.of("Alice", 21)); + history(Map.of("Alice", 28)); state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); InactivityService.load(); SimpleFactions previous = SimpleFactions.plugin; diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityRulesTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityRulesTest.java index 4f9f3b78..f23b1133 100644 --- a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityRulesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityRulesTest.java @@ -14,9 +14,9 @@ class InactivityRulesTest { @Test - void memberIsInactiveAfterTwentyOneDaysAndNotBefore() { - assertFalse(InactivityRules.isInactive(20)); - assertTrue(InactivityRules.isInactive(21)); + void memberIsInactiveAfterFourWeeksAndNotBefore() { + assertFalse(InactivityRules.isInactive(27)); + assertTrue(InactivityRules.isInactive(28)); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java index 56a71ab4..4a3cca61 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java @@ -48,16 +48,16 @@ class FactionCleanupCoverageTest { } @Test void dailyAccountingMarksInactiveMembersWithoutRemovingThemAndResetsOnlinePlayers() throws Exception { - disk.write("Cache/logins.json","{\"Alice\":20,\"Bob\":null,\"Cara\":30,\"former\":5}"); + disk.write("Cache/logins.json","{\"Alice\":27,\"Bob\":null,\"Cara\":30,\"former\":5}"); Faction faction=mock(Faction.class);when(faction.getMembers()).thenReturn(Arrays.asList("Alice","Bob","Cara","New",null," ","dummy_guard")); Player bob=ui.player("Bob");when(Bukkit.getPlayerExact("Bob")).thenReturn(bob); FactionCleanup.advanceOfflineDays(Arrays.asList(null,faction)); - assertEquals(21,FactionCleanup.daysOffline("Alice"));assertEquals(0,FactionCleanup.daysOffline("Bob")); + assertEquals(28,FactionCleanup.daysOffline("Alice"));assertEquals(0,FactionCleanup.daysOffline("Bob")); assertEquals(0,FactionCleanup.daysOffline("ALICE"),"Keys are never folded"); assertEquals(31,FactionCleanup.daysOffline("Cara"));assertEquals(6,FactionCleanup.daysOffline("former")); assertEquals(1,FactionCleanup.daysOffline("New"));assertEquals(0,FactionCleanup.daysOffline("dummy_guard")); verify(faction,never()).forceRemoveMember(anyString()); - verify(ui.plugin.getLogger()).log(eq(Level.INFO),contains("Alice (Alice) is inactive after 21 days")); + verify(ui.plugin.getLogger()).log(eq(Level.INFO),contains("Alice (Alice) is inactive after 28 days")); FactionCleanup.ping("Alice");assertEquals(0,FactionCleanup.daysOffline("Alice")); } @@ -92,10 +92,10 @@ class FactionCleanupCoverageTest { } @Test void cachedHistoryCanRecreateADeletedCacheFolderAndLogWithoutALivePlugin() throws Exception { - disk.write("Cache/logins.json","{\"Alice\":20}");assertEquals(20,FactionCleanup.daysOffline("Alice")); + disk.write("Cache/logins.json","{\"Alice\":27}");assertEquals(27,FactionCleanup.daysOffline("Alice")); disk.remove("Cache");Faction faction=mock(Faction.class);when(faction.getMembers()).thenReturn(List.of("Alice")); SimpleFactions previousPlugin=SimpleFactions.plugin;SimpleFactions.plugin=null; try {FactionCleanup.advanceOfflineDays(List.of(faction));}finally{SimpleFactions.plugin=previousPlugin;} - assertEquals(21,FactionCleanup.daysOffline("Alice"));assertTrue(Files.isRegularFile(disk.root.resolve("Cache/logins.json"))); + assertEquals(28,FactionCleanup.daysOffline("Alice"));assertTrue(Files.isRegularFile(disk.root.resolve("Cache/logins.json"))); } }