diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 1caebec..a1db0ff 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -2,9 +2,12 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TreeMap; import org.apache.commons.lang.WordUtils; @@ -69,8 +72,45 @@ public static void requestNodeBenefitSync() { syncFactionNodeBenefits(); } + /** Each active node's guild and that guild's realm at the last sync, to spot realm changes. */ + private static List lastOwners = List.of(); + /** Whether the last sync finished; a failed one is retried even if no owner changed. */ + private static boolean synced = false; + + /** + * Node benefits sit on each realm's own guild and only node events resync them. A dissolve, + * elevation or relocation moves a node to another realm without one, so {@link #realmCycle} + * resyncs when any active node's guild or realm changed since the last sync, or that sync failed. + */ + public static void syncIfRealmsChanged() { + if (!SimpleFactionsApi.ready()) return; + if (!synced || !sameOwners(owners(), lastOwners)) syncFactionNodeBenefits(); + } + + private static List owners() { + List owners = new ArrayList<>(); + for(Node n : nodes) { + if(!n.hasGuild() || !Boolean.TRUE.equals(n.getIsActive())) continue; + Guild g = n.getGuild(); + owners.add(n); + owners.add(g); + owners.add(g == null ? null : g.getFaction()); + } + return owners; + } + + private static boolean sameOwners(List now, List before) { + if (now.size() != before.size()) return false; + for (int i = 0; i < now.size(); i++) { + if (now.get(i) != before.get(i)) return false; + } + return true; + } + public static void syncFactionNodeBenefits() { if (!SimpleFactionsApi.ready()) return; + synced = false; + List owners = owners(); Map prestigeByFaction = new HashMap<>(); Map wealthByFaction = new HashMap<>(); for(Node n : nodes) { @@ -82,6 +122,7 @@ public static void syncFactionNodeBenefits() { prestigeByFaction.merge(f, n.getPrestigeGain(), Double::sum); wealthByFaction.merge(f, n.getWealthModifier(), Double::sum); } + Set realmGuilds = Collections.newSetFromMap(new IdentityHashMap<>()); for(Faction f : FactionManager.getCopy()) { double prestige = prestigeByFaction.getOrDefault(f, 0.0); double wealth = wealthByFaction.getOrDefault(f, 0.0); @@ -90,7 +131,17 @@ public static void syncFactionNodeBenefits() { Guild mainGuild = f.getOrCreateMainGuild(); mainGuild.addWealthModifier(new Modifier("Nodes", wealth, true)); mainGuild.updateWealth(); + realmGuilds.add(mainGuild); + } + // A realm's own guild that became an ordinary guild (its realm dissolved into an overlord) + // kept its last node value; only realm guilds carry one. + for(Guild g : FactionManager.getAllGuilds()) { + if(g == null || realmGuilds.contains(g)) continue; + if(g.getWealthModifiers().removeIf(m -> "Nodes".equalsIgnoreCase(m.getType()))) g.updateWealth(); } + // Only a finished sync counts, so the realm check retries one that failed part way. + lastOwners = owners; + synced = true; } public static int getExtraCapacity(Guild g) { @@ -191,6 +242,7 @@ public String getClickedFurniture(Block b) { public void start() { particleCycle(); hourCycle(); + realmCycle(); new BukkitRunnable() { public void run() @@ -239,6 +291,21 @@ public void run() } }.runTaskTimer(DowsingMain.plugin, 0L, 1200L); } + /** Every 30 s: resync node benefits when a node's guild or realm changed without a node event. */ + public void realmCycle(){ + new BukkitRunnable() + { + public void run() + { + if (!DowsingMain.plugin.nodesReady()) return; + try { + syncIfRealmsChanged(); + } catch(RuntimeException ex) { + DowsingMain.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Failed to check node realms", ex); + } + } + }.runTaskTimer(DowsingMain.plugin, 0L, 600L); + } public void particleCycle(){ new BukkitRunnable() { diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java index 3a64c60..79f0dc8 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerCyclesTest.java @@ -70,6 +70,66 @@ void factionBenefitsAggregateOnlyActiveOwnedNodesAndClearStaleBenefits() { verify(emptyMain).addWealthModifier(modifier.capture()); assertEquals(0.0, modifier.getValue().getAmount()); verify(emptyMain).updateWealth(); } + @Test + void syncDropsANodeValueLeftOnAGuildThatIsNoLongerItsRealmsOwn() { + Guild formerRealm = mock(Guild.class), ordinary = mock(Guild.class), realm = mock(Guild.class); + List stale = new ArrayList<>(List.of(new Modifier("Bank", 5.0, false), new Modifier("Nodes", 40.0, true))); + when(formerRealm.getWealthModifiers()).thenReturn(stale); + when(ordinary.getWealthModifiers()).thenReturn(new ArrayList<>(List.of(new Modifier("Bank", 1.0, false)))); + Faction owner = mock(Faction.class); when(owner.getOrCreateMainGuild()).thenReturn(realm); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(owner))); + // Only the guild a realm names as its own keeps a value, whatever its type says. + when(formerRealm.isBase()).thenReturn(true); + f.factions.when(FactionManager::getAllGuilds).thenReturn(new ArrayList<>(java.util.Arrays.asList(formerRealm, ordinary, realm, null))); + NodeManager.syncFactionNodeBenefits(); + assertEquals(List.of("Bank"), stale.stream().map(Modifier::getType).toList()); + verify(formerRealm).updateWealth(); + verify(ordinary, never()).updateWealth(); + verify(realm, never()).getWealthModifiers(); verify(realm).updateWealth(); + } + + @Test + void theMinuteCheckResyncsOnlyWhenANodesGuildOrRealmChanged() { + Faction first = mock(Faction.class), second = mock(Faction.class); + Guild firstMain = mock(Guild.class), secondMain = mock(Guild.class); + when(first.getOrCreateMainGuild()).thenReturn(firstMain); when(second.getOrCreateMainGuild()).thenReturn(secondMain); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(first, second))); + when(f.guild.getFaction()).thenReturn(first); + when(f.node.getIsActive()).thenReturn(true); when(f.node.getWealthModifier()).thenReturn(10.0); + Node idle = f.nodeAt(f.location); + NodeManager.nodes.addAll(List.of(f.node, idle)); + NodeManager.syncFactionNodeBenefits(); + verify(first, times(1)).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(first, times(1)).updatePrestige(); + // The guild's realm dissolved into another one: the node's value has to follow it. + when(f.guild.getFaction()).thenReturn(second); + NodeManager.syncIfRealmsChanged(); + verify(first, times(2)).updatePrestige(); + ArgumentCaptor modifier = ArgumentCaptor.forClass(Modifier.class); + verify(secondMain, times(2)).addWealthModifier(modifier.capture()); + assertEquals(10.0, modifier.getValue().getAmount()); + NodeManager.syncIfRealmsChanged(); + verify(first, times(2)).updatePrestige(); + Guild other = mock(Guild.class); when(other.getFaction()).thenReturn(second); + when(f.node.getGuild()).thenReturn(other); + NodeManager.syncIfRealmsChanged(); + verify(first, times(3)).updatePrestige(); + when(f.node.getGuild()).thenReturn(null); + NodeManager.syncIfRealmsChanged(); + verify(first, times(4)).updatePrestige(); + NodeManager.nodes.remove(idle); + NodeManager.syncIfRealmsChanged(); + verify(first, times(4)).updatePrestige(); + NodeManager.nodes.clear(); + NodeManager.syncIfRealmsChanged(); + verify(first, times(5)).updatePrestige(); + net.tfminecraft.dowsing.testsupport.FactionFixture.api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(false); + NodeManager.nodes.add(f.node); + NodeManager.syncIfRealmsChanged(); + verify(first, times(5)).updatePrestige(); + } + @Test void countsCapacityAndUpkeepRespectOwnershipSpecialNodesAndPurchasedLimits() { assertEquals(0, NodeManager.getExtraCapacity(null)); assertFalse(NodeManager.canPurchaseCapacity(null)); @@ -211,7 +271,7 @@ void timersUseTheirIntendedCadenceAndRunParticlesChecksAndEfficiencyGrowth() { Node claimed = f.node, unclaimed = f.nodeAt(f.location); when(unclaimed.isClaimable()).thenReturn(true); NodeManager.nodes.addAll(List.of(claimed, unclaimed)); try (Cycles cycles = new Cycles()) { - f.manager.start(); assertEquals(java.util.Set.of(5L, 1200L, 72000L), cycles.tasks.keySet()); + f.manager.start(); assertEquals(java.util.Set.of(5L, 600L, 1200L, 72000L), cycles.tasks.keySet()); cycles.tasks.get(5L).run(); verify(claimed).check(); verify(f.world).spawnParticle(Particle.HAPPY_VILLAGER, new Location(f.world, 2.5, 65, 3.5), 10); cycles.tasks.get(72000L).run(); verify(claimed).growEfficiency(); verify(unclaimed).growEfficiency(); @@ -234,6 +294,53 @@ void productionCycleCompletesRealNodeOutputThenConsumesTheNextInputBatch() { } } + @Test + void aSyncThatFailsPartWayIsRetriedByTheRealmCheck() { + Faction realm = mock(Faction.class); Guild main = mock(Guild.class); + when(realm.getOrCreateMainGuild()).thenReturn(main); + when(f.guild.getFaction()).thenReturn(realm); when(f.node.getIsActive()).thenReturn(true); + NodeManager.nodes.add(f.node); + NodeManager.syncFactionNodeBenefits(); + Faction moved = mock(Faction.class); when(f.guild.getFaction()).thenReturn(moved); + f.factions.when(FactionManager::getCopy).thenThrow(new IllegalStateException("half way")); + assertThrows(IllegalStateException.class, NodeManager::syncIfRealmsChanged); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(moved))); + Guild movedMain = mock(Guild.class); when(moved.getOrCreateMainGuild()).thenReturn(movedMain); + NodeManager.syncIfRealmsChanged(); + verify(moved).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(1)).updatePrestige(); + // A node event's sync that fails with the same owners is retried too. + f.factions.when(FactionManager::getCopy).thenThrow(new IllegalStateException("again")); + assertThrows(IllegalStateException.class, NodeManager::requestNodeBenefitSync); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(moved))); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(2)).updatePrestige(); + NodeManager.syncIfRealmsChanged(); + verify(moved, times(2)).updatePrestige(); + } + + @Test + void theRealmCycleResyncsWhenReadyAndLogsAFailureInsteadOfStopping() { + Faction realm = mock(Faction.class); Guild main = mock(Guild.class); + when(realm.getOrCreateMainGuild()).thenReturn(main); + f.factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>(List.of(realm))); + when(f.guild.getFaction()).thenReturn(realm); when(f.node.getIsActive()).thenReturn(true); + NodeManager.nodes.add(f.node); + try (Cycles cycles = new Cycles()) { + f.manager.start(); + when(DowsingMain.plugin.nodesReady()).thenReturn(false); + cycles.tasks.get(600L).run(); + verify(realm, never()).updatePrestige(); + when(DowsingMain.plugin.nodesReady()).thenReturn(true); + cycles.tasks.get(600L).run(); + verify(realm).updatePrestige(); + when(f.node.getIsActive()).thenThrow(new IllegalStateException("broken node")); + cycles.tasks.get(600L).run(); + verify(f.logger).log(eq(java.util.logging.Level.SEVERE), eq("Failed to check node realms"), any(IllegalStateException.class)); + } + } + @Test void failedOutputKeepsTheCyclePendingAndExceptionsDoNotStopOtherNodes() { Node inactive = f.nodeAt(f.location), unowned = f.nodeAt(f.location), broken = f.nodeAt(f.location), pending = f.node;