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..bc30f116 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityRules.java @@ -5,14 +5,17 @@ /** * 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, * 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/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/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/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); 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"))); } }