From a36ff51bf88a84072e5bb7ed2e75f8072077185e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:01:09 +0000 Subject: [PATCH 01/35] wip: member keys core --- pom.xml | 2 +- .../simplefactions/database/Database.java | 1 + .../simplefactions/database/GuildData.java | 2 + .../espionage/EspionageAccess.java | 4 +- .../espionage/EspionageCommands.java | 8 +- .../espionage/EspionageService.java | 14 +- .../simplefactions/government/Government.java | 6 +- .../government/election/Election.java | 10 +- .../government/movement/Movement.java | 12 +- .../simplefactions/guild/Guild.java | 34 +- .../simplefactions/identity/Members.java | 303 ++++++++++++++++++ .../InstallationProtectionListener.java | 2 +- .../chat/FactionChatRecipientResolver.java | 4 +- .../chat/GuildChatRecipientResolver.java | 4 +- .../managers/CapitalMovePrompt.java | 4 +- .../managers/CommandManager.java | 146 ++++----- .../managers/FactionManager.java | 27 +- .../managers/InventoryManager.java | 32 +- .../managers/InviteCommands.java | 28 +- .../managers/MercenaryCommandManager.java | 10 +- .../managers/PlayerManager.java | 16 +- .../managers/RelationManager.java | 20 +- .../managers/SessionManager.java | 6 +- .../simplefactions/managers/TitleManager.java | 4 +- .../simplefactions/managers/WarManager.java | 10 +- .../CampaignInstallationPickView.java | 8 +- .../inventory/CampaignRaidLaunchView.java | 8 +- .../managers/inventory/CampaignView.java | 46 +-- .../managers/inventory/CompanyView.java | 10 +- .../managers/inventory/ContractView.java | 12 +- .../managers/inventory/DeclareWarView.java | 4 +- .../managers/inventory/ElectionCreator.java | 4 +- .../managers/inventory/ElectionView.java | 6 +- .../managers/inventory/EspionageView.java | 10 +- .../managers/inventory/FactionCreator.java | 10 +- .../managers/inventory/FactionView.java | 20 +- .../managers/inventory/GovernmentCreator.java | 26 +- .../managers/inventory/GovernmentView.java | 34 +- .../managers/inventory/GuildView.java | 8 +- .../managers/inventory/InstallationView.java | 12 +- .../managers/inventory/InventoryUpdater.java | 6 +- .../managers/inventory/MilitaryView.java | 8 +- .../managers/inventory/MovementCreator.java | 24 +- .../managers/inventory/MovementView.java | 44 +-- .../managers/inventory/RelationView.java | 30 +- .../managers/inventory/TierTitleView.java | 20 +- .../managers/inventory/WarCreator.java | 6 +- .../managers/inventory/WarView.java | 10 +- .../simplefactions/map/MapSystem.java | 4 +- .../mercenary/company/MercenaryInvites.java | 4 +- .../mercenary/contract/ContractHandler.java | 4 +- .../simplefactions/objects/Faction.java | 17 +- .../simplefactions/utils/EconomicImpact.java | 4 +- .../simplefactions/utils/TabCompletion.java | 32 +- .../vehicles/VehicleFactionCommands.java | 10 +- .../BattleVehicleEligibilityService.java | 6 +- .../berth/FactionVehicleReleaseListener.java | 4 +- .../berth/VehicleTransferConsentService.java | 4 +- .../berth/VehicleTransferListener.java | 4 +- .../handover/VehicleHandoverService.java | 4 +- .../war/battle/ui/BattleCommandManager.java | 6 +- .../war/battle/warband/WarbandManager.java | 4 +- .../warband/WarbandMembershipService.java | 4 +- .../raid/CampaignRaidWarbandService.java | 10 +- .../war/campaign/raid/RaidCommandManager.java | 6 +- .../war/campaign/raid/RaidTabCompletion.java | 6 +- .../runtime/BattleAutoresolveService.java | 6 +- .../runtime/BattleScheduleLookups.java | 4 +- .../CharacterNameResolutionTest.java | 2 +- .../espionage/EspionageBypassTest.java | 4 +- .../espionage/EspionagePermissionsTest.java | 12 +- .../espionage/EspionageTestFixtures.java | 2 +- .../espionage/IntelligenceBrowsingTest.java | 6 +- .../espionage/IntelligencePolicyTest.java | 2 +- .../espionage/OfficeAppointmentsTest.java | 2 +- .../espionage/OfficePersistenceTest.java | 4 +- .../espionage/OfficeVacancyTest.java | 4 +- .../espionage/UnguardedFactionTest.java | 10 +- .../espionage/VassalIntelligenceTest.java | 8 +- .../managers/CommandManagerCoverageTest.java | 4 +- .../managers/FactionManagerCoverageTest.java | 2 +- .../contract/MercenaryMarketTest.java | 2 +- ...mainConfigurationBoundaryCoverageTest.java | 4 +- .../objects/FactionCoverageTest.java | 4 +- .../testsupport/NameKeyedMembers.java | 84 +++++ .../CampaignRaidIntruderServiceTest.java | 4 +- .../org.junit.jupiter.api.extension.Extension | 1 + src/test/resources/junit-platform.properties | 1 + 88 files changed, 955 insertions(+), 425 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/Members.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java create mode 100644 src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension create mode 100644 src/test/resources/junit-platform.properties diff --git a/pom.xml b/pom.xml index 6905dd15..8d25d52a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ full 0.2.4 2.16.0 - 2.3.0 + 2.4.0 2.10.0 2.1.0 diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 07144755..a045a9f1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -411,6 +411,7 @@ public boolean saveFactionChecked(Faction f) { GuildData gd = new GuildData(); gd.id = g.getId(); + gd.incarnation = g.getIncarnation(); gd.name = g.getOwnName(); gd.leader = g.getLeader(); gd.leaderCharacter = g.getLeaderCharacter(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 37655c61..ce0f22e7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -8,6 +8,8 @@ public class GuildData { public String id; + /** Immutable identity of this guild, kept across renames and never reused. */ + public String incarnation; public String name; public String leader; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java index f02c4517..12c4d61a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.espionage; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.EnumSet; import java.util.Set; @@ -63,6 +65,6 @@ public static boolean denied(Player player, SFInventoryHolder holder) { var spymaster = EspionageService.spymaster(faction); return spymaster == null || !spymaster.isHolder(player.getUniqueId()); } - return holder.getType() == SFGUI.SPYMASTER_SELECT && !faction.isLeader(player.getName()); + return holder.getType() == SFGUI.SPYMASTER_SELECT && !faction.isLeader(Members.key(player)); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java index 643a7531..1ddd054a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.espionage; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -40,7 +42,7 @@ public static boolean matches(String subcommand) { } public static boolean handle(Player player, String[] args) { - var faction = FactionManager.getByMember(player.getName()); + var faction = FactionManager.getByMember(Members.key(player)); if (faction == null) { player.sendMessage("§cYou need to belong to a faction."); return true; @@ -88,12 +90,12 @@ private static void usage(Player player) { public static List complete(Player player, String[] args) { List choices = new ArrayList<>(); - var faction = FactionManager.getByMember(player.getName()); + var faction = FactionManager.getByMember(Members.key(player)); if (faction == null || !args[0].equalsIgnoreCase("spymaster")) return choices; var holder = EspionageService.spymaster(faction); boolean isHolder = holder != null && holder.isHolder(player.getUniqueId()); if (args.length == 2) { - if (faction.isLeader(player.getName())) { + if (faction.isLeader(Members.key(player))) { choices.add("remove"); for (String name : faction.getMembers()) if (Bukkit.getPlayerExact(name) != null && EspionageService.eligible(faction, name)) choices.add(name); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 4bf5ce9d..f434081c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.espionage; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.LocalDate; import java.time.ZoneOffset; import java.util.LinkedHashMap; @@ -43,7 +45,7 @@ public static void loadAptitudes(java.nio.file.Path file) throws java.io.IOExcep } public static boolean eligible(Faction faction, String playerName) { - return faction.isMemberIgnoreCase(playerName) || faction.isLeader(playerName); + return faction.isMember(playerName) || faction.isLeader(playerName); } /** The leader's default holding follows the leadership; deliberate appointments stay. */ @@ -164,7 +166,7 @@ public static String duration(long millis) { public static boolean isOwn(Player viewer, Faction target) { return viewer != null && target != null - && (target.isMemberIgnoreCase(viewer.getName()) || target.isLeader(viewer.getName())); + && (target.isMember(Members.key(viewer)) || target.isLeader(Members.key(viewer))); } public static boolean bypasses(Player viewer) { @@ -270,7 +272,7 @@ public static java.util.List dirty = new java.util.LinkedHashSet<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 574e1fba..a5d11a1c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.government; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; @@ -358,7 +360,7 @@ private void replaceLeader() { } public boolean isCouncilMember(Player p) { - return p != null && isCouncilMember(p.getName()); + return p != null && isCouncilMember(Members.key(p)); } /** Name-based so offline checks (mercenary loyalty, contract signing) can use it too. */ @@ -430,7 +432,7 @@ public boolean canProposePolitical(Player p, Action action) { case GUILD_MEMBER: if(action == Action.NATIONHOOD) return false; //only leaders can demand that case GUILD_LEADER: - if(action == Action.NATIONHOOD && !FactionManager.getGuildByMember(p.getName()).canBeElevated(null)) return false; + if(action == Action.NATIONHOOD && !FactionManager.getGuildByMember(Members.key(p)).canBeElevated(null)) return false; if(!PoliticalActionLoader.getByAction(action).allowGuilds()) return false; break; case VASSAL_LEADER: 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 4346e629..1810b933 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.government.election; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -206,11 +208,11 @@ public void applyReasons(List lore, Candidate type, Player p) { lore.add(StringFormatter.formatHex("§7- #ba7872Election is in the voting phase")); } if(type == Candidate.LEADER) { - if(!gov.getFaction().isMember(p.getName())) { + if(!gov.getFaction().isMember(Members.key(p))) { lore.add(StringFormatter.formatHex("§7- #ba7872You must be a member of the faction to be Leader")); } - Guild g = gov.getFaction().getGuild(p.getName()); - if (g != null && !g.isBase() && g.isLeader(p.getName())) { + Guild g = gov.getFaction().getGuild(Members.key(p)); + if (g != null && !g.isBase() && g.isLeader(Members.key(p))) { lore.add(StringFormatter.formatHex( "§7- #ba7872Guild leaders cannot be faction leaders" )); @@ -262,7 +264,7 @@ public void tick() { if(active) { if(notify == 300) { for(Player p : Bukkit.getOnlinePlayers()) { - if(canVote(p.getName()) && gov.getVotingBooths().size() > 0) { + if(canVote(Members.key(p)) && gov.getVotingBooths().size() > 0) { p.sendMessage("§e================================="); p.sendMessage("§a§lActive Election! §7Head to a §e§lVoting Booth §7to vote!"); p.sendMessage("§eBooths:"); 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 10317d80..fbc9f3fc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.government.movement; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -586,11 +588,11 @@ private double getTotalTradePower() { } public Object getJoiningAs(Player p) { - Member relation = f.getRelationToFaction(p.getName()); + Member relation = f.getRelationToFaction(Members.key(p)); if (relation == Member.GUILD_LEADER || relation == Member.GUILD_MEMBER) { - return FactionManager.getGuildByMember(p.getName()); + return FactionManager.getGuildByMember(Members.key(p)); } else if (relation == Member.VASSAL_LEADER) { - return FactionManager.getByMember(p.getName()); + return FactionManager.getByMember(Members.key(p)); } else if (relation == Member.MEMBER) { return p.getName(); } @@ -598,8 +600,8 @@ public Object getJoiningAs(Player p) { } public boolean quickJoinCheck(Player p) { - if(isMember(p.getName())) return false; - Member relation = f.getRelationToFaction(p.getName()); + if(isMember(Members.key(p))) return false; + Member relation = f.getRelationToFaction(Members.key(p)); switch(relation) { case FOREIGNER: case GUILD_MEMBER: diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index aa2a3819..7d934ced 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.guild; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -104,6 +106,8 @@ public class Guild { private double donationAmount = 0.0; private List dividendEligible = new ArrayList<>(); private MercenaryCompany company; + /** Fixed for this guild's lifetime; a later guild with the same name gets a new one. */ + private String incarnation = java.util.UUID.randomUUID().toString(); public Guild(Faction f) { host = f; @@ -139,7 +143,7 @@ public Guild(String id, Player p, Faction f, int province) { host = f; this.id = format.formatId(id); this.name = StringFormatter.formatHex(format.formatName(id)); - this.leader = p.getName(); + this.leader = Members.key(p); rgb = RandomRGB.random(); stance = Stance.NEUTRAL; while(!RandomRGB.isFree(rgb)) { @@ -159,7 +163,7 @@ public Guild(String id, Player p, Faction f, int province) { for(Upgrade u : UpgradeLoader.getList()) { upgrades.put(u.getId(), new Upgrade(u, 0)); } - f.getOrCreateMainGuild().kick(p.getName()); //remove from main guild + f.getOrCreateMainGuild().kick(leader); //remove from main guild this.ledger = new Ledger(this); this.loanHandler = new LoanHandler(this); this.dividendEligible = new ArrayList<>(members); @@ -179,6 +183,7 @@ public Guild(GuildData data, Faction host) { this.id = data.id; this.name = data.name; this.leader = data.leader; + if (data.incarnation != null && !data.incarnation.isBlank()) this.incarnation = data.incarnation; this.rgb = data.rgb; this.capital = data.capital; this.stance = data.stance != null ? Stance.valueOf(data.stance) : Stance.NEUTRAL; @@ -413,20 +418,22 @@ public void setHost(Faction host) { } public List getInvites() { return invites; } public boolean isInvited(String p) { - return findIgnoreCase(invites, p) != null; + return find(invites, p) != null; } public void invite(String p) { if(p == null || isInvited(p)) return; invites.add(p); } public boolean consumeInvite(String p) { - String stored = findIgnoreCase(invites, p); + String stored = find(invites, p); if(stored == null) return false; invites.remove(stored); return true; } public String getId() { return id; } + public String getIncarnation() { return incarnation; } + /** A removed branch still saved above level 0. The level is not refunded. */ static void warnRetiredBranch(Faction host, String guildName, String branchId, int level) { if (level <= 0 || branchId == null) { @@ -447,8 +454,8 @@ static void warnRetiredBranch(Faction host, String guildName, String branchId, i /** Backing name, even while this guild is a faction base (display name follows the host). */ public String getOwnName() { return name; } public List getMembers() { return members; } - public boolean isMember(String p) { return findIgnoreCase(members, p) != null; } - public boolean isMember(Player p) { return isMember(p.getName()); } + public boolean isMember(String p) { return find(members, p) != null; } + public boolean isMember(Player p) { return isMember(Members.key(p)); } public void addMember(String p) { if(isMember(p)) return; if(!isBase() && host.getOrCreateMainGuild().isMember(p)) { @@ -458,7 +465,7 @@ public void addMember(String p) { members.add(p); } public void kick(String member) { - String stored = findIgnoreCase(members, member); + String stored = find(members, member); if(stored != null) members.remove(stored); } public String getLeader() { return isBase() ? host.getLeader() : leader; } @@ -489,9 +496,9 @@ public void setLeader(String leader) { } public boolean isLeader(String p) { String currentLeader = getLeader(); - return currentLeader != null && currentLeader.equalsIgnoreCase(p); + return currentLeader != null && currentLeader.equals(p); } - public boolean isLeader(Player p) { return isLeader(p.getName()); } + public boolean isLeader(Player p) { return isLeader(Members.key(p)); } public Map getBranches() { return branches; } public Branch getBranch(int i) { return branches.getOrDefault(i, null); @@ -1121,10 +1128,11 @@ private void clearFavoursAndRepressions() { repressed = false; } - public static String findIgnoreCase(List names, String name) { - if(name == null || names == null) return null; - for(String stored : names) { - if(stored != null && stored.equalsIgnoreCase(name)) return stored; + /** The stored member key equal to {@code key}, or null; keys match exactly. */ + public static String find(List keys, String key) { + if(key == null || keys == null) return null; + for(String stored : keys) { + if(key.equals(stored)) return stored; } return null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java new file mode 100644 index 00000000..1368b18c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -0,0 +1,303 @@ +package net.tfminecraft.simplefactions.identity; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Member keys: every stored member string is the lower-case ID of a character. + * A player acts for the character of their current session; with no session + * they are a stranger to every guild. + */ +public final class Members { + + /** Prefix of a leader seat whose original holder is waiting in quarantine. */ + public static final String QUARANTINED = "quarantine:"; + + /** Answers the identity questions; production uses the shared character provider. */ + public interface Resolver { + Optional session(Player player); + + boolean isCurrent(CharacterSession session); + + String key(Player player); + + Optional info(String key); + + Optional online(String key); + } + + private static final Resolver SHARED = new SharedResolver(); + private static volatile Resolver resolver = SHARED; + + private Members() {} + + public static void setResolver(Resolver next) { + resolver = next == null ? SHARED : next; + } + + public static void useSharedIdentity() { + resolver = SHARED; + } + + /** The current character's key, or null for a player without a ready session. */ + public static String key(Player player) { + return player == null ? null : resolver.key(player); + } + + /** Key form of a character ID. */ + public static String key(UUID characterId) { + return characterId == null ? null : characterId.toString().toLowerCase(Locale.ROOT); + } + + public static String key(CharacterRef ref) { + return ref == null ? null : key(ref.characterId()); + } + + public static Optional session(Player player) { + return player == null ? Optional.empty() : resolver.session(player); + } + + public static boolean isCurrent(CharacterSession session) { + return session != null && resolver.isCurrent(session); + } + + /** The character name for a key; offline characters are read from the provider. */ + public static String display(String key) { + if (key == null || key.isBlank()) return "Unknown"; + if (isQuarantined(key)) return "Unresolved member"; + if (characterId(key) == null) return key; + return resolver.info(key).map(CharacterInfo::name).map(Members::plain).orElse("Unknown"); + } + + /** The name of the character the player is playing, for messages to others. */ + public static String name(Player player) { + if (player == null) return "Unknown"; + String key = key(player); + return key == null ? player.getName() : display(key); + } + + /** + * The character name, with the account name after it for staff who + * bypass espionage, so they can tell who plays it. + */ + public static String display(Player viewer, String key) { + String name = display(key); + if (!net.tfminecraft.simplefactions.espionage.EspionageService.bypasses(viewer)) return name; + String account = accountName(key); + return account == null ? name : name + " \u00a77(" + account + ")"; + } + + /** The account that plays the character, or empty if unknown. */ + public static Optional accountId(String key) { + return ref(key).map(CharacterRef::ownerId); + } + + /** The Minecraft name of the account that plays the character, or null. */ + public static String accountName(String key) { + UUID owner = accountId(key).orElse(null); + if (owner == null) return null; + try { + return Bukkit.getServer() == null ? null : Bukkit.getOfflinePlayer(owner).getName(); + } catch (RuntimeException ignored) { + return null; + } + } + + /** Display names for several keys, in order. */ + public static List display(Collection keys) { + List names = new ArrayList<>(); + if (keys != null) for (String key : keys) names.add(display(key)); + return names; + } + + /** The online player currently playing that character. */ + public static Optional online(String key) { + if (key == null || key.isBlank()) return Optional.empty(); + return resolver.online(key); + } + + /** Nullable form of {@link #online(String)} for older call sites. */ + public static Player player(String key) { + return online(key).orElse(null); + } + + public static boolean isOnline(String key) { + return online(key).isPresent(); + } + + public static Optional ref(String key) { + if (key == null || characterId(key) == null) return Optional.empty(); + return resolver.info(key).map(CharacterInfo::ref); + } + + public static Optional info(String key) { + if (key == null || characterId(key) == null) return Optional.empty(); + return resolver.info(key); + } + + /** Whether the player is currently playing the character with that key. */ + public static boolean is(Player player, String key) { + String own = key(player); + return own != null && own.equals(key); + } + + public static boolean isQuarantined(String key) { + return key != null && key.startsWith(QUARANTINED); + } + + /** + * Finds a member among {@code keys} by what a player typed: an online + * player's Minecraft name or a character name. Null when nothing or more + * than one member matches. + */ + public static String find(Collection keys, String input) { + if (keys == null || input == null) return null; + String query = unquote(input); + Player online = onlineByName(query); + String onlineKey = key(online); + if (onlineKey != null && keys.contains(onlineKey)) return onlineKey; + String match = null; + for (String key : keys) { + if (key == null || !display(key).equalsIgnoreCase(query)) continue; + if (match != null && !match.equals(key)) return null; + match = key; + } + return match; + } + + /** The key of an online player named by Minecraft or character name, or null. */ + public static String findOnline(String input) { + if (input == null) return null; + String query = unquote(input); + String direct = key(onlineByName(query)); + if (direct != null) return direct; + String match = null; + for (Player player : onlinePlayers()) { + String key = key(player); + if (key == null || !display(key).equalsIgnoreCase(query)) continue; + if (match != null) return null; + match = key; + } + return match; + } + + /** + * An online player named by Minecraft name, else by character name. Ambiguous + * character names never pick a player; the sender is told why nothing matched. + */ + public static Player resolveOnline(Player sender, String input) { + String query = unquote(input); + Player exact = onlineByName(query); + if (exact != null) return exact; + List matches = new ArrayList<>(); + for (Player player : onlinePlayers()) { + if (player != null && name(player).equalsIgnoreCase(query)) matches.add(player); + } + if (matches.size() == 1) return matches.getFirst(); + if (sender != null) { + sender.sendMessage(matches.isEmpty() + ? "\u00a7cNo online player found by that character or Minecraft name." + : "\u00a7cSeveral characters share that name. Use their Minecraft name."); + } + return null; + } + + /** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */ + public static List completions(Collection names, String[] args) { + if (args.length < 2) return List.of(); + int skip = args.length - 2; + String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + return names.stream().filter(java.util.Objects::nonNull).map(ChatColor::stripColor).distinct() + .filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length())) + .map(name -> name.split(" ")) + .filter(words -> words.length > skip) + .map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length))) + .toList(); + } + + /** The parsed character ID of a key, or null if it is not one. */ + public static UUID characterId(String key) { + if (key == null) return null; + try { + UUID id = UUID.fromString(key); + return id.toString().equalsIgnoreCase(key) ? id : null; + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static Player onlineByName(String name) { + try { + return Bukkit.getServer() == null ? null : Bukkit.getPlayerExact(name); + } catch (RuntimeException ignored) { + return null; + } + } + + private static Collection onlinePlayers() { + try { + return Bukkit.getServer() == null ? List.of() : Bukkit.getOnlinePlayers(); + } catch (RuntimeException ignored) { + return List.of(); + } + } + + private static String unquote(String input) { + String query = input.strip(); + if (query.length() >= 2 && query.startsWith("\"") && query.endsWith("\"")) + query = query.substring(1, query.length() - 1).strip(); + return query; + } + + static String plain(String name) { + String stripped = ChatColor.stripColor(name); + return stripped == null || stripped.isBlank() ? "Unknown" : stripped.strip(); + } + + /** Reads everything from the shared character provider on every call. */ + static final class SharedResolver implements Resolver { + @Override + public Optional session(Player player) { + if (!Characters.available()) return Optional.empty(); + return Characters.currentSession(player) + .filter(session -> session.ref().ownerId().equals(player.getUniqueId())) + .filter(Characters::isCurrent); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return Characters.available() && Characters.isCurrent(session); + } + + @Override + public String key(Player player) { + return session(player).map(session -> Members.key(session.ref())).orElse(null); + } + + @Override + public Optional info(String key) { + UUID id = characterId(key); + if (id == null || !Characters.available()) return Optional.empty(); + return Characters.info(id).filter(info -> id.equals(info.ref().characterId())); + } + + @Override + public Optional online(String key) { + return info(key).map(info -> Bukkit.getPlayer(info.ref().ownerId())) + .filter(player -> key.equals(key(player))); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java index 9439ca89..91305c0d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java @@ -64,7 +64,7 @@ static boolean isOwnerMember(Player player, Installation installation) { return false; } String name = player.getName(); - return owner.isMemberIgnoreCase(name) || name.equalsIgnoreCase(owner.getLeader()); + return owner.isMember(name) || name.equalsIgnoreCase(owner.getLeader()); } private static String blockedMessage(Installation installation) { diff --git a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/FactionChatRecipientResolver.java b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/FactionChatRecipientResolver.java index 1cbb7095..a95637f0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/FactionChatRecipientResolver.java +++ b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/FactionChatRecipientResolver.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.integration.rpcharacters.chat; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.Collections; import java.util.Set; @@ -19,7 +21,7 @@ public Set resolve(Player sender, ChatChannel channel) { return Collections.emptySet(); } - Faction faction = FactionManager.getByMember(sender.getName()); + Faction faction = FactionManager.getByMember(Members.key(sender)); if (faction == null) { return Collections.emptySet(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java index 7ad6b868..3e72fbe5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java +++ b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.integration.rpcharacters.chat; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.Collections; import java.util.Set; @@ -19,7 +21,7 @@ public Set resolve(Player sender, ChatChannel channel) { return Collections.emptySet(); } - Guild guild = FactionManager.getGuildByMember(sender.getName()); + Guild guild = FactionManager.getGuildByMember(Members.key(sender)); if (guild == null) { return Collections.emptySet(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java index 177f8474..12c914e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.HashMap; import java.util.Map; @@ -93,7 +95,7 @@ static Outcome confirm(Player player, CapitalMovePending state, boolean confirme } Faction faction = state.faction(); // The leader or the capital may have changed while the menu was open. - if (FactionManager.getByLeader(player.getName()) != faction + if (FactionManager.getByLeader(Members.key(player)) != faction || faction.getCapital() != state.fromCapital()) { player.sendMessage("§cThe capital changed while you were deciding. Nothing was charged."); return Outcome.REFUSED; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 44749ab2..c9b1fd27 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.utils.BankAmount; import net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages; @@ -95,7 +97,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("create") && args.length == 2) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to be in a faction to make a guild"); return true; @@ -104,7 +106,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cYou are already in a guild"); return true; } - if(f.getLeader().equalsIgnoreCase(p.getName())) { + if(Members.is(p, f.getLeader())) { p.sendMessage("§cYou are the leader of the faction"); return true; } @@ -128,7 +130,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cNo guild by the id "+args[1]); return true; } - if(!guild.getLeader().equalsIgnoreCase(p.getName()) && !Permissions.isAdmin(sender)) { + if(!Members.is(p, guild.getLeader()) && !Permissions.isAdmin(sender)) { p.sendMessage("§cYou are not the leader of this guild"); return true; } @@ -164,7 +166,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cGuild "+guild.getName()+" §cdeleted!"); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("dummyLeader") && args.length == 1) { - Guild guild = FactionManager.getGuildByMember(p.getName()); + Guild guild = FactionManager.getGuildByMember(Members.key(p)); if(!Permissions.isAdmin(sender)) { p.sendMessage("§cYou do not have access to this command"); return true; @@ -176,7 +178,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, guild.dummyLeader(p); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("dummify") && args.length == 1) { - Guild guild = FactionManager.getGuildByMember(p.getName()); + Guild guild = FactionManager.getGuildByMember(Members.key(p)); if(!Permissions.isAdmin(sender)) { p.sendMessage("§cYou do not have access to this command"); return true; @@ -188,7 +190,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, guild.dummify(p); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("menu") && args.length == 1) { - Guild guild = FactionManager.getGuildByMember(p.getName()); + Guild guild = FactionManager.getGuildByMember(Members.key(p)); if(guild == null) { p.sendMessage("§cYou are not in a guild"); return true; @@ -201,7 +203,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, i.guildList(p); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("invite") && args.length >= 2) { - Guild guild = FactionManager.getGuildByLeader(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); return true; @@ -245,9 +247,9 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } } - boolean inThisFaction = guild.getFaction().isMemberIgnoreCase(name) + boolean inThisFaction = guild.getFaction().isMember(name) || (otherGuild != null && otherGuild.getFaction().equals(guild.getFaction())); - if(!inThisFaction && guild.getFaction().hasFactionRule(Rules.CLOSED_BORDERS) && !guild.getFaction().isLeader(p.getName())) { + if(!inThisFaction && guild.getFaction().hasFactionRule(Rules.CLOSED_BORDERS) && !guild.getFaction().isLeader(Members.key(p))) { p.sendMessage("§cThe faction has closed borders, cannot invite members from outside the faction"); return true; } @@ -257,21 +259,21 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, invited.sendMessage(InviteCommands.buttons(cmd2, guild.getId(), guild.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("leave") && args.length == 1) { - if(FactionManager.getGuildByMember(p.getName()) == null) { + if(FactionManager.getGuildByMember(Members.key(p)) == null) { p.sendMessage("§cYou are not in a guild"); return true; } - if(FactionManager.getGuildByLeader(p.getName()) != null) { + if(FactionManager.getGuildByLeader(Members.key(p)) != null) { p.sendMessage("§cA leader cannot abandon their guild."); p.sendMessage("§cUse /guild delete first"); return true; } - Guild g = FactionManager.getGuildByMember(p.getName()); - g.kick(p.getName()); + Guild g = FactionManager.getGuildByMember(Members.key(p)); + g.kick(Members.key(p)); p.sendMessage("§aLeft "+g.getName()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("kick") && args.length == 2) { - Guild guild = FactionManager.getGuildByLeader(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); return true; @@ -297,8 +299,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setbank") && args.length == 1) { - if(FactionManager.getGuildByLeader(p.getName()) != null) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + if(FactionManager.getGuildByLeader(Members.key(p)) != null) { + Guild g = FactionManager.getGuildByLeader(Members.key(p)); String bankBlockReason = BankPlacementValidator.failureReasonForGuild(g, p.getLocation()); if(bankBlockReason != null) { p.sendMessage(bankBlockReason); @@ -318,8 +320,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("deposit") && args.length == 2) { - if(FactionManager.getGuildByMember(p.getName()) != null) { - Guild g = FactionManager.getGuildByMember(p.getName()); + if(FactionManager.getGuildByMember(Members.key(p)) != null) { + Guild g = FactionManager.getGuildByMember(Members.key(p)); Bank b = g.getBank(); if(b == null) { p.sendMessage("§cYour guild has not established a bank"); @@ -356,8 +358,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("withdraw") && args.length == 2) { // Only the guild's leader may take money out; members of the main guild would // otherwise be able to empty the faction treasury. - if(FactionManager.getGuildByLeader(p.getName()) != null) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + if(FactionManager.getGuildByLeader(Members.key(p)) != null) { + Guild g = FactionManager.getGuildByLeader(Members.key(p)); Bank b = g.getBank(); if(b == null) { p.sendMessage("§cYour guild has not established a bank"); @@ -394,7 +396,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if(!Cache.requireProvinces(p)) { return true; } - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); if(g == null) { p.sendMessage("§cYou must be the leader of a guild to set the capital"); return true; @@ -426,7 +428,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setbanner") && args.length == 1) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); if(g == null) { p.sendMessage("§cYou must be the leader of a guild to change the banner!"); return true; @@ -446,7 +448,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aGuild banner changed!"); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setcolour") && args.length == 2) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); if(g == null) { p.sendMessage("§cYou must be the leader of a guild to change the colour!"); return true; @@ -472,12 +474,12 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { - Guild g = FactionManager.getGuildByMember(p.getName()); + Guild g = FactionManager.getGuildByMember(Members.key(p)); if(g == null) { p.sendMessage("§cYou need to have a guild to change leader"); return true; } - if(!p.getName().equalsIgnoreCase(g.getLeader())) { + if(!Members.is(p, g.getLeader())) { p.sendMessage("§cOnly the leader can set a new leader!"); return true; } @@ -491,7 +493,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } g.setLeader(args[1]); for(Player pl : Bukkit.getOnlinePlayers()) { - if(g.isMember(pl.getName())) { + if(g.isMember(Members.key(pl))) { pl.sendMessage("§a"+args[1]+ " is the new guild leader!"); } } @@ -510,7 +512,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cThis is the base guild, use /faction forceleader instead"); return true; } - String member = Guild.findIgnoreCase(g.getMembers(), args[2]); + String member = Members.find(g.getMembers(), args[2]); if(member == null) { p.sendMessage("§cPlayer is not in the guild"); return true; @@ -522,13 +524,13 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, g.setLeader(member); p.sendMessage("§a"+member+" now leads "+g.getName()); for(Player pl : Bukkit.getOnlinePlayers()) { - if(!pl.equals(p) && g.isMember(pl.getName())) { + if(!pl.equals(p) && g.isMember(Members.key(pl))) { pl.sendMessage("§a"+member+ " is the new guild leader!"); } } return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("rename") && args.length == 2) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); if(g == null) { p.sendMessage("§a[SimpleFactions]§c Error! You must be the leader of a guild to rename one!"); return true; @@ -543,7 +545,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("create") && args.length == 2) { - if(FactionManager.getByMember(p.getName()) != null) { + if(FactionManager.getByMember(Members.key(p)) != null) { p.sendMessage("§cYou already have a faction!"); return true; } @@ -566,8 +568,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if(!Cache.requireProvinces(p)) { return true; } - if(FactionManager.getByLeader(p.getName()) != null) { - Faction f = FactionManager.getByMember(p.getName()); + if(FactionManager.getByLeader(Members.key(p)) != null) { + Faction f = FactionManager.getByMember(Members.key(p)); if(f.getProvinces().isEmpty()) { p.sendMessage("§cUse §e/faction setcapital §cto claim your first province and found your capital city."); return true; @@ -586,7 +588,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if(!Cache.requireProvinces(p)) { return true; } - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to be a faction leader to construct installations"); return true; @@ -629,7 +631,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if(!Cache.requireProvinces(p)) { return true; } - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to be a faction leader to deconstruct installations"); return true; @@ -659,7 +661,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if (!Cache.requireProvinces(p)) { return true; } - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if (f == null) { p.sendMessage("§cYou need to be a faction leader to upgrade installations"); return true; @@ -717,7 +719,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p, transferId == null || transferId.isBlank() ? null : transferId); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("findvehicles")) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage(net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages.notLeader()); return true; @@ -743,8 +745,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } Faction f = null; if(args.length == 1) { - if(FactionManager.getByLeader(p.getName()) != null) { - f = FactionManager.getByMember(p.getName()); + if(FactionManager.getByLeader(Members.key(p)) != null) { + f = FactionManager.getByMember(Members.key(p)); } else { p.sendMessage("§cYou need to be a faction leader to unclaim land"); return true; @@ -783,12 +785,12 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, RequestManager.decline(p); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setcolour") && args.length == 2) { - if(FactionManager.getByLeader(p.getName()) != null) { + if(FactionManager.getByLeader(Members.key(p)) != null) { String rgb = args[1]; int result = FactionManager.validateRGB(rgb); if(result == 0) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); FactionManager.getMap().enqueue("nation", f.getRGB()); f.setRGB(rgb); FactionManager.getMap().enqueue("nation", f.getRGB()); @@ -812,7 +814,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } if(!Permissions.isAdmin(sender)) { - if(!f.getMembers().contains(p.getName())) { + if(!f.getMembers().contains(Members.key(p))) { p.sendMessage("§cCannot delete a faction you are not part of!"); return true; } @@ -824,7 +826,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cCannot delete a faction with active loans"); return true; } - if(!p.getName().equalsIgnoreCase(f.getLeader())) { + if(!Members.is(p, f.getLeader())) { p.sendMessage("§cOnly the faction leader can delete the faction!"); return true; } @@ -851,7 +853,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, i.factionList(p); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("menu") && args.length == 1) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou are not in a faction"); return true; @@ -860,12 +862,12 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, i.factionView(p, f); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("kick") && args.length == 2) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to have a faction to kick someone"); return true; } - if(!p.getName().equalsIgnoreCase(f.getLeader())) { + if(!Members.is(p, f.getLeader())) { p.sendMessage("§cOnly the leader can dismiss members!"); return true; } @@ -890,12 +892,12 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to have a faction to change leader"); return true; } - if(!p.getName().equalsIgnoreCase(f.getLeader())) { + if(!Members.is(p, f.getLeader())) { p.sendMessage("§cOnly the leader can set a new leader!"); return true; } @@ -917,18 +919,18 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } f.setLeader(args[1]); for(Player pl : Bukkit.getOnlinePlayers()) { - if(f.getMembers().contains(pl.getName())) { + if(f.getMembers().contains(Members.key(pl))) { pl.sendMessage("§a"+args[1]+ " is the new faction leader!"); } } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("invite") && args.length >= 2) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to have a faction to invite someone"); return true; } - if(!p.getName().equalsIgnoreCase(f.getLeader())) { + if(!Members.is(p, f.getLeader())) { p.sendMessage("§cOnly the leader can invite new members!"); return true; } @@ -938,7 +940,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } String name = invited.getName(); - if(f.isMemberIgnoreCase(name)) { + if(f.isMember(name)) { p.sendMessage("§cPlayer is already a member"); return true; } @@ -956,26 +958,26 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, invited.sendMessage(InviteCommands.buttons(cmd1, f.getId(), f.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("leave") && args.length == 1) { - if(FactionManager.getByMember(p.getName()) == null) { + if(FactionManager.getByMember(Members.key(p)) == null) { p.sendMessage("§cYou are not in a faction"); return true; } - if(FactionManager.getByLeader(p.getName()) != null) { + if(FactionManager.getByLeader(Members.key(p)) != null) { p.sendMessage("§cA leader cannot abandon their faction."); p.sendMessage("§cUse /faction delete first"); return true; } - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f.isInGuild(p.getName())) { p.sendMessage("§cYou are in a guild, use /guild leave instead"); return true; } - f.forceRemoveMember(p.getName()); + f.forceRemoveMember(Members.key(p)); p.sendMessage("§aLeft "+f.getName()); f.updatePrestige(); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("rename") && args.length == 2) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§a[SimpleFactions]§c Error! You must be the leader of a faction to rename one!"); return true; @@ -990,7 +992,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aFaction renamed to "+f.getName()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setrulertitle") && args.length == 2) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to change ruler title"); return true; @@ -1003,7 +1005,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, if(!Cache.requireProvinces(p)) { return true; } - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to set the capital"); return true; @@ -1057,7 +1059,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, CapitalMovePrompt.applyFactionCapitalMove(p, f, claim, name); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setrulingsystem") && args.length == 2) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to change ruling system!"); return true; @@ -1067,7 +1069,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aFaction ruling system changed to "+f.getGovernmentString()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setculture") && args.length == 2) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to change culture!"); return true; @@ -1077,7 +1079,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aFaction culture changed to "+f.getCulture()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setreligion") && args.length == 2) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to change religion!"); return true; @@ -1087,7 +1089,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aFaction religion changed to "+f.getReligion()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setbanner") && args.length == 1) { - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if(f == null) { p.sendMessage("§cYou must be the leader of a faction to change the banner!"); return true; @@ -1102,8 +1104,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aFaction banner changed!"); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setbank") && args.length == 1) { - if(FactionManager.getByLeader(p.getName()) != null) { - Faction f = FactionManager.getByMember(p.getName()); + if(FactionManager.getByLeader(Members.key(p)) != null) { + Faction f = FactionManager.getByMember(Members.key(p)); String bankBlockReason = BankPlacementValidator.failureReasonForFaction(f, p.getLocation()); if(bankBlockReason != null) { p.sendMessage(bankBlockReason); @@ -1123,8 +1125,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("deposit") && args.length == 2) { - if(FactionManager.getByMember(p.getName()) != null) { - Faction f = FactionManager.getByMember(p.getName()); + if(FactionManager.getByMember(Members.key(p)) != null) { + Faction f = FactionManager.getByMember(Members.key(p)); Bank b = f.getBank(); if(b == null) { p.sendMessage("§cYour faction has not established a bank"); @@ -1159,8 +1161,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("withdraw") && args.length == 2) { - if(FactionManager.getByLeader(p.getName()) != null) { - Faction f = FactionManager.getByMember(p.getName()); + if(FactionManager.getByLeader(Members.key(p)) != null) { + Faction f = FactionManager.getByMember(Members.key(p)); Bank b = f.getBank(); if(b == null) { p.sendMessage("§cYour faction has not established a bank"); @@ -1418,7 +1420,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced } f.setLeader(args[2]); for(Player pl : Bukkit.getOnlinePlayers()) { - if(f.getMembers().contains(pl.getName())) { + if(f.getMembers().contains(Members.key(pl))) { pl.sendMessage("§a"+args[2]+ " is the new faction leader!"); } } @@ -1443,7 +1445,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced } f.addMember(args[2]);; for(Player pl : Bukkit.getOnlinePlayers()) { - if(f.getMembers().contains(pl.getName())) { + if(f.getMembers().contains(Members.key(pl))) { pl.sendMessage("§a"+args[2]+ " joined the faction!"); } } @@ -1535,7 +1537,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced f.getGovernment().getElection().start(); p.sendMessage("§aElection started for " + f.getName() + "!"); for(Player pl : Bukkit.getOnlinePlayers()) { - if(f.getMembers().contains(pl.getName())) { + if(f.getMembers().contains(Members.key(pl))) { pl.sendMessage("§a§lElection Started! §7Vote at voting booths!"); pl.playSound(pl.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } @@ -1558,7 +1560,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced f.getGovernment().getElection().end(); p.sendMessage("§aElection ended for " + f.getName() + "!"); for(Player pl : Bukkit.getOnlinePlayers()) { - if(f.getMembers().contains(pl.getName())) { + if(f.getMembers().contains(Members.key(pl))) { pl.sendMessage("§e§lElection Ended! §7Results have been applied!"); pl.playSound(pl.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1f, 1.5f); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 81ecc581..bec38b6c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -685,9 +686,11 @@ public static boolean guildExists(String id) { } public static boolean canJoinGuild(Player p) { - Guild guild = getGuildByMember(p.getName()); + String key = Members.key(p); + if(key == null) return false; + Guild guild = getGuildByMember(key); if(guild == null) return true; - if(guild.getLeader().equalsIgnoreCase(p.getName())) return false; + if(key.equals(guild.getLeader())) return false; return guild.getType().isBase(); } @@ -695,13 +698,14 @@ public static Guild getGuildByMember(String player) { if(player == null) return null; for(Faction f : factions) { for(Guild guild : f.getGuildHandler().getGuilds()) { - if(Guild.findIgnoreCase(guild.getMembers(), player) != null) return guild; + if(Guild.find(guild.getMembers(), player) != null) return guild; } } return null; } public static Guild getGuildByLeader(String leader) { + if(leader == null) return null; for(Faction f : factions) { for(Guild guild : f.getGuildHandler().getGuilds()) { if(guild.isLeader(leader)) return guild; @@ -915,16 +919,17 @@ public static List getCopy(){ return c; } public static Faction getByLeader(String name) { + if(name == null) return null; for(Faction f : factions) { - if(f.getLeader().equalsIgnoreCase(name)) return f; + if(name.equals(f.getLeader())) return f; } return null; } public static Faction getByMember(String name) { if(name == null) return null; for(Faction f : factions) { - if(f.isMemberIgnoreCase(name)) return f; - if(f.getLeader().equalsIgnoreCase(name)) { + if(f.isMember(name)) return f; + if(name.equals(f.getLeader())) { f.addMember(name); return f; } @@ -1014,7 +1019,7 @@ public static void requestRelocation(Player sender, Guild g, Faction target, int public static void acceptRelocateRequest(Player p) { RelocateRequest req = (RelocateRequest) RequestManager.getRequest(p); - Faction reciever = FactionManager.getByLeader(p.getName()); + Faction reciever = FactionManager.getByLeader(Members.key(p)); if(reciever == null) { p.sendMessage("§cYou do not have a faction"); return; @@ -1073,7 +1078,7 @@ public static void requestElevation(Player sender, Guild target) { public static void acceptElevationRequest(Player p) { if (!(RequestManager.getRequest(p) instanceof ElevateRequest req)) return; - Guild guild = FactionManager.getGuildByLeader(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); return; @@ -1270,7 +1275,7 @@ boolean presentBooth(Player p, Block b) { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return true; } - if(!f.canVote(p.getName())) { + if(!f.canVote(Members.key(p))) { p.sendMessage("§cYou have no voting rights in this faction"); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return true; @@ -1289,7 +1294,7 @@ public void placeVotingBooth(BlockPlaceEvent e) { } void registerVotingBooth(Player p, Block b) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) return; if(!mayManageVotingBooths(p, f)) { p.sendMessage("§cOnly the faction leader can set up a voting booth"); @@ -1323,7 +1328,7 @@ boolean mayRemoveVotingBooth(Player p, Block b) { } private static boolean mayManageVotingBooths(Player p, Faction f) { - return p.getName().equalsIgnoreCase(f.getLeader()) || Permissions.isAdmin(p); + return Members.is(p, f.getLeader()) || Permissions.isAdmin(p); } void unregisterVotingBooth(Player p, Block b) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index d87ed35d..23412cc5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageAccess; import net.tfminecraft.simplefactions.managers.inventory.EspionageView; @@ -431,7 +433,7 @@ public boolean isPayingLoan(Player p) { } public void setPayingLoan(Player p, Loan loan) { - Guild guild = FactionManager.getGuildByLeader(p.getName());; + Guild guild = FactionManager.getGuildByLeader(Members.key(p));; if(guild == null) return; loanPayments.put(p, new LoanPayment(guild, loan)); p.closeInventory(); @@ -540,7 +542,7 @@ public void taxChat(Player p, AsyncPlayerChatEvent e) { */ private void submitRateProposal(Player p, Faction f, Proposal proposal) { Government gov = f.getGovernment(); - if(!gov.hasCouncil() && f.isLeader(p.getName())) { + if(!gov.hasCouncil() && f.isLeader(Members.key(p))) { p.sendMessage("§aChange applied!"); proposal.apply(null); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); @@ -556,7 +558,7 @@ private void submitRateProposal(Player p, Faction f, Proposal proposal) { p.sendTitle("", "§aProposal Added", 20, 80, 20); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } else if(gov.canProposeOrStartMovement(p)) { - Movement movement = gov.getMovementByMember(p.getName()); + Movement movement = gov.getMovementByMember(Members.key(p)); if(movement != null) { movement.createCause(p.getName(), proposal); p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!"); @@ -628,7 +630,7 @@ public void feeChat(Player p, AsyncPlayerChatEvent e) { @SuppressWarnings("deprecation") public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) { Guild g = loanPayments.get(p).getGuild(); - Guild compare = FactionManager.getGuildByLeader(p.getName()); + Guild compare = FactionManager.getGuildByLeader(Members.key(p)); if(compare == null) { loanPayments.remove(p); //player left guild return; @@ -815,7 +817,7 @@ public void slotChat(Player p, AsyncPlayerChatEvent e) { contractView.detailView(p, guild, contract.getId()); return; } - if (!company.isLeader(p.getName())) { + if (!company.isLeader(Members.key(p))) { slotChanges.remove(p); p.sendMessage("§cOnly the guild leader can change a contract's slots."); return; @@ -902,7 +904,7 @@ private boolean executeQueueCancel(Player player, QueueCancelPayload.Parsed pars return switch (parsed.type()) { case MILITARY -> { Faction f = FactionManager.getByString(parsed.ownerId()); - yield f != null && player.getName().equalsIgnoreCase(f.getLeader()) + yield f != null && Members.is(player, f.getLeader()) && f.getMilitary().cancelQueue(parsed.index()); } case GUILD_UPGRADE -> { @@ -911,21 +913,21 @@ private boolean executeQueueCancel(Player player, QueueCancelPayload.Parsed pars } case INSTALLATION -> { Faction f = FactionManager.getByString(parsed.ownerId()); - yield f != null && player.getName().equalsIgnoreCase(f.getLeader()) + yield f != null && Members.is(player, f.getLeader()) && f.getInstallationHandler().cancelPending(parsed.detail()); } case COMPANY_SLOT -> { Guild guild = FactionManager.getGuildByString(parsed.ownerId()); yield guild != null && guild.getCompany() != null - && guild.getCompany().isLeader(player.getName()) + && guild.getCompany().isLeader(Members.key(player)) && guild.getCompany().cancelSlotQueue(parsed.index()); } case COMPANY_UPGRADE -> { Guild guild = FactionManager.getGuildByString(parsed.ownerId()); yield guild != null && guild.getCompany() != null - && guild.getCompany().isLeader(player.getName()) + && guild.getCompany().isLeader(Members.key(player)) && guild.getCompany().cancelUpgradeQueue(parsed.index()); } }; @@ -1159,7 +1161,7 @@ public void clickButton(InventoryClickEvent e) { factionView(p, f); break; case DIPLOMACY_VIEW: - Faction viewer = FactionManager.getByMember(p.getName()); + Faction viewer = FactionManager.getByMember(Members.key(p)); if (viewer != null) { diplomacyListView(null, p, viewer, true); } else { @@ -1490,7 +1492,7 @@ public void clickButton(InventoryClickEvent e) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return; } - if(f == null || !f.getLeader().equalsIgnoreCase(p.getName())) return; + if(f == null || !Members.is(p, f.getLeader())) return; Regiment r = f.getMilitary().getRegiment(data); if(r == null) return; r.sizeDecrease(); @@ -1503,7 +1505,7 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if(data != null) { Faction f = confirming.get(p); - if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + if (f == null || !Members.is(p, f.getLeader())) { confirming.remove(p); p.closeInventory(); return; @@ -1529,7 +1531,7 @@ public void clickButton(InventoryClickEvent e) { Faction f = confirming.get(p); boolean fromCommand = installationConfirmFromCommand.getOrDefault(p, false); installationConfirmFromCommand.remove(p); - if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + if (f == null || !Members.is(p, f.getLeader())) { confirming.remove(p); p.closeInventory(); return; @@ -1557,7 +1559,7 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if(data != null) { Faction f = confirming.get(p); - if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + if (f == null || !Members.is(p, f.getLeader())) { confirming.remove(p); p.closeInventory(); return; @@ -1582,7 +1584,7 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if (data != null) { Faction f = confirming.get(p); - if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + if (f == null || !Members.is(p, f.getLeader())) { confirming.remove(p); p.closeInventory(); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java index 7b08b6c6..7bd87862 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -43,11 +45,11 @@ public static boolean handle(Player p, String command, String[] args) { if (guild) joinGuild(p, args[1]); else joinFaction(p, args[1]); } else if (guild) { - List invites = guildInvites(p.getName()); + List invites = guildInvites(Members.key(p)); joinOnlyInvite(p, GUILD, invites.stream().map(Guild::getId).toList(), invites.stream().map(Guild::getName).toList()); } else { - List invites = factionInvites(p.getName()); + List invites = factionInvites(Members.key(p)); joinOnlyInvite(p, FACTION, invites.stream().map(Faction::getId).toList(), invites.stream().map(Faction::getName).toList()); } @@ -119,7 +121,7 @@ static List factionInvites(String player) { } private static void joinGuild(Player p, String id) { - if (FactionManager.getGuildByLeader(p.getName()) != null) { + if (FactionManager.getGuildByLeader(Members.key(p)) != null) { p.sendMessage("§cYou are the leader of a guild"); return; } @@ -132,22 +134,22 @@ private static void joinGuild(Player p, String id) { p.sendMessage("§cNo guild by the id " + id); return; } - if (!g.consumeInvite(p.getName())) { + if (!g.consumeInvite(Members.key(p))) { p.sendMessage("§cYou need to be invited to this guild by the leader first!"); return; } - Guild previous = FactionManager.getGuildByMember(p.getName()); + Guild previous = FactionManager.getGuildByMember(Members.key(p)); if (previous != null) { - previous.kick(p.getName()); + previous.kick(Members.key(p)); } - g.addMember(p.getName()); + g.addMember(Members.key(p)); p.sendMessage("§aJoined " + g.getName()); g.getFaction().updatePrestige(); tellOthers(g.getFaction(), p, "§a" + p.getName() + " joined the guild " + g.getName() + "§a!"); } private static void joinFaction(Player p, String id) { - if (FactionManager.getByMember(p.getName()) != null) { + if (FactionManager.getByMember(Members.key(p)) != null) { p.sendMessage("§cAlready in a faction, leave your current faction first!"); return; } @@ -156,11 +158,11 @@ private static void joinFaction(Player p, String id) { p.sendMessage("§cNo faction with that name exists"); return; } - if (!f.consumeInvite(p.getName())) { + if (!f.consumeInvite(Members.key(p))) { p.sendMessage("§cYou need to be invited to this faction by the leader first!"); return; } - f.addMember(p.getName()); + f.addMember(Members.key(p)); p.sendMessage("§aJoined " + f.getName()); f.updatePrestige(); tellOthers(f, p, "§a" + p.getName() + " joined the faction!"); @@ -168,7 +170,7 @@ private static void joinFaction(Player p, String id) { private static void declineGuild(Player p, String id) { Guild g = FactionManager.getGuildByString(id); - if (g == null || !g.consumeInvite(p.getName())) { + if (g == null || !g.consumeInvite(Members.key(p))) { p.sendMessage("§cYou have no invite from that guild"); return; } @@ -178,7 +180,7 @@ private static void declineGuild(Player p, String id) { private static void declineFaction(Player p, String id) { Faction f = FactionManager.getByString(id); - if (f == null || !f.consumeInvite(p.getName())) { + if (f == null || !f.consumeInvite(Members.key(p))) { p.sendMessage("§cYou have no invite from that faction"); return; } @@ -188,7 +190,7 @@ private static void declineFaction(Player p, String id) { private static void tellOthers(Faction f, Player joiner, String message) { for (Player pl : Bukkit.getOnlinePlayers()) { - if (!pl.equals(joiner) && f.getMembers().contains(pl.getName())) { + if (!pl.equals(joiner) && f.getMembers().contains(Members.key(pl))) { pl.sendMessage(message); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java index ee7d2885..854a19c3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.List; import org.bukkit.command.Command; @@ -136,7 +138,7 @@ private boolean market(Player p, String[] args) { /** Hands out stage 1 of the negotiation book. */ private void draft(Player p) { MercenaryCompany company = company(p); - if (company == null || !company.isLeader(p.getName())) { + if (company == null || !company.isLeader(Members.key(p))) { p.sendMessage("§cOnly a company leader may draft a contract."); return; } @@ -153,7 +155,7 @@ private void draft(Player p) { */ private void offer(Player p, String factionName) { MercenaryCompany company = company(p); - if (company == null || !company.isLeader(p.getName())) { + if (company == null || !company.isLeader(Members.key(p))) { p.sendMessage("§cOnly a company leader may offer a contract."); return; } @@ -264,8 +266,8 @@ private static MercenaryCompany company(Player p) { } private static Guild leaderGuild(Player p) { - Guild guild = FactionManager.getGuildByLeader(p.getName()); - return guild != null ? guild : FactionManager.getGuildByMember(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); + return guild != null ? guild : FactionManager.getGuildByMember(Members.key(p)); } private static void report(Player p, MercenaryResult result) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 7481f4f5..7961f6cb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.function.Consumer; import org.bukkit.Bukkit; import org.bukkit.Chunk; @@ -112,7 +114,7 @@ public void run() { }.runTaskLater(SimpleFactions.plugin, 1L); } else if(stage == 3) { e.setCancelled(true); - Guild borrowerGuild = FactionManager.getGuildByLeader(p.getName()); + Guild borrowerGuild = FactionManager.getGuildByLeader(Members.key(p)); String offerId = LoanBook.offerId(newMeta); Loan loan = offerId == null ? null : LoanBook.createLoanFromBook(newMeta, borrowerGuild, offerId); String validate = newMeta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); @@ -221,7 +223,7 @@ private void signContractBook( return; } if(stage == ContractBook.STAGE_DRAFT) { - if(!company.isLeader(p.getName())) { + if(!company.isLeader(Members.key(p))) { p.sendMessage("§cOnly the company leader may draft a contract."); return; } @@ -235,7 +237,7 @@ private void signContractBook( return; } if(stage == ContractBook.STAGE_REVIEW) { - if(!company.isLeader(p.getName())) { + if(!company.isLeader(Members.key(p))) { p.sendMessage("§cOnly the company leader may publish an offer."); return; } @@ -360,8 +362,8 @@ public void bankPulse(PlayerBankPulseEvent e) { } private boolean isInBankChunk(Player p) { - Faction f = FactionManager.getByMember(p.getName()); - Guild g = FactionManager.getGuildByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); + Guild g = FactionManager.getGuildByMember(Members.key(p)); if (f == null && g == null) { p.sendMessage("§cYou must belong to a guild or faction to bank here."); @@ -377,8 +379,8 @@ private boolean isInBankChunk(Player p) { } private boolean inFactionOrGuildBankChunk(Player p) { - Faction f = FactionManager.getByMember(p.getName()); - Guild g = FactionManager.getGuildByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); + Guild g = FactionManager.getGuildByMember(Members.key(p)); Chunk playerChunk = p.getLocation().getChunk(); if (f != null && f.getBank() != null && f.getBank().getChunk() != null diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index 3fc9f446..b54222ec 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -626,15 +628,15 @@ private static void sendRequest(Player sender, Faction f, RelationType type) { return; } sender.sendMessage("§aSent a request to "+f.getName()+" §afor them to become your "+type.getName()); - p.sendMessage(FactionManager.getByLeader(sender.getName()).getName()+" §7is requesting that you become their "+type.getName()); + p.sendMessage(FactionManager.getByLeader(Members.key(sender)).getName()+" §7is requesting that you become their "+type.getName()); p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); - RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(sender.getName()).getOrCreateMainGuild(), type, false)); + RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(Members.key(sender)).getOrCreateMainGuild(), type, false)); } public static void acceptRequest(Player p) { RelationRequest req = (RelationRequest) RequestManager.getRequest(p); - Faction reciever = FactionManager.getByLeader(p.getName()); + Faction reciever = FactionManager.getByLeader(Members.key(p)); if(reciever == null) { p.sendMessage("§cYou do not have a faction"); return; @@ -664,15 +666,15 @@ private static void sendTradeRequest(Player sender, Faction f, RelationType type return; } sender.sendMessage("§aSent a request to "+f.getName()+" §ato set trade to "+type.getName()); - p.sendMessage(FactionManager.getByLeader(sender.getName()).getName()+" §7is requesting that you set trade to "+type.getName()); + p.sendMessage(FactionManager.getByLeader(Members.key(sender)).getName()+" §7is requesting that you set trade to "+type.getName()); p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); - RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(sender.getName()).getOrCreateMainGuild(), type, true)); + RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(Members.key(sender)).getOrCreateMainGuild(), type, true)); } public static void acceptTradeRequest(Player p) { RelationRequest req = (RelationRequest) RequestManager.getRequest(p); - Faction reciever = FactionManager.getByLeader(p.getName()); + Faction reciever = FactionManager.getByLeader(Members.key(p)); if(reciever == null) { p.sendMessage("§cYou do not have a faction"); return; @@ -695,15 +697,15 @@ private static void sendTreatyRequest(Player sender, Faction f, RelationType typ return; } sender.sendMessage("§aSent a request to "+f.getName()+" §ato set treaty to "+type.getName()); - p.sendMessage(FactionManager.getByLeader(sender.getName()).getName()+" §7is requesting that you set treaty to "+type.getName()); + p.sendMessage(FactionManager.getByLeader(Members.key(sender)).getName()+" §7is requesting that you set treaty to "+type.getName()); p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); - RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(sender.getName()).getOrCreateMainGuild(), type, false, true)); + RequestManager.addRequest(sender, p, new RelationRequest(FactionManager.getByLeader(Members.key(sender)).getOrCreateMainGuild(), type, false, true)); } public static void acceptTreatyRequest(Player p) { RelationRequest req = (RelationRequest) RequestManager.getRequest(p); - Faction reciever = FactionManager.getByLeader(p.getName()); + Faction reciever = FactionManager.getByLeader(Members.key(p)); if(reciever == null) { p.sendMessage("§cYou do not have a faction"); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java index 7723a06f..9f1c0818 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.HashMap; import java.util.Map; import java.util.ArrayList; @@ -78,12 +80,12 @@ public void onPlayerInteract(PlayerInteractEvent event) { if (clickedBlock == null) return; if (!clickedBlock.getType().equals(Material.LANTERN)) return; - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null) return; Council council = faction.getGovernment().getCouncil(); Session session = getSession(council); - if (session != null && (faction.getLeader().equals(player.getName()) || faction.getGovernment().isCouncilMember(player))) { + if (session != null && (Members.is(player, faction.getLeader()) || faction.getGovernment().isCouncilMember(player))) { if(session.isStarted() && clickedBlock.equals(session.getLantern())) { event.setCancelled(true); Proposal currentProposal = session.getCurrentProposal(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java index 3cfb094b..627a1e62 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -49,7 +51,7 @@ public void run() { private void completeTitle(Player p, Tier pending, String message) { if (!p.isOnline()) return; - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); Tier tier = TierLoader.getByString(pending.getId()); if (f == null || tier != pending || !tier.canForm() || !f.getGovernment().stateReport().status.canFormTitles()) return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java index 1e5b6791..20fe37c0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -493,18 +495,18 @@ public static void sendRequest(Player sender, Faction origin, Faction target, Wa return; } sender.sendMessage("§aSent a call to arms to "+target.getName()); - p.sendMessage(FactionManager.getByLeader(sender.getName()).getName()+" §7is requesting that you aid them in their war against "+w.getEnemy(origin).getName()); + p.sendMessage(FactionManager.getByLeader(Members.key(sender)).getName()+" §7is requesting that you aid them in their war against "+w.getEnemy(origin).getName()); p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Type §c/faction decline §7to decline"); p.sendMessage("§7Request will time out in 60 seconds"); - RequestManager.addRequest(sender, p, new WarRequest(FactionManager.getByLeader(sender.getName()).getOrCreateMainGuild(), w, target.getId())); + RequestManager.addRequest(sender, p, new WarRequest(FactionManager.getByLeader(Members.key(sender)).getOrCreateMainGuild(), w, target.getId())); } public static void acceptRequest(Player p) { if (!(RequestManager.getRequest(p) instanceof WarRequest req)) { return; } - Faction reciever = FactionManager.getByLeader(p.getName()); + Faction reciever = FactionManager.getByLeader(Members.key(p)); if(reciever == null) { p.sendMessage("§cYou do not have a faction"); return; @@ -545,7 +547,7 @@ public static void declineCallToArms(Player called, WarRequest req, boolean expl // A stored target that no longer exists penalises nobody. Faction receiver = req.getTargetFactionId() != null ? FactionManager.getByString(req.getTargetFactionId()) - : called != null ? FactionManager.getByLeader(called.getName()) : null; + : called != null ? FactionManager.getByLeader(Members.key(called)) : null; if (receiver != null && receiver.getGovernment() != null) { receiver.getGovernment().addStabilityModifier(new StabilityModifier( "Declined Call to Arms", diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java index 980fcadb..a4b5611b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.war.campaign.runtime.pick.BattleInstallationPickService.InstallationPickResults; import java.time.Instant; @@ -166,7 +168,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { return; } - if (!viewerFaction.isLeader(player.getName())) { + if (!viewerFaction.isLeader(Members.key(player))) { player.sendMessage("§cOnly your faction leader can select installations for this battle."); return; } @@ -200,11 +202,11 @@ static String toggleMessage(InstallationPickToggleResult result, String installa } private Faction resolveViewerFaction(Player player, War war) { - Faction leaderFaction = FactionManager.getByLeader(player.getName()); + Faction leaderFaction = FactionManager.getByLeader(Members.key(player)); if (leaderFaction != null && war.isParticipating(leaderFaction)) { return leaderFaction; } - Faction memberFaction = FactionManager.getByMember(player.getName()); + Faction memberFaction = FactionManager.getByMember(Members.key(player)); if (memberFaction != null && war.isParticipating(memberFaction)) { return memberFaction; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java index b6795cd4..86730dbc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidResults; import java.time.Instant; @@ -196,7 +198,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { return; } - if (!viewerFaction.isLeader(player.getName())) { + if (!viewerFaction.isLeader(Members.key(player))) { player.sendMessage(CampaignRaidMessages.NOT_LEADER); return; } @@ -287,11 +289,11 @@ private boolean validateParticipation(Player player, War war, Faction viewerFact } private Faction resolveViewerFaction(Player player, War war) { - Faction leaderFaction = FactionManager.getByLeader(player.getName()); + Faction leaderFaction = FactionManager.getByLeader(Members.key(player)); if (leaderFaction != null && war.isParticipating(leaderFaction)) { return leaderFaction; } - Faction memberFaction = FactionManager.getByMember(player.getName()); + Faction memberFaction = FactionManager.getByMember(Members.key(player)); if (memberFaction != null && war.isParticipating(memberFaction)) { return memberFaction; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignView.java index ec36b45d..bffe0f18 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidResults; import java.time.Instant; @@ -95,9 +97,9 @@ public void campaignView(Player player, War war, boolean open) { return; } - Faction viewerFaction = FactionManager.getByLeader(player.getName()); + Faction viewerFaction = FactionManager.getByLeader(Members.key(player)); if (viewerFaction == null) { - viewerFaction = FactionManager.getByMember(player.getName()); + viewerFaction = FactionManager.getByMember(Members.key(player)); } if (viewerFaction == null) { if (open) { @@ -221,7 +223,7 @@ private void populateHourToggles(Inventory inventory, War war, Faction viewerFac } private void populatePostBattleChoiceButtons(Inventory inventory, War war, Player player) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); for (int slot : List.of(40, 41, 42, 43)) { inventory.setItem(slot, inv.getFiller(Material.GRAY_STAINED_GLASS_PANE)); } @@ -306,9 +308,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { CampaignCreator.installationsEntryWarKey(), PersistentDataType.INTEGER); if (installationsEntryWarId != null && installationsEntryWarId == war.getId()) { - Faction viewerFaction = FactionManager.getByLeader(player.getName()); + Faction viewerFaction = FactionManager.getByLeader(Members.key(player)); if (viewerFaction == null) { - viewerFaction = FactionManager.getByMember(player.getName()); + viewerFaction = FactionManager.getByMember(Members.key(player)); } if (viewerFaction == null || !war.isParticipating(viewerFaction)) { player.sendMessage("§cYou are not a belligerent in this war."); @@ -323,15 +325,15 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { CampaignCreator.raidEntryWarKey(), PersistentDataType.INTEGER); if (raidEntryWarId != null && raidEntryWarId == war.getId()) { - Faction viewerFaction = FactionManager.getByLeader(player.getName()); + Faction viewerFaction = FactionManager.getByLeader(Members.key(player)); if (viewerFaction == null) { - viewerFaction = FactionManager.getByMember(player.getName()); + viewerFaction = FactionManager.getByMember(Members.key(player)); } if (viewerFaction == null || !war.isParticipating(viewerFaction)) { player.sendMessage(CampaignRaidMessages.NOT_PARTICIPANT); return; } - if (!viewerFaction.isLeader(player.getName())) { + if (!viewerFaction.isLeader(Members.key(player))) { player.sendMessage(CampaignRaidMessages.NOT_LEADER); return; } @@ -370,7 +372,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot retreat right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_retreat", String.valueOf(war.getId())); @@ -386,7 +388,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot surrender right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_surrender", String.valueOf(war.getId())); @@ -402,7 +404,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot accept white peace right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_accept_peace", String.valueOf(war.getId())); @@ -418,7 +420,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot push right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_push", String.valueOf(war.getId())); @@ -434,7 +436,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot hold right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_hold", String.valueOf(war.getId())); @@ -450,7 +452,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot attack right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_attack", String.valueOf(war.getId())); @@ -466,7 +468,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { player.sendMessage("§cYou cannot accept peace right now."); return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); inv.confirming.put(player, leader); inv.campaignConfirmWar.put(player, war.getId()); inv.confirmView(player, leader, "campaign_loser_peace", String.valueOf(war.getId())); @@ -483,7 +485,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { } private void handleHourToggleClick(Player player, War war, int hour) { - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null || !BattleVoterEligibility.canToggleVote(war, faction, CampaignClock.now())) { player.sendMessage("§cYou cannot vote for battle hours right now."); return; @@ -546,7 +548,7 @@ public void handleConfirm(Player player, String key, String data, boolean confir return; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); if (leader == null) { return; } @@ -649,7 +651,7 @@ public void handleConfirm(Player player, String key, String data, boolean confir } public boolean canRetreat(Player player, War war) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); if (leader == null) { return false; } @@ -664,7 +666,7 @@ public boolean canSurrender(Player player, War war) { } public boolean canAcceptWhitePeace(Player player, War war) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); if (leader == null) { return false; } @@ -672,17 +674,17 @@ public boolean canAcceptWhitePeace(Player player, War war) { } public boolean canMakePostBattleChoice(Player player, War war) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); return leader != null && CampaignPostBattleChoiceService.isChoiceLeader(war, leader); } public boolean isDefenderLeader(Player player, War war) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); return leader != null && leader.getId().equalsIgnoreCase(war.getDefenderLeaderId()); } public boolean isAttackerLeader(Player player, War war) { - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); return leader != null && leader.getId().equalsIgnoreCase(war.getAttackerLeaderId()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java index e1113459..2fb06c8d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryClickEvent; @@ -92,7 +94,7 @@ public void slotsView(Player player, Guild guild, Inventory i) { for (int slot = 0; slot < visibleSlots; slot++) { i.setItem(FIRST_SLOT_BUTTON + slot, creator.createSlotItem(company, firstVisibleSlot + slot)); } - if (company.isLeader(player.getName())) { + if (company.isLeader(Members.key(player))) { i.setItem(EXPAND_BUTTON, creator.createExpandButton(company)); } for (int x = 0; x < company.getSlotQueue().size() && x < 3; x++) { @@ -119,7 +121,7 @@ public void rosterView(Player player, Guild guild, Inventory i) { i.clear(); MercenaryCompany company = guild.getCompany(); if (company == null) return; - boolean leader = company.isLeader(player.getName()); + boolean leader = company.isLeader(Members.key(player)); for (int x = 0; x < company.getEnlisted().size() && x < 36; x++) { i.setItem(9 + x, creator.createRosterItem(company, company.getEnlisted().get(x), leader)); } @@ -223,7 +225,7 @@ private void clickSlots( } String queuePayload = queueCancelPayload(e.getCurrentItem()); if (queuePayload != null) { - if (!company.isLeader(p.getName())) return; + if (!company.isLeader(Members.key(p))) return; inv.openQueueCancelConfirm(p, guild.getFaction(), queuePayload, "§eCancel queued slot expansion?"); chirp(p); } @@ -241,7 +243,7 @@ private void clickUpgrade( InventoryClickEvent e, Inventory inventory, Player p, Guild guild, MercenaryCompany company) { String queuePayload = queueCancelPayload(e.getCurrentItem()); if (queuePayload != null) { - if (!company.isLeader(p.getName())) return; + if (!company.isLeader(Members.key(p))) return; inv.openQueueCancelConfirm(p, guild.getFaction(), queuePayload, "§eCancel queued company upgrade?"); chirp(p); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java index 9085b86b..b3aef9db 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryClickEvent; @@ -67,7 +69,7 @@ public void listView(Player player, Guild guild, Inventory i) { i.setItem(9 + index, creator.createContractItem(contract)); index++; } - if (company.isLeader(player.getName())) { + if (company.isLeader(Members.key(player))) { i.setItem(DRAFT_BUTTON, creator.createDraftButton(company)); } i.setItem(53, inv.createBackButton(SFGUI.CONTRACT_LIST_VIEW)); @@ -101,7 +103,7 @@ public void detailView(Player player, Guild guild, Inventory i, String contractI if (contract.isActive()) { long now = System.currentTimeMillis(); boolean pending = contract.hasPendingSlots(now); - if (company.isLeader(player.getName())) { + if (company.isLeader(Members.key(player))) { i.setItem(CHANGE_SLOTS_BUTTON, pending ? creator.createWithdrawSlotsButton() : creator.createChangeSlotsButton()); @@ -118,7 +120,7 @@ public void detailView(Player player, Guild guild, Inventory i, String contractI public static boolean canSign(Player player, MercenaryContract contract) { Faction hirer = contract.getHirer(); if (hirer == null || hirer.getGovernment() == null) return false; - return hirer.getGovernment().isCouncilMember(player.getName()); + return hirer.getGovernment().isCouncilMember(Members.key(player)); } /* ===================================================== @@ -142,7 +144,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { private void clickList(InventoryClickEvent e, Player p, Guild guild, MercenaryCompany company) { if (e.getSlot() == DRAFT_BUTTON) { - if (!company.isLeader(p.getName())) return; + if (!company.isLeader(Members.key(p))) return; for (ItemStack leftover : p.getInventory().addItem(ContractBook.draftBook(company)).values()) { p.getWorld().dropItemNaturally(p.getLocation(), leftover); } @@ -175,7 +177,7 @@ private void clickDetail( } if (!contract.isActive()) return; long now = System.currentTimeMillis(); - if (e.getSlot() == CHANGE_SLOTS_BUTTON && company.isLeader(p.getName())) { + if (e.getSlot() == CHANGE_SLOTS_BUTTON && company.isLeader(Members.key(p))) { if (contract.hasPendingSlots(now)) { report(p, company.getContractHandler().withdrawSlots(contractId, p.getName(), now)); detailView(p, guild, inventory, contractId); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java index ec9d3688..689c3b3e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.List; import org.bukkit.Bukkit; @@ -325,7 +327,7 @@ public void handleConfirm(Player player, WarDeclareRequest request, boolean conf Faction defender = request.getDefender(); if (FactionManager.getByString(attacker.getId()) != attacker || FactionManager.getByString(defender.getId()) != defender - || !attacker.isLeader(player.getName())) { + || !attacker.isLeader(Members.key(player))) { WarDeclareCodeService.clearSession(player); player.sendMessage("§cThis war declaration is no longer available."); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java index 4cf3cc66..ca316a72 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -54,7 +56,7 @@ public ItemStack createCandidateTypeItem(Player p, Faction f, Candidate c) { } else { lore.add(StringFormatter.formatHex("#28ed70Click to sign up")); } - if(gov.hasElection() && e.canVote(p.getName())){ + if(gov.hasElection() && e.canVote(Members.key(p))){ if(!e.hasVoted(c, p.getName())) { lore.add(StringFormatter.formatHex("#28ed70Click to view candidates")); } else { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java index 078bdad7..855bb9fa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.List; import org.bukkit.Sound; @@ -103,7 +105,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Candidate c = Candidate.valueOf(id); Election election = f.getGovernment().getElection(); if(election.isActive()) { - if(election.canVote(p.getName()) && !election.hasVoted(c, p.getName())) { + if(election.canVote(Members.key(p)) && !election.hasVoted(c, p.getName())) { votingView(p, f, c); } } else { @@ -129,7 +131,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(cid == null) return; try { Candidate type = Candidate.valueOf(cid); - if(election.canVote(p.getName()) && election.isActive()) { + if(election.canVote(Members.key(p)) && election.isActive()) { if(!election.hasVoted(type, p.getName()) && election.getCandidates(type).contains(candidateName)) { election.addVote(type, p.getName(), candidateName); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 8640afa5..29194aa2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.espionage.IntelligenceLedger; @@ -70,7 +72,7 @@ public static void foreign(Inventory inventory, Player viewer, Faction target, I } static ItemStack reportHeader(Player viewer, IntelligenceReport report) { - var observer = FactionManager.getByMember(viewer.getName()); + var observer = FactionManager.getByMember(Members.key(viewer)); var spy = observer == null ? null : observer.getEspionage().getSpymaster(); boolean missingSpymaster = !EspionageService.hasSpymaster(observer); String last = missingSpymaster ? "\u00a77The Spymaster's office stands vacant; no findings reach your court." @@ -288,7 +290,7 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana head.setItemMeta(meta); } inventory.setItem(13, head); - if (faction.isLeader(viewer.getName())) { + if (faction.isLeader(Members.key(viewer))) { long next = EspionageService.nextAppointmentAt(faction); inventory.setItem(11, item(Material.NAME_TAG, "Appoint Spymaster", "§7Choose a member of your faction.", buildUpTerms(), @@ -374,7 +376,7 @@ private static ItemStack conduct(boolean offense, int reduction) { } private static void candidates(Player viewer, Faction faction, InventoryManager manager, int page) { - if (!EspionageService.isOwn(viewer, faction) || !faction.isLeader(viewer.getName())) return; + if (!EspionageService.isOwn(viewer, faction) || !faction.isLeader(Members.key(viewer))) return; List members = faction.getMembers().stream().distinct() .filter(name -> EspionageService.eligible(faction, name)).sorted(String.CASE_INSENSITIVE_ORDER).toList(); int safePage = Math.max(0, Math.min(page, Math.max(0, (members.size() - 1) / 45))); @@ -460,7 +462,7 @@ else if (slot == 11 || slot == 15) { settings(viewer, faction, manager); } } else { - if (!faction.isLeader(viewer.getName())) { viewer.closeInventory(); return; } + if (!faction.isLeader(Members.key(viewer))) { viewer.closeInventory(); return; } if (slot == 53) spymasterOffice(viewer, faction, manager); else if (slot == 45 || slot == 52) candidates(viewer, faction, manager, menu.getPage() + (slot == 45 ? -1 : 1)); else if (event.getCurrentItem() != null && event.getCurrentItem().hasItemMeta()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 4cb45c40..6d0dd38c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.util.LegacyModelData; @@ -112,7 +114,7 @@ public List listLore(Player p, Faction f) { if (f.getProsperity() > 0) lore.add(StringFormatter.formatHex("#4bb244Prosperity: #4fd945" + f.getProsperity())); } else lore.add(StringFormatter.formatHex("#4bb244Prosperity: #4fd945" + intelligence(report, "Prosperity", ""))); if(p != null && !EspionageService.isOwn(p, f)) { - Faction origin = FactionManager.getByMember(p.getName()); + Faction origin = FactionManager.getByMember(Members.key(p)); if(origin != null) { Relation r = origin.getRelation(f.getId()); Relation ofR = f.getRelation(origin.getId()); @@ -497,7 +499,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { } else if(t.equals(MenuItemType.TIER)) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#c45749§lTier: "+f.getTier().getFormattedName())); - if(f.getLeader().equalsIgnoreCase(p.getName())) { + if(Members.is(p, f.getLeader())) { List lore = new ArrayList(); lore.add("§7Click to edit Tier"); m.setLore(lore); @@ -509,10 +511,10 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#8acc6eTitles")); List lore = new ArrayList(); - if(f.getLeader().equalsIgnoreCase(p.getName())) { + if(Members.is(p, f.getLeader())) { lore.add("§7Click to view Titles"); } else { - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); String overlord = RelationManager.getOverlord(f); if(pf != null && overlord != null && overlord.equalsIgnoreCase(pf.getId())) { lore.add("§7Click to view Titles to grant to "+f.getName()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java index 494b9b08..3cb35886 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Collections; @@ -123,9 +125,9 @@ public void factionView(Player player, Faction f, Inventory i) { } i.clear(); i.setItem(20, EspionageService.isOwn(player, f) ? EspionageView.positionButton() : EspionageView.foreignPositionsItem(player, f)); - if(f.getMembers().contains(player.getName())) i.setItem(1, creator.createMenuItem(player, f, MenuItemType.BANNER_GET)); + if(f.getMembers().contains(Members.key(player))) i.setItem(1, creator.createMenuItem(player, f, MenuItemType.BANNER_GET)); i.setItem(10, creator.createMenuItem(player, f, MenuItemType.BANNER)); - if(f.getLeader().equalsIgnoreCase(player.getName())) i.setItem(19, creator.createMenuItem(player, f, MenuItemType.BANNER_RANDOM)); + if(Members.is(player, f.getLeader())) i.setItem(19, creator.createMenuItem(player, f, MenuItemType.BANNER_RANDOM)); i.setItem(11, creator.createMenuItem(player, f, MenuItemType.GOVERNMENT)); i.setItem(12, creator.createMenuItem(player, f, MenuItemType.WEALTH)); i.setItem(13, creator.createMenuItem(player, f, MenuItemType.PRESTIGE)); @@ -136,7 +138,7 @@ public void factionView(Player player, Faction f, Inventory i) { i.setItem(25, creator.createMenuItem(player, f, MenuItemType.TAX)); i.setItem(28, creator.createMenuItem(player, f, MenuItemType.LAWS)); i.setItem(29, creator.createMenuItem(player, f, MenuItemType.MILITARY)); - if(f.canDissolve() && f.isLeader(player.getName())) i.setItem(30, creator.createDissolveItem(f)); + if(f.canDissolve() && f.isLeader(Members.key(player))) i.setItem(30, creator.createDissolveItem(f)); i.setItem(31, creator.createMenuItem(player, f, MenuItemType.DIPLOMACY)); i.setItem(32, creator.createMenuItem(player, f, MenuItemType.INSTALLATIONS)); i.setItem(33, creator.createMenuItem(player, f, MenuItemType.TIER)); @@ -361,7 +363,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { String factionId = m.getPersistentDataContainer().get(id, PersistentDataType.STRING); if(factionId == null) return; Faction f = FactionManager.getByString(factionId); - if(f != null && f.getLeader().equalsIgnoreCase(p.getName())) { + if(f != null && Members.is(p, f.getLeader())) { inv.tierView(null, p, f, true); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } @@ -373,10 +375,10 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(factionId == null) return; Faction f = FactionManager.getByString(factionId); if(f != null) { - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); if(pf == null) return; String overlord = RelationManager.getOverlord(f); - if(f.getLeader().equalsIgnoreCase(p.getName()) || (overlord != null && overlord.equalsIgnoreCase(pf.getId()))) { + if(Members.is(p, f.getLeader()) || (overlord != null && overlord.equalsIgnoreCase(pf.getId()))) { inv.titleView(null, p, f, true); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } @@ -385,9 +387,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); - if(!f.isLeader(p.getName())) return; + if(!f.isLeader(Members.key(p))) return; BannerFetcher.fetch("faction:" + f.getId(), patterns -> { - if (FactionManager.getByString(f.getId()) != f || !f.isLeader(p.getName())) return; + if (FactionManager.getByString(f.getId()) != f || !f.isLeader(Members.key(p))) return; if (patterns == null) { if (p.isOnline()) p.sendMessage("§cCould not generate a banner right now. Try again later."); return; @@ -401,7 +403,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); - if (f == null || !f.isMemberIgnoreCase(p.getName())) return; + if (f == null || !f.isMember(Members.key(p))) return; ItemStack i = new ItemStack(f.getBanner()); p.getInventory().addItem(i); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index 677ca7e6..9ee81d0b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -424,7 +426,7 @@ public ItemStack createStanceItem(Faction f, Guild guild, boolean canChange) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createToggleRefuseButton(Player p, Faction f) { - boolean refuse = f.getGovernment().getCouncil().refuses(p.getName()); + boolean refuse = f.getGovernment().getCouncil().refuses(Members.key(p)); ItemStack item = TLibs.getItemAPI().getCreator().getItemsAdderItem(refuse ? "mcicons:icon_cancel" : "mcicons:icon_confirm"); ItemMeta m = item.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#a27cbfToggle Council Stance")); @@ -534,7 +536,7 @@ public ItemStack createProposalItem(Player p, Faction f) { if(gov.isCouncilMember(p)) { lore.add(StringFormatter.formatHex("#b8ae61Create a new proposal to change")); lore.add(StringFormatter.formatHex("#b8ae61your faction's laws or taxes.")); - lore.add(StringFormatter.formatHex("#525d5dCurrently Active Proposals: #e3d5a1"+gov.getCouncil().getCurrentProposals(p.getName())+"/2")); + lore.add(StringFormatter.formatHex("#525d5dCurrently Active Proposals: #e3d5a1"+gov.getCouncil().getCurrentProposals(Members.key(p))+"/2")); } else { lore.add(StringFormatter.formatHex("#b8ae61Create a new movement to demand")); lore.add(StringFormatter.formatHex("#b8ae61changes to your faction's laws or taxes.")); @@ -542,7 +544,7 @@ public ItemStack createProposalItem(Player p, Faction f) { lore.add(StringFormatter.formatHex("#b8ae61ultimatum to the council.")); lore.add(StringFormatter.formatHex("§7(#812222Potential Civil War§7)")); } - if(gov.canProposeOrStartMovement(p) && gov.getMovementByMember(p.getName()) == null) lore.add(StringFormatter.formatHex("#28ed70Click to start")); + if(gov.canProposeOrStartMovement(p) && gov.getMovementByMember(Members.key(p)) == null) lore.add(StringFormatter.formatHex("#28ed70Click to start")); else lore.add(StringFormatter.formatHex("#89504eYou cannot start a new proposal/movement at this time.")); m.setLore(lore); item.setItemMeta(m); @@ -594,7 +596,7 @@ public ItemStack createCouncilMemberItem(Player player, Faction f, int slot) { String memberName = slot < members.size() ? members.get(slot) : null; boolean isEmpty = memberName == null; - boolean isLeader = player.getName().equalsIgnoreCase(f.getLeader()); + boolean isLeader = Members.is(player, f.getLeader()); boolean canModify = isLeader && ( council.getType().equals(Rules.APPOINTED_COUNCIL) || council.getType().equals(Rules.WEALTH_BASED_COUNCIL) || @@ -914,14 +916,14 @@ public ItemStack createFavourRepressGuildItem(Player p, Faction f, Guild guild, if(gov.canFavour(guild)) { lore.add(StringFormatter.formatHex("#eddda8If Toggled:")); EconomicImpact.applyFavourRepressChange(lore, p, f, guild, isFavourMode, false, m, false); - if(f.isLeader(p.getName())) { + if(f.isLeader(Members.key(p))) { if(!guild.isFavoured()) { lore.add(StringFormatter.formatHex("#eddda8Upkeep: §e" + guild.getRepressFavourCost()+" Administrative Power")); } lore.add(""); lore.add(StringFormatter.formatHex("#28ed70Click to toggle")); } - } else if(f.isLeader(p.getName())) { + } else if(f.isLeader(Members.key(p))) { lore.add(""); if(guild.isRepressed()) { lore.add(StringFormatter.formatHex("#89504eCannot favour: Guild is repressed")); @@ -939,14 +941,14 @@ public ItemStack createFavourRepressGuildItem(Player p, Faction f, Guild guild, if(gov.canRepress(guild)) { lore.add(StringFormatter.formatHex("#eddda8If Toggled:")); EconomicImpact.applyFavourRepressChange(lore, p, f, guild, isFavourMode, false, m, false); - if(f.isLeader(p.getName())) { + if(f.isLeader(Members.key(p))) { if(!guild.isRepressed()) { lore.add(StringFormatter.formatHex("#eddda8Upkeep: §e" + guild.getRepressFavourCost()+" Administrative Power")); } lore.add(""); lore.add(StringFormatter.formatHex("#28ed70Click to toggle")); } - } else if(f.isLeader(p.getName())) { + } else if(f.isLeader(Members.key(p))) { lore.add(""); if(guild.isFavoured()) { lore.add(StringFormatter.formatHex("#89504eCannot repress: Guild is favoured")); @@ -983,14 +985,14 @@ public ItemStack createFavourRepressVassalItem(Player p, Faction f, Faction vass if(gov.canFavour(mainGuild)) { lore.add(StringFormatter.formatHex("#eddda8If Toggled:")); EconomicImpact.applyFavourRepressChange(lore, p, f, mainGuild, isFavourMode, false, m, false); - if(f.isLeader(p.getName())) { + if(f.isLeader(Members.key(p))) { if(!mainGuild.isFavoured()) { lore.add(StringFormatter.formatHex("#eddda8Upkeep: §e" + mainGuild.getRepressFavourCost()+" Administrative Power")); } lore.add(""); lore.add(StringFormatter.formatHex("#28ed70Click to toggle")); } - } else if(f.isLeader(p.getName())) { + } else if(f.isLeader(Members.key(p))) { lore.add(""); if(mainGuild.isRepressed()) { lore.add(StringFormatter.formatHex("#89504eCannot favour: Vassal is repressed")); @@ -1008,14 +1010,14 @@ public ItemStack createFavourRepressVassalItem(Player p, Faction f, Faction vass if(gov.canRepress(mainGuild)) { lore.add(StringFormatter.formatHex("#eddda8If Toggled:")); EconomicImpact.applyFavourRepressChange(lore, p, f, mainGuild, isFavourMode, false, m, false); - if(f.isLeader(p.getName())) { + if(f.isLeader(Members.key(p))) { lore.add(""); if(!mainGuild.isRepressed()) { lore.add(StringFormatter.formatHex("#eddda8Upkeep: §e" + mainGuild.getRepressFavourCost()+" Administrative Power")); } lore.add(StringFormatter.formatHex("#28ed70Click to toggle")); } - } else if(f.isLeader(p.getName())) { + } else if(f.isLeader(Members.key(p))) { lore.add(""); if(mainGuild.isFavoured()) { lore.add(StringFormatter.formatHex("#89504eCannot repress: Vassal is favoured")); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 40020c80..1e554b17 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.List; @@ -77,10 +79,10 @@ public void governmentView(Player player, Faction f, Inventory i) { Government gov = f.getGovernment(); if(gov.getCouncil().canBeMember(player.getName(), true, true)) i.setItem(21, creator.createToggleRefuseButton(player, f)); if(gov.hasElections()) i.setItem(14, creator.createElectionItem(player, f)); - if(gov.getCouncil().canHostSession() && f.getLeader().equalsIgnoreCase(player.getName())) { + if(gov.getCouncil().canHostSession() && Members.is(player, f.getLeader())) { i.setItem(23, creator.createStartCouncilButton(player, f)); } - Guild viewer = FactionManager.getGuildByMember(player.getName()); + Guild viewer = FactionManager.getGuildByMember(Members.key(player)); if(viewer != null && gov.canAffectStability(viewer)) { i.setItem(28, creator.createStanceItem(f, viewer, viewer.isLeader(player))); } @@ -359,7 +361,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } } else if(slot == 15) { Government gov = f.getGovernment(); - if(!gov.canProposeOrStartMovement(p) || gov.getMovementByMember(p.getName()) != null) { + if(!gov.canProposeOrStartMovement(p) || gov.getMovementByMember(Members.key(p)) != null) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } @@ -370,7 +372,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { // Foreign viewers see this menu too; only those offered the button may use it. if(!EspionageService.canViewExact(p, f) || !gov.getCouncil().canBeMember(p.getName(), true, true)) return; - gov.getCouncil().toggleRefuse(p.getName()); + gov.getCouncil().toggleRefuse(Members.key(p)); governmentView(p, f, inventory); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } else if(slot == 24) { @@ -381,7 +383,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } else if(slot == 23) { if(!f.getGovernment().hasCouncil()) return; - if(!f.isLeader(p.getName()) || !f.getGovernment().getCouncil().canHostSession()) { + if(!f.isLeader(Members.key(p)) || !f.getGovernment().getCouncil().canHostSession()) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } @@ -518,7 +520,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { double cost = law.getCost()*law.getCompatibility(f.getLawHandler().getGroup(group).getCurrent().getId()); Proposal proposal = new Proposal(p.getName(), gov); proposal.setLawProposal(law); - if(!gov.hasCouncil() && f.isLeader(p.getName())) { + if(!gov.hasCouncil() && f.isLeader(Members.key(p))) { if(gov.getPower() < cost) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage("§cThe government does not have enough power to propose this! §7(Need §e"+cost+"§7, has §e"+gov.getPower()+"§7)"); @@ -546,7 +548,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); } else if(gov.canProposeOrStartMovement(p)) { - Movement movement = gov.getMovementByMember(p.getName()); + Movement movement = gov.getMovementByMember(Members.key(p)); if(movement != null) { if(!gov.canBeProposed(proposal)) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -598,7 +600,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { proposal.setPoliticalActionProposal(politicalAction); if(gov.canProposeOrStartMovement(p)) { - Movement movement = gov.getMovementByMember(p.getName()); + Movement movement = gov.getMovementByMember(Members.key(p)); if(movement != null) { if(!gov.canBeProposed(proposal)) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -661,7 +663,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(item == null) return; Faction f = FactionManager.getByString(((SFInventoryHolder)e.getInventory().getHolder()).getId()); if(f == null) return; - boolean isLeader = p.getName().equalsIgnoreCase(f.getLeader()); + boolean isLeader = Members.is(p, f.getLeader()); if(!isLeader) return; // Handle council member click for replace/appoint ItemMeta meta = item.getItemMeta(); @@ -688,7 +690,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(item == null) return; Faction f = FactionManager.getByString(((SFInventoryHolder)e.getInventory().getHolder()).getId()); if(f == null) return; - boolean isLeader = p.getName().equalsIgnoreCase(f.getLeader()); + boolean isLeader = Members.is(p, f.getLeader()); if(!isLeader) return; if(e.getSlot() == 45 || e.getSlot() == 52) { int seat = Integer.parseInt(h.getSecondaryId()); @@ -760,7 +762,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction f = FactionManager.getByString(h.getId()); if(f == null) return; - if(!f.isLeader(p.getName())) return; + if(!f.isLeader(Members.key(p))) return; String id = meta.getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); if(id == null) return; @@ -822,7 +824,7 @@ private static final class WarPeaceHolder extends SFInventoryHolder { WarPeaceHolder(Player player, Faction faction, Action action, boolean fromCause, int index) { super(faction.getId(), SFGUI.WAR_PEACE_SELECT, index, fromCause, action.name()); - movement = fromCause ? faction.getGovernment().getMovementByMember(player.getName()) : null; + movement = fromCause ? faction.getGovernment().getMovementByMember(Members.key(player)) : null; cause = movement != null && index >= 0 && index < movement.getCauses().size() ? movement.getCauses().get(index) : null; } @@ -830,7 +832,7 @@ private static final class WarPeaceHolder extends SFInventoryHolder { public Cause displayedWarPeaceCause(Player player, Faction faction, Inventory inventory) { if (!(inventory.getHolder() instanceof WarPeaceHolder holder) || holder.cause == null - || faction.getGovernment().getMovementByMember(player.getName()) != holder.movement + || faction.getGovernment().getMovementByMember(Members.key(player)) != holder.movement || !holder.movement.getCauses().contains(holder.cause)) return null; return holder.cause; } @@ -853,7 +855,7 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool return; } Movement movement = cause.getMovement(); - if (!cause.hasLeader() || !cause.getLeader().equals(p.getName())) { + if (!cause.hasLeader() || !Members.is(p, cause.getLeader())) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } @@ -866,7 +868,7 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool PoliticalAction politicalAction = PoliticalActionLoader.getByAction(action); proposal.setPoliticalActionProposal(politicalAction); proposal.setTarget(warId); - if (!gov.hasCouncil() && f.isLeader(p.getName())) { + if (!gov.hasCouncil() && f.isLeader(Members.key(p))) { proposal.apply(null); p.sendMessage("§aChange applied!"); governmentView(p, f, null); @@ -884,7 +886,7 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool return; } if (gov.canProposeOrStartMovement(p)) { - Movement movement = gov.getMovementByMember(p.getName()); + Movement movement = gov.getMovementByMember(Members.key(p)); if (movement != null) { if (!gov.canBeProposed(proposal)) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 38535d89..8486c6e6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Collections; @@ -194,7 +196,7 @@ public void guildView(Player player, Guild guild, Inventory i) { if(target != null && Cache.provincesEnabled) { i.setItem(34, creator.createRelocateItem(player, target, guild)); } - if(guild.getFaction().isLeader(player.getName()) && !guild.isBase()) { + if(guild.getFaction().isLeader(Members.key(player)) && !guild.isBase()) { if(guild.hasCapital()) { i.setItem(43, creator.createElevationItem(player, guild)); } @@ -408,7 +410,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } guildView(p, guild, inventory); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - } else if(guild.getFaction().isLeader(p.getName())) { + } else if(guild.getFaction().isLeader(Members.key(p))) { if(!guild.canBeEvicted(p)) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); return; @@ -429,7 +431,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } return; } else if(e.getSlot() == 43) { - if(!guild.getFaction().isLeader(p.getName())) return; + if(!guild.getFaction().isLeader(Members.key(p))) return; if(!guild.canBeElevated(p)) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index 9f2d70e1..bd44f892 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Comparator; @@ -131,7 +133,7 @@ public void installationDetailView(Player player, Faction f, String installation } inventory.clear(); - boolean leader = f.getLeader().equalsIgnoreCase(player.getName()); + boolean leader = Members.is(player, f.getLeader()); if (isPending) { inventory.setItem( 49, @@ -207,7 +209,7 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) ItemMeta meta = item.getItemMeta(); String queuePayload = meta.getPersistentDataContainer().get(Keys.QUEUE_CANCEL, PersistentDataType.STRING); if (queuePayload != null) { - if (!f.getLeader().equalsIgnoreCase(player.getName())) { + if (!Members.is(player, f.getLeader())) { return; } inv.openQueueCancelConfirm(player, f, queuePayload, "§eCancel construction?"); @@ -230,7 +232,7 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) ItemStack upgradeItem = event.getCurrentItem(); if (upgradeItem == null || !upgradeItem.hasItemMeta() - || !f.getLeader().equalsIgnoreCase(player.getName())) { + || !Members.is(player, f.getLeader())) { return; } String id = upgradeItem.getItemMeta() @@ -256,7 +258,7 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) handleBerthedVehicleClick(event, inventory, player, f); return; } - if (slot != 11 || !f.getLeader().equalsIgnoreCase(player.getName())) { + if (slot != 11 || !Members.is(player, f.getLeader())) { return; } ItemStack item = event.getCurrentItem(); @@ -278,7 +280,7 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) private void handleBerthedVehicleClick( InventoryClickEvent event, Inventory inventory, Player player, Faction faction) { - if (!faction.getLeader().equalsIgnoreCase(player.getName())) { + if (!Members.is(player, faction.getLeader())) { return; } ItemStack item = event.getCurrentItem(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java index 39d891b9..19b5f8e1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageAccess; import java.util.EnumSet; import java.util.Set; @@ -321,7 +323,7 @@ private void refreshCampaignPick(Player p, Inventory i, CampaignInventoryHolder if (h.getType() != SFGUI.CAMPAIGN_INSTALLATION_PICK_VIEW) return; War war = WarManager.getById(h.getWarId()); if (war == null || !war.isActive()) return; - Faction viewerFaction = FactionManager.getByMember(p.getName()); + Faction viewerFaction = FactionManager.getByMember(Members.key(p)); if (viewerFaction == null) return; if (!war.isParticipating(viewerFaction)) return; inv.campaignInstallationPickView.open(p, war, viewerFaction, false, i); @@ -330,7 +332,7 @@ private void refreshCampaignPick(Player p, Inventory i, CampaignInventoryHolder private void refreshCampaignRaid(Player p, Inventory i, CampaignRaidLaunchHolder h) { War war = WarManager.getById(h.getWarId()); if (war == null || !war.isActive()) return; - Faction viewerFaction = FactionManager.getByMember(p.getName()); + Faction viewerFaction = FactionManager.getByMember(Members.key(p)); if (viewerFaction == null) return; if (h.isSourcePage()) { inv.campaignRaidLaunchView.openSourcePage(p, war, viewerFaction, false, i); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java index 9ca7f800..8f8b8d59 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageService; import org.bukkit.Material; import org.bukkit.Sound; @@ -50,7 +52,7 @@ public void militaryView(Inventory i, Player player, Faction f, boolean open) { } var m = f.getMilitary(); i.setItem(10, creator.createMilitarySummary(f)); - boolean leader = f.getLeader().equalsIgnoreCase(player.getName()); + boolean leader = Members.is(player, f.getLeader()); for(int x = 0; x= movement.getPhase().getMaxOrganization() && movement.isLeader(p.getName())) { + } else if(phase.getIndex() < 4 && phase.getIndex() == movement.getPhase().getIndex()+1 && movement.getOrganization() >= movement.getPhase().getMaxOrganization() && movement.isLeader(Members.key(p))) { item = new ItemStack(Material.YELLOW_CONCRETE); } ItemMeta m = item.getItemMeta(); @@ -347,7 +349,7 @@ public ItemStack createPhaseItem(Player p, Phase phase, Movement movement) { lore.add(StringFormatter.formatHex("#7a7a7aThe current phase of the movement")); lore.add(StringFormatter.formatHex("#7a7a7awhich determines its max organization level")); lore.add(""); - if(movement.isLeader(p.getName())) { + if(movement.isLeader(Members.key(p))) { if(item.getType().equals(Material.RED_CONCRETE)) { lore.add(StringFormatter.formatHex("#c74d32Unavailable")); if(phase.getIndex() > 0 && movement.getPhase().getIndex() == phase.getIndex()-1) { @@ -634,7 +636,7 @@ public ItemStack createTargetButton(Player p, Cause cause) { lore.add(StringFormatter.formatHex("#7a7a7athe new faction leader.")); } - if (cause.hasLeader() && cause.getLeader().equals(p.getName())) { + if (cause.hasLeader() && Members.is(p, cause.getLeader())) { lore.add(" "); lore.add(StringFormatter.formatHex("#50e846Click to select target")); } else { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java index 05e28d9b..57808f74 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -97,11 +99,11 @@ public void movementView(Player player, Faction f, Movement movement, Inventory // Join as foreign backer button i.setItem(16, creator.createJoinAsForeignBackerButton(player, movement)); - if(movement.isLeader(player.getName())) { + if(movement.isLeader(Members.key(player))) { i.setItem(19, creator.createSendDemandsItem(movement)); i.setItem(34, creator.createEndMovmentItem(movement)); } - if(player.getName().equalsIgnoreCase(f.getLeader())) { + if(Members.is(player, f.getLeader())) { i.setItem(25, creator.createDemandDisbandItem(f, movement)); } @@ -376,8 +378,8 @@ private void handleMovementViewClick(InventoryClickEvent e, Movement movement, P int slot = e.getSlot(); //leader if (slot == 10) { - if(!movement.hasLeader() && movement.canBeLeader(p.getName())) { - movement.setLeader(p.getName()); + if(!movement.hasLeader() && movement.canBeLeader(Members.key(p))) { + movement.setLeader(Members.key(p)); movementView(p, f, movement, inventory); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); } @@ -393,7 +395,7 @@ else if (slot == 15) { Object supporter = getSupporterObject(p, movement); if (supporter != null) { movement.leave(supporter, null); - if (movement.isLeader(p.getName())) { + if (movement.isLeader(Members.key(p))) { movement.setLeader(null); } movementView(p, f, movement, inventory); @@ -415,7 +417,7 @@ else if (slot == 15) { } // Join as foreign backer button else if (slot == 16) { - Faction playerFaction = FactionManager.getByMember(p.getName()); + Faction playerFaction = FactionManager.getByMember(Members.key(p)); if (playerFaction != null && !playerFaction.getId().equals(f.getId())) { if (movement.getForeignBackers().contains(playerFaction)) { if (getSupporterObject(p, movement) != null) { @@ -439,7 +441,7 @@ else if (slot == 16) { } // Send demands button else if (slot == 19) { - if (movement.isLeader(p.getName()) && movement.getOrganization() >= 100) { + if (movement.isLeader(Members.key(p)) && movement.getOrganization() >= 100) { for(Cause cause : movement.getCauses()) { if(cause.getProposal().isPoliticalActionProposal() && cause.getProposal().needsTarget()) { if(!cause.getProposal().hasTarget()) { @@ -466,7 +468,7 @@ else if (slot == 19) { } } else if (slot == 25) { - if (!p.getName().equalsIgnoreCase(f.getLeader())) { + if (!Members.is(p, f.getLeader())) { return; } if (!MovementCrackdownQueries.canCrush(f, movement)) { @@ -497,7 +499,7 @@ else if (slot == 25) { // Phase buttons (slots 28-31) else if (slot >= 28 && slot <= 31) { ItemStack item = e.getCurrentItem(); - if (item != null && item.getType() == Material.YELLOW_CONCRETE && movement.isLeader(p.getName())) { + if (item != null && item.getType() == Material.YELLOW_CONCRETE && movement.isLeader(Members.key(p))) { if (meta.getPersistentDataContainer().has(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING)) { String phaseName = meta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); try { @@ -515,7 +517,7 @@ else if (slot >= 28 && slot <= 31) { } } else if (slot == 34) { - if(!movement.isLeader(p.getName())) { + if(!movement.isLeader(Members.key(p))) { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); return; } @@ -562,8 +564,8 @@ private void handleCauseViewClick(InventoryClickEvent e, Movement movement, Play return; } if(slot == 10) { - if(!cause.hasLeader() && cause.canBeLeader(p.getName())) { - cause.setLeader(p.getName()); + if(!cause.hasLeader() && cause.canBeLeader(Members.key(p))) { + cause.setLeader(Members.key(p)); causeView(p, f, movement, cause, inventory); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); } @@ -572,12 +574,12 @@ private void handleCauseViewClick(InventoryClickEvent e, Movement movement, Play else if (slot == 14) { Object joiningAs = movement.getJoiningAs(p); if(joiningAs == null) return; - if (cause.getFullMemberList().contains(p.getName())) { + if (cause.getFullMemberList().contains(Members.key(p))) { movement.leave(joiningAs, cause); - if (cause.hasLeader() && cause.getLeader().equals(p.getName())) { + if (cause.hasLeader() && Members.is(p, cause.getLeader())) { cause.setLeader(null); } - if (movement.isLeader(p.getName())) { + if (movement.isLeader(Members.key(p))) { movement.setLeader(null); } causeView(p, f, movement, cause, inventory); @@ -597,7 +599,7 @@ else if (slot == 14) { } // Target selection button else if (slot == 28) { - if (cause.getProposal().needsTarget() && cause.hasLeader() && cause.getLeader().equals(p.getName())) { + if (cause.getProposal().needsTarget() && cause.hasLeader() && Members.is(p, cause.getLeader())) { if (CouncilPeaceQueries.isWarEndAction(cause.getProposal().getPoliticalAction().getAction())) { inv.governmentView.warPeaceSelectView( p, f, cause.getProposal().getPoliticalAction().getAction(), true, cause.getIndex(), null); @@ -622,7 +624,7 @@ private void handleTargetSelectClick(InventoryClickEvent e, Movement movement, P Cause cause = causeAt(inventory, movement, causeIndex); if (cause == null) return; - if (!cause.hasLeader() || !cause.getLeader().equals(p.getName())) return; + if (!cause.hasLeader() || !Members.is(p, cause.getLeader())) return; // Validate target can become leader if (!f.canBecomeLeader(targetName)) { @@ -641,7 +643,7 @@ private void handleDemandsViewClick(InventoryClickEvent e, Movement movement, Pl // Accept button (green concrete) if (slot == 29) { - if (p.getName().equalsIgnoreCase(f.getLeader())) { + if (Members.is(p, f.getLeader())) { p.sendMessage(StringFormatter.formatHex("§aYou have accepted the movement's demands.")); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1, 2f); LogManager.movement( @@ -664,7 +666,7 @@ private void handleDemandsViewClick(InventoryClickEvent e, Movement movement, Pl } // Decline button (red concrete) else if (slot == 33) { - if (p.getName().equalsIgnoreCase(f.getLeader())) { + if (Members.is(p, f.getLeader())) { LogManager.movement( "DEMANDS_REJECT movementId=%s faction=%s power=%.1f", movement.getId(), @@ -696,7 +698,7 @@ else if (slot == 33) { private void handleCrackdownViewClick(InventoryClickEvent e, Movement movement, Player p, Faction f, Inventory inventory, ItemMeta meta) { int slot = e.getSlot(); - if (!movement.isLeader(p.getName())) { + if (!movement.isLeader(Members.key(p))) { return; } if (slot == 29) { @@ -794,7 +796,7 @@ public void handleEndConfirm(Player player, String movementId, boolean confirmed player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); return; } - if (!movement.isLeader(player.getName())) { + if (!movement.isLeader(Members.key(player))) { player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); movementView(player, f, movement, null); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java index 1bfcaed8..74d41f0f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.List; import org.bukkit.Material; @@ -71,7 +73,7 @@ public void diplomacyListView(Inventory i, Player player, Faction f, boolean ope private void fillDiplomacyList(Inventory i, Player player, Faction f, int page) { i.clear(); - boolean own = f.getMembers() != null && f.getMembers().contains(player.getName()); + boolean own = f.getMembers() != null && f.getMembers().contains(Members.key(player)); List entries = own ? DiplomacyQueries.ownDirectory(f) : DiplomacyQueries.foreignList(f); @@ -105,7 +107,7 @@ private void handleDiplomacyListClick(InventoryClickEvent e, Inventory inventory return; } int page = Math.max(0, holder.getPage()); - boolean own = viewed.getMembers() != null && viewed.getMembers().contains(p.getName()); + boolean own = viewed.getMembers() != null && viewed.getMembers().contains(Members.key(p)); int slot = e.getSlot(); if (slot == LIST_PREV_SLOT && page > 0) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -145,8 +147,8 @@ public void diplomacyView(Inventory i, Player player, Faction f, boolean open) { if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.DIPLOMACY_VIEW), 54, "§7Diplomacy View"); } - if(!f.getMembers().contains(player.getName()) && FactionManager.getByMember(player.getName()) != null) { - Faction pf = FactionManager.getByMember(player.getName()); + if(!f.getMembers().contains(Members.key(player)) && FactionManager.getByMember(Members.key(player)) != null) { + Faction pf = FactionManager.getByMember(Members.key(player)); Relation r = pf.getRelation(f.getId()); i.setItem(21, creator.createRelationItem(f, pf)); RelationType trade = pf.getDiplomacyHandler().getTradeRelation(f.getId()); @@ -176,7 +178,7 @@ public void attitudeView(Inventory i, Player player, Faction f, boolean open) { if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.ATTITUDE_VIEW), 27, "§7Change Attitude"); } - Faction origin = FactionManager.getByMember(player.getName()); + Faction origin = FactionManager.getByMember(Members.key(player)); for(int x = 0; x 0) { @@ -100,13 +102,13 @@ public void titleTypeView(Inventory i, Player player, Faction f, Tier tier, bool } else if (i != null && i.getHolder() instanceof SFInventoryHolder holder) { holder.setPage(page); } - Faction pf = FactionManager.getByLeader(player.getName()); + Faction pf = FactionManager.getByLeader(Members.key(player)); String overlord = RelationManager.getOverlord(f); int slot = 0; for (int x = 0; x < 53; x++) { i.setItem(x, null); } - if(f.getLeader().equalsIgnoreCase(player.getName())) { + if(Members.is(player, f.getLeader())) { int maxPerPage = 45; List items = creator.getSortedTitleItems(player, f, TitleLoader.getByTier(tier)); int start = page * maxPerPage; @@ -145,7 +147,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); - if (f == null || !f.isLeader(p.getName())) return; + if (f == null || !f.isLeader(Members.key(p))) return; NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "index"); Integer index = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.INTEGER); String tierId = clicked.getItemMeta().getPersistentDataContainer().get( @@ -178,7 +180,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction f = FactionManager.getByString(h.getId()); if (f == null) return; if(type.equals(Material.WRITABLE_BOOK)) { - if (!f.isLeader(p.getName())) return; + if (!f.isLeader(Members.key(p))) return; if (!f.getGovernment().stateReport().status.canFormTitles()) { p.sendMessage("§cA " + f.getGovernment().stateReport().status.getLabel() + " cannot form titles."); p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -232,7 +234,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if("claim".equalsIgnoreCase(action)) { if(!type.equals(Material.YELLOW_CONCRETE)) return; - if (!f.isLeader(p.getName()) || FactionManager.getTitleOwner(t) != null + if (!f.isLeader(Members.key(p)) || FactionManager.getTitleOwner(t) != null || !t.canBeCreatedBy(f, TitleManager.getProvinces(f), TitleManager.getTitles(f), f.getGovernment().deJureScore())) return; if (f.getGovernment() != null && !f.getGovernment().stateReport().status.canFormTitles()) { @@ -258,7 +260,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } else if("grant".equalsIgnoreCase(action)) { if(!type.equals(Material.GREEN_CONCRETE)) return; - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); if(pf == null || !pf.getId().equalsIgnoreCase(RelationManager.getOverlord(f)) || !TitleManager.getGrantableTitles(pf, f, t.getTier()).contains(t)) return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarCreator.java index 39270b50..6184952d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -130,7 +132,7 @@ public ItemStack createSecondaryItem( boolean subject, boolean called, boolean backer) { - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); ItemStack i = new ItemStack(f.getBanner()); ItemMeta m = i.getItemMeta(); m.setDisplayName(f.getName()); @@ -183,7 +185,7 @@ public ItemStack createSecondaryItem( // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createParticipantItem(Player p, Participant par, String type, War w, boolean full, boolean warGoal) { - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); Faction f = par.getLeader(); ItemStack i = new ItemStack(f.getBanner()); ItemMeta m = i.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarView.java index e5caa3e9..d90e77e9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/WarView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -163,7 +165,7 @@ private boolean canEditCounterGoal(War war, Player player) { || war.getWarType() == WarType.RAID || CivilWarBorderLock.isCivilWar(war)) { return false; } - Faction leader = FactionManager.getByLeader(player.getName()); + Faction leader = FactionManager.getByLeader(Members.key(player)); return leader != null && war.getDefenderLeaderId() != null && war.getDefenderLeaderId().equalsIgnoreCase(leader.getId()); } @@ -479,7 +481,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { warView(null, p, w, true); return; } - Faction pf = FactionManager.getByLeader(p.getName()); + Faction pf = FactionManager.getByLeader(Members.key(p)); if(pf == null) return; Participant par = w.getParticipant(pf); if(par == null) return; @@ -505,9 +507,9 @@ private boolean canShowCampaignView(War w, Player player) { if (axis == null || axis.isEmpty()) { return false; } - Faction faction = FactionManager.getByLeader(player.getName()); + Faction faction = FactionManager.getByLeader(Members.key(player)); if (faction == null) { - faction = FactionManager.getByMember(player.getName()); + faction = FactionManager.getByMember(Members.key(player)); } return faction != null && w.getParticipant(faction) != null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java index 94918d67..c3d23dc4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.map; +import net.tfminecraft.simplefactions.identity.Members; + import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -286,7 +288,7 @@ private void claimProvince( } public Faction getRelocationTarget(Player p) { - Guild guild = FactionManager.getGuildByLeader(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(guild == null) return null; if(guild.isBase()) return null; if(!guild.hasCapital()) return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java index 6df0e846..470617fb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.mercenary.company; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -16,7 +18,7 @@ private MercenaryInvites() { public static void invite(Player leader, String targetName) { Guild guild = net.tfminecraft.simplefactions.managers.FactionManager - .getGuildByLeader(leader.getName()); + .getGuildByLeader(Members.key(leader)); MercenaryResult check = MercenaryCompanyService .canInvite(guild, leader.getName(), targetName); if (!check.ok()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java index 27a08629..3b22f76c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.mercenary.contract; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -399,7 +401,7 @@ private static void tell(String player, String message) { private static void tellGovernment(Faction hirer, String message) { if (hirer == null || hirer.getGovernment() == null || SimpleFactions.plugin == null) return; for (Player online : Bukkit.getOnlinePlayers()) { - if (online != null && hirer.getGovernment().isCouncilMember(online.getName())) { + if (online != null && hirer.getGovernment().isCouncilMember(Members.key(online))) { online.sendMessage(message); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 5ac3e5e8..3dbec89e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -519,21 +519,18 @@ public void setInvited(List invited) { this.invited = invited; } public boolean isInvited(String name) { - return Guild.findIgnoreCase(invited, name) != null; + return Guild.find(invited, name) != null; } public void invite(String name) { if(name == null || isInvited(name)) return; invited.add(name); } public boolean consumeInvite(String name) { - String stored = Guild.findIgnoreCase(invited, name); + String stored = Guild.find(invited, name); if(stored == null) return false; invited.remove(stored); return true; } - public boolean isMemberIgnoreCase(String name) { - return Guild.findIgnoreCase(getMembers(), name) != null; - } public List getWealthModifiers() { List list = new ArrayList<>(getOrCreateMainGuild().getWealthModifiers()); for(Guild guild : guildHandler.getGuilds()) { @@ -694,6 +691,14 @@ public boolean isMember(String member) { public boolean isLeader(String member) { return getOrCreateMainGuild().isLeader(member); } + /** Whether the player's current character leads this realm. */ + public boolean isLeader(org.bukkit.entity.Player player) { + return isLeader(net.tfminecraft.simplefactions.identity.Members.key(player)); + } + /** Whether the player's current character belongs to this realm. */ + public boolean isMember(org.bukkit.entity.Player player) { + return isMember(net.tfminecraft.simplefactions.identity.Members.key(player)); + } public boolean canBecomeLeader(String member) { if(isLeader(member)) return false; if(!isMember(member)) return false; @@ -708,7 +713,7 @@ public boolean canRemainLeader(String name) { } public boolean canBeCleanKicked(String p) { - if(leader.equalsIgnoreCase(p)) return false; + if(leader != null && leader.equals(p)) return false; return !guildHandler.isGuildLeader(p); } public Double getWealth() { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpact.java b/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpact.java index 0352fe38..de60b32b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpact.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpact.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.utils; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.List; import java.util.Map; @@ -133,7 +135,7 @@ private static Guild viewer(Player player) { if (player == null) { return null; } - return FactionManager.getGuildByMember(player.getName()); + return FactionManager.getGuildByMember(Members.key(player)); } private static boolean defer( diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index 6ee514ec..ceeaab8a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.utils; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.VehicleTabCompletions; @@ -41,7 +43,7 @@ private boolean eitherCommand(Command cmd) { private List completeCompany(Player p, String[] args) { List completions = new ArrayList<>(); - Guild guild = FactionManager.getGuildByLeader(p.getName()); + Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(args.length <= 1) { if(guild != null && guild.getCompany() == null) completions.add("found"); if(guild != null && guild.hasCompany()) { @@ -150,7 +152,7 @@ private List completeTransferVehicleIds(Player p, String[] args) { private List completeInstallationIds(Player p, String prefix, boolean includePendingConstruction) { List completions = new ArrayList<>(); - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if (f == null) { return completions; } @@ -211,7 +213,7 @@ else if(cmd.getName().equalsIgnoreCase("guild") && args.length >= 0 && args.leng if(Cache.provincesEnabled) { completions.add("setcapital"); } - if(FactionManager.getGuildByLeader(p.getName()) != null) { + if(FactionManager.getGuildByLeader(Members.key(p)) != null) { completions.add("invite"); completions.add("kick"); completions.add("setleader"); @@ -236,7 +238,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") } if(sender instanceof Player) { Player p = (Player) sender; - if(FactionManager.getByLeader(p.getName()) == null) { + if(FactionManager.getByLeader(Members.key(p)) == null) { return new ArrayList<>(); } return completeConstructKinds(args); @@ -248,7 +250,7 @@ else if (cmd.getName().equalsIgnoreCase("faction") && args[0].equalsIgnoreCase("upgrade")) { if (!Cache.provincesEnabled || !(sender instanceof Player p) - || FactionManager.getByLeader(p.getName()) == null) { + || FactionManager.getByLeader(Members.key(p)) == null) { return new ArrayList<>(); } return completeInstallationIds(p, args.length >= 2 ? args[1] : "", false); @@ -320,8 +322,8 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le Player p = (Player) sender; List completions = new ArrayList<>(); completions.add("list"); - if(FactionManager.getByMember(p.getName()) != null) completions.add("menu"); - if(FactionManager.getByMember(p.getName()) != null) completions.addAll(List.of("positions", "spymaster", "espionage")); + if(FactionManager.getByMember(Members.key(p)) != null) completions.add("menu"); + if(FactionManager.getByMember(Members.key(p)) != null) completions.addAll(List.of("positions", "spymaster", "espionage")); if (p.hasPermission(net.tfminecraft.simplefactions.espionage.EspionageConfig.reloadPermission())) completions.add("reloadespionage"); completions.add("create"); completions.add("accept"); @@ -329,7 +331,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le completions.add("join"); completions.add("vehicle"); - if(FactionManager.getByLeader(p.getName()) != null) { + if(FactionManager.getByLeader(Members.key(p)) != null) { if(Cache.provincesEnabled) { completions.add("claim"); completions.add("construct"); @@ -402,12 +404,12 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le Player p = (Player) sender; Faction f = null; if(cmd.getName().equalsIgnoreCase("guild")) { - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); if(g != null) { f = g.getFaction(); } } else { - f = FactionManager.getByLeader(p.getName()); + f = FactionManager.getByLeader(Members.key(p)); } if(f == null) { return new ArrayList<>(); @@ -421,7 +423,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("delete")){ if(sender instanceof Player){ Player p = (Player) sender; - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); List completions = new ArrayList(); if(f != null) { completions.add(f.getId()); @@ -431,7 +433,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 2 && args[0].equalsIgnoreCase("delete")){ if(sender instanceof Player){ Player p = (Player) sender; - Guild g = FactionManager.getGuildByMember(p.getName()); + Guild g = FactionManager.getGuildByMember(Members.key(p)); List completions = new ArrayList(); if(g != null && !g.isBase()) { completions.add(g.getId()); @@ -459,7 +461,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ Player p = (Player) sender; - Guild g = FactionManager.getGuildByLeader(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); List completions = new ArrayList(); if(g != null && !g.isBase()) { for(String member : g.getMembers()) { @@ -471,7 +473,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ Player p = (Player) sender; - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); List completions = new ArrayList(); if(f != null) { completions.addAll(f.getMembers()); @@ -482,7 +484,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("setleader")){ if(sender instanceof Player){ Player p = (Player) sender; - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); List completions = new ArrayList(); if(f != null) { for(String member : f.getMembers()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java index 518dbdbb..31f8a389 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -32,7 +34,7 @@ public final class VehicleFactionCommands { private VehicleFactionCommands() {} public static void armTransfer(Player player, String installationId) { - Faction faction = FactionManager.getByLeader(player.getName()); + Faction faction = FactionManager.getByLeader(Members.key(player)); if (faction == null) { player.sendMessage(VehicleTransferMessages.notLeader()); return; @@ -68,7 +70,7 @@ public static void armTransfer(Player player, String installationId) { } public static void armTake(Player player) { - Faction faction = FactionManager.getByLeader(player.getName()); + Faction faction = FactionManager.getByLeader(Members.key(player)); if (faction == null) { player.sendMessage(FactionVehicleReleaseMessages.notLeader()); return; @@ -85,7 +87,7 @@ public static void armTake(Player player) { } public static void armGive(Player player, String targetName) { - Faction faction = FactionManager.getByLeader(player.getName()); + Faction faction = FactionManager.getByLeader(Members.key(player)); if (faction == null) { player.sendMessage(FactionVehicleReleaseMessages.notLeader()); return; @@ -167,7 +169,7 @@ public static void armMaintenancePay(Player player) { } public static void armMaintenancePay(Player player, PaymentSource source) { - if (source == PaymentSource.POUCH && FactionManager.getByLeader(player.getName()) == null) { + if (source == PaymentSource.POUCH && FactionManager.getByLeader(Members.key(player)) == null) { player.sendMessage(VehicleMaintenanceMessages.notLeader()); return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java index 8b1930c1..596587e7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.battle; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; @@ -106,9 +108,9 @@ public static BattleVehicleEligibilityResult check( return BattleVehicleEligibilityResult.NOT_CAMPAIGN_BATTLE; } - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null) { - faction = FactionManager.getByLeader(player.getName()); + faction = FactionManager.getByLeader(Members.key(player)); } if (faction == null || !war.isParticipating(faction)) { return BattleVehicleEligibilityResult.NOT_CAMPAIGN_BATTLE; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java index 2a13563e..a10b4c52 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.berth; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -40,7 +42,7 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - Faction faction = FactionManager.getByLeader(leader.getName()); + Faction faction = FactionManager.getByLeader(Members.key(leader)); if (faction == null) { sessionManager.clear(leader.getUniqueId()); leader.sendMessage(FactionVehicleReleaseMessages.notLeader()); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java index 5747fc66..a11b887a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.berth; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleOwnerSync; @@ -268,7 +270,7 @@ ActiveVehicle resolveVehicle(String vehicleUuid) { private static Faction resolveProposerFaction(VehicleTransferConsentRequest req) { Player proposer = Bukkit.getPlayer(req.getProposerLeaderUuid()); Faction faction = FactionManager.getByString(req.getDestinationFactionId()); - if (proposer != null && faction != null && proposer.getName().equalsIgnoreCase(faction.getLeader())) { + if (proposer != null && faction != null && Members.is(proposer, faction.getLeader())) { return faction; } return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java index 5e009906..52f60cd5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.berth; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleService.CanRegisterResult; @@ -51,7 +53,7 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - Faction faction = FactionManager.getByLeader(leader.getName()); + Faction faction = FactionManager.getByLeader(Members.key(leader)); if (faction == null) { sessionManager.clear(leader.getUniqueId()); leader.sendMessage(VehicleTransferMessages.notLeader()); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java index a1e43349..2cb46d5d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.handover; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.Objects; import java.util.UUID; import java.util.function.BiPredicate; @@ -94,7 +96,7 @@ private String personalVehicleType(String ownerName, String vehicleUuid) { } public void offer(Player owner, Player recipient, String vehicleUuid, String vehicleTypeId) { - Guild guild = FactionManager.getGuildByMember(owner.getName()); + Guild guild = FactionManager.getGuildByMember(Members.key(owner)); VehicleHandoverRequest request = new VehicleHandoverRequest( guild, vehicleUuid, diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java index 02bafc4c..5cfde1da 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.ui; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.command.Command; @@ -245,7 +247,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } } - Faction playerFaction = FactionManager.getByMember(p.getName()); + Faction playerFaction = FactionManager.getByMember(Members.key(p)); if (w.isFaction()) { if (playerFaction == null) { p.sendMessage("§cThis is a faction warband, you need a faction to join"); @@ -286,7 +288,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String Warband warband = WarbandManager.getByLeader(p); CampaignBattleJoinService.CampaignBattleContext ctx = CampaignBattleJoinService.findCampaignBattleForWarband(warband); - Faction faction = FactionManager.getByMember(p.getName()); + Faction faction = FactionManager.getByMember(Members.key(p)); FactionManager.inv.confirming.put(p, faction); FactionManager.inv.confirmBattleRetreatView(p, ctx.battle().getId()); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandManager.java index a6dc107e..2aa2da20 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.warband; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -160,7 +162,7 @@ public void invenClick(InventoryClickEvent e) { refreshOpenWarbandLists(); return; } else if (w.isFaction()) { - Faction f = FactionManager.getByMember(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); if (f == null) { p.sendMessage("§cThis is a faction warband, you need a faction to join"); refreshOpenWarbandLists(); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java index 093fa035..273cc0a6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.warband; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -130,7 +132,7 @@ private Faction resolveFactionForWarband(Warband warband, UUID playerId) { if (player == null) { return null; } - return FactionManager.getByMember(player.getName()); + return FactionManager.getByMember(Members.key(player)); } private void detachFromBattle(UUID playerId, Warband warband) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java index 8bd1df46..339a680b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -46,9 +48,9 @@ public static boolean isRaidWarbandHiddenFromPlayer(Warband warband, org.bukkit. return false; } net.tfminecraft.simplefactions.objects.Faction faction = - net.tfminecraft.simplefactions.managers.FactionManager.getByMember(player.getName()); + net.tfminecraft.simplefactions.managers.FactionManager.getByMember(Members.key(player)); if (faction == null) { - faction = net.tfminecraft.simplefactions.managers.FactionManager.getByLeader(player.getName()); + faction = net.tfminecraft.simplefactions.managers.FactionManager.getByLeader(Members.key(player)); } for (War war : WarManager.getActive()) { CampaignRaid raid = CampaignRaidService.getActive(war); @@ -167,9 +169,9 @@ public static void tryEnrollDefenderOnLogin(Player player) { if (player == null) { return; } - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null) { - faction = FactionManager.getByLeader(player.getName()); + faction = FactionManager.getByLeader(Members.key(player)); } if (faction == null || WarbandManager.getByMemberId(player.getUniqueId()) != null) { return; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java index cb2753d0..7807643e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -29,9 +31,9 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null) { - faction = FactionManager.getByLeader(player.getName()); + faction = FactionManager.getByLeader(Members.key(player)); } if (faction == null) { player.sendMessage(CampaignRaidMessages.NOT_PARTICIPANT); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidTabCompletion.java index 41c82b46..af550d15 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidTabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidTabCompletion.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -26,9 +28,9 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe return completions; } if (args.length == 2 && args[0].equalsIgnoreCase("join")) { - Faction faction = FactionManager.getByMember(player.getName()); + Faction faction = FactionManager.getByMember(Members.key(player)); if (faction == null) { - faction = FactionManager.getByLeader(player.getName()); + faction = FactionManager.getByLeader(Members.key(player)); } if (faction == null) { return List.of(); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java index 5f087419..8da5b940 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.runtime; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.Instant; import java.util.Map; import java.util.Random; @@ -77,7 +79,7 @@ public static SendResult sendProposeRequest(Player proposer, War war, Belligeren return SendResult.NOT_ALLOWED; } - Faction proposerFaction = FactionManager.getByLeader(proposer.getName()); + Faction proposerFaction = FactionManager.getByLeader(Members.key(proposer)); if (proposerFaction == null || !isWarLeader(proposerFaction, war, side)) { return SendResult.NOT_ALLOWED; } @@ -114,7 +116,7 @@ public static void acceptRequest(Player acceptor) { BelligerentRole opposingSide = req.getProposerSide() == BelligerentRole.ATTACKER ? BelligerentRole.DEFENDER : BelligerentRole.ATTACKER; - Faction acceptorFaction = FactionManager.getByLeader(acceptor.getName()); + Faction acceptorFaction = FactionManager.getByLeader(Members.key(acceptor)); if (acceptorFaction == null || !isWarLeader(acceptorFaction, war, opposingSide)) { acceptor.sendMessage("§cYou cannot accept this request."); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java index e20675a6..3442f2d7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.runtime; +import net.tfminecraft.simplefactions.identity.Members; + import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.function.Function; @@ -25,7 +27,7 @@ public static Function uuidToFaction() { if (online == null) { return null; } - return FactionManager.getByMember(online.getName()); + return FactionManager.getByMember(Members.key(online)); }; } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index 4ed6657a..f0a0645f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -28,7 +28,7 @@ class CharacterNameResolutionTest { assertEquals("Unknown", CharacterNames.forForeign(account)); assertEquals("Unknown", CharacterNames.display(viewer, account, faction)); assertEquals(account, CharacterNames.of(account), "Invitations keep account fallback"); - when(faction.isMemberIgnoreCase("ForeignViewer")).thenReturn(true); + when(faction.isMember("ForeignViewer")).thenReturn(true); assertEquals(account, CharacterNames.display(viewer, account, faction)); when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); assertEquals(account + " \u00a77(" + account + ")", CharacterNames.display(viewer, account, faction)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java index bdc1be96..3a76afb5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java @@ -25,7 +25,7 @@ private void protect(Faction faction) { holder.playerName = "Spy"; state.appoint(holder, 50); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); } @Test @@ -35,7 +35,7 @@ void configuredPermissionChangesOnReloadAndOwnViewsRemainExact() { when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); Faction own = mock(Faction.class), foreign = mock(Faction.class); protect(foreign); - when(own.isMemberIgnoreCase("Viewer")).thenReturn(true); + when(own.isMember("Viewer")).thenReturn(true); var config = new org.bukkit.configuration.file.YamlConfiguration(); config.set(net.tfminecraft.simplefactions.testsupport.EspionageModes.MILITARY_ONLY, false); try { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index 29aef22f..274540b5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -68,7 +68,7 @@ void sabotageRequiresTheOfficeHoldersUuidEvenForLeadersAndMembers() { assignment.characterId = "character"; state.appoint(assignment, () -> 60); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isMember(anyString())).thenReturn(true); when(faction.isLeader("Leader")).thenReturn(true); when(leader.getName()).thenReturn("Leader"); when(leader.getUniqueId()).thenReturn(UUID.randomUUID()); @@ -98,7 +98,7 @@ void onlyFactionLeaderCanAppointOrRemove() { Player candidate = mock(Player.class); Faction faction = mock(Faction.class); when(member.getName()).thenReturn("Member"); - when(faction.isMemberIgnoreCase("Member")).thenReturn(true); + when(faction.isMember("Member")).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.appoint(member, faction, candidate)); assertFalse(EspionageService.remove(member, faction)); @@ -116,7 +116,7 @@ void unaffiliatedAndOwnMembersDoNotConsumeForeignRolls() { try (var factions = mockStatic(FactionManager.class)) { assertNull(EspionageService.report(viewer, target)); assertNull(target.getEspionage().cachedReport("unused", 0, 0)); - when(target.isMemberIgnoreCase("Visitor")).thenReturn(true); + when(target.isMember("Visitor")).thenReturn(true); assertNull(EspionageService.report(viewer, target)); factions.verify(() -> FactionManager.getByMember("Visitor"), times(1)); } @@ -132,7 +132,7 @@ void expiredMembershipAndReplacedOfficeCloseExistingPrivateScreens() { try (var factions = mockStatic(FactionManager.class)) { factions.when(() -> FactionManager.getByString("faction")).thenReturn(faction); assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW))); - when(faction.isMemberIgnoreCase("FormerSpy")).thenReturn(true); + when(faction.isMember("FormerSpy")).thenReturn(true); assertFalse(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW))); assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.SPYMASTER_SETTINGS))); assertFalse(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.SPECIAL_POSITIONS))); @@ -164,7 +164,7 @@ void departedSpymasterLosesOfficeAndCannotSabotage() { void leaderIsAlwaysEligibleAndOneOfficeKeepsFullAptitude() { Faction faction = mock(Faction.class); when(faction.isLeader("Leader")).thenReturn(true); - when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isMember(anyString())).thenReturn(true); when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); assertTrue(EspionageService.eligible(faction, "Leader")); assertTrue(EspionageService.eligible(faction, "Spy")); @@ -224,7 +224,7 @@ void defaultHoldingFollowsANewLeaderButAppointmentsStay() { Faction faction = mock(Faction.class); when(faction.getLeader()).thenReturn("NewLeader"); when(faction.isLeader("NewLeader")).thenReturn(true); - when(faction.isMemberIgnoreCase("OldLeader")).thenReturn(true); + when(faction.isMember("OldLeader")).thenReturn(true); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); var appointed = new SpecialPositionAssignment(); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java index 9a576e59..bc2beac9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java @@ -12,6 +12,6 @@ static void protect(Faction faction) { holder.playerName = "Spy"; state.appoint(holder, 50); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java index fe77ab73..73c265a4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -39,7 +39,7 @@ private Faction faction(String id) { var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; faction.getEspionage().appoint(holder, 50); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); when(faction.getMembers()).thenReturn(List.of()); when(faction.getGuildHandler().getGuilds()).thenReturn(List.of()); when(faction.getMilitary().getRegiments()).thenReturn(List.of()); @@ -53,7 +53,7 @@ void browsingOnlyReadsCacheAndMenuRefreshIsSharedOncePerDay() { when(first.getName()).thenReturn("First"); when(second.getName()).thenReturn("Second"); Faction observer = faction("observer"), target = faction("target"); - when(observer.isMemberIgnoreCase(anyString())).thenReturn(true); + when(observer.isMember(anyString())).thenReturn(true); try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { factions.when(() -> FactionManager.getByMember("First")).thenReturn(observer); factions.when(() -> FactionManager.getByMember("Second")).thenReturn(observer); @@ -98,7 +98,7 @@ void prestigeIsPublicAndWealthRankingUsesOnlyKnownMidpoints() { Player viewer = mock(Player.class); when(viewer.getName()).thenReturn("Viewer"); Faction observer = faction("own"), estimated = faction("estimated"), hidden = faction("hidden"); - when(observer.isMemberIgnoreCase("Viewer")).thenReturn(true); + when(observer.isMember("Viewer")).thenReturn(true); when(observer.getWealth()).thenReturn(100.0); when(observer.getPrestige()).thenReturn(1.0); when(estimated.getPrestige()).thenReturn(5.0); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java index 3e8e80bf..f9ed481c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java @@ -59,7 +59,7 @@ class IntelligencePolicyTest { var target = mock(Faction.class); var state = new EspionageState(); when(target.getEspionage()).thenReturn(state); - when(target.isMemberIgnoreCase("Account")).thenReturn(true); + when(target.isMember("Account")).thenReturn(true); when(target.getMembers()).thenReturn(List.of("Account")); var holder = new SpecialPositionAssignment(); holder.playerName = "Account"; diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java index 402f3feb..9b6944a0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -133,7 +133,7 @@ void anotherAppointmentWaitsForTheCooldownEvenAfterRemoval() { void newHolderBuildsUpToFullAptitudeOverTheConfiguredDays() { long day = 86_400_000L; var faction = mock(Faction.class); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); var holder = assignment("Spy"); new EspionageState().appoint(holder, 80, 10 * day); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 30924aee..9206d899 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -35,7 +35,7 @@ class OfficePersistenceTest { when(faction.getEspionage()).thenReturn(state); when(actor.getName()).thenReturn("Leader"); when(faction.isLeader("Leader")).thenReturn(true); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.remove(actor, faction)); assertSame(holder, state.getSpymaster()); @@ -103,7 +103,7 @@ class OfficePersistenceTest { when(faction.getEspionage()).thenReturn(state); when(actor.getName()).thenReturn("Spy"); when(actor.getUniqueId()).thenReturn(holder.playerId); - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.setSabotage(actor, faction, true, 100)); assertFalse(EspionageService.setSabotage(actor, faction, false, 75)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index e2779e80..5e380685 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -44,7 +44,7 @@ class OfficeVacancyTest { assertEquals("Vacant Spymaster", penalties.getFirst().getName()); } var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; - when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember("Spy")).thenReturn(true); state.appoint(holder, 80); assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); state.removeSpymaster(); @@ -67,7 +67,7 @@ class OfficeVacancyTest { state.addUnrest(SpecialPosition.SPYMASTER, 10, 7, 1000); assertEquals(2, EspionageService.stabilityModifiers(faction, 1000).size()); assertEquals(1, EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).size()); - when(faction.isMemberIgnoreCase("FormerSpy")).thenReturn(true); + when(faction.isMember("FormerSpy")).thenReturn(true); assertTrue(EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).isEmpty()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java index 50a8fa16..5c085bbf 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java @@ -39,7 +39,7 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti holder.playerName = name; holder.playerId = UUID.randomUUID(); holder.characterId = "character"; - when(faction.isMemberIgnoreCase(name)).thenReturn(true); + when(faction.isMember(name)).thenReturn(true); faction.getEspionage().appoint(holder, aptitude); return holder; } @@ -88,9 +88,9 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti var target = faction("target"); var holder = appoint(target, "Spy", 50); try (var characters = mockStatic(OfficeCharacters.class); var databases = mockConstruction(Database.class)) { - when(target.isMemberIgnoreCase("Spy")).thenReturn(false); + when(target.isMember("Spy")).thenReturn(false); assertTrue(EspionageService.canViewExact(viewer, target)); - when(target.isMemberIgnoreCase("Spy")).thenReturn(true); + when(target.isMember("Spy")).thenReturn(true); characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); assertTrue(EspionageService.canViewExact(viewer, target)); assertSame(holder, target.getEspionage().getSpymaster()); @@ -159,9 +159,9 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti factions.when(() -> FactionManager.getByMember("ForeignViewer")).thenReturn(observer); factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, target))); assertSame(cached, EspionageService.report(viewer, target)); - when(observer.isMemberIgnoreCase("ObserverSpy")).thenReturn(false); + when(observer.isMember("ObserverSpy")).thenReturn(false); assertNull(EspionageService.report(viewer, target), "Ineligible observers cannot read cached findings"); - when(observer.isMemberIgnoreCase("ObserverSpy")).thenReturn(true); + when(observer.isMember("ObserverSpy")).thenReturn(true); try (var characters = mockStatic(OfficeCharacters.class)) { characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); assertNull(EspionageService.report(viewer, target), "Dead observers cannot read cached findings"); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index c53658f7..4dd2069a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -97,7 +97,7 @@ class VassalIntelligenceTest { holder.playerName = "Account1"; state.appoint(holder, 70); when(target.getEspionage()).thenReturn(state); - when(target.isMemberIgnoreCase(anyString())).thenReturn(true); + when(target.isMember(anyString())).thenReturn(true); var report = EspionageService.createReport(Map.of(), -50, IntelligenceTier.DETAILED, new Random(2)); try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { names.when(() -> CharacterNames.forForeign(anyString())).thenAnswer(call -> call.getArgument(0)); @@ -196,7 +196,7 @@ class VassalIntelligenceTest { state.appoint(holder, 60); when(faction.getId()).thenReturn("faction"); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isMember(anyString())).thenReturn(true); when(faction.getOverlord()).thenReturn(overlord); when(faction.getVassals()).thenReturn(List.of(vassal)); when(spy.getName()).thenReturn("Spy"); @@ -238,7 +238,7 @@ class VassalIntelligenceTest { state.appoint(holder, 60); state.share(SharingPartner.VASSALS, IntelligenceTier.RUMOURS); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isMember(anyString())).thenReturn(true); when(spy.getName()).thenReturn("Spy"); when(spy.getUniqueId()).thenReturn(holder.playerId); try (var databases = mockConstruction(Database.class)) { @@ -257,7 +257,7 @@ class VassalIntelligenceTest { state.appoint(holder, 60); when(faction.getId()).thenReturn("faction"); when(faction.getEspionage()).thenReturn(state); - when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isMember(anyString())).thenReturn(true); when(faction.getVassals()).thenReturn(List.of(vassal)); var vassalState = new EspionageState(); vassalState.report("faction", 0, 1, IntelligenceReport::new); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index 296b332b..501eef5e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -1805,10 +1805,10 @@ void factionInvitationsRequireLeadershipCapacityAndAnUnalignedInvitee() { verify(faction, never()).invite(anyString()); Player alice = gui.player("Alice"); characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(alice); - when(faction.isMemberIgnoreCase("Alice")).thenReturn(true); + when(faction.isMember("Alice")).thenReturn(true); assertTrue(run("faction", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("already a member")); - when(faction.isMemberIgnoreCase("Alice")).thenReturn(false); + when(faction.isMember("Alice")).thenReturn(false); Faction other = mock(Faction.class); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(other); assertTrue(run("faction", "invite", "Lady", "Alice")); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index e38c0dde..a995472f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -127,7 +127,7 @@ private Faction faction(String id, String leader) { when(faction.getRGB()).thenReturn(id + "-rgb"); List members = new ArrayList<>(List.of(leader)); when(faction.getMembers()).thenReturn(members); - when(faction.isMemberIgnoreCase(anyString())) + when(faction.isMember(anyString())) .thenAnswer(call -> members.stream().anyMatch(name -> name.equalsIgnoreCase(call.getArgument(0)))); doAnswer(call -> members.add(call.getArgument(0))).when(faction).addMember(anyString()); when(faction.getProvinces()).thenReturn(new ArrayList<>()); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarketTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarketTest.java index 84964075..f7bf99c9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarketTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarketTest.java @@ -223,7 +223,7 @@ private void governedHirer(String... council) { List members = new java.util.ArrayList<>(List.of(council)); members.add("Farmhand"); when(fixture.hirer.getMembers()).thenReturn(members); - when(fixture.hirer.isMemberIgnoreCase(anyString())).thenAnswer(inv -> { + when(fixture.hirer.isMember(anyString())).thenAnswer(inv -> { String name = inv.getArgument(0); return members.stream().anyMatch(m -> m.equalsIgnoreCase(name)); }); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java index 1d779a5f..21a9e5a9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java @@ -475,10 +475,10 @@ void guildMembershipQueriesAndForcedRemovalAgreeWithoutChangingLeaders() { assertFalse(guilds.isGuildLeader("Traveler")); guilds.forceKick("traveler"); assertNull(guilds.getGuildByMember("Traveler")); - assertFalse(faction.isMemberIgnoreCase("Traveler")); + assertFalse(faction.isMember("Traveler")); assertEquals(List.of("Bob"), traders.getMembers()); assertEquals("Bob", traders.getLeader()); - assertTrue(faction.isMemberIgnoreCase("Citizen")); + assertTrue(faction.isMember("Citizen")); guilds.forceKick("already gone"); assertTrue(guilds.isGuildLeader("Alice")); assertTrue(guilds.isGuildLeader("Bob")); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java index 43d4e062..d9f20319 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java @@ -145,8 +145,8 @@ void factionInvitationsAreCaseInsensitiveAndCanBeConsumedExactlyOnce() { faction.setInvited(new ArrayList<>(List.of("Cara"))); assertTrue(faction.isInvited("cara")); faction.addMember("Bob"); - assertTrue(faction.isMemberIgnoreCase("bob")); - assertFalse(faction.isMemberIgnoreCase("missing")); + assertTrue(faction.isMember("bob")); + assertFalse(faction.isMember("missing")); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java new file mode 100644 index 00000000..7628d8c4 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java @@ -0,0 +1,84 @@ +package net.tfminecraft.simplefactions.testsupport; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; + +/** + * Member keys are opaque strings, so most tests key each mocked player by its + * Minecraft name. Tests about real character keys install + * {@link Members#useSharedIdentity()} themselves. + */ +public final class NameKeyedMembers implements Members.Resolver, BeforeEachCallback, AfterEachCallback { + public static final NameKeyedMembers INSTANCE = new NameKeyedMembers(); + + public static void install() { + Members.setResolver(INSTANCE); + } + + @Override + public void beforeEach(ExtensionContext context) { + install(); + } + + @Override + public void afterEach(ExtensionContext context) { + install(); + } + + @Override + public Optional session(Player player) { + String name = player.getName(); + if (name == null) return Optional.empty(); + UUID owner = player.getUniqueId() != null ? player.getUniqueId() : id("owner:" + name); + return Optional.of(new CharacterSession(new CharacterRef(owner, id(name)), 1L)); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return true; + } + + @Override + public String key(Player player) { + return player.getName(); + } + + @Override + public Optional info(String key) { + return Optional.empty(); + } + + @Override + public Optional online(String key) { + Player player = null; + try { + player = Bukkit.getPlayerExact(key); + } catch (RuntimeException ignored) { + // No server in this test. + } + if (player == null) { + try { + player = Bukkit.getPlayer(key); + } catch (RuntimeException ignored) { + // No server in this test. + } + } + return Optional.ofNullable(player); + } + + private static UUID id(String name) { + return UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderServiceTest.java index 68824f7a..758652f9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderServiceTest.java @@ -102,8 +102,8 @@ void setUp() { when(attacker.getMembers()).thenReturn(new ArrayList<>(List.of("Bob"))); when(defender.getMembers()).thenReturn(new ArrayList<>(List.of("Carol"))); - when(attacker.isMemberIgnoreCase("Bob")).thenReturn(true); - when(defender.isMemberIgnoreCase("Carol")).thenReturn(true); + when(attacker.isMember("Bob")).thenReturn(true); + when(defender.isMember("Carol")).thenReturn(true); Instant raidWindow = BattleWindowService.atScheduleHour(BATTLE_DAY, 19); CampaignRaidService.beginMuster(war, attacker, "port-atk", "port-def", raidWindow); diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..3a242080 --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +net.tfminecraft.simplefactions.testsupport.NameKeyedMembers diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 00000000..6efc0d5e --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled=true From c473b20c509931d1a67574fffff5f3c547402db5 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:07:12 +0000 Subject: [PATCH 02/35] wip: commands and identity on member keys --- .../identity/LeaderCharacterListener.java | 12 +- .../identity/LeaderCharacters.java | 35 ++--- .../simplefactions/identity/Members.java | 14 +- .../managers/CommandManager.java | 131 ++++++++++-------- .../managers/FactionManager.java | 45 +++--- .../managers/InviteCommands.java | 16 ++- .../managers/PlayerManager.java | 18 +-- 7 files changed, 148 insertions(+), 123 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index 333cb4d8..32636bc8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -19,25 +19,25 @@ public void onCharacterStart(CharacterSessionStartEvent event) { if (!player.isOnline() || Bukkit.getPlayer(player.getUniqueId()) != player || !event.getSession().ref().ownerId().equals(player.getUniqueId()) || !Characters.isCurrent(event.getSession())) return; - refresh(player.getName()); + refresh(Members.key(event.getSession().ref())); } /** - * Re-reads every realm and guild leader's character (or only `player`'s - * when given) and, if any name changed, asks the map to ship nation.json - * on its next cycle. + * Re-reads every realm and guild leader's character (or only the + * character with member key `player` when given) and, if any name + * changed, asks the map to ship nation.json on its next cycle. */ public static void refresh(String player) { boolean changed = false; for (Faction faction : FactionManager.factions) { if (faction == null) continue; - if (player == null || player.equalsIgnoreCase(faction.getLeader())) { + if (player == null || player.equals(faction.getLeader())) { changed |= LeaderCharacters.refresh(faction); } // Guild leaders too; a realm's own guild follows the realm above. for (Guild guild : faction.getGuildHandler().getGuilds()) { if (guild == null || guild.isBase()) continue; - if (player != null && !player.equalsIgnoreCase(guild.getLeader())) continue; + if (player != null && !player.equals(guild.getLeader())) continue; changed |= LeaderCharacters.refresh(guild); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java index 40411fb5..2f522904 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java @@ -5,12 +5,12 @@ import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.objects.Faction; -/** Character names remembered with their account owner for the public map. */ +/** Leader character names remembered with the leader's member key for the public map. */ public final class LeaderCharacters { - /** Active character name of an online player, or null if unknown. */ + /** Name of the character with a member key, or null if unknown. */ public interface Probe { - String activeCharacterName(String player); + String characterName(String key); /** False while RPCharacters is missing, so remembered names are not kept either. */ default boolean available() { @@ -18,7 +18,7 @@ default boolean available() { } } - /** A remembered character name and the player it belongs to. */ + /** A remembered character name and the member key it belongs to. */ public record Remembered(String name, String player) { public static final Remembered NONE = new Remembered(null, null); } @@ -33,21 +33,8 @@ public static void useSharedIdentity() { @Override public boolean available() { return net.tfminecraft.tlibs.character.Characters.available(); } - @Override public String activeCharacterName(String account) { - var online = org.bukkit.Bukkit.getPlayerExact(account); - if (online != null) { - var session = net.tfminecraft.tlibs.character.Characters.currentSession(online).orElse(null); - if (session == null || !session.ref().ownerId().equals(online.getUniqueId())) return null; - var info = net.tfminecraft.tlibs.character.Characters.info(session.ref().characterId()).orElse(null); - return info != null && info.ref().equals(session.ref()) - && online.isOnline() && org.bukkit.Bukkit.getPlayer(online.getUniqueId()) == online - && net.tfminecraft.tlibs.character.Characters.isCurrent(session) ? info.name() : null; - } - var cached = org.bukkit.Bukkit.getOfflinePlayerIfCached(account); - if (cached == null) return null; - return net.tfminecraft.tlibs.character.Characters.lastPlayed(cached.getUniqueId()) - .filter(info -> info.ref().ownerId().equals(cached.getUniqueId())) - .map(net.tfminecraft.tlibs.character.CharacterInfo::name).orElse(null); + @Override public String characterName(String key) { + return Members.info(key).map(net.tfminecraft.tlibs.character.CharacterInfo::name).orElse(null); } }); } @@ -61,17 +48,17 @@ public static void reset() { } /** - * What to remember for `leader` now: their active character if they are - * online with one, else what was remembered for this same leader, else - * nothing. Without RPCharacters it is always nothing. + * What to remember for `leader` now: the leader character's name, else + * what was remembered for this same leader, else nothing. Without + * RPCharacters it is always nothing. */ public static Remembered resolve(String leader, String rememberedName, String rememberedFor) { if (leader == null || leader.isBlank()) return Remembered.NONE; Probe current = probe; if (current == null || !current.available()) return Remembered.NONE; - String active = clean(current.activeCharacterName(leader)); + String active = clean(current.characterName(leader)); if (active != null) return new Remembered(active, leader); - if (rememberedName != null && leader.equalsIgnoreCase(rememberedFor)) { + if (rememberedName != null && leader.equals(rememberedFor)) { String kept = clean(rememberedName); if (kept != null) return new Remembered(kept, leader); } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java index 1368b18c..13486ea1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -74,6 +74,11 @@ public static boolean isCurrent(CharacterSession session) { return session != null && resolver.isCurrent(session); } + /** The current character key of the online account with that Minecraft name, or null. */ + public static String ofAccountName(String accountName) { + return accountName == null ? null : key(onlineByName(accountName)); + } + /** The character name for a key; offline characters are read from the provider. */ public static String display(String key) { if (key == null || key.isBlank()) return "Unknown"; @@ -166,6 +171,8 @@ public static boolean isQuarantined(String key) { public static String find(Collection keys, String input) { if (keys == null || input == null) return null; String query = unquote(input); + String typedKey = key(characterId(query)); + if (typedKey != null && keys.contains(typedKey)) return typedKey; Player online = onlineByName(query); String onlineKey = key(online); if (onlineKey != null && keys.contains(onlineKey)) return onlineKey; @@ -178,10 +185,15 @@ public static String find(Collection keys, String input) { return match; } - /** The key of an online player named by Minecraft or character name, or null. */ + /** + * The key of an online player named by Minecraft or character name, or + * null. A typed character ID is taken as it is, for staff. + */ public static String findOnline(String input) { if (input == null) return null; String query = unquote(input); + String typedKey = key(characterId(query)); + if (typedKey != null) return typedKey; String direct = key(onlineByName(query)); if (direct != null) return direct; String match = null; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index c9b1fd27..6f862ca6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -61,6 +61,8 @@ public class CommandManager implements Listener, CommandExecutor{ private Formatter format = new Formatter(); + /** Sent to a player acting without a ready character session. */ + public static final String NO_CHARACTER = "§cYou need to be playing a character to do that."; public String cmd1 = "faction"; public String cmd2 = "guild"; @@ -96,13 +98,18 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§a[SimpleFactions]§c Error with command format, use the gameplay guide for a list of commands"); return true; } + // Without a character the player is a stranger; only staff commands remain. + if(Members.key(p) == null && !Permissions.isAdmin(sender)) { + p.sendMessage(NO_CHARACTER); + return true; + } if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("create") && args.length == 2) { Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to be in a faction to make a guild"); return true; } - if(f.isInGuild(p.getName())) { + if(f.isInGuild(Members.key(p))) { p.sendMessage("§cYou are already in a guild"); return true; } @@ -156,8 +163,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, guild.getFaction().getGuildHandler().removeGuild(guild.getId()); guild.getFaction().getProvinceHandler().revalidateClaims(); for(String member : guild.getMembers()) { - Player pl = Bukkit.getPlayerExact(member); - if(pl != null && !member.equalsIgnoreCase(guild.getLeader())) { + Player pl = Members.player(member); + if(pl != null && !member.equals(guild.getLeader())) { pl.sendMessage("§cYour guild has been deleted!"); pl.sendMessage("§aJoined "+guild.getFaction().getName()); } @@ -217,13 +224,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } String invitee = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); - Player invited = net.tfminecraft.simplefactions.espionage.CharacterNames.resolveOnline(p, invitee); + Player invited = Members.resolveOnline(p, invitee); if(invited == null) return true; if(invited.getUniqueId().equals(p.getUniqueId())) { p.sendMessage("§cCannot invite yourself"); return true; } - String name = invited.getName(); + String name = Members.key(invited); + if(name == null) { + p.sendMessage("§cThat player has not chosen a character yet"); + return true; + } if(guild.isInvited(name)) { p.sendMessage("§cPlayer is already invited"); return true; @@ -254,8 +265,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } guild.invite(name); - p.sendMessage("§aInvited "+name); - invited.sendMessage("§a"+p.getName()+" invited you to the guild "+guild.getName()); + p.sendMessage("§aInvited "+Members.display(name)); + invited.sendMessage("§a"+Members.name(p)+" invited you to the guild "+guild.getName()); invited.sendMessage(InviteCommands.buttons(cmd2, guild.getId(), guild.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("leave") && args.length == 1) { @@ -282,21 +293,18 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cThis is the base guild, use /faction kick instead"); return true; } - if(!guild.isMember(args[1])) { + String kicked = Members.find(guild.getMembers(), args[1]); + if(kicked == null) { p.sendMessage("§cPlayer is not a member"); return true; } - if(args[1].equalsIgnoreCase(guild.getLeader())) { + if(kicked.equals(guild.getLeader())) { p.sendMessage("§cYou cannot dismiss the leader!"); return true; } - guild.kick(args[1]); - p.sendMessage("§aKicked "+args[1]); - for(Player pl : Bukkit.getOnlinePlayers()) { - if(pl.getName().equalsIgnoreCase(args[1])) { - pl.sendMessage("§a"+p.getName()+ " kicked you from "+guild.getName()); - } - } + guild.kick(kicked); + p.sendMessage("§aKicked "+Members.display(kicked)); + Members.online(kicked).ifPresent(pl -> pl.sendMessage("§a"+Members.name(p)+ " kicked you from "+guild.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setbank") && args.length == 1) { if(FactionManager.getGuildByLeader(Members.key(p)) != null) { @@ -344,7 +352,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } pouch.change(amount*-1); g.getBank().deposit(amount); - g.getLedger().getHistory().addDeposit(p.getName(), amount); + g.getLedger().getHistory().addDeposit(Members.key(p), amount); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Guild Balance: #b39122"+b.getWealth()+"#dbaf1dd")); @@ -483,18 +491,19 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cOnly the leader can set a new leader!"); return true; } - if(!g.isMember(args[1])) { + String heir = Members.find(g.getMembers(), args[1]); + if(heir == null) { p.sendMessage("§cPlayer is not in the guild"); return true; } - if(args[1].equalsIgnoreCase(g.getLeader())) { + if(heir.equals(g.getLeader())) { p.sendMessage("§cPlayer is already the leader"); return true; } - g.setLeader(args[1]); + g.setLeader(heir); for(Player pl : Bukkit.getOnlinePlayers()) { if(g.isMember(Members.key(pl))) { - pl.sendMessage("§a"+args[1]+ " is the new guild leader!"); + pl.sendMessage("§a"+Members.display(heir)+ " is the new guild leader!"); } } return true; @@ -522,10 +531,10 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } g.setLeader(member); - p.sendMessage("§a"+member+" now leads "+g.getName()); + p.sendMessage("§a"+Members.display(member)+" now leads "+g.getName()); for(Player pl : Bukkit.getOnlinePlayers()) { if(!pl.equals(p) && g.isMember(Members.key(pl))) { - pl.sendMessage("§a"+member+ " is the new guild leader!"); + pl.sendMessage("§a"+Members.display(member)+ " is the new guild leader!"); } } return true; @@ -553,7 +562,11 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, == DisplayNameGate.Result.NEEDS_CONFIRM) { return true; } - Faction f = new Faction(args[1], p.getName()); + if(Members.key(p) == null) { + p.sendMessage(NO_CHARACTER); + return true; + } + Faction f = new Faction(args[1], Members.key(p)); FactionCreateEvent factionCreateEvent = new FactionCreateEvent(p, f); Bukkit.getPluginManager().callEvent(factionCreateEvent); if(!factionCreateEvent.isCancelled()) { @@ -871,25 +884,22 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cOnly the leader can dismiss members!"); return true; } - if(args[1].equalsIgnoreCase(f.getLeader())) { + String kicked = Members.find(f.getMembers(), args[1]); + if(kicked != null && kicked.equals(f.getLeader())) { p.sendMessage("§cYou cannot dismiss the leader!"); return true; } - if(!f.getMembers().contains(args[1])) { + if(kicked == null) { p.sendMessage("§cPlayer is not a member"); return true; } - if(f.isInGuild(args[1])) { + if(f.isInGuild(kicked)) { p.sendMessage("§cPlayer is a member of a guild"); return true; } - f.forceRemoveMember(args[1]);; - p.sendMessage("§aKicked "+args[1]); - for(Player pl : Bukkit.getOnlinePlayers()) { - if(pl.getName().equalsIgnoreCase(args[1])) { - pl.sendMessage("§a"+p.getName()+ " kicked you from "+f.getName()); - } - } + f.forceRemoveMember(kicked); + p.sendMessage("§aKicked "+Members.display(kicked)); + Members.online(kicked).ifPresent(pl -> pl.sendMessage("§a"+Members.name(p)+ " kicked you from "+f.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { Faction f = FactionManager.getByMember(Members.key(p)); @@ -905,22 +915,23 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cCannot set leader in a democracy!"); return true; } - if(!f.getMembers().contains(args[1])) { + String heir = Members.find(f.getMembers(), args[1]); + if(heir == null) { p.sendMessage("§cPlayer is not in the faction"); return true; } - if(args[1].equalsIgnoreCase(f.getLeader())) { + if(heir.equals(f.getLeader())) { p.sendMessage("§cPlayer is already the leader"); return true; } - if(!f.canBecomeLeader(args[1])) { + if(!f.canBecomeLeader(heir)) { p.sendMessage("§cPlayer is not eligible to be leader (perhaps they are a guild leader?)"); return true; } - f.setLeader(args[1]); + f.setLeader(heir); for(Player pl : Bukkit.getOnlinePlayers()) { if(f.getMembers().contains(Members.key(pl))) { - pl.sendMessage("§a"+args[1]+ " is the new faction leader!"); + pl.sendMessage("§a"+Members.display(heir)+ " is the new faction leader!"); } } return true; @@ -934,12 +945,16 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cOnly the leader can invite new members!"); return true; } - Player invited = net.tfminecraft.simplefactions.espionage.CharacterNames.resolveOnline(p, + Player invited = Members.resolveOnline(p, String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); if(invited == null) { return true; } - String name = invited.getName(); + String name = Members.key(invited); + if(name == null) { + p.sendMessage("§cThat player has not chosen a character yet"); + return true; + } if(f.isMember(name)) { p.sendMessage("§cPlayer is already a member"); return true; @@ -953,8 +968,8 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } f.invite(name); - p.sendMessage("§aInvited "+name); - invited.sendMessage("§a"+p.getName()+ " invited you to "+f.getName()); + p.sendMessage("§aInvited "+Members.display(name)); + invited.sendMessage("§a"+Members.name(p)+ " invited you to "+f.getName()); invited.sendMessage(InviteCommands.buttons(cmd1, f.getId(), f.getName())); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("leave") && args.length == 1) { @@ -968,7 +983,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } Faction f = FactionManager.getByMember(Members.key(p)); - if(f.isInGuild(p.getName())) { + if(f.isInGuild(Members.key(p))) { p.sendMessage("§cYou are in a guild, use /guild leave instead"); return true; } @@ -1149,7 +1164,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } pouch.change(amount*-1); f.getBank().deposit(amount); - f.getOrCreateMainGuild().getLedger().getHistory().addDeposit(p.getName(), amount); + f.getOrCreateMainGuild().getLedger().getHistory().addDeposit(Members.key(p), amount); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Faction Balance: #b39122"+b.getWealth()+"#dbaf1dd")); @@ -1406,22 +1421,23 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } - if(!f.getMembers().contains(args[2])) { + String heir = Members.find(f.getMembers(), args[2]); + if(heir == null) { p.sendMessage("§cPlayer is not in the faction"); return true; } - if(args[2].equalsIgnoreCase(f.getLeader())) { + if(heir.equals(f.getLeader())) { p.sendMessage("§cPlayer is already the leader"); return true; } - if(!f.canBecomeLeader(args[2])) { + if(!f.canBecomeLeader(heir)) { p.sendMessage("§cPlayer is not eligible to be leader (perhaps they are a guild leader?)"); return true; } - f.setLeader(args[2]); + f.setLeader(heir); for(Player pl : Bukkit.getOnlinePlayers()) { if(f.getMembers().contains(Members.key(pl))) { - pl.sendMessage("§a"+args[2]+ " is the new faction leader!"); + pl.sendMessage("§a"+Members.display(heir)+ " is the new faction leader!"); } } return true; @@ -1435,18 +1451,23 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } - if(f.getMembers().contains(args[2])) { + String joiner = Members.findOnline(args[2]); + if(joiner == null) { + p.sendMessage("§cNo online character or character ID matches "+args[2]); + return true; + } + if(f.getMembers().contains(joiner)) { p.sendMessage("§cPlayer is already in the faction"); return true; } - if(FactionManager.getByMember(args[2]) != null) { + if(FactionManager.getByMember(joiner) != null) { p.sendMessage("§cPlayer is already in a faction"); return true; } - f.addMember(args[2]);; + f.addMember(joiner); for(Player pl : Bukkit.getOnlinePlayers()) { if(f.getMembers().contains(Members.key(pl))) { - pl.sendMessage("§a"+args[2]+ " joined the faction!"); + pl.sendMessage("§a"+Members.display(joiner)+ " joined the faction!"); } } return true; @@ -1485,7 +1506,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced } p.sendMessage("§6"+amount+" §cwas withdrawn from the bank of "+f.getName()); for(String s : f.getMembers()) { - Player member = Bukkit.getPlayer(s); + Player member = Members.player(s); if(member != null && member.isOnline()) { member.sendMessage("§6"+amount+" §cwas withdrawn from the faction by admins, §cmake a ticket if you believe this was a mistake"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index bec38b6c..5fbc6733 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -528,7 +528,7 @@ public void time() { if(timer%300 == 0) { for(Faction f : factions) { if(f.getProvinces().size() == 0) continue; - Player leader = Bukkit.getPlayerExact(f.getLeader()); + Player leader = Members.player(f.getLeader()); if(leader == null) continue; if(TitleManager.overProvinceCap(f)) { leader.sendMessage("§cYou are over your province cap! Other nations can steal your provinces from you!"); @@ -1005,7 +1005,7 @@ public static Title usurp(Player p, Faction usurping, Faction losing) { //Guild relocation public static void requestRelocation(Player sender, Guild g, Faction target, int capital, String settlementName) { - Player p = Bukkit.getPlayerExact(target.getLeader()); + Player p = Members.player(target.getLeader()); if(p == null) { sender.sendMessage("§cTarget faction leader is not online"); return; @@ -1033,14 +1033,14 @@ public static void acceptRelocateRequest(Player p) { if(reciever.getSettlementHandler().requiresFoundingName(capital) && (req.getSettlementName() == null || req.getSettlementName().isBlank())) { p.sendMessage("§cA city name is required to relocate here"); - Player sp = Bukkit.getPlayerExact(sender.getLeader()); + Player sp = Members.player(sender.getLeader()); if(sp != null && sp.isOnline()) { sp.sendMessage("§cRelocation failed - a city name is required at the destination"); } return; } double cost = sender.getRelocationCost(capital); - Player sp = Bukkit.getPlayerExact(sender.getLeader()); + Player sp = Members.player(sender.getLeader()); if(sender.getBank() == null || !Double.isFinite(cost) || cost < 0 || sender.getBank().getWealth() < cost) { p.sendMessage("§c"+sender.getName()+" does not have enough funds to relocate (Cost: "+Formatter.formatDouble(cost)+"d)"); if(sp != null && sp.isOnline()) sp.sendMessage("§cYour guild does not have enough funds to relocate (Cost: "+Formatter.formatDouble(cost)+"d)"); @@ -1064,7 +1064,7 @@ public static void acceptRelocateRequest(Player p) { } public static void requestElevation(Player sender, Guild target) { - Player p = Bukkit.getPlayerExact(target.getLeader()); + Player p = Members.player(target.getLeader()); if(p == null) { sender.sendMessage("§cTarget guild leader is not online"); return; @@ -1105,22 +1105,22 @@ public static void acceptElevationRequest(Player p) { } public static void requestMovementJoin(Player sender, Movement movement, String type, Cause cause) { - Player p = Bukkit.getPlayerExact(movement.getLeader()); + Player p = Members.player(movement.getLeader()); if(p == null) { sender.sendMessage("§cTarget movement leader is not online"); return; } - p.sendMessage(sender.getName()+" §7wants to join your movement as a "+type); + p.sendMessage(Members.name(sender)+" §7wants to join your movement as a "+type); p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); - sender.sendMessage("§aJoin request sent to "+movement.getLeader()); - RequestManager.addRequest(sender, p, new MovementJoinRequest(null, sender.getName(), type, movement, cause)); + sender.sendMessage("§aJoin request sent to "+Members.display(movement.getLeader())); + RequestManager.addRequest(sender, p, new MovementJoinRequest(null, Members.key(sender), type, movement, cause)); } public static void acceptMovementJoinRequest(Player p) { if (!(RequestManager.getRequest(p) instanceof MovementJoinRequest req)) return; String sender = req.getPlayer(); - Player sp = Bukkit.getPlayerExact(sender); + Player sp = Members.player(sender); if(sp == null || !sp.isOnline()) { p.sendMessage("§cRequest sender is not online"); return; @@ -1130,7 +1130,7 @@ public static void acceptMovementJoinRequest(Player p) { p.sendMessage("§cTarget faction no longer exists"); return; } - Movement target = f.getGovernment().getMovementByLeader(p.getName()); + Movement target = f.getGovernment().getMovementByLeader(Members.key(p)); if(target == null) { p.sendMessage("§cYou are not the leader of a movement"); return; @@ -1158,7 +1158,7 @@ public static void acceptMovementJoinRequest(Player p) { return; } target.joinAsForeignBacker(fac); - p.sendMessage("§a"+sender+" has joined your movement as a "+req.getType()+"!"); + p.sendMessage("§a"+Members.display(sender)+" has joined your movement as a "+req.getType()+"!"); return; } if(!target.canJoin(o, cause)) { @@ -1175,33 +1175,34 @@ public static void acceptMovementJoinRequest(Player p) { return; } target.join(o, cause); - p.sendMessage("§a"+sender+" has joined your movement as a "+req.getType()+"!"); + p.sendMessage("§a"+Members.display(sender)+" has joined your movement as a "+req.getType()+"!"); } + /** Asks the character with member key {@code targetName} to be the movement's wanted leader. */ public static void requestMovementLeaderTarget(Player sender, Movement movement, Cause cause, String targetName) { if (sender == null || movement == null || cause == null || targetName == null) { return; } - Player target = Bukkit.getPlayerExact(targetName); + Player target = Members.player(targetName); if (target == null || !target.isOnline()) { sender.sendMessage("§cThat player must be online to become the wanted leader."); return; } Faction host = movement.getFaction(); if (host == null || !host.canBecomeLeader(targetName)) { - sender.sendMessage("§c" + targetName + " cannot be the wanted leader."); + sender.sendMessage("§c" + Members.display(targetName) + " cannot be the wanted leader."); return; } - target.sendMessage(sender.getName() + " §7wants you to be the wanted leader of their movement."); + target.sendMessage(Members.name(sender) + " §7wants you to be the wanted leader of their movement."); target.sendMessage("§7Type §a/faction accept §7to accept"); target.sendMessage("§7Request will time out in 60 seconds"); - sender.sendMessage("§aLeader request sent to " + targetName); + sender.sendMessage("§aLeader request sent to " + Members.display(targetName)); RequestManager.addRequest( sender, target, new MovementLeaderTargetRequest( null, - sender.getName(), + Members.key(sender), movement, cause, targetName)); @@ -1209,7 +1210,7 @@ public static void requestMovementLeaderTarget(Player sender, Movement movement, public static void acceptMovementLeaderTargetRequest(Player p) { if (!(RequestManager.getRequest(p) instanceof MovementLeaderTargetRequest req)) return; - if (!p.getName().equalsIgnoreCase(req.getProposedName())) { + if (!Members.is(p, req.getProposedName())) { p.sendMessage("§cThat request is not for you."); return; } @@ -1224,7 +1225,7 @@ public static void acceptMovementLeaderTargetRequest(Player p) { p.sendMessage("§cSpecified cause no longer exists"); return; } - if (!cause.hasLeader() || !cause.getLeader().equalsIgnoreCase(req.getRequester())) { + if (!cause.hasLeader() || !cause.getLeader().equals(req.getRequester())) { p.sendMessage("§cThe requester no longer leads that cause."); return; } @@ -1236,9 +1237,9 @@ public static void acceptMovementLeaderTargetRequest(Player p) { cause.getProposal().setTarget(req.getProposedName()); new Database().saveFaction(host); p.sendMessage("§aYou are now the wanted leader of this movement."); - Player requester = Bukkit.getPlayerExact(req.getRequester()); + Player requester = Members.player(req.getRequester()); if (requester != null && requester.isOnline()) { - requester.sendMessage("§a" + p.getName() + " accepted becoming the wanted leader."); + requester.sendMessage("§a" + Members.name(p) + " accepted becoming the wanted leader."); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java index 7bd87862..546767b3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java @@ -38,6 +38,10 @@ public static boolean handles(String command, String[] args) { public static boolean handle(Player p, String command, String[] args) { boolean guild = command.equalsIgnoreCase(GUILD); + if (Members.key(p) == null) { + p.sendMessage(CommandManager.NO_CHARACTER); + return true; + } if (args[0].equalsIgnoreCase("decline")) { if (guild) declineGuild(p, args[1]); else declineFaction(p, args[1]); @@ -145,7 +149,7 @@ private static void joinGuild(Player p, String id) { g.addMember(Members.key(p)); p.sendMessage("§aJoined " + g.getName()); g.getFaction().updatePrestige(); - tellOthers(g.getFaction(), p, "§a" + p.getName() + " joined the guild " + g.getName() + "§a!"); + tellOthers(g.getFaction(), p, "§a" + Members.name(p) + " joined the guild " + g.getName() + "§a!"); } private static void joinFaction(Player p, String id) { @@ -165,7 +169,7 @@ private static void joinFaction(Player p, String id) { f.addMember(Members.key(p)); p.sendMessage("§aJoined " + f.getName()); f.updatePrestige(); - tellOthers(f, p, "§a" + p.getName() + " joined the faction!"); + tellOthers(f, p, "§a" + Members.name(p) + " joined the faction!"); } private static void declineGuild(Player p, String id) { @@ -175,7 +179,7 @@ private static void declineGuild(Player p, String id) { return; } p.sendMessage("§aDeclined the invite to " + g.getName()); - tellLeader(g.getLeader(), "§c" + p.getName() + " declined your invite to " + g.getName()); + tellLeader(g.getLeader(), "§c" + Members.name(p) + " declined your invite to " + g.getName()); } private static void declineFaction(Player p, String id) { @@ -185,7 +189,7 @@ private static void declineFaction(Player p, String id) { return; } p.sendMessage("§aDeclined the invite to " + f.getName()); - tellLeader(f.getLeader(), "§c" + p.getName() + " declined your invite to " + f.getName()); + tellLeader(f.getLeader(), "§c" + Members.name(p) + " declined your invite to " + f.getName()); } private static void tellOthers(Faction f, Player joiner, String message) { @@ -197,8 +201,6 @@ private static void tellOthers(Faction f, Player joiner, String message) { } private static void tellLeader(String leader, String message) { - if (leader == null) return; - Player online = Bukkit.getPlayerExact(leader); - if (online != null) online.sendMessage(message); + Members.online(leader).ifPresent(online -> online.sendMessage(message)); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 7961f6cb..753f9c7d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -173,7 +173,7 @@ public void run() { issuer.getLoanHandler().issueLoan(loan); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); p.sendMessage("§aLoan taken!"); - Player lender = Bukkit.getPlayer(issuer.getLeader()); + Player lender = Members.player(issuer.getLeader()); if(lender != null && lender.isOnline()) { lender.sendMessage("§aYour loan has been accepted by "+borrowerGuild.getName()+"!"); lender.playSound(lender, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); @@ -190,7 +190,7 @@ public void run() { m.setPages(completeMeta.getPages()); m.setDisplayName("§6Loan Agreement §7"+Cache.getFantasyDate(System.currentTimeMillis())); m.setTitle("§6Loan Agreement §7"+Cache.getFantasyDate(System.currentTimeMillis())); - m.setAuthor(issuer.getLeader()+" and "+p.getName()); + m.setAuthor(Members.display(issuer.getLeader())+" and "+Members.name(p)); i.setItemMeta(m); replacement.accept(i); } @@ -267,12 +267,12 @@ private void signContractBook( p.sendMessage((result.ok() ? "§a" : "§c")+result.message()); if(!result.ok()) return; p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); - Player leader = Bukkit.getPlayer(company.getLeader()); + Player leader = Members.player(company.getLeader()); if(leader != null && leader.isOnline()) { leader.sendMessage("§a"+result.message()); leader.playSound(leader, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } - replaceHeldBook(replacement, signedAgreement(company, contract, p.getName())); + replaceHeldBook(replacement, signedAgreement(company, contract, Members.name(p))); } } @@ -312,7 +312,7 @@ private ItemStack signedAgreement( String title = "§6Mercenary Contract §7"+Cache.getFantasyDate(System.currentTimeMillis()); m.setDisplayName(title); m.setTitle(title); - m.setAuthor(company.getLeader()+" and "+signer); + m.setAuthor(Members.display(company.getLeader())+" and "+signer); i.setItemMeta(m); return i; } @@ -322,12 +322,14 @@ public void earnMoney(PlayerEarnMoneyEvent e) { String playerName = e.getPlayer(); double paidTax = 0; double gross = e.getAmount(); - if(FactionManager.getByMember(playerName) != null) { - Faction f = FactionManager.getByMember(playerName); + // The earner's realm is the one of the character they are playing now. + String member = Members.ofAccountName(playerName); + if(FactionManager.getByMember(member) != null) { + Faction f = FactionManager.getByMember(member); if(f.getTaxRate(TaxTarget.CITIZENS, null, true) > 0) { if(f.getBank() != null) { paidTax = f.getTaxRate(TaxTarget.CITIZENS, null, true)/100.0*gross; - f.giveTax(playerName, paidTax); + f.giveTax(member, paidTax); } } } From 16290fef94494cf7b41988d8eb26460cbe7dea86 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:14:48 +0000 Subject: [PATCH 03/35] wip: migration, quarantine, waiting state --- .../simplefactions/SimpleFactions.java | 118 ++++- .../simplefactions/api/SimpleFactionsApi.java | 19 + .../events/SimpleFactionsReadyEvent.java | 21 + .../simplefactions/identity/Members.java | 18 + .../managers/CommandManager.java | 3 + .../migration/CharacterMigration.java | 281 ++++++++++++ .../migration/MigrationFiles.java | 56 +++ .../migration/MigrationManifest.java | 60 +++ .../simplefactions/migration/Quarantine.java | 112 +++++ .../migration/QuarantineCommands.java | 94 ++++ .../simplefactions/migration/StoreMapper.java | 420 ++++++++++++++++++ 11 files changed, 1195 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/events/SimpleFactionsReadyEvent.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/CharacterMigration.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/MigrationFiles.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/MigrationManifest.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/Quarantine.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/QuarantineCommands.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 62c639a5..ce2ac6b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -14,6 +14,7 @@ import org.bukkit.scheduler.BukkitTask; import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.migration.CharacterMigration; import net.tfminecraft.simplefactions.map.ProvinceGrid; import net.tfminecraft.simplefactions.map.infra.TrackProvinceCache; import net.tfminecraft.simplefactions.map.presence.ProvincePresenceListener; @@ -255,18 +256,32 @@ public class SimpleFactions extends JavaPlugin{ new VehicleMaintenanceDecayTask(); private boolean startupComplete; + /** Set when loading began, so a failed start still releases what it took. */ + private boolean servicesBegun; + /** True once the data is migrated and every service has started. */ + private volatile boolean started; + private boolean startFailed; + private BukkitTask identityPoll; + private CharacterMigration.Heirs migrationHeirs = CharacterMigration.SHARED; + /** Every command in plugin.yml; each answers "starting up" until the plugin has started. */ + static final java.util.List COMMANDS = java.util.List.of( + "faction", "guild", "warband", "battle", "raid", "war", "movement", "ledger", "company", "mercenaries"); + private static final long IDENTITY_POLL_TICKS = 5L * 20L; @Override public void onEnable() { try { - net.tfminecraft.tlibs.character.Characters.available(); + net.tfminecraft.tlibs.character.Characters.legacyHeirsReady(); } catch (LinkageError error) { incompatibleCharacterApi = true; - getLogger().severe("SimpleFactions requires TLibs 2.3.0 or newer; disabling."); + getLogger().severe("SimpleFactions requires TLibs 2.4.0 or newer; disabling."); getServer().getPluginManager().disablePlugin(this); return; } startupComplete = false; + started = false; + startFailed = false; + servicesBegun = false; config = getConfig(); plugin = this; FactionManager.inv = inventoryManager; @@ -279,6 +294,85 @@ public void onEnable() { getServer().getPluginManager().disablePlugin(this); return; } + awaitIdentity(); + } + + /** + * Faction data names characters, so nothing loads, ticks, listens or saves + * until the character provider can name every legacy account's heir. + * Commands answer that factions are starting up meanwhile. + */ + private void awaitIdentity() { + for (String name : COMMANDS) { + org.bukkit.command.PluginCommand command = getCommand(name); + if (command == null) continue; + command.setExecutor((sender, cmd, label, args) -> { + sender.sendMessage("§eFactions are starting up; try again in a moment."); + return true; + }); + command.setTabCompleter((sender, cmd, label, args) -> java.util.List.of()); + } + if (identityReady()) { + start(); + return; + } + getLogger().severe("Waiting for character identity (RPCharacters) before loading any faction data; " + + "factions stay offline until it is ready"); + getServer().getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + if ("RPCharacters".equalsIgnoreCase(event.getPlugin().getName()) && identityReady()) start(); + } + }, this); + identityPoll = getServer().getScheduler().runTaskTimer(this, () -> { + if (identityReady()) start(); + }, IDENTITY_POLL_TICKS, IDENTITY_POLL_TICKS); + } + + static boolean identityReady() { + return net.tfminecraft.tlibs.character.Characters.available() + && net.tfminecraft.tlibs.character.Characters.legacyHeirsReady(); + } + + /** Whether the data is migrated and everything has started; see {@code SimpleFactionsApi.ready()}. */ + public boolean isStarted() { + return started; + } + + void useMigrationHeirs(CharacterMigration.Heirs heirs) { + migrationHeirs = heirs; + } + + /** Migrates the stores if needed, then loads and starts everything. */ + void start() { + if (started || startFailed) return; + if (identityPoll != null) { + identityPoll.cancel(); + identityPoll = null; + } + try { + CharacterMigration.Result result = + new CharacterMigration(getDataFolder().toPath(), migrationHeirs, getLogger()).run(); + if (result.migrated() > 0 || result.assignmentsApplied() > 0) { + getLogger().info("Character migration: " + result.migrated() + " stores migrated, " + + result.assignmentsApplied() + " quarantine assignments applied"); + } + if (result.quarantined() > 0) { + getLogger().warning(result.quarantined() + " quarantined faction records need staff; see /f admin quarantine list"); + } + } catch (java.io.IOException | RuntimeException error) { + startFailed = true; + getLogger().log(java.util.logging.Level.SEVERE, + "Cannot migrate faction data to characters; factions stay offline until this is fixed", error); + return; + } + startServices(); + if (!started) return; + getServer().getPluginManager().callEvent(new net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent()); + } + + private void startServices() { + servicesBegun = true; net.tfminecraft.simplefactions.identity.LeaderCharacters.useSharedIdentity(); net.tfminecraft.simplefactions.prestige.MemberPlaytime.useSharedIdentity(); net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility.useSharedIdentity(); @@ -388,11 +482,19 @@ public void onEnable() { inventoryManager.start(); vehicleMaintenanceDecayTask.start(); startupComplete = true; + started = true; net.tfminecraft.simplefactions.identity.LeaderCharacterListener.refresh(null); } @Override public void onDisable() { if (incompatibleCharacterApi) return; + if (identityPoll != null) identityPoll.cancel(); + identityPoll = null; + started = false; + if (!servicesBegun) { + // Still waiting for identity: nothing was loaded, so nothing may be saved. + return; + } if (trackRefreshTask != null) trackRefreshTask.cancel(); net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.unregister(); MercenaryStatService.clearAll(); @@ -686,9 +788,9 @@ public void saveVehicleFees() { vehicleFeeStore.save(); } - /** Remembers who a vehicle was just handed to, for the transfer fee on a later release. */ - public void recordVehicleOwner(String vehicleUuid, String playerName) { - vehicleFeeStore.setLastOwner(vehicleUuid, playerName); + /** Remembers the member key of the character a vehicle was just handed to, for a later transfer fee. */ + public void recordVehicleOwner(String vehicleUuid, String memberKey) { + vehicleFeeStore.setLastOwner(vehicleUuid, memberKey); vehicleFeeStore.save(); } @@ -703,8 +805,10 @@ public void recordVehicleOwners() { try { for (net.tfminecraft.vehicleframework.data.OwnedVehicleSummary vehicle : net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.allPersonalVehicles(vehicleRegistry)) { - String owner = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries - .playerNameFromOwner(vehicle.getOwner()); + // VehicleFramework still owns vehicles by account; the fee follows that account's character. + String owner = net.tfminecraft.simplefactions.identity.Members.ofAccountOrHeir( + net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries + .playerNameFromOwner(vehicle.getOwner())); if (owner != null) { vehicleFeeStore.setLastOwner(vehicle.getUuid(), owner); } diff --git a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java new file mode 100644 index 00000000..a4badfb8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java @@ -0,0 +1,19 @@ +package net.tfminecraft.simplefactions.api; + +import net.tfminecraft.simplefactions.SimpleFactions; + +/** + * Entry point for other plugins. Until {@link #ready()} is true, SimpleFactions + * is waiting for character identity or migrating, and its lookups + * ({@code FactionManager.getByMember} and friends) return null or empty: treat + * that as "unavailable", never as "no faction", and do not persist decisions + * based on it. {@code SimpleFactionsReadyEvent} fires when it becomes ready. + */ +public final class SimpleFactionsApi { + private SimpleFactionsApi() {} + + public static boolean ready() { + SimpleFactions plugin = SimpleFactions.getInstance(); + return plugin != null && plugin.isStarted(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/events/SimpleFactionsReadyEvent.java b/src/main/java/net/tfminecraft/simplefactions/events/SimpleFactionsReadyEvent.java new file mode 100644 index 00000000..321989b5 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/events/SimpleFactionsReadyEvent.java @@ -0,0 +1,21 @@ +package net.tfminecraft.simplefactions.events; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** + * Fired once SimpleFactions has migrated its data to characters and started. + * Before it, faction lookups answer nothing; see {@code SimpleFactionsApi.ready()}. + */ +public class SimpleFactionsReadyEvent extends Event { + private static final HandlerList HANDLERS = new HandlerList(); + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java index 13486ea1..c709b7e9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -79,6 +79,24 @@ public static String ofAccountName(String accountName) { return accountName == null ? null : key(onlineByName(accountName)); } + /** + * The member key standing for an account known only by name: its current + * character if online, else its reviewed legacy heir. Used where another + * plugin still records ownership by account (stage 6 moves those). + */ + public static String ofAccountOrHeir(String accountName) { + String online = ofAccountName(accountName); + if (online != null || accountName == null) return online; + try { + if (Bukkit.getServer() == null) return null; + org.bukkit.OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(accountName); + if (cached == null || cached.getUniqueId() == null || !Characters.available()) return null; + return Characters.legacyHeir(cached.getUniqueId()).map(Members::key).orElse(null); + } catch (RuntimeException unavailable) { + return null; + } + } + /** The character name for a key; offline characters are read from the provider. */ public static String display(String key) { if (key == null || key.isBlank()) return "Unknown"; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 6f862ca6..3713d645 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -78,6 +78,9 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return net.tfminecraft.simplefactions.guild.network.TradeGraphCommand.handle(sender, args); if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("reloadespionage")) return EspionageCommands.reload(sender, args); + if(cmd.getName().equalsIgnoreCase(cmd1) && net.tfminecraft.simplefactions.migration.QuarantineCommands.matches(args)) { + return net.tfminecraft.simplefactions.migration.QuarantineCommands.handle(sender, args); + } if(cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("addguildbank")) { return addGuildBank(sender, args); } diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/CharacterMigration.java b/src/main/java/net/tfminecraft/simplefactions/migration/CharacterMigration.java new file mode 100644 index 00000000..9c4cf1c7 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/CharacterMigration.java @@ -0,0 +1,281 @@ +package net.tfminecraft.simplefactions.migration; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Turns the stored names of SimpleFactions 3 into member keys, once, store by + * store. Each store's original is copied to {@code migrations/backup/} with + * its hash and marked in progress before the new file replaces it; a store + * still in progress after a crash is restored from that copy and redone. + */ +public final class CharacterMigration { + public static final String MANIFEST = "migrations/character-v1.json"; + public static final String QUARANTINE = "migrations/quarantine.json"; + private static final String BACKUP = "migrations/backup/"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** Where names and accounts lead: the account behind a name and the account's reviewed heir. */ + public interface Heirs { + Optional account(String name); + + Optional heir(UUID account); + } + + /** DenarEconomy's name index first, then the server's user cache; heirs from the identity provider. */ + public static final Heirs SHARED = new Heirs() { + @Override + public Optional account(String name) { + try { + UUID id = net.tfminecraft.denareconomy.accounts.OfflineModifier.playerId(name); + if (id != null) return Optional.of(id); + } catch (RuntimeException | LinkageError unavailable) { + // Fall back to the user cache below. + } + OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(name); + return cached == null ? Optional.empty() : Optional.ofNullable(cached.getUniqueId()); + } + + @Override + public Optional heir(UUID account) { + return Characters.legacyHeir(account); + } + }; + + /** What a run did, for the log and for tests. */ + public record Result(int migrated, int skipped, int quarantined, int assignmentsApplied) {} + + private final Path root; + private final Heirs heirs; + private final Logger logger; + + public CharacterMigration(Path root, Heirs heirs, Logger logger) { + this.root = root; + this.heirs = heirs; + this.logger = logger; + } + + /** Whether every store has been migrated already. */ + public boolean complete() throws IOException { + return MigrationManifest.load(root.resolve(MANIFEST)).complete; + } + + public Result run() throws IOException { + Path manifestFile = root.resolve(MANIFEST); + MigrationManifest manifest = MigrationManifest.load(manifestFile); + Quarantine quarantine = Quarantine.load(root.resolve(QUARANTINE)); + int migrated = 0; + int skipped = 0; + if (!manifest.complete) { + Map> names = new HashMap<>(); + Map> accounts = new HashMap<>(); + for (String store : stores()) { + MigrationManifest.Entry entry = manifest.entry(store); + if (entry != null && !MigrationManifest.IN_PROGRESS.equals(entry.state)) continue; + if (entry != null) restore(store, entry); + if (migrate(store, manifest, manifestFile, quarantine, names, accounts)) migrated++; + else skipped++; + } + manifest.complete = true; + manifest.save(manifestFile); + } + int applied = applyAssignments(quarantine); + int open = (int) quarantine.records().stream().filter(record -> record.assigned == null).count(); + return new Result(migrated, skipped, open, applied); + } + + /** Every store that may hold names or accounts, in a stable order. */ + List stores() throws IOException { + List stores = new ArrayList<>(); + for (String folder : List.of("Data", "Wars", "Warbands", "Battles")) { + Path directory = root.resolve(folder); + if (!Files.isDirectory(directory)) continue; + try (Stream files = Files.list(directory)) { + files.filter(file -> file.getFileName().toString().endsWith(".json") && Files.isRegularFile(file)) + .map(file -> folder + "/" + file.getFileName()).sorted().forEach(stores::add); + } + } + for (String cache : List.of("Cache/logins.json", "Cache/vehicle_fees.json", "Cache/vehicles_registry.json")) { + if (Files.isRegularFile(root.resolve(cache))) stores.add(cache); + } + return stores; + } + + private void restore(String store, MigrationManifest.Entry entry) throws IOException { + Path backup = root.resolve(entry.backup); + if (!Files.isRegularFile(backup) || !MigrationFiles.sha256(Files.readAllBytes(backup)).equals(entry.hash)) { + throw new IOException("Backup of " + store + " is missing or does not match its hash; restore it by hand"); + } + MigrationFiles.write(root.resolve(store), Files.readAllBytes(backup)); + logger.warning("Restored " + store + " from its backup to redo an interrupted migration"); + } + + private boolean migrate(String store, MigrationManifest manifest, Path manifestFile, Quarantine quarantine, + Map> names, Map> accounts) throws IOException { + Path file = root.resolve(store); + byte[] original = Files.readAllBytes(file); + String hash = MigrationFiles.sha256(original); + String backup = BACKUP + store; + MigrationFiles.write(root.resolve(backup), original); + manifest.mark(store, MigrationManifest.IN_PROGRESS, backup, hash); + manifest.save(manifestFile); + + JsonElement tree; + try { + tree = JsonParser.parseString(new String(original, StandardCharsets.UTF_8)); + } catch (JsonParseException malformed) { + logger.log(Level.SEVERE, "Cannot read " + store + "; it was left unchanged and is not migrated", malformed); + manifest.mark(store, MigrationManifest.UNREADABLE, backup, hash); + manifest.save(manifestFile); + return false; + } + StoreMapper mapper = new StoreMapper(store, heirs, names, accounts); + if (store.startsWith("Data/") && tree instanceof JsonObject faction) mapper.faction(faction, hash); + else if (store.startsWith("Wars/") && tree instanceof JsonObject war) mapper.war(war); + else if (store.equals("Cache/logins.json") && tree instanceof JsonObject logins) mapper.logins(logins); + else if (store.equals("Cache/vehicle_fees.json") && tree instanceof JsonObject fees) mapper.vehicleFees(fees); + else if (store.equals("Cache/vehicles_registry.json") && tree instanceof JsonArray rows) mapper.vehicleRegistry(rows); + // Warbands and battles hold accounts for live combat only, which stay as they are. + + quarantine.merge(mapper.records()); + quarantine.save(); + for (String warning : mapper.warnings()) logger.warning(warning); + if (mapper.changed()) MigrationFiles.write(file, GSON.toJson(tree)); + manifest.mark(store, MigrationManifest.DONE, backup, hash); + manifest.save(manifestFile); + return true; + } + + /** + * Writes staff assignments into their stores: every place holding the + * record's token now holds the character's key. Runs before the stores + * are loaded, so it never races the plugin's own saves. + */ + int applyAssignments(Quarantine quarantine) throws IOException { + int applied = 0; + for (Quarantine.Record record : quarantine.records()) { + if (record.assigned == null || record.applied) continue; + Path file = root.resolve(record.store); + if (Files.isRegularFile(file)) { + byte[] current = Files.readAllBytes(file); + JsonElement tree = JsonParser.parseString(new String(current, StandardCharsets.UTF_8)); + if (replace(tree, record.token, record.assigned, "")) { + MigrationFiles.write(root.resolve(BACKUP + "assign-" + record.id + "/" + record.store), current); + MigrationFiles.write(file, GSON.toJson(tree)); + } + } + record.applied = true; + quarantine.save(); + applied++; + logger.info("Gave quarantine record " + record.id + " (" + record.original + ") to character " + record.assigned); + } + return applied; + } + + /** + * Replaces every string and map key equal to {@code token}; merged entries + * follow the migration's rules. {@code path} is the chain of field names. + */ + static boolean replace(JsonElement element, String token, String key, String path) { + boolean changed = false; + if (element instanceof JsonArray array) { + Set seen = new java.util.HashSet<>(); + JsonArray rewritten = new JsonArray(); + for (JsonElement child : array) { + String value = StoreMapper.string(child); + if (token.equals(value)) { + changed = true; + value = key; + child = new JsonPrimitive(key); + } + if (value != null && memberList(path) && !seen.add(value)) { + changed = true; + continue; + } + changed |= replace(child, token, key, path); + rewritten.add(child); + } + if (changed) { + while (!array.isEmpty()) array.remove(0); + array.addAll(rewritten); + } + } else if (element instanceof JsonObject object) { + Map rewritten = new LinkedHashMap<>(); + for (Map.Entry entry : object.entrySet()) { + String name = entry.getKey(); + JsonElement value = entry.getValue(); + if (token.equals(StoreMapper.string(value))) { + value = new JsonPrimitive(key); + changed = true; + } + changed |= replace(value, token, key, path + "/" + name); + if (token.equals(name)) { + name = key; + changed = true; + } + JsonElement existing = rewritten.get(name); + rewritten.put(name, existing == null ? value : StoreMapper.merge(existing, value, mergeFor(path))); + } + if (changed) { + for (String old : new ArrayList<>(object.keySet())) object.remove(old); + rewritten.forEach(object::add); + } + } + return changed; + } + + private static final Set LISTS_OF_MEMBERS = Set.of( + "members", "dividendEligible", "enlisted", "council members", "eligible voters", "citizens"); + private static final Set SUMMED = Set.of( + "citizen taxes", "deposits today", "pendingWages", "CITIZENS", "DEPOSITS"); + + private static String last(String path) { + return path.substring(path.lastIndexOf('/') + 1); + } + + static boolean memberList(String path) { + return LISTS_OF_MEMBERS.contains(last(path)) || path.contains("/election candidates/"); + } + + /** Logins keep the most recent; money and vote counts add up; anything else keeps the first. */ + static StoreMapper.Merge mergeFor(String path) { + if (path.isEmpty()) return StoreMapper.Merge.MIN; + if (SUMMED.contains(last(path)) || path.contains("/previous votes/")) return StoreMapper.Merge.SUM; + return StoreMapper.Merge.FIRST; + } + + /** The key a record would be assigned: a character ID, lower-cased. */ + public static String characterKey(String input) { + UUID id = Members.characterId(input == null ? null : input.strip()); + return id == null ? null : Members.key(id); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/MigrationFiles.java b/src/main/java/net/tfminecraft/simplefactions/migration/MigrationFiles.java new file mode 100644 index 00000000..6e2a688c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/MigrationFiles.java @@ -0,0 +1,56 @@ +package net.tfminecraft.simplefactions.migration; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.UUID; + +/** Durable file writes for the migration: synced temp file, then an atomic rename. */ +final class MigrationFiles { + private MigrationFiles() {} + + static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + static String sha256(String text) { + return sha256(text.getBytes(StandardCharsets.UTF_8)); + } + + static void write(Path file, String text) throws IOException { + write(file, text.getBytes(StandardCharsets.UTF_8)); + } + + static void write(Path file, byte[] bytes) throws IOException { + Path target = file.toAbsolutePath(); + Files.createDirectories(target.getParent()); + Path temp = target.resolveSibling(target.getFileName() + "." + UUID.randomUUID() + ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + try { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/MigrationManifest.java b/src/main/java/net/tfminecraft/simplefactions/migration/MigrationManifest.java new file mode 100644 index 00000000..b216b632 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/MigrationManifest.java @@ -0,0 +1,60 @@ +package net.tfminecraft.simplefactions.migration; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.TreeMap; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; + +/** + * {@code migrations/character-v1.json}: per store, whether it has been + * migrated, with the backup of the original and that original's hash. + */ +final class MigrationManifest { + static final String IN_PROGRESS = "in-progress"; + static final String DONE = "done"; + static final String UNREADABLE = "unreadable"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + static final class Entry { + String state; + String backup; + String hash; + } + + String migration = "character-v1"; + boolean complete; + Map stores = new TreeMap<>(); + + static MigrationManifest load(Path file) throws IOException { + if (!Files.exists(file)) return new MigrationManifest(); + try { + MigrationManifest manifest = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), MigrationManifest.class); + if (manifest == null || manifest.stores == null) throw new JsonParseException("empty manifest"); + return manifest; + } catch (JsonParseException malformed) { + throw new IOException("Unreadable migration manifest " + file, malformed); + } + } + + void save(Path file) throws IOException { + MigrationFiles.write(file, GSON.toJson(this)); + } + + Entry entry(String store) { + return stores.get(store); + } + + void mark(String store, String state, String backup, String hash) { + Entry entry = new Entry(); + entry.state = state; + entry.backup = backup; + entry.hash = hash; + stores.put(store, entry); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/Quarantine.java b/src/main/java/net/tfminecraft/simplefactions/migration/Quarantine.java new file mode 100644 index 00000000..94c9353f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/Quarantine.java @@ -0,0 +1,112 @@ +package net.tfminecraft.simplefactions.migration; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; + +/** + * {@code migrations/quarantine.json}: everything the migration could not give + * to a character. A record keeps the original name (or account), every place + * it was found with the complete original JSON there, and the token that + * holds its seat in the migrated store until staff assign a character. + */ +public final class Quarantine { + /** A name that was never a known account, or an account with no reviewed heir. */ + public static final String NAME = "name"; + /** An account UUID with no reviewed heir; the store keeps the account UUID. */ + public static final String ACCOUNT = "account"; + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + /** One place a quarantined name or account was found. */ + public static final class Occurrence { + public String path; + public JsonElement fragment; + + Occurrence(String path, JsonElement fragment) { + this.path = path; + this.fragment = fragment; + } + } + + /** Everything one unmapped name or account held in one store. */ + public static final class Record { + public String id; + public String store; + public String kind; + public String original; + public String token; + public List occurrences = new ArrayList<>(); + /** Character ID chosen by staff, or null while unresolved. */ + public String assigned; + /** True once the assignment has been written into the store. */ + public boolean applied; + + /** Whether it held a realm or guild leader's seat. */ + public boolean holdsLeader() { + for (Occurrence occurrence : occurrences) { + if (occurrence.path.equals("$.leader") || occurrence.path.matches("\\$\\.guilds\\[[^]]*]\\.leader")) { + return true; + } + } + return false; + } + } + + private final Path file; + private final Map records = new LinkedHashMap<>(); + + private Quarantine(Path file) { + this.file = file; + } + + public static Quarantine load(Path file) throws IOException { + Quarantine quarantine = new Quarantine(file); + if (!Files.exists(file)) return quarantine; + try { + List loaded = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), + new TypeToken>() {}.getType()); + if (loaded == null) throw new JsonParseException("empty quarantine"); + for (Record record : loaded) quarantine.records.put(record.id, record); + return quarantine; + } catch (JsonParseException malformed) { + throw new IOException("Unreadable quarantine " + file, malformed); + } + } + + public void save() throws IOException { + MigrationFiles.write(file, GSON.toJson(new ArrayList<>(records.values()))); + } + + public Collection records() { + return records.values(); + } + + public Record get(String id) { + return id == null ? null : records.get(id); + } + + /** Adds the records of one store; a redone store replaces its earlier records with the same IDs. */ + void merge(Collection found) { + for (Record record : found) { + Record existing = records.get(record.id); + if (existing != null) { + record.assigned = existing.assigned; + record.applied = existing.applied; + } + records.put(record.id, record); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/QuarantineCommands.java b/src/main/java/net/tfminecraft/simplefactions/migration/QuarantineCommands.java new file mode 100644 index 00000000..fdf9d308 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/QuarantineCommands.java @@ -0,0 +1,94 @@ +package net.tfminecraft.simplefactions.migration; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.logging.Level; + +import org.bukkit.command.CommandSender; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.utils.Permissions; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.Characters; + +/** {@code /f admin quarantine list} and {@code /f admin quarantine assign }. */ +public final class QuarantineCommands { + private QuarantineCommands() {} + + public static boolean matches(String[] args) { + return args.length >= 2 && args[0].equalsIgnoreCase("admin") && args[1].equalsIgnoreCase("quarantine"); + } + + public static boolean handle(CommandSender sender, String[] args) { + if (!Permissions.isAdmin(sender)) { + sender.sendMessage("§a[SimpleFactions]§c You do not have access to this command"); + return true; + } + Path root = SimpleFactions.getInstance().getDataFolder().toPath(); + Quarantine quarantine; + try { + quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + } catch (IOException unreadable) { + sender.sendMessage("§cCannot read the quarantine: " + unreadable.getMessage()); + return true; + } + if (args.length == 3 && args[2].equalsIgnoreCase("list")) { + list(sender, quarantine); + } else if (args.length == 5 && args[2].equalsIgnoreCase("assign")) { + assign(sender, quarantine, args[3], args[4]); + } else { + sender.sendMessage("§cUsage: /faction admin quarantine list | assign "); + } + return true; + } + + private static void list(CommandSender sender, Quarantine quarantine) { + if (quarantine.records().isEmpty()) { + sender.sendMessage("§aNothing is quarantined."); + return; + } + sender.sendMessage("§eQuarantined faction records:"); + for (Quarantine.Record record : quarantine.records()) { + String status = record.applied ? "§agiven to " + record.assigned + : record.assigned != null ? "§6assigned to " + record.assigned + ", applies at the next restart" + : "§cunresolved"; + sender.sendMessage("§f" + record.id + " §7" + record.kind + " §f" + record.original + " §7in " + record.store + + " (" + record.occurrences.size() + " place" + (record.occurrences.size() == 1 ? "" : "s") + + (record.holdsLeader() ? ", holds a leader's seat" : "") + ") " + status); + } + } + + private static void assign(CommandSender sender, Quarantine quarantine, String id, String character) { + Quarantine.Record record = quarantine.get(id); + if (record == null) { + sender.sendMessage("§cNo quarantine record " + id); + return; + } + if (record.applied) { + sender.sendMessage("§cRecord " + id + " was already given to " + record.assigned); + return; + } + String key = CharacterMigration.characterKey(character); + if (key == null) { + sender.sendMessage("§cThat is not a character ID"); + return; + } + CharacterInfo info = Characters.info(Members.characterId(key)).orElse(null); + if (info == null) { + sender.sendMessage("§cNo character with ID " + key); + return; + } + record.assigned = key; + try { + quarantine.save(); + } catch (IOException failure) { + SimpleFactions.getInstance().getLogger().log(Level.SEVERE, "Cannot save the quarantine", failure); + sender.sendMessage("§cCannot save the quarantine; nothing changed"); + record.assigned = null; + return; + } + sender.sendMessage("§aRecord " + id + " (" + record.original + ") goes to " + Members.display(key) + + " at the next restart, before factions load."); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java b/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java new file mode 100644 index 00000000..9fe6c111 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java @@ -0,0 +1,420 @@ +package net.tfminecraft.simplefactions.migration; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** + * Rewrites the name and account fields of one store into member keys. Names + * resolve to an account, then to that account's reviewed heir; a name or + * account that does not is replaced by a quarantine token and recorded. + */ +final class StoreMapper { + enum Merge { SUM, FIRST, MIN } + + private final String store; + private final CharacterMigration.Heirs heirs; + private final Map> names; + private final Map> accounts; + private final Map records = new LinkedHashMap<>(); + private final List warnings = new ArrayList<>(); + private boolean changed; + + StoreMapper(String store, CharacterMigration.Heirs heirs, + Map> names, Map> accounts) { + this.store = store; + this.heirs = heirs; + this.names = names; + this.accounts = accounts; + } + + Collection records() { + return records.values(); + } + + List warnings() { + return warnings; + } + + boolean changed() { + return changed; + } + + /* ---------- stores ---------- */ + + /** {@code Data/.json}. */ + void faction(JsonObject root, String originalHash) { + scalar(root, "leader", "$"); + forgetLeaderCharacter(root); + String factionId = string(root, "id"); + for (JsonObject guild : objects(root, "guilds")) { + String path = "$.guilds[" + string(guild, "id") + "]"; + if (!guild.has("incarnation")) { + guild.addProperty("incarnation", UUID.nameUUIDFromBytes(("incarnation:" + store + ":" + + string(guild, "id") + ":" + originalHash).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString()); + changed = true; + } + scalar(guild, "leader", path); + forgetLeaderCharacter(guild); + list(guild, "members", path); + list(guild, "dividendEligible", path); + keys(guild, "citizen taxes", Merge.SUM, path); + keys(guild, "deposits today", Merge.SUM, path); + for (String ledger : List.of("ledger last day", "ledger lifetime")) { + JsonObject sources = object(guild, ledger); + if (sources == null) continue; + for (String source : List.of("CITIZENS", "DEPOSITS")) { + keys(sources, source, Merge.SUM, path + "." + ledger); + } + } + JsonObject company = object(guild, "company"); + if (company != null) { + String companyPath = path + ".company"; + list(company, "enlisted", companyPath); + keys(company, "activeWageOverrides", Merge.FIRST, companyPath); + keys(company, "peacetimeWageOverrides", Merge.FIRST, companyPath); + keys(company, "pendingWages", Merge.SUM, companyPath); + } + } + oneGuildPerMember(root, factionId); + JsonObject government = object(root, "governmentData"); + if (government != null) government(government, "$.governmentData"); + } + + /** {@code Wars/war_.json}. */ + void war(JsonObject root) { + scalar(root, "civilWarWantedLeaderName", "$"); + int index = 0; + for (JsonObject move : objects(root, "civilWarMemberMoves")) { + scalar(move, "player", "$.civilWarMemberMoves[" + index++ + "]"); + } + } + + /** {@code Cache/logins.json}: days offline by name; merged names keep the most recent login. */ + void logins(JsonObject root) { + rekey(root, Merge.MIN, "$"); + } + + /** {@code Cache/vehicle_fees.json}: last owners by name, paid builds by payer account. */ + void vehicleFees(JsonObject root) { + JsonObject owners = object(root, "lastOwners"); + if (owners != null) { + for (String vehicle : new ArrayList<>(owners.keySet())) { + scalar(owners, vehicle, "$.lastOwners"); + } + } + int index = 0; + for (JsonObject build : objects(root, "paidBuilds")) { + accountField(build, "payerUuid", "$.paidBuilds[" + index++ + "]"); + } + } + + /** {@code Cache/vehicles_registry.json}: a list of rows with the owning account. */ + void vehicleRegistry(JsonArray rows) { + for (int index = 0; index < rows.size(); index++) { + if (rows.get(index) instanceof JsonObject row) { + accountField(row, "playerUuid", "$[" + index + "]"); + } + } + } + + /* ---------- government ---------- */ + + private void government(JsonObject government, String path) { + list(government, "council members", path); + list(government, "eligible voters", path); + JsonObject candidates = object(government, "election candidates"); + if (candidates != null) { + for (String type : candidates.keySet()) list(candidates, type, path + ".election candidates"); + } + JsonObject votes = object(government, "election votes"); + if (votes != null) { + for (String type : votes.keySet()) votes(votes, type, path + ".election votes"); + } + JsonObject previous = object(government, "previous votes"); + if (previous != null) { + for (String type : previous.keySet()) keys(previous, type, Merge.SUM, path + ".previous votes"); + } + proposals(government, path); + for (JsonObject movement : objects(government, "movements")) { + String movementPath = path + ".movements[" + string(movement, "id") + "]"; + scalar(movement, "leader", movementPath); + pool(object(movement, "supporters"), movementPath + ".supporters"); + int index = 0; + for (JsonObject cause : objects(movement, "causes")) { + String causePath = movementPath + ".causes[" + index++ + "]"; + scalar(cause, "leader", causePath); + pool(object(cause, "members"), causePath + ".members"); + JsonObject proposal = object(cause, "proposal"); + if (proposal == null) continue; + scalar(proposal, "proposer", causePath + ".proposal"); + if ("CHANGE_LEADER".equals(string(proposal, "action key"))) { + scalar(proposal, "target", causePath + ".proposal"); + } + } + } + } + + private void pool(JsonObject pool, String path) { + if (pool != null) list(pool, "citizens", path); + } + + /** Council proposals are {@code proposer:kind:...}; a leader change ends with the wanted leader. */ + private void proposals(JsonObject government, String path) { + if (!(government.get("proposals") instanceof JsonArray proposals)) return; + JsonArray rewritten = new JsonArray(); + for (int index = 0; index < proposals.size(); index++) { + JsonElement element = proposals.get(index); + String text = string(element); + int separator = text == null ? -1 : text.indexOf(':'); + if (separator <= 0) { + rewritten.add(element); + continue; + } + String entryPath = path + ".proposals[" + index + "]"; + String rest = text.substring(separator + 1); + String proposer = name(text.substring(0, separator), entryPath, element); + String changeLeader = "action:CHANGE_LEADER:"; + if (rest.startsWith(changeLeader) && rest.length() > changeLeader.length()) { + rest = changeLeader + name(rest.substring(changeLeader.length()), entryPath, element); + } + rewritten.add(proposer + ":" + rest); + } + government.add("proposals", rewritten); + } + + /** Ballots by voter: a merged voter keeps the first ballot; candidates are names too. */ + private void votes(JsonObject parent, String field, String path) { + JsonObject ballots = object(parent, field); + if (ballots == null) return; + String ballotsPath = path + "." + field; + JsonObject rewritten = new JsonObject(); + for (Map.Entry ballot : ballots.entrySet()) { + JsonObject fragment = new JsonObject(); + fragment.add(ballot.getKey(), ballot.getValue()); + String voter = name(ballot.getKey(), ballotsPath, fragment); + if (rewritten.has(voter)) continue; + String candidate = string(ballot.getValue()); + rewritten.add(voter, candidate == null ? ballot.getValue() + : new JsonPrimitive(name(candidate, ballotsPath, fragment))); + } + parent.add(field, rewritten); + } + + /* ---------- field helpers ---------- */ + + private void scalar(JsonObject object, String field, String path) { + String value = string(object.get(field)); + if (value == null) return; + object.addProperty(field, name(value, path + "." + field, object.get(field))); + } + + private void list(JsonObject object, String field, String path) { + if (!(object.get(field) instanceof JsonArray values)) return; + String listPath = path + "." + field; + Set seen = new LinkedHashSet<>(); + JsonArray rewritten = new JsonArray(); + for (int index = 0; index < values.size(); index++) { + JsonElement element = values.get(index); + String value = string(element); + if (value == null) { + rewritten.add(element); + continue; + } + String key = name(value, listPath + "[" + index + "]", element); + if (seen.add(key)) rewritten.add(key); + else changed = true; + } + object.add(field, rewritten); + } + + private void keys(JsonObject parent, String field, Merge merge, String path) { + if (parent.get(field) instanceof JsonObject map) rekey(map, merge, path + "." + field); + } + + private void rekey(JsonObject map, Merge merge, String path) { + Map rewritten = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + JsonObject fragment = new JsonObject(); + fragment.add(entry.getKey(), entry.getValue()); + String key = name(entry.getKey(), path + "[" + entry.getKey() + "]", fragment); + JsonElement existing = rewritten.get(key); + rewritten.put(key, existing == null ? entry.getValue() : merge(existing, entry.getValue(), merge)); + } + for (String old : new ArrayList<>(map.keySet())) map.remove(old); + rewritten.forEach(map::add); + } + + static JsonElement merge(JsonElement kept, JsonElement added, Merge merge) { + if (merge == Merge.FIRST || !isNumber(kept) || !isNumber(added)) return kept; + double a = kept.getAsDouble(); + double b = added.getAsDouble(); + if (merge == Merge.MIN) return new JsonPrimitive(Math.min(a, b)); + boolean whole = kept.getAsString().matches("-?\\d+") && added.getAsString().matches("-?\\d+"); + return whole ? new JsonPrimitive(kept.getAsLong() + added.getAsLong()) : new JsonPrimitive(a + b); + } + + private static boolean isNumber(JsonElement element) { + return element instanceof JsonPrimitive primitive && primitive.isNumber(); + } + + private void accountField(JsonObject row, String field, String path) { + String value = string(row.get(field)); + if (value == null) return; + row.addProperty(field, account(value, path + "." + field, row)); + } + + /** + * A realm's members belong to one guild. Merged names can leave a key in + * several guilds; it stays where it leads, else in the first ordinary + * guild, else in the realm's own guild. + */ + private void oneGuildPerMember(JsonObject root, String factionId) { + List guilds = objects(root, "guilds"); + JsonObject base = base(guilds, factionId); + List leadersFirst = new ArrayList<>(); + if (base != null) leadersFirst.add(base); + for (JsonObject guild : guilds) if (guild != base) leadersFirst.add(guild); + Map home = new LinkedHashMap<>(); + for (JsonObject guild : leadersFirst) { + String leader = string(guild, "leader"); + if (leader != null) home.putIfAbsent(leader, guild); + } + for (JsonObject guild : guilds) { + if (guild == base) continue; + for (String member : strings(guild, "members")) home.putIfAbsent(member, guild); + } + for (JsonObject guild : guilds) { + if (!(guild.get("members") instanceof JsonArray members)) continue; + JsonArray kept = new JsonArray(); + for (JsonElement element : members) { + String member = string(element); + JsonObject owner = member == null ? null : home.get(member); + if (owner == null || owner == guild) { + kept.add(element); + } else { + changed = true; + warnings.add(store + ": " + member + " was in several guilds; kept in " + + string(owner, "id") + ", removed from " + string(guild, "id")); + } + } + guild.add("members", kept); + } + } + + private static JsonObject base(List guilds, String factionId) { + for (JsonObject guild : guilds) { + if (factionId != null && factionId.equals(string(guild, "id"))) return guild; + } + return null; + } + + private void forgetLeaderCharacter(JsonObject object) { + // Leader names are re-read from the leader's character when the plugin starts. + if (object.remove("leader character") != null) changed = true; + if (object.remove("leader character of") != null) changed = true; + } + + /* ---------- resolution ---------- */ + + /** The member key for a stored name, or the quarantine token holding its place. */ + String name(String raw, String path, JsonElement fragment) { + if (Members.isQuarantined(raw)) return raw; + UUID asKey = Members.characterId(raw); + if (asKey != null) return Members.key(asKey); + Optional key = names.computeIfAbsent(raw.toLowerCase(Locale.ROOT), lower -> heirs.account(raw) + .flatMap(this::heirKey)); + changed = true; + if (key.isPresent()) return key.get(); + Quarantine.Record record = record(Quarantine.NAME, raw, null); + record.occurrences.add(new Quarantine.Occurrence(path, fragment.deepCopy())); + return record.token; + } + + /** The heir character's ID for an account field; unmapped accounts stay and are recorded. */ + String account(String raw, String path, JsonElement fragment) { + UUID account; + try { + account = UUID.fromString(raw); + } catch (IllegalArgumentException notAnAccount) { + return raw; + } + Optional key = heirKey(account); + if (key.isPresent()) { + changed = true; + return key.get(); + } + Quarantine.Record record = record(Quarantine.ACCOUNT, raw.toLowerCase(Locale.ROOT), raw); + record.occurrences.add(new Quarantine.Occurrence(path, fragment.deepCopy())); + return raw; + } + + private Optional heirKey(UUID account) { + return accounts.computeIfAbsent(account, id -> heirs.heir(id).map(CharacterRef::characterId).map(Members::key)); + } + + private Quarantine.Record record(String kind, String original, String token) { + String id = "q" + MigrationFiles.sha256(store + "\n" + kind + "\n" + original.toLowerCase(Locale.ROOT)).substring(0, 12); + return records.computeIfAbsent(id, ignored -> { + Quarantine.Record record = new Quarantine.Record(); + record.id = id; + record.store = store; + record.kind = kind; + record.original = original; + record.token = token != null ? token : Members.QUARANTINED + id; + return record; + }); + } + + /* ---------- JSON helpers ---------- */ + + private static String string(JsonObject object, String field) { + return object == null ? null : string(object.get(field)); + } + + static String string(JsonElement element) { + return element instanceof JsonPrimitive primitive && primitive.isString() ? primitive.getAsString() : null; + } + + private static JsonObject object(JsonObject parent, String field) { + return parent.get(field) instanceof JsonObject object ? object : null; + } + + private static List objects(JsonObject parent, String field) { + List objects = new ArrayList<>(); + if (parent.get(field) instanceof JsonArray array) { + array.forEach(element -> { + if (element instanceof JsonObject object) objects.add(object); + }); + } + return objects; + } + + private static List strings(JsonObject parent, String field) { + List values = new ArrayList<>(); + if (parent.get(field) instanceof JsonArray array) { + Consumer add = element -> { + String value = string(element); + if (value != null) values.add(value); + }; + array.forEach(add); + } + return values; + } +} From 700cbab068e3a412f78f651937f614c8037e9b35 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:22:33 +0000 Subject: [PATCH 04/35] refactor: key government, guilds and factions by character Council seats, refusals, election candidates and votes, session voters, movement and cause leaders, pool citizens and proposal proposers and targets are member keys: matched exactly, shown by character name and turned into players through Members. Movement admin commands take character names with spaces. Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/government/Council.java | 13 +- .../simplefactions/government/Government.java | 24 +- .../government/election/Election.java | 24 +- .../government/movement/Movement.java | 34 +-- .../government/movement/MovementJoinCopy.java | 21 +- .../government/movement/Pool.java | 3 +- .../movement/admin/MovementAdminService.java | 35 ++- .../admin/MovementCommandManager.java | 15 +- .../movement/admin/MovementTabCompletion.java | 40 ++-- .../government/movement/cause/Cause.java | 16 +- .../government/proposal/Proposal.java | 8 +- .../government/session/Session.java | 19 +- .../government/stability/StateStability.java | 2 +- .../simplefactions/guild/Guild.java | 2 +- .../simplefactions/guild/loans/LoanBook.java | 6 +- .../managers/SessionManager.java | 6 +- .../simplefactions/objects/Faction.java | 10 +- .../CharacterKeyedPoliticsTest.java | 224 ++++++++++++++++++ .../government/GovernmentCoverageTest.java | 6 +- .../GovernmentProcessesCoverageTest.java | 5 +- .../movement/MovementCoverageTest.java | 3 +- .../admin/MovementAdminServiceTest.java | 16 +- .../guild/GuildCoverageTest.java | 26 +- .../guild/GuildDeleteCommandTest.java | 16 +- .../simplefactions/guild/GuildInviteTest.java | 21 +- .../guild/SharedAccountRivalGuildsTest.java | 76 ++++++ .../simplefactions/guild/TestCharacters.java | 71 ++++++ ...mainConfigurationBoundaryCoverageTest.java | 8 +- .../objects/FactionCoverageTest.java | 17 +- .../objects/FactionInviteTest.java | 10 +- 30 files changed, 613 insertions(+), 164 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/government/CharacterKeyedPoliticsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/SharedAccountRivalGuildsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Council.java b/src/main/java/net/tfminecraft/simplefactions/government/Council.java index 21bbc33c..c570fec3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Council.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Council.java @@ -5,9 +5,7 @@ import java.util.List; import java.util.Set; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.utils.Wealth; import net.tfminecraft.simplefactions.enums.Rules; @@ -132,7 +130,7 @@ public boolean toggleRefuse(String name) { public boolean canBeMember(String name, boolean ignoreSize, boolean ignoreRefuses) { if(members.contains(name)) return false; - if(f.getLeader().equalsIgnoreCase(name)) return false; + if(f.getLeader().equals(name)) return false; if(!ignoreSize && getCurrentSize() >= getMaxSize()) return false; if(!ignoreRefuses && refuses.contains(name)) return false; if(f.getOrCreateMainGuild().isMember(name)) return true; @@ -149,7 +147,7 @@ public boolean canRemainMember(String name) { if (refuses.contains(name)) { return false; } - if (f.getLeader().equalsIgnoreCase(name)) { + if (f.getLeader().equals(name)) { return false; } if (f.getOrCreateMainGuild().isMember(name)) { @@ -251,9 +249,8 @@ public boolean hasEnoughValidVoters() { int total = getEligibleVoters().size(); int validCount = 0; - for (String voterName : getEligibleVoters()) { - Player voterPlayer = Bukkit.getPlayer(voterName); - if ((voterPlayer != null && voterPlayer.isOnline()) || isDummyAccount(voterName)) { + for (String voter : getEligibleVoters()) { + if (Members.isOnline(voter) || isDummyAccount(voter)) { validCount++; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index a5d11a1c..249e6e5f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -179,7 +179,7 @@ public void endMovement(Movement movement) { public Movement getMovementByLeader(String leader) { for (Movement movement : movements) { - if (movement.getLeader() != null && movement.getLeader().equalsIgnoreCase(leader)) { + if (movement.getLeader() != null && movement.getLeader().equals(leader)) { return movement; } } @@ -363,14 +363,14 @@ public boolean isCouncilMember(Player p) { return p != null && isCouncilMember(Members.key(p)); } - /** Name-based so offline checks (mercenary loyalty, contract signing) can use it too. */ - public boolean isCouncilMember(String name) { - if(name == null) return false; - return council.isMember(name) || f.getLeader().equalsIgnoreCase(name); + /** Key-based so offline checks (mercenary loyalty, contract signing) can use it too. */ + public boolean isCouncilMember(String key) { + if(key == null) return false; + return council.isMember(key) || key.equals(f.getLeader()); } public boolean canProposeOrStartMovement(Player p) { - String name = p.getName(); + String name = Members.key(p); if(isCouncilMember(p)) { return council.getProposalHandler().canPropose(name); } @@ -378,7 +378,7 @@ public boolean canProposeOrStartMovement(Player p) { if(guild != null) { if(guild.getFaction().getId().equalsIgnoreCase(f.getId())) { if(guild.isBase()) return true; //Member of the base guild in the faction - if(guild.getLeader().equalsIgnoreCase(name))return true; //Guild leader of a guild in the faction + if(guild.isLeader(name))return true; //Guild leader of a guild in the faction } } Faction faction = FactionManager.getByLeader(name); @@ -393,8 +393,8 @@ public Faction getFaction() { } public boolean canPropose(Player p) { - String name = p.getName(); - if(name.equalsIgnoreCase(f.getLeader())) return true; + String name = Members.key(p); + if(Members.is(p, f.getLeader())) return true; if(council.canPropose(name)) return true; return false; } @@ -413,11 +413,11 @@ public boolean canProposePolitical(Player p, Action action) { if(warEnd && !CouncilPeaceQueries.isParticipatingInAny(f)) { return false; } - String name = p.getName(); + String name = Members.key(p); if(warEnd && canPropose(p)) { return true; } - if(name.equalsIgnoreCase(f.getLeader())) return false; + if(Members.is(p, f.getLeader())) return false; if(council.isMember(name)) return false; if(action == Action.SNAP_ELECTIONS && !hasElections()) return false; Member relation = f.getRelationToFaction(name); @@ -432,7 +432,7 @@ public boolean canProposePolitical(Player p, Action action) { case GUILD_MEMBER: if(action == Action.NATIONHOOD) return false; //only leaders can demand that case GUILD_LEADER: - if(action == Action.NATIONHOOD && !FactionManager.getGuildByMember(Members.key(p)).canBeElevated(null)) return false; + if(action == Action.NATIONHOOD && !FactionManager.getGuildByMember(name).canBeElevated(null)) return false; if(!PoliticalActionLoader.getByAction(action).allowGuilds()) return false; break; case VASSAL_LEADER: 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 1810b933..1a99b50e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java @@ -150,7 +150,7 @@ public void clearCandidates() { public int getVotes(Candidate c, String player) { int count = 0; for(Map.Entry entry : votes.get(c).entrySet()) { - if(entry.getValue().equalsIgnoreCase(player)) count++; + if(entry.getValue().equals(player)) count++; } return count; } @@ -182,7 +182,8 @@ public List getWinners(Candidate c) { result.sort((a, b) -> { int cmp = Integer.compare(voteCounts.get(b), voteCounts.get(a)); if (cmp != 0) return cmp; - return a.compareToIgnoreCase(b); + cmp = Members.display(a).compareToIgnoreCase(Members.display(b)); + return cmp != 0 ? cmp : a.compareTo(b); }); return result; @@ -201,27 +202,28 @@ public boolean isCandiate(Candidate c, String player) { } public void applyReasons(List lore, Candidate type, Player p) { - if(isCandiate(type, p.getName())) return; - if(!canBeCandidate(type, p.getName())) { + String key = Members.key(p); + if(isCandiate(type, key)) return; + if(!canBeCandidate(type, key)) { lore.add(StringFormatter.formatHex("§cYou cannot apply for this position:")); if(active) { lore.add(StringFormatter.formatHex("§7- #ba7872Election is in the voting phase")); } if(type == Candidate.LEADER) { - if(!gov.getFaction().isMember(Members.key(p))) { + if(!gov.getFaction().isMember(key)) { lore.add(StringFormatter.formatHex("§7- #ba7872You must be a member of the faction to be Leader")); } - Guild g = gov.getFaction().getGuild(Members.key(p)); - if (g != null && !g.isBase() && g.isLeader(Members.key(p))) { + Guild g = gov.getFaction().getGuild(key); + if (g != null && !g.isBase() && g.isLeader(key)) { lore.add(StringFormatter.formatHex( "§7- #ba7872Guild leaders cannot be faction leaders" )); } - if(otherCandidateExists(Candidate.LEADER, p.getName())) { + if(otherCandidateExists(Candidate.LEADER, key)) { lore.add(StringFormatter.formatHex("§7- #ba7872Your guild already has a Leader candidate")); } } else if(type == Candidate.COUNCIL) { - if(otherCandidateExists(Candidate.COUNCIL, p.getName())) { + if(otherCandidateExists(Candidate.COUNCIL, key)) { lore.add(StringFormatter.formatHex("§7- #ba7872Your guild already has a Council candidate")); } } @@ -301,7 +303,7 @@ public void removeCandidate(Candidate c, String candidate) { Map voteMap = votes.get(c); voteMap.entrySet().removeIf(entry -> - entry.getValue().equalsIgnoreCase(candidate) + entry.getValue().equals(candidate) ); } @@ -310,7 +312,7 @@ public boolean otherCandidateExists(Candidate type, String player) { if(g == null) return false; if(g.isBase()) return false; for(String candidate : candidates.get(type)) { - if(candidate.equalsIgnoreCase(player)) continue; + if(candidate.equals(player)) continue; Guild cg = gov.getFaction().getGuildHandler().getGuildByMember(candidate); if(cg == null) continue; if(cg.isBase()) continue; 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 fbc9f3fc..ca5fcf5c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java @@ -232,19 +232,19 @@ public void checkSupporters() { supporters.getFactions().removeIf(faction -> !canFactionBeSupporter(faction, false)); } - public boolean canCitizenBeSupporter(String playerName, boolean feedback) { - return citizenSupporterBlockReason(playerName, false) == null; + public boolean canCitizenBeSupporter(String member, boolean feedback) { + return citizenSupporterBlockReason(member, false) == null; } - public String citizenSupporterBlockReason(String playerName, boolean staff) { - Member relation = f.getRelationToFaction(playerName); + public String citizenSupporterBlockReason(String member, boolean staff) { + Member relation = f.getRelationToFaction(member); if (relation != Member.MEMBER) { - return MovementJoinCopy.citizenMustBeMember(staff, playerName, f); + return MovementJoinCopy.citizenMustBeMember(staff, member, f); } for (Movement otherMovement : f.getGovernment().getMovements()) { if (otherMovement.getId().equals(this.id)) continue; - if (otherMovement.isMember(playerName)) { - return MovementJoinCopy.citizenAlreadySupportingOther(staff, playerName, f); + if (otherMovement.isMember(member)) { + return MovementJoinCopy.citizenAlreadySupportingOther(staff, member, f); } } return null; @@ -400,12 +400,12 @@ public boolean hasLeader() { } public boolean isLeader(String player) { - return leader != null && leader.equalsIgnoreCase(player); + return leader != null && leader.equals(player); } public boolean canBeLeader(String player) { for(Cause cause : causes) { - if(cause.getLeader() != null && cause.getLeader().equalsIgnoreCase(player)) { + if(cause.getLeader() != null && cause.getLeader().equals(player)) { return true; } } @@ -422,7 +422,7 @@ public List getCauses() { public Cause getCauseByLeader(String leaderName) { for (Cause cause : causes) { - if (cause.getLeader() != null && cause.getLeader().equalsIgnoreCase(leaderName)) { + if (cause.getLeader() != null && cause.getLeader().equals(leaderName)) { return cause; } } @@ -588,20 +588,22 @@ private double getTotalTradePower() { } public Object getJoiningAs(Player p) { - Member relation = f.getRelationToFaction(Members.key(p)); + String key = Members.key(p); + Member relation = f.getRelationToFaction(key); if (relation == Member.GUILD_LEADER || relation == Member.GUILD_MEMBER) { - return FactionManager.getGuildByMember(Members.key(p)); + return FactionManager.getGuildByMember(key); } else if (relation == Member.VASSAL_LEADER) { - return FactionManager.getByMember(Members.key(p)); + return FactionManager.getByMember(key); } else if (relation == Member.MEMBER) { - return p.getName(); + return key; } return null; } public boolean quickJoinCheck(Player p) { - if(isMember(Members.key(p))) return false; - Member relation = f.getRelationToFaction(Members.key(p)); + String key = Members.key(p); + if(isMember(key)) return false; + Member relation = f.getRelationToFaction(key); switch(relation) { case FOREIGNER: case GUILD_MEMBER: diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementJoinCopy.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementJoinCopy.java index e7592145..773cac86 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementJoinCopy.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementJoinCopy.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.government.movement; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy; @@ -25,18 +26,18 @@ public static String guildId(Guild guild) { return guild.getId(); } - public static String citizenMustBeMember(boolean staff, String playerName, Faction host) { + public static String citizenMustBeMember(boolean staff, String member, Faction host) { return pick( staff, "§cYou must be a member of the movement's faction to support it.", - "§c" + playerName + " is not a citizen of " + factionId(host) + "."); + "§c" + Members.display(member) + " is not a citizen of " + factionId(host) + "."); } - public static String citizenAlreadySupportingOther(boolean staff, String playerName, Faction host) { + public static String citizenAlreadySupportingOther(boolean staff, String member, Faction host) { return pick( staff, "§cYou are already supporting another movement in this faction.", - "§c" + playerName + " is already supporting another movement in " + factionId(host) + "."); + "§c" + Members.display(member) + " is already supporting another movement in " + factionId(host) + "."); } public static String oneProvinceHostGuild(boolean staff, Guild guild) { @@ -116,25 +117,25 @@ public static String backerAlreadyBackingOther(boolean staff, Faction joining, F "§c" + factionId(joining) + " is already backing another movement in " + factionId(host) + "."); } - public static String causeCitizenMustBeMember(boolean staff, String playerName, Faction host) { + public static String causeCitizenMustBeMember(boolean staff, String member, Faction host) { return pick( staff, "§cYou must be a member of the movement's faction to join it.", - "§c" + playerName + " is not a citizen of " + factionId(host) + "."); + "§c" + Members.display(member) + " is not a citizen of " + factionId(host) + "."); } - public static String causeCitizensNotAllowed(boolean staff, String playerName) { + public static String causeCitizensNotAllowed(boolean staff, String member) { return pick( staff, "§cCitizens are not allowed to join this movement as members.", - "§cCitizens cannot join this cause (blocked for " + playerName + ")."); + "§cCitizens cannot join this cause (blocked for " + Members.display(member) + ")."); } - public static String alreadyCauseMember(boolean staff, String playerName) { + public static String alreadyCauseMember(boolean staff, String member) { return pick( staff, "§cYou are already a member of this movement.", - "§c" + playerName + " is already a member of this movement."); + "§c" + Members.display(member) + " is already a member of this movement."); } public static String causeGuildsNotAllowed(boolean staff, Guild guild) { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/Pool.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/Pool.java index 33674376..4b7d79e4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Pool.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Pool.java @@ -4,6 +4,7 @@ import java.util.List; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; @@ -70,7 +71,7 @@ public List getAllMembers() { public List getFormattedList() { List formattedList = new ArrayList<>(); for (String citizen : citizens) { - formattedList.add(StringFormatter.formatHex(citizen + " #77d1a3(Citizen)")); + formattedList.add(StringFormatter.formatHex(Members.display(citizen) + " #77d1a3(Citizen)")); } for (Guild guild : guilds) { formattedList.add(StringFormatter.formatHex(guild.getName() + " #d1b83b(Guild)")); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java index ffe15a6d..5e06068c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java @@ -5,6 +5,7 @@ import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.LogManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -140,12 +141,14 @@ public static Result target(String movementId, String causeIndex, String player) return new Result(false, TARGET_USAGE); } Faction host = movement.getFaction(); - if (host == null || !host.canBecomeLeader(player)) { + String key = host == null ? null : memberKey(host.getMembers(), player); + if (key == null || !host.canBecomeLeader(key)) { return new Result(false, "§c" + player + " cannot be the wanted leader."); } - cause.getProposal().setTarget(player); + cause.getProposal().setTarget(key); persist(movement); - return new Result(true, "§aSet wanted leader of " + movement.getId() + " cause " + causeIndex + " to " + player + "."); + return new Result(true, "§aSet wanted leader of " + movement.getId() + " cause " + causeIndex + " to " + + Members.display(key) + "."); } private static String missingTargetReason(Movement movement) { @@ -178,7 +181,7 @@ private static Result mutateMember( return new Result(false, "§cUnknown cause index: " + causeIndex); } } - Object obj = resolveMember(memberType, targetId); + Object obj = resolveMember(movement, cause, memberType, targetId); if (obj == null) { return new Result(false, "§cUnknown " + memberType + ": " + targetId); } @@ -272,18 +275,32 @@ private static Cause resolveCause(Movement movement, String indexArg) { } } - private static Object resolveMember(String type, String target) { + private static Object resolveMember(Movement movement, Cause cause, String type, String target) { if (type == null || target == null || target.isBlank()) { return null; } return switch (type.toLowerCase(java.util.Locale.ROOT)) { - case "citizen" -> target; + case "citizen" -> memberKey(citizenCandidates(movement, cause), target); case "guild" -> FactionManager.getGuildByString(target); case "vassal" -> FactionManager.getByString(target); default -> null; }; } + /** Current pool citizens plus the host's members, so staff can remove a citizen who has since left. */ + private static List citizenCandidates(Movement movement, Cause cause) { + Pool pool = cause == null ? movement.getSupporters() : cause.getPool(); + List keys = new ArrayList<>(pool.getCitizens()); + if (movement.getFaction() != null) keys.addAll(movement.getFaction().getCompleteMemberList()); + return keys; + } + + /** A member key typed as-is, or found by Minecraft or character name. */ + static String memberKey(List keys, String input) { + if (keys.contains(input)) return input; + return Members.find(keys, input); + } + private static boolean isPresent(Movement movement, Cause cause, Object obj) { Pool pool = cause == null ? movement.getSupporters() : cause.getPool(); if (obj instanceof String citizen) { @@ -296,7 +313,7 @@ private static boolean isPresent(Movement movement, Cause cause, Object obj) { } private static String formatListLine(Faction host, Movement movement) { - String leader = movement.getLeader() == null ? "None" : movement.getLeader(); + String leader = movement.getLeader() == null ? "None" : Members.display(movement.getLeader()); String phase = movement.getPhase() == null ? "-" : movement.getPhase().name(); int supporters = movement.getSupporters() == null ? 0 : movement.getSupporters().getAllMembers().size(); int backers = movement.getForeignBackers() == null ? 0 : movement.getForeignBackers().size(); @@ -348,7 +365,7 @@ public static List hostCitizenNames(Movement movement) { if (host == null || host.getOrCreateMainGuild() == null || host.getOrCreateMainGuild().getMembers() == null) { return List.of(); } - return new ArrayList<>(host.getOrCreateMainGuild().getMembers()); + return Members.display(host.getOrCreateMainGuild().getMembers()); } public static List hostGuildIds(Movement movement) { @@ -407,7 +424,7 @@ public static List wantedLeaderNames(Movement movement) { } for (String member : host.getMembers()) { if (host.canBecomeLeader(member)) { - names.add(member); + names.add(Members.display(member)); } } return names; diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java index 918684d2..0cc8ffc5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java @@ -66,7 +66,7 @@ private boolean handleTarget(Player player, String[] args) { player.sendMessage(MovementAdminService.TARGET_USAGE); return true; } - MovementAdminService.Result result = MovementAdminService.target(args[2], args[3], args[4]); + MovementAdminService.Result result = MovementAdminService.target(args[2], args[3], tail(args, 4)); player.sendMessage(result.message()); return true; } @@ -94,16 +94,16 @@ private boolean handleMutate(Player player, boolean joining, String[] args) { return true; } result = joining - ? MovementAdminService.joinSupporter(movementId, args[4], args[5]) - : MovementAdminService.leaveSupporter(movementId, args[4], args[5]); + ? MovementAdminService.joinSupporter(movementId, args[4], tail(args, 5)) + : MovementAdminService.leaveSupporter(movementId, args[4], tail(args, 5)); } else if (slot.equals("cause")) { if (args.length < 7) { player.sendMessage(usage); return true; } result = joining - ? MovementAdminService.joinCause(movementId, args[4], args[5], args[6]) - : MovementAdminService.leaveCause(movementId, args[4], args[5], args[6]); + ? MovementAdminService.joinCause(movementId, args[4], args[5], tail(args, 6)) + : MovementAdminService.leaveCause(movementId, args[4], args[5], tail(args, 6)); } else { player.sendMessage(usage); return true; @@ -111,4 +111,9 @@ private boolean handleMutate(Player player, boolean joining, String[] args) { player.sendMessage(result.message()); return true; } + + /** The rest of the arguments, so character names may contain spaces. */ + private static String tail(String[] args, int from) { + return String.join(" ", java.util.Arrays.copyOfRange(args, from, args.length)); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java index a1ba2ac0..28beb23c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java @@ -7,6 +7,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.utils.Permissions; import net.tfminecraft.simplefactions.government.movement.Movement; @@ -42,10 +43,7 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 4) { return prefixList(args[3], MovementAdminService.causeIndices(movement)); } - if (args.length == 5) { - return prefixList(args[4], MovementAdminService.wantedLeaderNames(movement)); - } - return List.of(); + return names(args, 4, MovementAdminService.wantedLeaderNames(movement)); } if (action.equals("demands")) { if (args.length == 3) { @@ -86,10 +84,7 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 5) { return prefix(args[4], "citizen", "guild", "vassal"); } - if (args.length == 6) { - return prefixList(args[5], targets(movement, args[4])); - } - return List.of(); + return targets(movement, args, 5); } if (slot.equals("cause")) { if (args.length == 5) { @@ -98,22 +93,35 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 6) { return prefix(args[5], "citizen", "guild", "vassal"); } - if (args.length == 7) { - return prefixList(args[6], targets(movement, args[5])); - } + return targets(movement, args, 6); } return List.of(); } - private static List targets(Movement movement, String memberType) { - return switch (memberType.toLowerCase(java.util.Locale.ROOT)) { - case "citizen" -> MovementAdminService.hostCitizenNames(movement); - case "guild" -> MovementAdminService.hostGuildIds(movement); - case "vassal" -> MovementAdminService.hostSubjectIds(movement); + /** Completes the target at {@code from}; citizen names may span several words. */ + private static List targets(Movement movement, String[] args, int from) { + String memberType = args[from - 1].toLowerCase(java.util.Locale.ROOT); + if (memberType.equals("citizen")) { + return names(args, from, MovementAdminService.hostCitizenNames(movement)); + } + if (args.length != from + 1) { + return List.of(); + } + return switch (memberType) { + case "guild" -> prefixList(args[from], MovementAdminService.hostGuildIds(movement)); + case "vassal" -> prefixList(args[from], MovementAdminService.hostSubjectIds(movement)); default -> List.of(); }; } + /** Completes a character name that starts at {@code from} and may contain spaces. */ + private static List names(String[] args, int from, List names) { + String[] tail = new String[args.length - from + 1]; + tail[0] = ""; + System.arraycopy(args, from, tail, 1, args.length - from); + return Members.completions(names, tail); + } + private static List prefix(String typed, String... candidates) { List completions = new ArrayList<>(); String lower = typed == null ? "" : typed.toLowerCase(java.util.Locale.ROOT); 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 6fc239d1..00c16149 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 @@ -129,20 +129,20 @@ public void checkMembers(Pool pool) { } } - public boolean canMemberJoin(String playerName, boolean checkExisting, boolean feedback) { - return memberBlockReason(playerName, checkExisting, false) == null; + public boolean canMemberJoin(String member, boolean checkExisting, boolean feedback) { + return memberBlockReason(member, checkExisting, false) == null; } - public String memberBlockReason(String playerName, boolean checkExisting, boolean staff) { - Member relation = movement.getFaction().getRelationToFaction(playerName); + public String memberBlockReason(String member, boolean checkExisting, boolean staff) { + Member relation = movement.getFaction().getRelationToFaction(member); if (relation != Member.MEMBER) { - return MovementJoinCopy.causeCitizenMustBeMember(staff, playerName, movement.getFaction()); + return MovementJoinCopy.causeCitizenMustBeMember(staff, member, movement.getFaction()); } if (!proposal.getPoliticalAction().allowCitizens()) { - return MovementJoinCopy.causeCitizensNotAllowed(staff, playerName); + return MovementJoinCopy.causeCitizensNotAllowed(staff, member); } - if (checkExisting && movement.isMember(playerName)) { - return MovementJoinCopy.alreadyCauseMember(staff, playerName); + if (checkExisting && movement.isMember(member)) { + return MovementJoinCopy.alreadyCauseMember(staff, member); } return null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java index 60c31fac..ebb41a6c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java @@ -4,6 +4,8 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.identity.Members; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.Material; import java.util.ArrayList; @@ -171,7 +173,7 @@ public ItemStack getAsBook(Player p) { : isFeeProposal() ? "Fee Proposal" : "Political Action Proposal"; if (title.length() > 32) title = title.substring(0, 32); meta.setTitle(title); - meta.setAuthor(proposer != null ? proposer : "Unknown"); + meta.setAuthor(Members.display(proposer)); Faction f = gov != null ? gov.getFaction() : null; @@ -179,7 +181,7 @@ public ItemStack getAsBook(Player p) { List first = new ArrayList<>(); first.add(StringFormatter.formatHex("#93c9a7" + title)); first.add(""); - first.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7" + proposer)); + first.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7" + Members.display(proposer))); if (isLawProposal() && law != null) { LawGroup group = f != null ? f.getLawHandler().getGroup(law.getGroup()) : null; @@ -228,7 +230,7 @@ public ItemStack getAsBook(Player p) { first.add(StringFormatter.formatHex("#b8ae61Action: #c2bea7"+action.getDisplay())); switch(action) { case CHANGE_LEADER: - first.add(StringFormatter.formatHex("#d4c9aeTarget: "+ (hasTarget() ? "#51e0a2"+ target : "#a3462cNo target"))); + first.add(StringFormatter.formatHex("#d4c9aeTarget: "+ (hasTarget() ? "#51e0a2"+ Members.display(target) : "#a3462cNo target"))); break; case WHITE_PEACE: case SURRENDER: diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java index 37011281..26a6bb20 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java @@ -6,7 +6,6 @@ import java.util.HashSet; import java.util.Set; -import org.bukkit.Bukkit; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.block.Block; @@ -14,6 +13,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitRunnable; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.government.Council; import net.tfminecraft.simplefactions.government.proposal.Proposal; @@ -32,7 +32,7 @@ public class Session { private Map proposals = new LinkedHashMap<>(); private final Map decidedVoteCounts = new HashMap<>(); private SessionHologram hologram; - private Map currentVotes = new HashMap<>(); // Player name -> Vote + private Map currentVotes = new HashMap<>(); // member key -> Vote private Set eligibleVoters = new HashSet<>(); // Council members + leader // Keep the existing legacy text representation, formatting, and exact-string comparisons. @@ -80,7 +80,7 @@ private void initializeVoting() { eligibleVoters.clear(); // Add leader - eligibleVoters.add(leader.getName()); + eligibleVoters.add(Members.key(leader)); // Add council members for (String member : c.getMembers()) { @@ -89,18 +89,17 @@ private void initializeVoting() { // Auto-abstain offline members for (String member : eligibleVoters) { - Player memberPlayer = Bukkit.getPlayer(member); - if (memberPlayer == null || !memberPlayer.isOnline()) { + if (!Members.isOnline(member)) { currentVotes.put(member, Vote.ABSTAIN); } } } - public boolean recordVote(String playerName, Vote vote) { - if (!started || terminated || getCurrentProposal() == null || !eligibleVoters.contains(playerName)) { + public boolean recordVote(String voter, Vote vote) { + if (!started || terminated || getCurrentProposal() == null || !eligibleVoters.contains(voter)) { return false; } - currentVotes.put(playerName, vote); + currentVotes.put(voter, vote); updateHologram(); // Check if all votes are cast @@ -213,7 +212,7 @@ public void end() { if (terminated) return; endSession(); // Create session report - SessionReport report = new SessionReport(leader.getName(), f); + SessionReport report = new SessionReport(Members.name(leader), f); // Gather all results for(Proposal p : proposals.keySet()) { @@ -240,7 +239,7 @@ public void end() { // Send title to all online eligible voters for (String voterName : eligibleVoters) { - org.bukkit.entity.Player voter = Bukkit.getPlayer(voterName); + Player voter = Members.player(voterName); if (voter != null && voter.isOnline()) { voter.sendTitle("§aSession Completed!", "§7View the report in your inventory", 20, 60, 20); } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java index 1cbbe463..b44d3a3e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java @@ -81,7 +81,7 @@ private static boolean seated(Faction faction, Guild richest) { if (faction.getGovernment().getCouncilMembers() == null || richest.getMembers() == null) return false; for (String member : richest.getMembers()) { for (String seat : faction.getGovernment().getCouncilMembers()) { - if (member != null && member.equalsIgnoreCase(seat)) return true; + if (member != null && member.equals(seat)) return true; } } return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 7d934ced..8af07128 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -390,7 +390,7 @@ public void dummify(Player p) { } dummy = dummy+x; members.set(i, dummy); - p.sendMessage("§a"+dummy+" replaced "+member); + p.sendMessage("§a"+dummy+" replaced "+Members.display(member)); if(isLeader(member)) { p.sendMessage("§a"+dummy+" became leader"); if(isBase()) host.setLeader(dummy); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java index fb75d588..6da0bf38 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java @@ -7,6 +7,8 @@ import org.bukkit.Material; import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.identity.Members; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; import org.bukkit.persistence.PersistentDataType; @@ -77,12 +79,12 @@ public static ItemStack getLoanBook(Loan loan) { "\n" + // "Issuer:\n" + // "-------------------\n" + // - loan.getIssuer().getLeader()+"\n" + // + Members.display(loan.getIssuer().getLeader())+"\n" + // "\n" + // "-------------------\n" + // "Loan Taker:\n" + // "\n" + // - (loan.getBorrower() != null ? loan.getBorrower().getLeader() : "[SIGN BOOK TO ACCEPT]") + "\n" + // + (loan.getBorrower() != null ? Members.display(loan.getBorrower().getLeader()) : "[SIGN BOOK TO ACCEPT]") + "\n" + // "-------------------\n" + // "This contract is binding upon signing."); meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, loan.getIssuer().getId()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java index 9f1c0818..b27f89be 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java @@ -142,9 +142,9 @@ public void run() { private void recordChatVote(Player player, Vote vote) { if (!player.isOnline()) return; - String playerName = player.getName(); + String key = Members.key(player); - Faction faction = FactionManager.getByMember(playerName); + Faction faction = FactionManager.getByMember(key); if (faction == null) return; Council council = faction.getGovernment().getCouncil(); @@ -162,7 +162,7 @@ private void recordChatVote(Player player, Vote vote) { } } - if (session.recordVote(playerName, vote)) { + if (session.recordVote(key, vote)) { player.sendMessage("§aYour vote (" + vote.getDisplay() + ") has been recorded!"); Sound sound = switch (vote) { case YAY -> Sound.BLOCK_NOTE_BLOCK_CHIME; diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 3dbec89e..7276a5f1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -32,6 +32,7 @@ import net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler; import net.tfminecraft.simplefactions.diplomacy.Relation; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.loaders.GuildLoader; import net.tfminecraft.simplefactions.loaders.RankLoader; import net.tfminecraft.simplefactions.loaders.TierLoader; @@ -693,11 +694,11 @@ public boolean isLeader(String member) { } /** Whether the player's current character leads this realm. */ public boolean isLeader(org.bukkit.entity.Player player) { - return isLeader(net.tfminecraft.simplefactions.identity.Members.key(player)); + return isLeader(Members.key(player)); } /** Whether the player's current character belongs to this realm. */ public boolean isMember(org.bukkit.entity.Player player) { - return isMember(net.tfminecraft.simplefactions.identity.Members.key(player)); + return isMember(Members.key(player)); } public boolean canBecomeLeader(String member) { if(isLeader(member)) return false; @@ -1013,7 +1014,7 @@ public void updateTier() { temp = getHighestTitle().getTier(); } if(tier == null || !tier.getId().equalsIgnoreCase(temp.getId())) tier = new Tier(temp, -1); - Player p = Bukkit.getPlayerExact(leader); + Player p = Members.player(leader); for(Faction subject : RelationManager.getSubjects(this)) { if(subject.getTier().getTier() > tier.getTier()) { RelationManager.endVassalage(subject, this, false); @@ -1425,8 +1426,7 @@ public void provinceCap() { public int numOnline() { int count = 0; for(String m : guildHandler.getAllMembers()){ - Player p = Bukkit.getPlayerExact(m); - if(p != null && p.isOnline()) count++; + if(Members.isOnline(m)) count++; } return count; } diff --git a/src/test/java/net/tfminecraft/simplefactions/government/CharacterKeyedPoliticsTest.java b/src/test/java/net/tfminecraft/simplefactions/government/CharacterKeyedPoliticsTest.java new file mode 100644 index 00000000..73416a84 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/CharacterKeyedPoliticsTest.java @@ -0,0 +1,224 @@ +package net.tfminecraft.simplefactions.government; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.government.election.Election; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.MovementJoinCopy; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.admin.MovementAdminService; +import net.tfminecraft.simplefactions.government.movement.admin.MovementTabCompletion; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.TestCharacters; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; + +/** Government, elections and movements store character keys and show character names. */ +class CharacterKeyedPoliticsTest { + private PersistenceFilesFixture disk; + private FactionDomainFixture fixture; + private TestCharacters characters; + private String ruler; + private String ash; + private String zed; + private Faction realm; + + @BeforeEach + void setUp() throws Exception { + disk = new PersistenceFilesFixture(); + java.nio.file.Files.createDirectories(disk.root.resolve("Data")); + fixture = new FactionDomainFixture(); + characters = TestCharacters.install(); + ruler = characters.character(UUID.randomUUID(), "Queen Ilsa"); + ash = characters.character(UUID.randomUUID(), "Ash Vale"); + zed = characters.character(UUID.randomUUID(), "Zed Moor"); + realm = fixture.saved("realm", ruler); + realm.addMember(ash); + realm.addMember(zed); + } + + @AfterEach + void tearDown() throws Exception { + try { + fixture.close(); + } finally { + disk.close(); + } + } + + private Player playing(String account, String key) { + Player player = fixture.player(account); + characters.play(player, key); + return player; + } + + @Test + void theRulerActsThroughTheirCurrentCharacterOnly() { + UUID account = UUID.randomUUID(); + String alt = characters.character(account, "Other Self"); + Player player = playing("RulerAccount", ruler); + Government government = realm.getGovernment(); + assertTrue(realm.isLeader(player)); + assertTrue(government.canPropose(player)); + assertTrue(government.isCouncilMember(player)); + assertFalse(government.canProposePolitical(player, Action.CHANGE_LEADER)); + + characters.play(player, alt); + assertFalse(realm.isLeader(player)); + assertFalse(government.canPropose(player)); + assertFalse(government.isCouncilMember(player)); + assertFalse(government.canProposeOrStartMovement(player)); + + characters.play(player, null); + assertFalse(government.canPropose(player)); + assertFalse(government.canProposeOrStartMovement(player)); + assertFalse(government.canProposePolitical(player, Action.CHANGE_LEADER)); + } + + @Test + void councilQuorumCountsCharactersBeingPlayed() { + Council council = realm.getGovernment().getCouncil(); + council.addMemberForce(ash); + council.addMemberForce(zed); + Player account = playing("Account", ruler); + assertFalse(council.hasEnoughValidVoters()); + playing("AshAccount", ash); + assertFalse(council.hasEnoughValidVoters()); + playing("ZedAccount", zed); + assertTrue(council.hasEnoughValidVoters()); + characters.play(account, null); + assertFalse(council.hasEnoughValidVoters()); + assertTrue(council.canRemainMember(ash)); + assertFalse(council.canBeMember(ruler, true, true)); + } + + @Test + void electionTiesAreOrderedByCharacterName() { + Election election = realm.getGovernment().getElection(); + election.restoreFromData( + Map.of("LEADER", new ArrayList<>(List.of(zed, ash))), + Map.of("LEADER", Map.of(ruler, zed)), + Map.of("LEADER", Map.of(zed, 2, ash, 2)), + List.of(ruler)); + assertEquals(List.of(ash, zed), election.getWinners(Candidate.LEADER)); + assertEquals(1, election.getVotes(Candidate.LEADER, zed)); + election.removeCandidate(Candidate.LEADER, zed); + assertEquals(0, election.getVotes(Candidate.LEADER, zed)); + + List lore = new ArrayList<>(); + election.applyReasons(lore, Candidate.LEADER, playing("Outsider", null)); + assertFalse(lore.isEmpty()); + lore.clear(); + election.applyReasons(lore, Candidate.LEADER, playing("ZedAccount", zed)); + assertTrue(lore.isEmpty()); + } + + @Test + void movementsStoreCitizensByKeyAndShowTheirNames() { + Proposal proposal = new Proposal(ash, realm.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + realm.getGovernment().startMovement(ash, proposal); + Movement movement = realm.getGovernment().getMovementByLeader(ash); + assertTrue(movement.isLeader(ash)); + assertTrue(movement.canBeLeader(ash)); + assertEquals(movement.getCauses().getFirst(), movement.getCauseByLeader(ash)); + + Player zedPlayer = playing("ZedAccount", zed); + assertTrue(movement.quickJoinCheck(zedPlayer)); + assertEquals(zed, movement.getJoiningAs(zedPlayer)); + assertNull(movement.getJoiningAs(playing("Stranger", null))); + movement.join(movement.getJoiningAs(zedPlayer), null); + assertTrue(movement.isMember(zed)); + assertFalse(movement.quickJoinCheck(zedPlayer)); + assertTrue(String.join("\n", movement.getSupporters().getFormattedList()).contains("Zed Moor")); + assertTrue(MovementJoinCopy.alreadyCauseMember(true, zed).contains("Zed Moor")); + + proposal.setTarget(zed); + String book = pages(proposal.getAsBook()); + assertTrue(book.contains("Proposed by: Ash Vale")); + assertTrue(book.contains("Target: Zed Moor")); + } + + @Test + void staffPickCitizensAndWantedLeadersByCharacterName() { + Proposal proposal = new Proposal(ash, realm.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + realm.getGovernment().startMovement(ash, proposal); + Movement movement = realm.getGovernment().getMovementByLeader(ash); + + assertTrue(MovementAdminService.listLines().getFirst().contains("leader: §fAsh Vale")); + assertTrue(MovementAdminService.hostCitizenNames(movement).contains("Zed Moor")); + assertTrue(MovementAdminService.wantedLeaderNames(movement).contains("Zed Moor")); + + MovementAdminService.Result missing = MovementAdminService.target(movement.getId(), "0", "Nobody Here"); + assertFalse(missing.ok()); + MovementAdminService.Result set = MovementAdminService.target(movement.getId(), "0", "zed moor"); + assertTrue(set.ok(), set.message()); + assertTrue(set.message().endsWith("to Zed Moor.")); + assertEquals(zed, proposal.getTarget()); + + MovementAdminService.Result joined = MovementAdminService.joinSupporter(movement.getId(), "citizen", zed); + assertTrue(joined.ok(), joined.message()); + realm.forceRemoveMember(zed); + MovementAdminService.Result left = MovementAdminService.leaveSupporter(movement.getId(), "citizen", "Zed Moor"); + assertTrue(left.ok(), left.message()); + assertFalse(movement.getSupporters().getCitizens().contains(zed)); + } + + @Test + void staffCommandsAcceptAndCompleteCharacterNamesWithSpaces() { + Proposal proposal = new Proposal(ash, realm.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + realm.getGovernment().startMovement(ash, proposal); + Movement movement = realm.getGovernment().getMovementByLeader(ash); + Player staff = playing("Staff", null); + when(staff.hasPermission(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + when(staff.isOp()).thenReturn(true); + Command command = mock(Command.class); + when(command.getName()).thenReturn("movement"); + MovementTabCompletion tabs = new MovementTabCompletion(); + String id = movement.getId(); + + assertEquals(List.of("Moor"), tabs.onTabComplete(staff, command, "movement", + new String[] {"admin", "target", id, "0", "Zed", ""})); + assertEquals(List.of("Zed Moor"), tabs.onTabComplete(staff, command, "movement", + new String[] {"admin", "join", id, "supporter", "citizen", "Z"})); + assertEquals(List.of("Moor"), tabs.onTabComplete(staff, command, "movement", + new String[] {"admin", "join", id, "cause", "0", "citizen", "Zed", "M"})); + assertEquals(List.of(), tabs.onTabComplete(staff, command, "movement", + new String[] {"admin", "join", id, "supporter", "guild", "x", "y"})); + assertEquals(List.of(), tabs.onTabComplete(staff, command, "movement", + new String[] {"admin", "join", id, "supporter", "other", "x"})); + + new net.tfminecraft.simplefactions.government.movement.admin.MovementCommandManager() + .onCommand(staff, command, "movement", new String[] {"admin", "target", id, "0", "Zed", "Moor"}); + assertEquals(zed, proposal.getTarget()); + } + + private static String pages(ItemStack item) { + assertEquals(Material.WRITTEN_BOOK, item.getType()); + return org.bukkit.ChatColor.stripColor(String.join("\n", ((BookMeta) item.getItemMeta()).getPages())); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java index aa76bee3..1f212fb8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java @@ -265,7 +265,8 @@ void governmentPermissionChecksUseRealCouncilGuildAndSubjectRoles() { fixture.saved("foreign", "Iris"); assertFalse(government.isCouncilMember((String) null)); assertFalse(government.isCouncilMember((org.bukkit.entity.Player) null)); - assertTrue(government.isCouncilMember("aLiCe")); + assertTrue(government.isCouncilMember("Alice")); + assertFalse(government.isCouncilMember("aLiCe")); assertTrue(government.isCouncilMember(fixture.player("Bob"))); assertFalse(government.isCouncilMember("Cara")); assertTrue(government.canPropose(fixture.player("Alice"))); @@ -457,7 +458,8 @@ void movementRoundTripRetainsAllProposalKindsPoolsBackersAndFrozenState() { political.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); political.setTarget("Cara"); government.startMovement("Bob", law); - Movement movement = government.getMovementByLeader("bOB"); + assertNull(government.getMovementByLeader("bOB")); + Movement movement = government.getMovementByLeader("Bob"); assertNotNull(movement); assertTrue(government.hasMovements()); assertSame(movement, government.getMovementById(movement.getId().toUpperCase())); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java index 76b174d3..20ea2500 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java @@ -170,7 +170,8 @@ void electionRanksLiveAndArchivedResultsWithStableAlphabeticalTiesAndIndependent election.serializeVotes().get("LEADER").clear(); election.serializePreviousVotes().get("LEADER").clear(); assertEquals(3, election.getCandidates(Candidate.LEADER).size()); - assertEquals(1, election.getVotes(Candidate.LEADER, "cARA")); + assertEquals(1, election.getVotes(Candidate.LEADER, "Cara")); + assertEquals(0, election.getVotes(Candidate.LEADER, "cARA")); election.start(); assertNull(election.getVote(Candidate.LEADER, "Alice")); election.addVote(Candidate.LEADER, "Alice", "Drew"); @@ -357,7 +358,7 @@ void sessionTimesOutBeforeSelectionAndRejectsInsufficientQuorumWithoutConsumingP assertEquals( List.of(proposal), faction.getGovernment().getCouncil().getProposalHandler().getProposals()); - when(fixture.online.get("Bob").isOnline()).thenReturn(false); + fixture.online.remove("Bob"); Session denied = session(faction); denied.onLanternClick(lantern()); assertFalse(denied.isStarted()); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java index 7fac3218..f3d27811 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java @@ -145,7 +145,8 @@ void organizationRespectsPhaseBoundsAndOnlyAdjacentPhasesCanBeSelected() { Movement movement = movement(faction, "Bob"); assertSame(faction, movement.getFaction()); assertTrue(movement.hasLeader()); - assertTrue(movement.isLeader("bOB")); + assertTrue(movement.isLeader("Bob")); + assertFalse(movement.isLeader("bOB")); assertFalse(movement.isLeader("Cara")); assertEquals(Phase.GATHERING, movement.getPhase()); assertEquals(20, movement.getMaxOrganization()); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java index 17a79f03..958a3ef8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java @@ -42,7 +42,8 @@ void joinSupporter_citizen_callsJoin() { when(movement.getId()).thenReturn("alice_movement"); when(movement.isFrozen()).thenReturn(false); when(movement.getSupporters()).thenReturn(supporters); - when(movement.getFaction()).thenReturn(mock(Faction.class)); + Faction host = hostWith("Bob"); + when(movement.getFaction()).thenReturn(host); when(movement.canJoin(eq("Bob"), isNull(), eq(false))).thenReturn(true); doAnswer(invocation -> { supporters.addCitizen("Bob"); @@ -160,6 +161,8 @@ void ineligible_doesNotJoin() { Movement movement = mock(Movement.class); when(movement.isFrozen()).thenReturn(false); when(movement.getSupporters()).thenReturn(new Pool()); + Faction host = hostWith("Bob"); + when(movement.getFaction()).thenReturn(host); when(movement.joinBlockReason(eq("Bob"), isNull(), eq(true))) .thenReturn("§cBob is not a citizen of Lantan."); @@ -182,7 +185,8 @@ void joinCause_callsJoinWithCause() { when(movement.getCauses()).thenReturn(List.of(cause)); when(cause.getPool()).thenReturn(pool); when(cause.getIndex()).thenReturn(0); - when(movement.getFaction()).thenReturn(mock(Faction.class)); + Faction host = hostWith("Bob"); + when(movement.getFaction()).thenReturn(host); when(movement.canJoin(eq("Bob"), eq(cause), eq(false))).thenReturn(true); doAnswer(invocation -> { pool.addCitizen("Bob"); @@ -262,6 +266,7 @@ void target_setsWantedLeaderWithoutOnlineCheck() { when(cause.getProposal()).thenReturn(proposal); when(proposal.isPoliticalActionProposal()).thenReturn(true); when(proposal.getPoliticalAction()).thenReturn(new net.tfminecraft.simplefactions.government.movement.PoliticalAction(net.tfminecraft.simplefactions.government.movement.Action.CHANGE_LEADER)); + when(host.getMembers()).thenReturn(List.of("dummy_11")); when(host.canBecomeLeader("dummy_11")).thenReturn(true); try (MockedStatic factions = mockStatic(FactionManager.class); @@ -272,4 +277,11 @@ void target_setsWantedLeaderWithoutOnlineCheck() { verify(proposal).setTarget("dummy_11"); } } + + private static Faction hostWith(String... members) { + Faction host = mock(Faction.class); + when(host.getCompleteMemberList()).thenReturn(List.of(members)); + when(host.getMembers()).thenReturn(List.of(members)); + return host; + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java index 60672c41..9773d286 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java @@ -87,7 +87,8 @@ void realmGuildLeadershipTracksTheCurrentFactionLeader() { when(fixture.host.getLeader()).thenReturn("Successor"); assertEquals("Successor", guild.getLeader()); assertFalse(guild.isLeader("Leader")); - assertTrue(guild.isLeader("successor")); + assertFalse(guild.isLeader("successor")); + assertTrue(guild.isLeader("Successor")); assertTrue(guild.isLeader(fixture.ui.player("Successor"))); } @@ -177,28 +178,33 @@ void savedGuildDataSurvivesJsonRoundTripAndRestoresFinancialSettings() { } @Test - void membershipsAndInvitationsAreCaseInsensitiveAndLeaveTheMainGuildOnce() { + void membershipsAndInvitationsMatchExactKeysAndLeaveTheMainGuildOnce() { Guild guild = fixture.guild("guild", 0); guild.invite(null); - guild.invite("BOB"); - guild.invite("bob"); - assertEquals(List.of("BOB"), guild.getInvites()); + guild.invite("Bob"); + guild.invite("Bob"); + assertEquals(List.of("Bob"), guild.getInvites()); assertTrue(guild.isInvited("Bob")); + assertFalse(guild.isInvited("BOB")); assertFalse(guild.consumeInvite("missing")); when(fixture.main.isMember("Bob")).thenReturn(true); guild.addMember("Bob"); verify(fixture.main).kick("Bob"); assertTrue(guild.getInvites().isEmpty()); - guild.addMember("BOB"); + guild.addMember("Bob"); assertEquals(List.of("Leader", "Alice", "Bob"), guild.getMembers()); - assertTrue(guild.isMember(fixture.ui.player("bOb"))); + assertTrue(guild.isMember(fixture.ui.player("Bob"))); + assertFalse(guild.isMember(fixture.ui.player("bOb"))); guild.kick("bOB"); + assertTrue(guild.isMember("Bob")); + guild.kick("Bob"); assertFalse(guild.isMember("Bob")); guild.kick("missing"); assertEquals(2, guild.getMembers().size()); - assertNull(Guild.findIgnoreCase(null, "Bob")); - assertNull(Guild.findIgnoreCase(List.of("Bob"), null)); - assertEquals("Alice", Guild.findIgnoreCase(java.util.Arrays.asList(null, "Alice"), "ALICE")); + assertNull(Guild.find(null, "Bob")); + assertNull(Guild.find(List.of("Bob"), null)); + assertNull(Guild.find(java.util.Arrays.asList(null, "Alice"), "ALICE")); + assertEquals("Alice", Guild.find(java.util.Arrays.asList(null, "Alice"), "Alice")); } @ParameterizedTest diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java index 919bdead..a124bb3e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java @@ -49,16 +49,24 @@ void aLeaderCannotDeleteGuildFinancesThatStillNeedSettling(FinancialBlock block) } } - @ParameterizedTest - @ValueSource(strings = {"Leader", "lEaDeR"}) - void aLeaderCanDeleteAnEmptyDebtFreeGuildAndReturnItsMembers(String name) { - try (Fixture fixture = new Fixture(name, false)) { + @Test + void aLeaderCanDeleteAnEmptyDebtFreeGuildAndReturnItsMembers() { + try (Fixture fixture = new Fixture("Leader", false)) { assertTrue(fixture.run("delete", "smiths")); fixture.assertDeleted(); } } + @Test + void aKeyThatOnlyDiffersInCaseIsNotTheLeader() { + try (Fixture fixture = new Fixture("lEaDeR", false)) { + assertTrue(fixture.run("delete", "smiths")); + + fixture.assertUntouched(); + } + } + @Test void aLeaderCanDeleteAGuildWithoutABank() { try (Fixture fixture = new Fixture("Leader", false)) { diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildInviteTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildInviteTest.java index 6437cf0b..88395924 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildInviteTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildInviteTest.java @@ -17,29 +17,32 @@ class GuildInviteTest { @Test - void inviteMatchesAndConsumesIgnoreCase() throws Exception { + void inviteMatchesAndConsumesExactKeys() throws Exception { Guild guild = mock(Guild.class, withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); setField(guild, "invites", new ArrayList()); guild.invite("steve"); - assertTrue(guild.isInvited("Steve")); - guild.invite("STEVE"); + assertTrue(guild.isInvited("steve")); + assertFalse(guild.isInvited("Steve")); + guild.invite("steve"); assertEquals(1, guild.getInvites().size()); - assertTrue(guild.consumeInvite("Steve")); + assertFalse(guild.consumeInvite("STEVE")); + assertTrue(guild.consumeInvite("steve")); assertFalse(guild.isInvited("steve")); assertTrue(guild.getInvites().isEmpty()); } @Test - void findIgnoreCaseReturnsStoredSpelling() { + void findReturnsOnlyAnExactKey() { ArrayList names = new ArrayList<>(); names.add("Steve"); - assertEquals("Steve", Guild.findIgnoreCase(names, "steve")); - assertEquals(null, Guild.findIgnoreCase(names, "Alex")); + assertEquals("Steve", Guild.find(names, "Steve")); + assertEquals(null, Guild.find(names, "steve")); + assertEquals(null, Guild.find(names, "Alex")); } @Test - void kickRemovesIgnoreCase() throws Exception { + void kickRemovesOnlyAnExactKey() throws Exception { Guild guild = mock(Guild.class, withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); ArrayList members = new ArrayList<>(); members.add("Steve"); @@ -47,6 +50,8 @@ void kickRemovesIgnoreCase() throws Exception { setField(guild, "host", mock(Faction.class)); guild.kick("steve"); + assertEquals(1, members.size()); + guild.kick("Steve"); assertTrue(members.isEmpty()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/SharedAccountRivalGuildsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/SharedAccountRivalGuildsTest.java new file mode 100644 index 00000000..13c7cde7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/SharedAccountRivalGuildsTest.java @@ -0,0 +1,76 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; + +/** One account, two characters, two rival guilds: each character acts only for its own guild. */ +class SharedAccountRivalGuildsTest { + private FactionDomainFixture fixture; + private TestCharacters characters; + + @BeforeEach + void setUp() { + fixture = new FactionDomainFixture(); + characters = TestCharacters.install(); + } + + @AfterEach + void tearDown() { + fixture.close(); + } + + @Test + void eachCharacterOfOneAccountLeadsOnlyItsOwnRivalGuild() { + UUID account = UUID.randomUUID(); + String aldric = characters.character(account, "Aldric Vane"); + String mira = characters.character(account, "Mira Thorn"); + assertNotEquals(aldric, mira); + assertEquals(Members.accountId(aldric), Members.accountId(mira)); + + Faction north = fixture.saved("north", characters.character(UUID.randomUUID(), "North King")); + Faction south = fixture.saved("south", characters.character(UUID.randomUUID(), "South Queen")); + Guild smiths = fixture.guild(north, "smiths", aldric); + Guild masons = fixture.guild(south, "masons", mira); + Player player = fixture.player("SharedAccount"); + + characters.play(player, aldric); + assertSame(smiths, FactionManager.getGuildByMember(Members.key(player))); + assertSame(north, FactionManager.getByMember(Members.key(player))); + assertTrue(smiths.isLeader(player)); + assertTrue(smiths.isMember(player)); + assertFalse(masons.isLeader(player)); + assertFalse(masons.isMember(player)); + assertFalse(south.isMember(player)); + assertEquals("Aldric Vane", Members.name(player)); + + characters.play(player, mira); + assertSame(masons, FactionManager.getGuildByMember(Members.key(player))); + assertSame(south, FactionManager.getByMember(Members.key(player))); + assertTrue(masons.isLeader(player)); + assertFalse(smiths.isLeader(player)); + assertFalse(smiths.isMember(player)); + assertFalse(north.isMember(player)); + assertEquals("Mira Thorn", Members.display(masons.getLeader())); + + characters.play(player, null); + assertNull(FactionManager.getGuildByMember(Members.key(player))); + assertFalse(smiths.isLeader(player)); + assertFalse(masons.isLeader(player)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java b/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java new file mode 100644 index 00000000..b290feb1 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java @@ -0,0 +1,71 @@ +package net.tfminecraft.simplefactions.guild; + +import java.time.Instant; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; + +/** Real character keys for tests: each player plays at most one named character at a time. */ +public final class TestCharacters implements Members.Resolver { + private final Map characters = new HashMap<>(); + private final Map playing = new IdentityHashMap<>(); + + /** Installs a fresh resolver; the global test extension restores the default after each test. */ + public static TestCharacters install() { + TestCharacters resolver = new TestCharacters(); + Members.setResolver(resolver); + return resolver; + } + + /** Registers a character owned by {@code owner} and returns its member key. */ + public String character(UUID owner, String name) { + CharacterRef ref = new CharacterRef(owner, UUID.randomUUID()); + String key = Members.key(ref); + characters.put(key, new CharacterInfo(ref, name, CharacterStatus.ALIVE, "human", Set.of(), 0L, Instant.EPOCH)); + return key; + } + + /** Starts a session for {@code key} on that player; null ends it. */ + public void play(Player player, String key) { + if (key == null) playing.remove(player); + else playing.put(player, key); + } + + @Override + public Optional session(Player player) { + return Optional.ofNullable(playing.get(player)) + .map(key -> new CharacterSession(characters.get(key).ref(), 1L)); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return true; + } + + @Override + public String key(Player player) { + return playing.get(player); + } + + @Override + public Optional info(String key) { + return Optional.ofNullable(characters.get(key)); + } + + @Override + public Optional online(String key) { + return playing.entrySet().stream().filter(entry -> entry.getValue().equals(key)) + .map(Map.Entry::getKey).findFirst(); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java index 21a9e5a9..7f5c0e3b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java @@ -469,11 +469,15 @@ void guildMembershipQueriesAndForcedRemovalAgreeWithoutChangingLeaders() { Guild traders = fixture.guild(faction, "traders", "Bob"); traders.addMember("Traveler"); var guilds = faction.getGuildHandler(); - assertSame(traders, guilds.getGuildByMember("traveler")); + assertSame(traders, guilds.getGuildByMember("Traveler")); + assertNull(guilds.getGuildByMember("traveler")); assertNull(guilds.getGuildByMember("missing")); - assertTrue(guilds.isGuildLeader("BOB")); + assertTrue(guilds.isGuildLeader("Bob")); + assertFalse(guilds.isGuildLeader("BOB")); assertFalse(guilds.isGuildLeader("Traveler")); guilds.forceKick("traveler"); + assertSame(traders, guilds.getGuildByMember("Traveler")); + guilds.forceKick("Traveler"); assertNull(guilds.getGuildByMember("Traveler")); assertFalse(faction.isMember("Traveler")); assertEquals(List.of("Bob"), traders.getMembers()); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java index d9f20319..73573eba 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java @@ -131,21 +131,24 @@ void onePrestigePassAsksForEachMembersPlaytimeOnceWithoutChangingPrestige() { } @Test - void factionInvitationsAreCaseInsensitiveAndCanBeConsumedExactlyOnce() { + void factionInvitationsMatchExactKeysAndCanBeConsumedExactlyOnce() { Faction faction = fixture.saved("home", "Alice"); faction.invite(null); faction.invite("Bob"); - faction.invite("BOB"); + faction.invite("Bob"); assertEquals(List.of("Bob"), faction.getInvited()); - assertTrue(faction.isInvited("bob")); + assertTrue(faction.isInvited("Bob")); + assertFalse(faction.isInvited("bob")); assertFalse(faction.consumeInvite("Cara")); - assertTrue(faction.consumeInvite("bOB")); + assertFalse(faction.consumeInvite("bOB")); + assertTrue(faction.consumeInvite("Bob")); assertFalse(faction.consumeInvite("Bob")); assertTrue(faction.getInvited().isEmpty()); faction.setInvited(new ArrayList<>(List.of("Cara"))); - assertTrue(faction.isInvited("cara")); + assertTrue(faction.isInvited("Cara")); faction.addMember("Bob"); - assertTrue(faction.isMember("bob")); + assertTrue(faction.isMember("Bob")); + assertFalse(faction.isMember("bob")); assertFalse(faction.isMember("missing")); } @@ -693,8 +696,6 @@ void onlineCountProsperityAndCapacityUseTheCurrentRosterAndTerritory() { faction.addMember("Bob"); faction.addMember("Cara"); fixture.player("Alice"); - var offline = fixture.player("Bob"); - when(offline.isOnline()).thenReturn(false); fixture.player("Visitor"); assertEquals(1, faction.numOnline()); var north = new Province(7, "PLAINS", 1); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/FactionInviteTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/FactionInviteTest.java index 8550a7fa..4ff76137 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/FactionInviteTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionInviteTest.java @@ -15,15 +15,17 @@ class FactionInviteTest { @Test - void inviteMatchesAndConsumesIgnoreCase() throws Exception { + void inviteMatchesAndConsumesExactKeys() throws Exception { Faction faction = mock(Faction.class, withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); setField(faction, "invited", new ArrayList()); faction.invite("steve"); - assertTrue(faction.isInvited("Steve")); - faction.invite("STEVE"); + assertFalse(faction.isInvited("Steve")); + assertTrue(faction.isInvited("steve")); + faction.invite("steve"); assertEquals(1, faction.getInvited().size()); - assertTrue(faction.consumeInvite("Steve")); + assertFalse(faction.consumeInvite("Steve")); + assertTrue(faction.consumeInvite("steve")); assertFalse(faction.isInvited("steve")); assertTrue(faction.getInvited().isEmpty()); } From f261d298c6cda5f1ec409b991b860c17b7ed623d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:23:05 +0000 Subject: [PATCH 05/35] refactor: key war, diplomacy and requests by character Member strings in the war, raid, civil war and request code are now character member keys. Combat state stays keyed by account; warband rejoin waits for the same character's session to start. Co-Authored-By: Claude Opus 5.5 --- .../managers/RelationManager.java | 14 ++-- .../simplefactions/managers/WarManager.java | 10 +-- .../campaign/CampaignBattleJoinService.java | 29 +++---- .../campaign/CampaignBattleLaunchService.java | 5 +- .../CampaignBattleOutcomeService.java | 7 +- .../campaign/CampaignBattleRosterService.java | 3 +- .../CampaignBattleSignupReminderService.java | 3 +- .../CampaignNavalAutoLossReminderService.java | 3 +- .../warband/CampaignWarbandBattleService.java | 16 ++-- .../warband/CampaignWarbandSignupService.java | 23 +++--- .../military/BattleCasualtyService.java | 5 +- .../war/battle/ui/BattleCommandManager.java | 31 ++++---- .../war/battle/warband/Warband.java | 3 +- .../warband/WarbandMembershipListener.java | 10 ++- .../warband/WarbandMembershipService.java | 71 ++++++++++------- .../battle/warband/WarbandRejoinState.java | 12 ++- .../AttackerNavalContestService.java | 5 +- .../CampaignOffensiveForfeitService.java | 5 +- .../raid/CampaignRaidJoinService.java | 4 +- .../raid/CampaignRaidWarbandService.java | 28 +++---- .../war/campaign/raid/RaidCommandManager.java | 2 +- .../fight/CampaignRaidBattleEndService.java | 3 +- .../raid/fight/CampaignRaidBattleService.java | 3 +- .../raid/fight/CampaignRaidLaunchService.java | 3 +- .../CampaignRaidMusterReminderService.java | 3 +- .../intruder/CampaignRaidIntruderService.java | 11 +-- .../runtime/BattleAutoresolveService.java | 6 +- .../runtime/BattleScheduleLookups.java | 11 ++- .../pick/BattleInstallationPickService.java | 8 +- .../war/civilwar/CivilWarStartService.java | 2 +- .../civilwar/CivilWarTempRebelFactory.java | 6 +- .../wartime/CivilWarUntangleService.java | 2 +- .../war/freeze/PreparationFreeze.java | 11 +-- .../freeze/VfBuildersConstructionFreeze.java | 16 +++- .../CampaignBattleJoinServiceTest.java | 4 +- .../BattleRosterBoundaryCoverageTest.java | 16 +++- .../BattleRosterLifecycleCoverageTest.java | 54 ++++++------- .../warband/WarbandMembershipServiceTest.java | 72 +++++++++++++++-- .../CampaignRaidLifecycleCoverageTest.java | 18 ++--- ...CampaignScheduleLifecycleCoverageTest.java | 56 ++++++++++++-- .../VfBuildersConstructionFreezeTest.java | 77 +++++++++++++++++++ 41 files changed, 456 insertions(+), 215 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index b54222ec..0e009a39 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -282,7 +282,7 @@ public static boolean setRelation( relation.setType(r); origin.setRelation(target, relation); if(reverseChange) { - Player l = Bukkit.getPlayerExact(target.getLeader()); + Player l = Members.player(target.getLeader()); if(l != null && l.isOnline()) { l.sendMessage(StringFormatter.formatHex("#a89977Your relation with "+origin.getName()+" #a89977has been changed to "+r.getLink().getName())); } @@ -622,7 +622,7 @@ public static boolean setAttitude(Player p, Attitude a, Faction target, Faction } private static void sendRequest(Player sender, Faction f, RelationType type) { - Player p = Bukkit.getPlayerExact(f.getLeader()); + Player p = Members.player(f.getLeader()); if(p == null || !p.isOnline()) { sender.sendMessage("§cCannot send request, target faction leader is not online!"); return; @@ -646,7 +646,7 @@ public static void acceptRequest(Player p) { p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); return; } - Player sp = Bukkit.getPlayerExact(sender.getLeader()); + Player sp = Members.player(sender.getLeader()); // The war may have started after the request was sent String blocked = wartimeBlock(req.getType(), reciever, sender); if(blocked != null) { @@ -660,7 +660,7 @@ public static void acceptRequest(Player p) { } private static void sendTradeRequest(Player sender, Faction f, RelationType type) { - Player p = Bukkit.getPlayerExact(f.getLeader()); + Player p = Members.player(f.getLeader()); if(p == null || !p.isOnline()) { sender.sendMessage("§cCannot send request, target faction leader is not online!"); return; @@ -684,14 +684,14 @@ public static void acceptTradeRequest(Player p) { p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); return; } - Player sp = Bukkit.getPlayerExact(sender.getLeader()); + Player sp = Members.player(sender.getLeader()); if (applyTradeRelation(p, req.getType(), reciever, sender, false) && sp != null && sp.isOnline()) { sp.sendMessage(reciever.getName()+" §aaccepted your request and set trade to "+req.getType().getName()); } } private static void sendTreatyRequest(Player sender, Faction f, RelationType type) { - Player p = Bukkit.getPlayerExact(f.getLeader()); + Player p = Members.player(f.getLeader()); if(p == null || !p.isOnline()) { sender.sendMessage("§cCannot send request, target faction leader is not online!"); return; @@ -715,7 +715,7 @@ public static void acceptTreatyRequest(Player p) { p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); return; } - Player sp = Bukkit.getPlayerExact(sender.getLeader()); + Player sp = Members.player(sender.getLeader()); if (applyTreatyRelation(p, req.getType(), reciever, sender, false) && sp != null && sp.isOnline()) { sp.sendMessage(reciever.getName()+" §aaccepted your request and set treaty to "+req.getType().getName()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java index 20fe37c0..9db1673e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java @@ -334,7 +334,7 @@ private static void notifyWarDeclared(Iterable members) { if (members == null) return; for (String member : members) { if (member == null || member.isBlank()) continue; - Player p = Bukkit.getPlayerExact(member); + Player p = Members.player(member); if (p != null && p.isOnline()) { p.sendTitle("§cWar Declared!", "§e/war list §7to view", 10, 120, 10); declaredHorn.accept(p); @@ -444,7 +444,7 @@ private static void notifyWarEnded(War w, WarEndReason reason) { members.addAll(BattleSideMembers.collectEligibleMemberNames(w.getAttackers())); members.addAll(BattleSideMembers.collectEligibleMemberNames(w.getDefenders())); for (String member : members) { - Player p = Bukkit.getPlayerExact(member); + Player p = Members.player(member); if (p != null && p.isOnline()) { p.sendMessage(message); } @@ -489,7 +489,7 @@ public static void sendRequest(Player sender, Faction origin, Faction target, Wa sender.sendMessage(result.message()); return; } - Player p = Bukkit.getPlayerExact(target.getLeader()); + Player p = Members.player(target.getLeader()); if(p == null || !p.isOnline()) { sender.sendMessage("§cCannot send request, target faction leader is not online!"); return; @@ -528,7 +528,7 @@ public static void acceptRequest(Player p) { WarCommitmentService.snapshotLevyForFighter(war, reciever); //A secondary only counts as a participant once it has joined, so contracts are re-checked here net.tfminecraft.simplefactions.mercenary.contract.MercenaryLoyaltyWatcher.onWarJoined(reciever); - Player sp = Bukkit.getPlayerExact(origin.getLeader()); + Player sp = Members.player(origin.getLeader()); if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your call to arms"); p.sendMessage("§aYour faction has joined the "+war.getName()); notifyWarDeclared(reciever.getMembers()); @@ -556,7 +556,7 @@ public static void declineCallToArms(Player called, WarRequest req, boolean expl } Faction origin = req.getFaction(); if (origin != null && origin.getLeader() != null) { - Player caller = Bukkit.getPlayerExact(origin.getLeader()); + Player caller = Members.player(origin.getLeader()); if (caller != null && caller.isOnline()) { String who = receiver != null ? receiver.getName() : "They"; caller.sendMessage(who + " §cdeclined your call to arms"); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinService.java index bbc61af3..9f45d9e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandBattleService; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -73,7 +74,7 @@ public static String validateWarbandMemberJoin( if (joiningPlayer == null) { return "Invalid campaign warband join"; } - return validateWarbandMemberJoin(war, battle, sideId, warband, joiningPlayer.getName(), joiningPlayer.getUniqueId()); + return validateWarbandMemberJoin(war, battle, sideId, warband, Members.key(joiningPlayer), joiningPlayer.getUniqueId()); } public static String validateWarbandMemberJoin( @@ -81,8 +82,8 @@ public static String validateWarbandMemberJoin( Battle battle, String sideId, Warband warband, - String joiningPlayerName) { - return validateWarbandMemberJoin(war, battle, sideId, warband, joiningPlayerName, null); + String memberKey) { + return validateWarbandMemberJoin(war, battle, sideId, warband, memberKey, null); } public static String validateWarbandMemberJoin( @@ -90,12 +91,12 @@ public static String validateWarbandMemberJoin( Battle battle, String sideId, Warband warband, - String joiningPlayerName, + String memberKey, java.util.UUID playerId) { - if (war == null || battle == null || warband == null || joiningPlayerName == null) { + if (war == null || battle == null || warband == null || memberKey == null) { return "Invalid campaign warband join"; } - Faction faction = FactionManager.getByMember(joiningPlayerName); + Faction faction = FactionManager.getByMember(memberKey); if (faction == null) { return "You must be in a faction to join this campaign battle"; } @@ -104,11 +105,11 @@ public static String validateWarbandMemberJoin( return "Your faction is not on this battle side"; } String mercenaryError = validateMercenaryRoster( - war, battle, sideId, battleSide, joiningPlayerName, playerId); + war, battle, sideId, battleSide, memberKey, playerId); if (mercenaryError != null) { return mercenaryError; } - Side playerSide = rosterSideFor(war, joiningPlayerName, faction); + Side playerSide = rosterSideFor(war, memberKey, faction); if (playerSide == null || playerSide != battleSide) { return "Your faction is not on this battle side"; } @@ -117,16 +118,16 @@ public static String validateWarbandMemberJoin( } if (battle.hasStarted()) { return CampaignWarbandBattleService.validateMidBattleJoin( - war, battle, sideId, warband, joiningPlayerName, playerId); + war, battle, sideId, warband, memberKey, playerId); } return validateRosterHasRoom(war, battle, sideId, warband, 1); } - public static Side rosterSideFor(War war, String playerName, Faction faction) { + public static Side rosterSideFor(War war, String memberKey, Faction faction) { if (war == null) { return null; } - Side contracted = MercenaryEngagements.sideFor(war, playerName); + Side contracted = MercenaryEngagements.sideFor(war, memberKey); if (contracted != null) { return contracted; } @@ -138,10 +139,10 @@ public static String validateMercenaryRoster( Battle battle, String sideId, Side battleSide, - String joiningPlayerName, + String memberKey, java.util.UUID playerId) { MercenaryEngagements.Engagement engagement = - MercenaryEngagements.forPlayer(war, joiningPlayerName); + MercenaryEngagements.forPlayer(war, memberKey); if (engagement == null) { return null; } @@ -151,7 +152,7 @@ public static String validateMercenaryRoster( } Side opposing = war.getOppositeSide(engagement.hirer()); if (!net.tfminecraft.simplefactions.mercenary.contract.MercenaryLoyalty.canDeployAgainst( - joiningPlayerName, opposing)) { + memberKey, opposing)) { return "You cannot march on your own realm"; } BattleSide roster = battle.getSideById(sideId); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java index 81edd2ef..6efd99a9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.List; @@ -277,13 +278,13 @@ private static void sendToOnlineAdmins(String message) { private static void broadcastToBelligerents(War war, String message) { for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java index 53ef2c99..5bb5d762 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import java.util.Map; import java.util.Optional; @@ -229,7 +230,7 @@ public static void notifyPostBattleChoicePending(War war) { : "§eChoose §6attack §eor §6accept white peace §eon the campaign map."; for (String memberName : BattleSideMembers.collectEligibleMemberNames( CampaignCoalitionService.toSide(war, leaderCoalition))) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } @@ -260,13 +261,13 @@ private static void broadcastBattleEnded( return; } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(resultLine); } } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(resultLine); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterService.java index 3ba7ae5a..9d8627da 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandSignupService; import java.time.Instant; @@ -153,7 +154,7 @@ private static void broadcastJoinReady(War war, Battle battle) { private static void notifySide(Side side, String message) { for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleSignupReminderService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleSignupReminderService.java index 0fe44bc4..37acee3d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleSignupReminderService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleSignupReminderService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import java.time.Instant; import java.util.List; @@ -86,7 +87,7 @@ private static void broadcastToUnassignedSide( net.tfminecraft.simplefactions.war.core.Side side, String message) { for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player == null || !player.isOnline()) { continue; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignNavalAutoLossReminderService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignNavalAutoLossReminderService.java index b3fcd207..29c8d14c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignNavalAutoLossReminderService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignNavalAutoLossReminderService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.identity.Members; import java.time.Instant; import org.bukkit.Bukkit; @@ -46,7 +47,7 @@ public static void processReminders(War war, Instant now) { if (offensiveLeader == null || offensiveLeader.getLeader() == null) { return; } - Player player = Bukkit.getPlayerExact(offensiveLeader.getLeader()); + Player player = Members.player(offensiveLeader.getLeader()); if (player == null || !player.isOnline()) { return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandBattleService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandBattleService.java index a939c9c4..99738a4f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandBattleService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandBattleService.java @@ -25,16 +25,16 @@ public static String validateMidBattleJoin( Battle battle, String sideId, Warband warband, - String joiningPlayerName, + String memberKey, UUID playerId) { - if (war == null || battle == null || warband == null || joiningPlayerName == null) { + if (war == null || battle == null || warband == null || memberKey == null) { return "Invalid campaign warband join"; } if (!battle.hasStarted()) { return null; } net.tfminecraft.simplefactions.objects.Faction faction = - net.tfminecraft.simplefactions.managers.FactionManager.getByMember(joiningPlayerName); + net.tfminecraft.simplefactions.managers.FactionManager.getByMember(memberKey); if (faction == null) { return "You must be in a faction to join this campaign battle"; } @@ -44,12 +44,12 @@ public static String validateMidBattleJoin( return "Your faction is not on this battle side"; } String mercenaryError = CampaignBattleJoinService.validateMercenaryRoster( - war, battle, sideId, battleSide, joiningPlayerName, playerId); + war, battle, sideId, battleSide, memberKey, playerId); if (mercenaryError != null) { return mercenaryError; } net.tfminecraft.simplefactions.war.core.Side playerSide = - CampaignBattleJoinService.rosterSideFor(war, joiningPlayerName, faction); + CampaignBattleJoinService.rosterSideFor(war, memberKey, faction); if (playerSide == null || playerSide != battleSide) { return "Your faction is not on this battle side"; } @@ -176,8 +176,8 @@ private static BattleSide findSideForWarband(Warband warband) { public static boolean isWarSideMainLeader( net.tfminecraft.simplefactions.war.core.War war, Warband warband, - String playerName) { - if (war == null || warband == null || playerName == null || playerName.isBlank()) { + String memberKey) { + if (war == null || warband == null || memberKey == null || memberKey.isBlank()) { return false; } String sideId = warband.getCampaignSideId(); @@ -193,7 +193,7 @@ public static boolean isWarSideMainLeader( } for (net.tfminecraft.simplefactions.war.core.Participant par : side.getMainParticipants()) { if (par.getLeader() != null - && playerName.equalsIgnoreCase(par.getLeader().getLeader())) { + && memberKey.equals(par.getLeader().getLeader())) { return true; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupService.java index 65139363..b5dd4989 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.campaign.warband; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleJoinService; import java.time.Instant; import java.util.UUID; @@ -45,30 +46,30 @@ public static String signup(Player player, Warband warband, Faction playerFactio if (player == null || warband == null) { return "Invalid warband signup"; } - return signupMember(player.getUniqueId(), player.getName(), warband, playerFaction, player); + return signupMember(player.getUniqueId(), Members.key(player), warband, playerFaction, player); } - public static String signupMember(UUID playerId, String playerName, Warband warband, Faction playerFaction) { - return signupMember(playerId, playerName, warband, playerFaction, null); + public static String signupMember(UUID playerId, String memberKey, Warband warband, Faction playerFaction) { + return signupMember(playerId, memberKey, warband, playerFaction, null); } public static String signupMember( UUID playerId, - String playerName, + String memberKey, Warband warband, Faction playerFaction, Player onlinePlayer) { - return signupMember(playerId, playerName, warband, playerFaction, onlinePlayer, CampaignClock.now()); + return signupMember(playerId, memberKey, warband, playerFaction, onlinePlayer, CampaignClock.now()); } public static String signupMember( UUID playerId, - String playerName, + String memberKey, Warband warband, Faction playerFaction, Player onlinePlayer, Instant now) { - if (playerId == null || playerName == null || warband == null) { + if (playerId == null || memberKey == null || warband == null) { return "Invalid warband signup"; } if (onlinePlayer != null) { @@ -90,14 +91,14 @@ public static String signupMember( } if (ctx != null) { String joinError = CampaignBattleJoinService.validateWarbandMemberJoin( - ctx.war(), ctx.battle(), ctx.sideId(), warband, playerName, playerId); + ctx.war(), ctx.battle(), ctx.sideId(), warband, memberKey, playerId); if (joinError != null) { return joinError; } } boolean firstSignup = warband.getRealMemberCount() == 0; - applyLeaderRules(ctx, warband, playerId, playerName); + applyLeaderRules(ctx, warband, playerId, memberKey); warband.addMember(playerId); if (ctx != null && firstSignup) { @@ -115,12 +116,12 @@ private static void applyLeaderRules( CampaignBattleContext ctx, Warband warband, UUID playerId, - String playerName) { + String memberKey) { if (warband.isPendingLeader()) { warband.setLeaderId(playerId); return; } - if (ctx != null && CampaignWarbandBattleService.isWarSideMainLeader(ctx.war(), warband, playerName)) { + if (ctx != null && CampaignWarbandBattleService.isWarSideMainLeader(ctx.war(), warband, memberKey)) { warband.setLeaderId(playerId); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java index 22579bbc..a5d0e8b8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.military; +import net.tfminecraft.simplefactions.identity.Members; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -353,13 +354,13 @@ private static void broadcastLosses(War war, int attackerLosses, int defenderLos + defenderLosses + " regiments."; for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java index 5cfde1da..61047fd2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java @@ -157,7 +157,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§cOnly the leader can dismiss members!"); return true; } - Player kickTarget = Bukkit.getPlayerExact(args[1]); + Player kickTarget = onlineTarget(args[1]); if (kickTarget != null && kickTarget.getUniqueId().equals(w.getLeaderId())) { p.sendMessage("§cYou cannot dismiss the leader!"); return true; @@ -168,12 +168,8 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } w.removePlayer(kickTarget);; BattlePersistenceService.persistWarband(w); - p.sendMessage("§aKicked "+args[1]); - for(Player pl : Bukkit.getOnlinePlayers()) { - if(pl.getName().equalsIgnoreCase(args[1])) { - pl.sendMessage("§a"+p.getName()+ " kicked you from "+w.getId()); - } - } + p.sendMessage("§aKicked "+Members.name(kickTarget)); + kickTarget.sendMessage("§a"+Members.name(p)+ " kicked you from "+w.getId()); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { Warband w = WarbandManager.getByPlayer(p); @@ -185,7 +181,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§cOnly the leader can set a new leader!"); return true; } - Player newLeader = Bukkit.getPlayerExact(args[1]); + Player newLeader = onlineTarget(args[1]); if (newLeader == null || !w.hasMember(newLeader)) { p.sendMessage("§cPlayer is not in the warband"); return true; @@ -198,7 +194,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String BattlePersistenceService.persistWarband(w); for(Player pl : Bukkit.getOnlinePlayers()) { if (w.hasMember(pl)) { - pl.sendMessage("§a"+args[1]+ " is the new warband leader!"); + pl.sendMessage("§a"+Members.name(newLeader)+ " is the new warband leader!"); } } return true; @@ -212,7 +208,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§cOnly the leader can invite new members!"); return true; } - Player invitee = Bukkit.getPlayerExact(args[1]); + Player invitee = onlineTarget(args[1]); if (invitee == null) { p.sendMessage("§cPlayer must be online to receive an invitation"); return true; @@ -223,13 +219,9 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } w.invite(invitee); BattlePersistenceService.persistWarband(w); - p.sendMessage("§aInvited "+args[1]); - for(Player pl : Bukkit.getOnlinePlayers()) { - if(pl.getName().equalsIgnoreCase(args[1])) { - pl.sendMessage("§a"+p.getName()+ " invited you to "+w.getId()); - pl.sendMessage("§aType /warband join "+w.getId() +"§a to join"); - } - } + p.sendMessage("§aInvited "+Members.name(invitee)); + invitee.sendMessage("§a"+Members.name(p)+ " invited you to "+w.getId()); + invitee.sendMessage("§aType /warband join "+w.getId() +"§a to join"); return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("join") && args.length == 2) { if(WarbandManager.getByPlayer(p) != null) { @@ -522,6 +514,11 @@ private static Integer parseInteger(Player player, String value) { } } + /** An online player named by Minecraft or character name. */ + private static Player onlineTarget(String input) { + return Members.player(Members.findOnline(input)); + } + private void warnIfNotSiege(Player p, Battle b) { if (b.getBattleType() != BattleType.SIEGE) { p.sendMessage("§eWarning: battle type is not siege"); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java index 2d888bed..79865587 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.battle.warband; +import net.tfminecraft.simplefactions.identity.Members; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; @@ -223,7 +224,7 @@ public String getMemberDisplayName(UUID memberId) { } Player online = Bukkit.getPlayer(memberId); if (online != null && online.getName() != null) { - return online.getName(); + return Members.name(online); } OfflinePlayer offline = Bukkit.getOfflinePlayer(memberId); if (offline.getName() != null && !offline.getName().isBlank()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipListener.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipListener.java index 68d53311..167eba8a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipListener.java @@ -2,17 +2,19 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; + public class WarbandMembershipListener implements Listener { @EventHandler public void onQuit(PlayerQuitEvent e) { - WarbandMembershipService.getInstance().handleQuit(e.getPlayer().getUniqueId()); + WarbandMembershipService.getInstance().handleQuit(e.getPlayer()); } + /** Joining alone restores nothing; the place returns with the same character. */ @EventHandler - public void onJoin(PlayerJoinEvent e) { - WarbandMembershipService.getInstance().handleJoin(e.getPlayer()); + public void onSessionStart(CharacterSessionStartEvent e) { + WarbandMembershipService.getInstance().handleSessionStart(e.getPlayer(), e.getSession()); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java index 273cc0a6..998b57c3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java @@ -16,6 +16,8 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; public class WarbandMembershipService { private static WarbandMembershipService instance; @@ -34,21 +36,47 @@ public static void resetForTests() { } public void handleQuit(UUID playerId) { + handleQuit(playerId, getOnlinePlayer(playerId)); + } + + public void handleQuit(Player player) { + if (player != null) { + handleQuit(player.getUniqueId(), player); + } + } + + private void handleQuit(UUID playerId, Player player) { Warband warband = WarbandManager.getByMemberId(playerId); if (warband == null) { return; } - Faction faction = resolveFactionForWarband(warband, playerId); - detachFromBattle(playerId, warband); - pendingRejoin.put(playerId, new WarbandRejoinState(warband.getId(), faction)); + Faction faction = resolveFactionForWarband(warband, player); + // Only the character that left may take the place back. + CharacterRef character = Members.session(player).map(CharacterSession::ref).orElse(null); + detachFromBattle(player, warband); + if (character != null) { + pendingRejoin.put(playerId, new WarbandRejoinState(warband.getId(), faction, character)); + } warband.removeMember(playerId); } - public boolean handleJoin(Player player) { - return attemptRejoin(player.getUniqueId(), player.getName(), player); + /** Restores the place only when the character that quit starts playing again. */ + public boolean handleSessionStart(Player player, CharacterSession session) { + if (player == null || session == null) { + return false; + } + WarbandRejoinState state = pendingRejoin.get(player.getUniqueId()); + if (state == null) { + return false; + } + if (!state.isFor(session.ref())) { + pendingRejoin.remove(player.getUniqueId()); + return false; + } + return attemptRejoin(player.getUniqueId(), Members.key(player), player); } - boolean attemptRejoin(UUID playerId, String playerName, Player onlinePlayer) { + boolean attemptRejoin(UUID playerId, String memberKey, Player onlinePlayer) { WarbandRejoinState state = pendingRejoin.remove(playerId); if (state == null) { return false; @@ -57,8 +85,8 @@ boolean attemptRejoin(UUID playerId, String playerName, Player onlinePlayer) { if (warband == null || !state.belongsTo(warband)) { return false; } - Faction playerFaction = warband.isFaction() ? FactionManager.getByMember(playerName) : null; - if (!evaluateRejoin(warband, playerId, playerFaction, state)) { + Faction playerFaction = warband.isFaction() ? FactionManager.getByMember(memberKey) : null; + if (!evaluateRejoin(warband, playerId, memberKey, playerFaction, state)) { return false; } warband.addMember(playerId); @@ -76,7 +104,8 @@ public void clearPendingRejoin(UUID playerId) { pendingRejoin.remove(playerId); } - public boolean evaluateRejoin(Warband warband, UUID playerId, Faction playerFaction, WarbandRejoinState state) { + public boolean evaluateRejoin( + Warband warband, UUID playerId, String memberKey, Faction playerFaction, WarbandRejoinState state) { if (warband.isFaction()) { if (!state.hasFaction()) { return false; @@ -91,7 +120,7 @@ public boolean evaluateRejoin(Warband warband, UUID playerId, Faction playerFact CampaignBattleContext ctx = CampaignBattleJoinService.findCampaignBattleForWarband(warband); if (ctx != null && ctx.battle().hasStarted()) { return CampaignBattleJoinService.validateWarbandMemberJoin( - ctx.war(), ctx.battle(), ctx.sideId(), warband, playerNameFromId(playerId), playerId) == null; + ctx.war(), ctx.battle(), ctx.sideId(), warband, memberKey, playerId) == null; } if (ctx != null) { return CampaignBattleJoinService.validateRosterHasRoom( @@ -106,17 +135,6 @@ public boolean evaluateRejoin(Warband warband, UUID playerId, Faction playerFact return true; } - private String playerNameFromId(UUID playerId) { - Player player = getOnlinePlayer(playerId); - if (player != null && player.getName() != null) { - return player.getName(); - } - if (Bukkit.getOfflinePlayer(playerId).getName() != null) { - return Bukkit.getOfflinePlayer(playerId).getName(); - } - return null; - } - private Player getOnlinePlayer(UUID playerId) { if (Bukkit.getServer() == null) { return null; @@ -124,19 +142,14 @@ private Player getOnlinePlayer(UUID playerId) { return Bukkit.getPlayer(playerId); } - private Faction resolveFactionForWarband(Warband warband, UUID playerId) { - if (!warband.isFaction()) { - return null; - } - Player player = getOnlinePlayer(playerId); - if (player == null) { + private Faction resolveFactionForWarband(Warband warband, Player player) { + if (!warband.isFaction() || player == null) { return null; } return FactionManager.getByMember(Members.key(player)); } - private void detachFromBattle(UUID playerId, Warband warband) { - Player player = getOnlinePlayer(playerId); + private void detachFromBattle(Player player, Warband warband) { if (player == null) { return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java index 9d90c866..3499e664 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java @@ -1,18 +1,24 @@ package net.tfminecraft.simplefactions.war.battle.warband; -import java.util.UUID; - import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.character.CharacterRef; public final class WarbandRejoinState { private final String warbandId; private final String factionId; private final Warband originalWarband; + private final CharacterRef character; - public WarbandRejoinState(String warbandId, Faction faction) { + public WarbandRejoinState(String warbandId, Faction faction, CharacterRef character) { this.warbandId = warbandId; this.factionId = faction != null ? faction.getId() : null; this.originalWarband = WarbandManager.getByString(warbandId); + this.character = character; + } + + /** Whether the character that quit is the one now playing. */ + boolean isFor(CharacterRef ref) { + return character != null && character.equals(ref); } boolean belongsTo(Warband warband) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/AttackerNavalContestService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/AttackerNavalContestService.java index c10eceaa..d5a6a305 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/AttackerNavalContestService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/AttackerNavalContestService.java @@ -2,6 +2,7 @@ +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService.CampaignCoalition; import net.tfminecraft.simplefactions.war.campaign.progression.postbattle.CampaignPostBattleChoiceService; import java.util.Optional; @@ -157,13 +158,13 @@ private static void broadcastAutoLoss(War war) { } String message = CampaignUiCopy.navalAutoLossBroadcast(); for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/postbattle/CampaignOffensiveForfeitService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/postbattle/CampaignOffensiveForfeitService.java index 6e6c4742..bbb51280 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/postbattle/CampaignOffensiveForfeitService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/postbattle/CampaignOffensiveForfeitService.java @@ -2,6 +2,7 @@ +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService.CampaignCoalition; @@ -82,13 +83,13 @@ private static void broadcastForfeit( + winnerLabel + " wins by forfeit."; for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } } for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidJoinService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidJoinService.java index b0782747..f6b9618d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidJoinService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidJoinService.java @@ -77,7 +77,7 @@ public static List listJoinableRaidIds(Faction faction) { public static JoinResult join( War war, UUID playerId, - String playerName, + String memberKey, Faction faction, String raidId, Instant now) { @@ -111,7 +111,7 @@ public static JoinResult join( return JoinResult.REJECTED_MOUNTED_ON_VEHICLE; } raid.getMusterParticipantIds().add(playerKey); - CampaignRaidWarbandService.signupAttacker(war, raid, playerId, playerName); + CampaignRaidWarbandService.signupAttacker(war, raid, playerId, memberKey); return JoinResult.OK; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java index 339a680b..5ddd820a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidWarbandService.java @@ -10,9 +10,10 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; + import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -116,8 +117,8 @@ private static Warband raidWarband(String id, String sideId) { return band != null && band.isFaction() && sideId.equals(band.getCampaignSideId()) ? band : null; } - public static void signupAttacker(War war, CampaignRaid raid, UUID playerId, String playerName) { - if (war == null || raid == null || playerId == null || playerName == null) { + public static void signupAttacker(War war, CampaignRaid raid, UUID playerId, String memberKey) { + if (war == null || raid == null || playerId == null || memberKey == null) { return; } createRaidWarbands(war, raid); @@ -125,12 +126,12 @@ public static void signupAttacker(War war, CampaignRaid raid, UUID playerId, Str if (warband == null || warband.hasMember(playerId)) { return; } - applyRaidLeaderRules(war, warband, playerId, playerName); + applyRaidLeaderRules(war, warband, playerId, memberKey); warband.addMember(playerId); } - public static void signupDefender(War war, CampaignRaid raid, UUID playerId, String playerName) { - if (war == null || raid == null || playerId == null || playerName == null) { + public static void signupDefender(War war, CampaignRaid raid, UUID playerId, String memberKey) { + if (war == null || raid == null || playerId == null || memberKey == null) { return; } createRaidWarbands(war, raid); @@ -138,7 +139,7 @@ public static void signupDefender(War war, CampaignRaid raid, UUID playerId, Str if (warband == null || warband.hasMember(playerId)) { return; } - applyRaidLeaderRules(war, warband, playerId, playerName); + applyRaidLeaderRules(war, warband, playerId, memberKey); warband.addMember(playerId); } @@ -154,14 +155,14 @@ public static void enrollOnlineDefenders(War war, CampaignRaid raid) { return; } for (String memberName : BattleSideMembers.collectEligibleMemberNames(defenderSide)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player == null || !player.isOnline()) { continue; } if (WarbandManager.getByMemberId(player.getUniqueId()) != null) { continue; } - signupDefender(war, raid, player.getUniqueId(), player.getName()); + signupDefender(war, raid, player.getUniqueId(), memberName); } } @@ -191,7 +192,7 @@ public static void tryEnrollDefenderOnLogin(Player player) { if (playerCoalition != defendingCoalition) { continue; } - signupDefender(war, raid, player.getUniqueId(), player.getName()); + signupDefender(war, raid, player.getUniqueId(), Members.key(player)); return; } } @@ -239,12 +240,12 @@ private static void ensureWarband(War war, CampaignRaid raid, boolean attacker) WarbandManager.addWarband(warband); } - private static void applyRaidLeaderRules(War war, Warband warband, UUID playerId, String playerName) { + private static void applyRaidLeaderRules(War war, Warband warband, UUID playerId, String memberKey) { if (warband.isPendingLeader()) { warband.setLeaderId(playerId); return; } - if (CampaignWarbandBattleService.isWarSideMainLeader(war, warband, playerName)) { + if (CampaignWarbandBattleService.isWarSideMainLeader(war, warband, memberKey)) { warband.setLeaderId(playerId); } } @@ -271,8 +272,9 @@ private static void deleteIfPresent(Warband warband) { public static final class Listener implements org.bukkit.event.Listener { private final Map pendingLeaderPromotion = new ConcurrentHashMap<>(); + /** Defenders enrol once their character is playing, not on login. */ @EventHandler - public void onJoin(PlayerJoinEvent event) { + public void onSessionStart(CharacterSessionStartEvent event) { CampaignRaidWarbandService.tryEnrollDefenderOnLogin(event.getPlayer()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java index 7807643e..42a081cb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/RaidCommandManager.java @@ -47,7 +47,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } JoinResult result = CampaignRaidJoinService.join( - war, player.getUniqueId(), player.getName(), faction, args[1], CampaignClock.now()); + war, player.getUniqueId(), Members.key(player), faction, args[1], CampaignClock.now()); if (result != JoinResult.OK) { String message = CampaignRaidMessages.messageForJoinResult(result); if (message != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleEndService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleEndService.java index ec0c6751..f2e167cb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleEndService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleEndService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid.fight; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaid; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidService; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidMessages; @@ -65,7 +66,7 @@ private static void broadcastToSide(Side side, String message) { return; } for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleService.java index a3c7ad6f..746d3c1d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidBattleService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid.fight; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaid; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidService; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidWarbandService; @@ -241,7 +242,7 @@ private static void alertDefenders(War war, CampaignRaid raid, Installation targ : war.getDefenders(); String targetName = target.getName(); for (String memberName : BattleSideMembers.collectEligibleMemberNames(defendingSide)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendTitle("§cRAID INCOMING", "§eDefend " + targetName, 10, 120, 10); player.playSound(player, Sound.ITEM_GOAT_HORN_SOUND_2, SoundCategory.MASTER, 10f, 0.6f); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidLaunchService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidLaunchService.java index b8308ea8..1234241b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidLaunchService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidLaunchService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid.fight; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaid; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidService; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidResults; @@ -62,7 +63,7 @@ private static void broadcastRaidStarted(War war, CampaignRaid raid) { private static void broadcastToSide(Side side, String message) { for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidMusterReminderService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidMusterReminderService.java index 1b26accd..96556128 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidMusterReminderService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/fight/CampaignRaidMusterReminderService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid.fight; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaid; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidService; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidState; @@ -186,7 +187,7 @@ private static void broadcastToAttackerCoalition(War war, CampaignRaid raid, Str ? war.getAttackers() : war.getDefenders(); for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderService.java index 9c1ac179..e72625b0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/raid/intruder/CampaignRaidIntruderService.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.war.campaign.raid.intruder; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaid; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidService; import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidState; @@ -64,7 +65,7 @@ public static void onProvinceEnter(Player player, int provinceId) { if (raid == null) { continue; } - if (!shouldPenalize(war, raid, player.getUniqueId(), player.getName(), provinceId)) { + if (!shouldPenalize(war, raid, player.getUniqueId(), Members.key(player), provinceId)) { continue; } warnOnEnter(player, raid); @@ -80,7 +81,7 @@ public static void processTick() { } for (Player player : Bukkit.getOnlinePlayers()) { int provinceId = ProvincePresenceService.getInstance().getCurrentProvince(player.getUniqueId()); - if (!shouldPenalize(war, raid, player.getUniqueId(), player.getName(), provinceId)) { + if (!shouldPenalize(war, raid, player.getUniqueId(), Members.key(player), provinceId)) { continue; } player.sendMessage(CampaignRaidMessages.INTRUDER); @@ -93,9 +94,9 @@ static boolean shouldPenalize( War war, CampaignRaid raid, UUID playerId, - String playerName, + String memberKey, int playerProvinceId) { - if (war == null || raid == null || playerId == null || playerName == null) { + if (war == null || raid == null || playerId == null || memberKey == null) { return false; } if (raid.getState() != CampaignRaidState.FIGHTING) { @@ -108,7 +109,7 @@ static boolean shouldPenalize( if (target == null || playerProvinceId != target.getProvince()) { return false; } - Faction faction = FactionManager.getByMember(playerName); + Faction faction = FactionManager.getByMember(memberKey); CampaignCoalition coalition = CampaignRaidService.coalitionForFaction(war, faction); if (coalition == null || coalition != raid.getAttackerCoalition()) { return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java index 8da5b940..cb5d6ea0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java @@ -86,7 +86,7 @@ public static SendResult sendProposeRequest(Player proposer, War war, Belligeren Faction opposingLeaderFaction = opposingLeaderFaction(war, side); - Player target = Bukkit.getPlayerExact(opposingLeaderFaction.getLeader()); + Player target = Members.player(opposingLeaderFaction.getLeader()); if (target == null || !target.isOnline()) { return SendResult.OPPOSING_LEADER_OFFLINE; } @@ -135,7 +135,7 @@ public static void acceptRequest(Player acceptor) { Faction proposerFaction = req.getFaction(); if (proposerFaction != null) { - Player proposer = Bukkit.getPlayerExact(proposerFaction.getLeader()); + Player proposer = Members.player(proposerFaction.getLeader()); if (proposer != null && proposer.isOnline()) { proposer.sendMessage(acceptorFaction.getName() + " §aaccepted autoresolve"); } @@ -331,7 +331,7 @@ private static String winnerLabel(War war, CampaignCoalition winner) { private static void sendToSide(Side side, String message) { for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player player = Bukkit.getPlayerExact(memberName); + Player player = Members.player(memberName); if (player != null && player.isOnline()) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java index 3442f2d7..e6236d0f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleLookups.java @@ -47,17 +47,16 @@ public static Function uuidToFactionForWar(War war) { }; } - // Existing configuration identifies offline profiles by player name, not UUID. - @SuppressWarnings("deprecation") + /** Battle votes stay keyed by account, so a member key maps to the account that plays it. */ public static Function memberNameToUuid() { - return name -> { - if (name == null || name.isBlank()) { + return key -> { + if (key == null || key.isBlank()) { return null; } if (Bukkit.getServer() == null) { - return spoofMemberUuid(name); + return spoofMemberUuid(key); } - return Bukkit.getOfflinePlayer(name).getUniqueId(); + return Members.accountId(key).orElse(null); }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/pick/BattleInstallationPickService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/pick/BattleInstallationPickService.java index 0df02798..e020d5f2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/pick/BattleInstallationPickService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/pick/BattleInstallationPickService.java @@ -35,15 +35,15 @@ private BattleInstallationPickService() {} public static InstallationPickToggleResult togglePick( War war, Faction faction, - String playerName, + String memberKey, String installationId) { - return togglePick(war, faction, playerName, installationId, CampaignClock.now()); + return togglePick(war, faction, memberKey, installationId, CampaignClock.now()); } public static InstallationPickToggleResult togglePick( War war, Faction faction, - String playerName, + String memberKey, String installationId, Instant now) { if (war == null || !war.isActive()) { @@ -52,7 +52,7 @@ public static InstallationPickToggleResult togglePick( if (faction == null || war.getSide(faction) == null) { return InstallationPickToggleResult.REJECTED_NOT_PARTICIPANT; } - if (playerName == null || !faction.isLeader(playerName)) { + if (memberKey == null || !faction.isLeader(memberKey)) { return InstallationPickToggleResult.REJECTED_NOT_LEADER; } if (installationId == null || installationId.isBlank()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarStartService.java b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarStartService.java index a82e5599..9b639c83 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarStartService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarStartService.java @@ -393,7 +393,7 @@ private static void moveToRebelMain(Faction host, Faction rebels, String player, private static void recordMemberMove(AppliedStart applied, CivilWarMemberMove move) { for (CivilWarMemberMove existing : applied.memberMoves) { - if (existing != null && move.player().equalsIgnoreCase(existing.player())) { + if (existing != null && move.player().equals(existing.player())) { return; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarTempRebelFactory.java b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarTempRebelFactory.java index 996f14dd..99b3b495 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarTempRebelFactory.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarTempRebelFactory.java @@ -13,12 +13,12 @@ public final class CivilWarTempRebelFactory { private CivilWarTempRebelFactory() {} - public static Faction create(Faction host, String leaderName) { - if (host == null || leaderName == null) { + public static Faction create(Faction host, String leaderKey) { + if (host == null || leaderKey == null) { return null; } String id = uniqueId(host); - Faction rebels = new Faction(id, leaderName); + Faction rebels = new Faction(id, leaderKey); applyRebelIdentity(rebels, host); FactionManager.addFaction(rebels); return rebels; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarUntangleService.java b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarUntangleService.java index 88b471d0..8efc0380 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarUntangleService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarUntangleService.java @@ -154,7 +154,7 @@ private static void restoreMembers(Faction host, CivilWarSnapshot snapshot, WarE } boolean wantedLeader = attackerWin && wanted != null - && wanted.equalsIgnoreCase(move.player()); + && wanted.equals(move.player()); host.getGuildHandler().forceKick(move.player()); if (wantedLeader) { host.getOrCreateMainGuild().addMember(move.player()); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java index 957ae6b9..b7104579 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.freeze; +import net.tfminecraft.simplefactions.identity.Members; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collection; @@ -101,12 +102,12 @@ public static boolean isFrozen(MercenaryCompany company) { } /** Freeze covering a player's own projects, through their faction or their company. */ - public static Instant frozenUntil(String playerName, Instant now) { - if (playerName == null || !anyWarFrozen(now)) { + public static Instant frozenUntil(String memberKey, Instant now) { + if (memberKey == null || !anyWarFrozen(now)) { return null; } - Instant latest = frozenUntil(FactionManager.getByMember(playerName), now); - return later(latest, frozenUntil(MercenaryCompanies.findByMember(playerName), now)); + Instant latest = frozenUntil(FactionManager.getByMember(memberKey), now); + return later(latest, frozenUntil(MercenaryCompanies.findByMember(memberKey), now)); } /** Refusal for a hire by this faction, or null when hiring is open. */ @@ -173,7 +174,7 @@ private static void notifyMembers(War war, long hours) { String message = "§eThe battle was postponed. For the next " + hours + "h your regiments, installations, " + "war upgrades, mercenary companies and vehicle projects are frozen, and no mercenaries can be hired."; for (String member : members) { - Player player = Bukkit.getPlayerExact(member); + Player player = Members.player(member); if (player != null) { player.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java index daaec6c7..34d67800 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -4,10 +4,12 @@ import java.util.UUID; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.Characters; import net.tfminecraft.vfbuilders.api.ConstructionFreeze; /** Holds VFBuilders projects started by players whose battle was postponed. */ @@ -25,12 +27,20 @@ public static void register(Plugin plugin) { @Override public String freezeReason(UUID constructorUuid) { - OfflinePlayer player = Bukkit.getOfflinePlayer(constructorUuid); Instant now = Instant.now(); - Instant until = PreparationFreeze.frozenUntil(player.getName(), now); + Instant until = PreparationFreeze.frozenUntil(constructorKey(constructorUuid), now); if (until == null) { return null; } return "battle postponed (" + PreparationFreeze.formatRemaining(until, now) + ")"; } + + /** Projects belong to accounts: the character playing now, else the one played last. */ + static String constructorKey(UUID constructorUuid) { + Player online = Bukkit.getPlayer(constructorUuid); + if (online != null) { + return Members.key(online); + } + return Characters.lastPlayed(constructorUuid).map(info -> Members.key(info.ref())).orElse(null); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java index 5e66a625..0ef35f07 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java @@ -194,7 +194,7 @@ void campaignRejoin_allowedWhenRosterHasRoom() { BattleManager.addBattle(battle); WarbandManager.addWarband(warband); - WarbandRejoinState state = new WarbandRejoinState(warband.getId(), attacker); + WarbandRejoinState state = new WarbandRejoinState(warband.getId(), attacker, null); WarbandMembershipService service = WarbandMembershipService.getInstance(); try (MockedStatic factions = mockStatic(FactionManager.class); @@ -207,7 +207,7 @@ void campaignRejoin_allowedWhenRosterHasRoom() { .thenReturn(5); bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenReturn(null); - assertTrue(service.evaluateRejoin(warband, leaderId, attacker, state)); + assertTrue(service.evaluateRejoin(warband, leaderId, "leader", attacker, state)); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java index 32b2d53e..fdf42450 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java @@ -25,6 +25,7 @@ import org.bukkit.OfflinePlayer; import org.bukkit.event.Event; import org.bukkit.event.player.PlayerJoinEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; @@ -100,7 +101,7 @@ void unfinishedAndOrdinaryFieldBattlesCannotBeEndedByTheRaidRule() { } @Test - void quitAndJoinEventsRestoreAnOpenManualRosterWithoutDuplicatingMembers() { + void quitAndSessionStartEventsRestoreAnOpenManualRosterWithoutDuplicatingMembers() { Warband band = new Warband("event_rejoin", rig.alice); band.setLocked(false); WarbandManager.addWarband(band); @@ -108,11 +109,20 @@ void quitAndJoinEventsRestoreAnOpenManualRosterWithoutDuplicatingMembers() { listener.onQuit(new PlayerQuitEvent(rig.alice, "left")); assertFalse(band.hasMember(rig.alice)); assertNotNull(WarbandMembershipService.getInstance().getPendingRejoin(rig.alice.getUniqueId())); - listener.onJoin(new PlayerJoinEvent(rig.alice, "joined")); + // Logging in alone restores nothing: the listener only answers character sessions. + assertTrue( + java.util.Arrays.stream(WarbandMembershipListener.class.getDeclaredMethods()) + .noneMatch( + method -> + method.getParameterCount() == 1 + && method.getParameterTypes()[0] == PlayerJoinEvent.class)); + assertFalse(band.hasMember(rig.alice)); + var session = net.tfminecraft.simplefactions.identity.Members.session(rig.alice).orElseThrow(); + listener.onSessionStart(new CharacterSessionStartEvent(rig.alice, session)); assertTrue(band.hasMember(rig.alice)); assertEquals(1, band.getMemberCount()); assertNull(WarbandMembershipService.getInstance().getPendingRejoin(rig.alice.getUniqueId())); - listener.onJoin(new PlayerJoinEvent(rig.alice, "joined")); + listener.onSessionStart(new CharacterSessionStartEvent(rig.alice, session)); assertEquals(1, band.getMemberCount()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java index 55501026..4ac05f65 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java @@ -41,7 +41,6 @@ import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.OfflinePlayer; import org.bukkit.boss.BossBar; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; @@ -75,6 +74,10 @@ void setup() throws Exception { membership = WarbandMembershipService.getInstance(); } + private static net.tfminecraft.tlibs.character.CharacterSession session(Player player) { + return net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow(); + } + @AfterEach void cleanup() throws Exception { if (rig != null) rig.close(); @@ -97,11 +100,11 @@ void quitDetachesThePlayerAndJoiningRestoresTheSameOpenRosterAndBossBar() { assertFalse(life.getPlayers().contains(rig.alice)); assertEquals(band.getId(), membership.getPendingRejoin(rig.alice.getUniqueId()).getWarbandId()); assertFalse(membership.getPendingRejoin(rig.alice.getUniqueId()).hasFaction()); - assertTrue(membership.handleJoin(rig.alice)); + assertTrue(membership.handleSessionStart(rig.alice, session(rig.alice))); assertTrue(band.hasMember(rig.alice)); assertTrue(life.getPlayers().contains(rig.alice)); assertNull(membership.getPendingRejoin(rig.alice.getUniqueId())); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); } @Test @@ -111,7 +114,7 @@ void aDeletedWarbandCannotTransferItsPendingMemberIntoAnUnrelatedReplacement() { WarbandManager.deleteWarband(original); Warband replacement = openBand("reused_name", rig.bob); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertEquals(Set.of(rig.bob.getUniqueId()), new HashSet<>(replacement.getMemberIds())); assertNull(membership.getPendingRejoin(rig.alice.getUniqueId())); } @@ -123,12 +126,12 @@ void lockedRejoinRequiresAStillValidInvitation() { band.invite(rig.alice); band.setLocked(true); membership.handleQuit(rig.alice.getUniqueId()); - assertTrue(membership.handleJoin(rig.alice)); + assertTrue(membership.handleSessionStart(rig.alice, session(rig.alice))); assertTrue(band.hasMember(rig.alice)); membership.handleQuit(rig.alice.getUniqueId()); band.uninvite(rig.alice); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertFalse(band.hasMember(rig.alice)); assertTrue(band.hasMember(rig.bob)); } @@ -139,11 +142,11 @@ void deletedOrExplicitlyClearedRejoinStateDoesNotCreateMembership() { Warband band = openBand("deleted", rig.alice); membership.handleQuit(rig.alice.getUniqueId()); WarbandManager.deleteWarband(band); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); Warband another = openBand("cleared", rig.alice); membership.handleQuit(rig.alice.getUniqueId()); membership.clearPendingRejoin(rig.alice.getUniqueId()); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertFalse(another.hasMember(rig.alice)); } @@ -161,7 +164,7 @@ void membershipSingletonStartsEmptyAndResetDiscardsOnlyItsPendingSessions() thro assertNotSame(fresh, WarbandMembershipService.getInstance()); assertNull(WarbandMembershipService.getInstance().getPendingRejoin(rig.alice.getUniqueId())); assertFalse(band.hasMember(rig.alice)); - assertTrue(fresh.handleJoin(rig.alice)); + assertTrue(fresh.handleSessionStart(rig.alice, session(rig.alice))); assertTrue(band.hasMember(rig.alice)); } @@ -180,7 +183,7 @@ void quittingAnUnassignedWarbandLeavesOtherBattleRostersAndBarsUntouched() { assertTrue(otherBattle.hasStarted()); assertTrue(other.hasMember(other.getLeaderId())); for (BossBar bar : rig.bars) verify(bar, never()).removePlayer(rig.alice); - assertTrue(membership.handleJoin(rig.alice)); + assertTrue(membership.handleSessionStart(rig.alice, session(rig.alice))); assertNull(BattleManager.getBattleByMemberId(rig.alice.getUniqueId())); } @@ -229,7 +232,7 @@ void campaignMembersRejoinTheSameFactionBeforeAndDuringBattle(boolean started) { membership.handleQuit(rig.alice.getUniqueId()); assertEquals( rig.attacker.getId(), membership.getPendingRejoin(rig.alice.getUniqueId()).getFactionId()); - assertTrue(membership.handleJoin(rig.alice)); + assertTrue(membership.handleSessionStart(rig.alice, session(rig.alice))); assertTrue(band.hasMember(rig.alice)); } @@ -239,25 +242,22 @@ void pendingFactionRosterRejectsAFullSideAndADeletedFaction() { Battle battle = battle("limited_battle", band, true); membership.handleQuit(rig.alice.getUniqueId()); for (int i = 0; i < 4; i++) band.addMember(UUID.randomUUID()); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertFalse(band.hasMember(rig.alice)); band.addPlayer(rig.alice); membership.handleQuit(rig.alice.getUniqueId()); FactionManager.factions.remove(rig.attacker); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertFalse(band.hasMember(rig.alice)); assertFalse(battle.hasStarted()); } @Test - void factionRejoinUsesSavedOfflineNameWhenTheOnlineLookupHasGoneAway() { + void factionRejoinUsesTheCapturedMemberKeyWhenTheOnlineLookupHasGoneAway() { Warband band = factionBand("offline_name"); Battle battle = battle("offline_name_battle", band, true); assertNull(battle.start()); membership.handleQuit(rig.alice.getUniqueId()); - OfflinePlayer profile = mock(OfflinePlayer.class); - when(profile.getName()).thenReturn("Alice"); - when(Bukkit.getOfflinePlayer(rig.alice.getUniqueId())).thenReturn(profile); when(Bukkit.getPlayer(rig.alice.getUniqueId())).thenReturn(null); assertTrue(membership.attemptRejoin(rig.alice.getUniqueId(), "Alice", null)); assertTrue(band.hasMember(rig.alice)); @@ -266,41 +266,41 @@ void factionRejoinUsesSavedOfflineNameWhenTheOnlineLookupHasGoneAway() { membership.evaluateRejoin( band, rig.alice.getUniqueId(), + "Alice", rig.defender, - new WarbandRejoinState(band.getId(), rig.attacker))); + new WarbandRejoinState(band.getId(), rig.attacker, null))); } @Test - void factionMembershipChangesAndAnUnavailableOfflineNameRefuseRejoin() { + void factionMembershipChangesAndAMissingMemberKeyRefuseRejoin() { Warband band = factionBand("changed_membership"); Player member = rig.player("Member"); rig.attacker.addMember("Member"); band.addPlayer(member); membership.handleQuit(member.getUniqueId()); rig.attacker.forceRemoveMember("Member"); - assertFalse(membership.handleJoin(member)); + assertFalse(membership.handleSessionStart(member, session(member))); assertFalse(band.hasMember(member)); assertTrue(band.hasMember(rig.alice)); membership.handleQuit(rig.alice.getUniqueId()); assertTrue( - membership.handleJoin(rig.alice), "An unchanged faction can rejoin a standalone band"); + membership.handleSessionStart(rig.alice, session(rig.alice)), "An unchanged faction can rejoin a standalone band"); Battle battle = battle("unknown_profile_battle", band, true); assertNull(battle.start()); membership.handleQuit(rig.alice.getUniqueId()); when(Bukkit.getPlayer(rig.alice.getUniqueId())).thenReturn(null); - when(Bukkit.getOfflinePlayer(rig.alice.getUniqueId())).thenReturn(mock(OfflinePlayer.class)); - assertFalse(membership.attemptRejoin(rig.alice.getUniqueId(), "Alice", null)); + assertFalse(membership.attemptRejoin(rig.alice.getUniqueId(), null, null)); assertFalse(band.hasMember(rig.alice)); } @Test - void quittingWithoutAnOnlineProfileDoesNotInventARejoinFaction() { + void quittingWithoutAnOnlineCharacterLeavesNoRejoinState() { Warband band = factionBand("offline_quit"); rig.domain.online.remove("Alice"); membership.handleQuit(rig.alice.getUniqueId()); - assertFalse(membership.getPendingRejoin(rig.alice.getUniqueId()).hasFaction()); + assertNull(membership.getPendingRejoin(rig.alice.getUniqueId())); rig.domain.online.put("Alice", rig.alice); - assertFalse(membership.handleJoin(rig.alice)); + assertFalse(membership.handleSessionStart(rig.alice, session(rig.alice))); assertFalse(band.hasMember(rig.alice)); } @@ -365,7 +365,7 @@ void raidMusterBlocksVehicleEntryAndTheStartedFightReleasesIt() { assertTrue(defenders.hasMember(rig.bob)); membership.handleQuit(rig.bob.getUniqueId()); rig.bars.forEach(bar -> clearInvocations(bar)); - assertTrue(membership.handleJoin(rig.bob)); + assertTrue(membership.handleSessionStart(rig.bob, session(rig.bob))); assertTrue(defenders.hasMember(rig.bob)); for (BossBar bar : rig.bars) verify(bar, never()).addPlayer(rig.bob); fighting.end(); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipServiceTest.java index c06e080f..ec6e8b3b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipServiceTest.java @@ -11,10 +11,14 @@ import java.lang.reflect.Field; import java.util.UUID; +import org.bukkit.entity.Player; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; @@ -22,6 +26,7 @@ class WarbandMembershipServiceTest { private UUID leaderId; private UUID memberId; private WarbandMembershipService service; + private Player member; @BeforeEach void setUp() { @@ -30,13 +35,16 @@ void setUp() { leaderId = UUID.randomUUID(); memberId = UUID.randomUUID(); service = WarbandMembershipService.getInstance(); + member = mock(Player.class); + when(member.getUniqueId()).thenReturn(memberId); + when(member.getName()).thenReturn("member"); } @Test void quit_savesRejoinStateAndRemovesMember() { Warband warband = createOpenWarband("alpha", leaderId, memberId); - service.handleQuit(memberId); + service.handleQuit(member); assertNull(WarbandManager.getByMemberId(memberId)); WarbandRejoinState state = service.getPendingRejoin(memberId); @@ -49,7 +57,7 @@ void quit_savesRejoinStateAndRemovesMember() { @Test void join_restoresWhenWarbandStillOpen() { createOpenWarband("alpha", leaderId, memberId); - service.handleQuit(memberId); + service.handleQuit(member); assertTrue(service.attemptRejoin(memberId, "member", null)); assertNull(service.getPendingRejoin(memberId)); @@ -59,7 +67,7 @@ void join_restoresWhenWarbandStillOpen() { @Test void join_skipsWhenWarbandDeleted() { Warband warband = createOpenWarband("alpha", leaderId, memberId); - service.handleQuit(memberId); + service.handleQuit(member); WarbandManager.deleteWarband(warband); assertFalse(service.attemptRejoin(memberId, "member", null)); @@ -76,9 +84,63 @@ void evaluateRejoin_factionMismatchBlocksRejoin() throws Exception { Warband warband = createOpenWarband("alpha", leaderId, memberId); markAsFaction(warband); - WarbandRejoinState state = new WarbandRejoinState("alpha", slotFaction); + WarbandRejoinState state = new WarbandRejoinState("alpha", slotFaction, null); + + assertFalse(service.evaluateRejoin(warband, memberId, "member", otherFaction, state)); + } + + @Test + void rejoinNeedsPendingStateAndAFactionCapturedForAFactionWarband() throws Exception { + Warband warband = createOpenWarband("alpha", leaderId, memberId); + markAsFaction(warband); + + assertFalse(service.attemptRejoin(memberId, "member", null)); + assertFalse(service.evaluateRejoin( + warband, memberId, "member", null, new WarbandRejoinState("alpha", null, null))); + } + + @Test + void quitWithoutACharacterLeavesNoRejoinState() { + Warband warband = createOpenWarband("alpha", leaderId, memberId); + + service.handleQuit((Player) null); + assertTrue(warband.hasMember(memberId)); + service.handleQuit(memberId); + + assertFalse(warband.hasMember(memberId)); + assertNull(service.getPendingRejoin(memberId)); + } + + @Test + void sessionStart_restoresTheSameCharacter() { + Warband warband = createOpenWarband("alpha", leaderId, memberId); + service.handleQuit(member); + + assertTrue(service.handleSessionStart(member, Members.session(member).orElseThrow())); + assertTrue(warband.hasMember(memberId)); + assertNull(service.getPendingRejoin(memberId)); + } + + @Test + void sessionStart_dropsTheStateForAnotherCharacterOnTheSameAccount() { + Warband warband = createOpenWarband("alpha", leaderId, memberId); + service.handleQuit(member); + CharacterSession other = new CharacterSession(new CharacterRef(memberId, UUID.randomUUID()), 2L); + + assertFalse(service.handleSessionStart(member, other)); + assertNull(service.getPendingRejoin(memberId)); + assertFalse(warband.hasMember(memberId)); + assertFalse(service.handleSessionStart(member, Members.session(member).orElseThrow())); + assertFalse(warband.hasMember(memberId)); + } + + @Test + void sessionStart_ignoresMissingArgumentsAndPlayersWithNothingPending() { + CharacterSession session = Members.session(member).orElseThrow(); - assertFalse(service.evaluateRejoin(warband, memberId, otherFaction, state)); + assertFalse(service.handleSessionStart(null, session)); + assertFalse(service.handleSessionStart(member, null)); + assertFalse(service.handleSessionStart(member, session)); } // Exercise the retained compatibility entry point so legacy behavior stays covered. diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidLifecycleCoverageTest.java index dd9d42ac..203626b5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/raid/CampaignRaidLifecycleCoverageTest.java @@ -483,9 +483,9 @@ void defenderLoginEnrollsOnlyAnEligibleFreeDefenderDuringTheFight() { Warband existing = new Warband("lifecycle_existing", reserved); WarbandManager.addWarband(existing); var listener = new CampaignRaidWarbandService.Listener(); - listener.onJoin( - new org.bukkit.event.player.PlayerJoinEvent( - late, net.kyori.adventure.text.Component.empty())); + listener.onSessionStart( + new net.tfminecraft.tlibs.character.CharacterSessionStartEvent( + late, net.tfminecraft.simplefactions.identity.Members.session(late).orElseThrow())); assertNotNull(CampaignRaidWarbandService.getDefenderWarband(raid)); assertEquals(0, CampaignRaidWarbandService.getDefenderWarband(raid).getRealMemberCount()); CampaignRaidLaunchService.startFight(rig.war, raid.getMusterEndsAt()); @@ -493,9 +493,9 @@ void defenderLoginEnrollsOnlyAnEligibleFreeDefenderDuringTheFight() { assertFalse(defenders.hasMember(late)); assertFalse(defenders.hasMember(reserved)); when(late.isOnline()).thenReturn(true); - listener.onJoin( - new org.bukkit.event.player.PlayerJoinEvent( - late, net.kyori.adventure.text.Component.empty())); + listener.onSessionStart( + new net.tfminecraft.tlibs.character.CharacterSessionStartEvent( + late, net.tfminecraft.simplefactions.identity.Members.session(late).orElseThrow())); assertTrue(defenders.hasMember(late)); assertSame(existing, WarbandManager.getByPlayer(reserved)); CampaignRaidWarbandService.tryEnrollDefenderOnLogin(rig.alice); @@ -1595,9 +1595,9 @@ void reverseRaidLeaderAuthorityAndLateLoginFollowTheWarbandCoalition(boolean sch when(rig.alice.isOnline()).thenReturn(true); new CampaignRaidWarbandService.Listener() - .onJoin( - new org.bukkit.event.player.PlayerJoinEvent( - rig.alice, net.kyori.adventure.text.Component.empty())); + .onSessionStart( + new net.tfminecraft.tlibs.character.CharacterSessionStartEvent( + rig.alice, net.tfminecraft.simplefactions.identity.Members.session(rig.alice).orElseThrow())); assertAll( () -> assertTrue(CampaignWarbandBattleService.isWarSideMainLeader(rig.war, raiders, "Bob")), diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/schedule/CampaignScheduleLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/schedule/CampaignScheduleLifecycleCoverageTest.java index 12cad4af..9955dc9f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/schedule/CampaignScheduleLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/schedule/CampaignScheduleLifecycleCoverageTest.java @@ -422,6 +422,48 @@ void trimmerPreservesRequiredFightsAndBorderWhileRemovingOptionalKindsFromEachLe CampaignScheduleTrimmer.maxBattlesForGoal(null)); } + /** Name-keyed members, plus one offline character the provider knows. */ + private static void knowCharacter(String key, UUID account) { + var names = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.INSTANCE; + net.tfminecraft.simplefactions.identity.Members.setResolver( + new net.tfminecraft.simplefactions.identity.Members.Resolver() { + @Override + public java.util.Optional session( + org.bukkit.entity.Player player) { + return names.session(player); + } + + @Override + public boolean isCurrent(net.tfminecraft.tlibs.character.CharacterSession session) { + return true; + } + + @Override + public String key(org.bukkit.entity.Player player) { + return names.key(player); + } + + @Override + public java.util.Optional info(String other) { + if (!key.equals(other)) return java.util.Optional.empty(); + return java.util.Optional.of( + new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(account, UUID.fromString(key)), + "Offline", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, + null, + Set.of(), + 0, + Instant.EPOCH)); + } + + @Override + public java.util.Optional online(String other) { + return names.online(other); + } + }); + } + @Test void rosterLookupsResolveOnlineOfflineAndFallbackIdentitiesWithoutInventingMembership() { var live = BattleScheduleLookups.uuidToFaction(); @@ -439,9 +481,13 @@ void rosterLookupsResolveOnlineOfflineAndFallbackIdentitiesWithoutInventingMembe var carol = rig.player("Carol"); assertSame( outsider, BattleScheduleLookups.uuidToFactionForWar(rig.war).apply(carol.getUniqueId())); - rig.attacker.getOrCreateMainGuild().addMember("Offline"); - UUID offline = BattleScheduleLookups.memberNameToUuid().apply("Offline"); - assertSame(rig.attacker, BattleScheduleLookups.uuidToFactionForWar(rig.war).apply(offline)); + UUID account = UUID.randomUUID(); + String offlineKey = UUID.randomUUID().toString(); + knowCharacter(offlineKey, account); + rig.attacker.getOrCreateMainGuild().addMember(offlineKey); + assertEquals(account, BattleScheduleLookups.memberNameToUuid().apply(offlineKey)); + assertNull(BattleScheduleLookups.memberNameToUuid().apply("Unknown")); + assertSame(rig.attacker, BattleScheduleLookups.uuidToFactionForWar(rig.war).apply(account)); when(Bukkit.getServer()).thenReturn(null); assertEquals( BattleScheduleLookups.spoofMemberUuid("Offline"), @@ -478,7 +524,7 @@ void lifecycleTransitionsClearPriorTargetsAndPreserveVoteMembershipAndCalendarRu BattleScheduleService.castSpoofVotes( rig.war, 21, - BattleScheduleLookups.memberNameToUuid(), + BattleScheduleLookups.spoofMemberNameToUuid(), BelligerentRole.ATTACKER, BelligerentRole.DEFENDER); assertEquals(2, added); @@ -487,7 +533,7 @@ void lifecycleTransitionsClearPriorTargetsAndPreserveVoteMembershipAndCalendarRu BattleScheduleService.castSpoofVotes( rig.war, 21, - BattleScheduleLookups.memberNameToUuid(), + BattleScheduleLookups.spoofMemberNameToUuid(), BelligerentRole.ATTACKER, BelligerentRole.DEFENDER)); assertEquals( diff --git a/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java new file mode 100644 index 00000000..39b62305 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java @@ -0,0 +1,77 @@ +package net.tfminecraft.simplefactions.war.freeze; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicesManager; +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.identity.Members; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; + +class VfBuildersConstructionFreezeTest { + private final UUID account = UUID.randomUUID(); + private final ServicesManager services = mock(ServicesManager.class); + private MockedStatic bukkit; + + @BeforeEach + void setUp() { + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + PreparationFreeze.setActiveWars(List::of); + } + + @AfterEach + void tearDown() { + PreparationFreeze.setActiveWars(null); + bukkit.close(); + } + + @Test + void anOnlineBuilderIsJudgedByTheCharacterPlayingNow() { + Player builder = mock(Player.class); + when(builder.getName()).thenReturn("Builder"); + bukkit.when(() -> Bukkit.getPlayer(account)).thenReturn(builder); + + assertEquals("Builder", VfBuildersConstructionFreeze.constructorKey(account)); + assertNull(new VfBuildersConstructionFreeze().freezeReason(account)); + } + + @Test + void anOfflineBuilderIsJudgedByTheCharacterPlayedLast() { + UUID characterId = UUID.randomUUID(); + CharacterIdentity identity = mock(CharacterIdentity.class); + when(services.load(CharacterIdentity.class)).thenReturn(identity); + when(identity.lastPlayed(any())).thenReturn(Optional.empty()); + when(identity.lastPlayed(account)).thenReturn(Optional.of(new CharacterInfo( + new CharacterRef(account, characterId), "Hilde", CharacterStatus.ALIVE, null, Set.of(), 0, + Instant.EPOCH))); + + assertEquals(Members.key(characterId), VfBuildersConstructionFreeze.constructorKey(account)); + assertNull(VfBuildersConstructionFreeze.constructorKey(UUID.randomUUID())); + } + + @Test + void anOfflineBuilderWithoutTheCharacterProviderHasNoCharacter() { + assertNull(VfBuildersConstructionFreeze.constructorKey(account)); + } +} From 5204182e3e7751050f5615fe963d43435efdd257 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:24:27 +0000 Subject: [PATCH 06/35] wip: tests for members, migration and quarantine --- .../identity/LeaderCharactersTest.java | 162 +++--- .../simplefactions/identity/MembersTest.java | 228 ++++++++ .../identity/SharedLeaderCharactersTest.java | 273 ++-------- .../migration/CharacterMigrationTest.java | 493 ++++++++++++++++++ .../migration/QuarantineCommandsTest.java | 161 ++++++ .../testsupport/SharedIdentity.java | 165 ++++++ 6 files changed, 1168 insertions(+), 314 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/migration/QuarantineCommandsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/testsupport/SharedIdentity.java diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java index 500d2853..aed9072f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java @@ -26,6 +26,7 @@ import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.simplefactions.testsupport.SharedIdentity; class LeaderCharactersTest { @@ -50,7 +51,7 @@ void readsTheActiveCharacterOfAnOnlineLeader() { void keepsTheRememberedNameWhileTheLeaderIsOffline() { online(Map.of()); LeaderCharacters.Remembered result = - LeaderCharacters.resolve("rushork", "Grunk the Bold", "RUSHORK"); + LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork"); assertEquals("Grunk the Bold", result.name()); } @@ -99,7 +100,7 @@ void withoutRpCharactersARememberedNameIsDropped() { void anUnavailableProbeIsNotMistakenForAnOfflineLeader() { LeaderCharacters.setProbe(new LeaderCharacters.Probe() { @Override - public String activeCharacterName(String player) { + public String characterName(String key) { return null; } @@ -141,94 +142,97 @@ void refreshingLeavesARealmsOwnGuildToTheRealm() { } @Test - void sharedStartImmediatelyRefreshesMatchingRealmAndGuildOnly() { - try (var identity = new SharedLeaderCharactersTest.Fixture(); var world = new LeaderWorld()) { - Player leader = identity.player("rushork"); - CharacterSession ready = identity.activate(leader, UUID.randomUUID(), "Grunk the Bold"); - - new LeaderCharacterListener().onCharacterStart(new CharacterSessionStartEvent(leader, ready)); - - verify(world.realm).rememberLeaderCharacter("Grunk the Bold", "rushork"); - verify(world.guild).rememberLeaderCharacter("Grunk the Bold", "rushork"); - verify(world.base, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any()); - verify(world.unrelated, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any()); - verify(world.map).markLeaderNamesChanged(); - identity.bukkit.verify(org.bukkit.Bukkit::getScheduler, never()); + void sharedStartRefreshesOnlyTheRealmAndGuildThatCharacterLeads() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + Player leader = identity.connect("rushork"); + CharacterRef grunk = identity.character(leader.getUniqueId(), "Grunk the Bold"); + try (var world = new LeaderWorld(Members.key(grunk))) { + CharacterSession ready = identity.play(leader, grunk); + + new LeaderCharacterListener().onCharacterStart(new CharacterSessionStartEvent(leader, ready)); + + verify(world.realm).rememberLeaderCharacter("Grunk the Bold", Members.key(grunk)); + verify(world.guild).rememberLeaderCharacter("Grunk the Bold", Members.key(grunk)); + verify(world.base, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + verify(world.unrelated, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + verify(world.map).markLeaderNamesChanged(); + } } } @Test - void staleStartCannotRefreshNamesAfterAToBToAOrProviderLoss() { - try (var identity = new SharedLeaderCharactersTest.Fixture(); var world = new LeaderWorld()) { - Player leader = identity.player("rushork"); - UUID characterA = UUID.randomUUID(); - CharacterSession originalA = identity.activate(leader, characterA, "Original A"); - CharacterSession b = identity.activate(leader, UUID.randomUUID(), "B"); - CharacterSession freshA = identity.activate(leader, characterA, "Fresh A"); - LeaderCharacterListener listener = new LeaderCharacterListener(); - - listener.onCharacterStart(new CharacterSessionStartEvent(leader, originalA)); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, b)); - identity.unregister(); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, freshA)); - verify(world.realm, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any()); - verify(world.map, never()).markLeaderNamesChanged(); - - identity.register(); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, freshA)); - verify(world.realm).rememberLeaderCharacter("Fresh A", "rushork"); - verify(world.guild).rememberLeaderCharacter("Fresh A", "rushork"); - verify(world.map).markLeaderNamesChanged(); + void anotherCharacterOfTheLeadersPlayerRefreshesNothing() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + Player leader = identity.connect("rushork"); + CharacterRef grunk = identity.character(leader.getUniqueId(), "Grunk"); + CharacterRef other = identity.character(leader.getUniqueId(), "Someone else"); + try (var world = new LeaderWorld(Members.key(grunk))) { + CharacterSession session = identity.play(leader, other); + new LeaderCharacterListener().onCharacterStart(new CharacterSessionStartEvent(leader, session)); + verify(world.realm, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + verify(world.map, never()).markLeaderNamesChanged(); + } } } @Test - void startRequiresOnlineOriginalConnectionAndCorrectSessionOwner() { - try (var identity = new SharedLeaderCharactersTest.Fixture(); var world = new LeaderWorld()) { - Player leader = identity.player("rushork"); + void staleOrForeignStartsRefreshNothing() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + Player leader = identity.connect("rushork"); UUID owner = leader.getUniqueId(); - CharacterSession ready = identity.activate(leader, UUID.randomUUID(), "Grunk"); - LeaderCharacterListener listener = new LeaderCharacterListener(); - when(leader.isOnline()).thenReturn(false); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); - when(leader.isOnline()).thenReturn(true); - identity.connections.put(owner, mock(Player.class)); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); - identity.connections.put(owner, leader); - CharacterSession foreign = new CharacterSession( - new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), 123); - identity.sessions.put(foreign.ref().ownerId(), foreign); - listener.onCharacterStart(new CharacterSessionStartEvent(leader, foreign)); - verify(world.realm, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any()); - verify(world.map, never()).markLeaderNamesChanged(); - - listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); - verify(world.realm).rememberLeaderCharacter("Grunk", "rushork"); + CharacterRef grunk = identity.character(owner, "Grunk"); + try (var world = new LeaderWorld(Members.key(grunk))) { + LeaderCharacterListener listener = new LeaderCharacterListener(); + CharacterSession stale = identity.play(leader, grunk); + identity.play(leader, grunk); + listener.onCharacterStart(new CharacterSessionStartEvent(leader, stale)); + CharacterSession ready = identity.play(leader, grunk); + when(leader.isOnline()).thenReturn(false); + listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); + when(leader.isOnline()).thenReturn(true); + identity.connections.put(owner, mock(Player.class)); + listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); + identity.connections.put(owner, leader); + CharacterSession foreign = new CharacterSession( + new CharacterRef(UUID.randomUUID(), grunk.characterId()), 123); + listener.onCharacterStart(new CharacterSessionStartEvent(leader, foreign)); + verify(world.realm, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + listener.onCharacterStart(new CharacterSessionStartEvent(leader, ready)); + verify(world.realm).rememberLeaderCharacter("Grunk", Members.key(grunk)); + } } } @Test void sharedUnknownPreservesRealmAndGuildNamesButProviderLossClearsBoth() { - try (var identity = new SharedLeaderCharactersTest.Fixture(); var world = new LeaderWorld()) { - when(world.realm.getLeaderCharacter()).thenReturn("Remembered ruler"); - when(world.realm.getLeaderCharacterOf()).thenReturn("RUSHORK"); - when(world.guild.getLeaderCharacter()).thenReturn("Remembered guild leader"); - when(world.guild.getLeaderCharacterOf()).thenReturn("RUSHORK"); - - assertFalse(LeaderCharacters.refresh(world.realm)); - assertFalse(LeaderCharacters.refresh(world.guild)); - verify(world.realm).rememberLeaderCharacter("Remembered ruler", "rushork"); - verify(world.guild).rememberLeaderCharacter("Remembered guild leader", "rushork"); - - identity.unregister(); - assertTrue(LeaderCharacters.refresh(world.realm)); - assertTrue(LeaderCharacters.refresh(world.guild)); - verify(world.realm).rememberLeaderCharacter(null, null); - verify(world.guild).rememberLeaderCharacter(null, null); + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + String key = Members.key(UUID.randomUUID()); + try (var world = new LeaderWorld(key)) { + when(world.realm.getLeaderCharacter()).thenReturn("Remembered ruler"); + when(world.realm.getLeaderCharacterOf()).thenReturn(key); + when(world.guild.getLeaderCharacter()).thenReturn("Remembered guild leader"); + when(world.guild.getLeaderCharacterOf()).thenReturn(key); + + assertFalse(LeaderCharacters.refresh(world.realm)); + assertFalse(LeaderCharacters.refresh(world.guild)); + verify(world.realm).rememberLeaderCharacter("Remembered ruler", key); + verify(world.guild).rememberLeaderCharacter("Remembered guild leader", key); + + identity.unregister(); + assertTrue(LeaderCharacters.refresh(world.realm)); + assertTrue(LeaderCharacters.refresh(world.guild)); + verify(world.realm).rememberLeaderCharacter(null, null); + verify(world.guild).rememberLeaderCharacter(null, null); + } } } @@ -241,9 +245,9 @@ private static final class LeaderWorld implements AutoCloseable { final Guild unrelated = mock(Guild.class); final MapSystem map = mock(MapSystem.class); - LeaderWorld() { - when(realm.getLeader()).thenReturn("rushork"); - when(guild.getLeader()).thenReturn("rushork"); + LeaderWorld(String leader) { + when(realm.getLeader()).thenReturn(leader); + when(guild.getLeader()).thenReturn(leader); when(base.isBase()).thenReturn(true); when(unrelated.getLeader()).thenReturn("anotheraccount"); GuildHandler handler = mock(GuildHandler.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java new file mode 100644 index 00000000..5429a336 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java @@ -0,0 +1,228 @@ +package net.tfminecraft.simplefactions.identity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.testsupport.SharedIdentity; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; + +class MembersTest { + + @Test + void aPlayerIsTheCharacterOfTheirCurrentSessionOnly() { + try (var identity = new SharedIdentity()) { + Player player = identity.connect("Steve"); + CharacterRef ada = identity.character(player.getUniqueId(), "Ada"); + CharacterRef bea = identity.character(player.getUniqueId(), "Bea"); + assertNull(Members.key(player), "no session, no member"); + assertTrue(Members.session(player).isEmpty()); + + CharacterSession first = identity.play(player, ada); + assertEquals(Members.key(ada), Members.key(player)); + assertEquals(ada.characterId().toString(), Members.key(player)); + assertTrue(Members.is(player, Members.key(ada))); + assertFalse(Members.is(player, Members.key(bea))); + assertEquals(player, Members.player(Members.key(ada))); + assertTrue(Members.online(Members.key(bea)).isEmpty(), "the same account playing Ada is not Bea"); + assertEquals("Ada", Members.name(player)); + assertTrue(Members.isCurrent(first)); + + identity.play(player, bea); + assertFalse(Members.isCurrent(first)); + assertEquals(Members.key(bea), Members.key(player)); + assertFalse(Members.isOnline(Members.key(ada))); + assertTrue(Members.isOnline(Members.key(bea))); + } + } + + @Test + void aSessionOfAnotherAccountIsNotThePlayers() { + try (var identity = new SharedIdentity()) { + Player player = identity.connect("Steve"); + CharacterRef foreign = identity.character(UUID.randomUUID(), "Foreign"); + identity.sessions.put(player.getUniqueId(), new CharacterSession(foreign, 9)); + assertNull(Members.key(player)); + assertEquals("Steve", Members.name(player)); + identity.unregister(); + assertNull(Members.key(player)); + assertFalse(Members.isCurrent(new CharacterSession(foreign, 9))); + assertTrue(Members.info(Members.key(foreign)).isEmpty()); + } + } + + @Test + void displayNamesCharactersWithoutLeakingKeys() { + try (var identity = new SharedIdentity()) { + CharacterRef ada = identity.character(identity.account("Steve"), "§6Ada"); + CharacterRef blank = identity.character(identity.account("Alex"), " "); + assertEquals("Ada", Members.display(Members.key(ada))); + assertEquals("Unknown", Members.display(Members.key(blank))); + assertEquals("Unknown", Members.display(Members.key(UUID.randomUUID()))); + assertEquals("Unknown", Members.display((String) null)); + assertEquals("Unknown", Members.display(" ")); + assertEquals("Unresolved member", Members.display(Members.QUARANTINED + "q1")); + assertEquals("dummy_1", Members.display("dummy_1")); + assertEquals(List.of("Ada", "Unknown"), Members.display(List.of(Members.key(ada), Members.key(UUID.randomUUID())))); + assertEquals(List.of(), Members.display((List) null)); + assertEquals(ada, Members.ref(Members.key(ada)).orElseThrow()); + assertEquals(ada.ownerId(), Members.accountId(Members.key(ada)).orElseThrow()); + assertTrue(Members.ref("dummy_1").isEmpty()); + assertTrue(Members.ref(null).isEmpty()); + assertTrue(Members.info(null).isEmpty()); + assertEquals("Unknown", Members.name(null)); + } + } + + @Test + void staffSeeWhichAccountPlaysACharacter() { + try (var identity = new SharedIdentity()) { + CharacterRef ada = identity.character(identity.account("Steve"), "Ada"); + Player staff = mock(Player.class); + when(staff.hasPermission(anyString())).thenReturn(true); + Player player = mock(Player.class); + assertEquals("Ada §7(Steve)", Members.display(staff, Members.key(ada))); + assertEquals("Ada", Members.display(player, Members.key(ada))); + assertEquals("Unknown", Members.display(staff, Members.key(UUID.randomUUID()))); + assertEquals("Steve", Members.accountName(Members.key(ada))); + assertNull(Members.accountName(Members.key(UUID.randomUUID()))); + } + } + + @Test + void typedNamesFindMembersByCharacterOrOnlineAccount() { + try (var identity = new SharedIdentity()) { + Player steve = identity.connect("Steve"); + CharacterRef ada = identity.character(steve.getUniqueId(), "Ada Lovelace"); + identity.play(steve, ada); + CharacterRef twinA = identity.character(identity.account("A"), "Twin"); + CharacterRef twinB = identity.character(identity.account("B"), "Twin"); + String adaKey = Members.key(ada); + Set keys = Set.of(adaKey, Members.key(twinA), Members.key(twinB)); + + assertEquals(adaKey, Members.find(keys, "Steve")); + assertEquals(adaKey, Members.find(keys, "\"ada lovelace\"")); + assertEquals(adaKey, Members.find(keys, adaKey.toUpperCase())); + assertNull(Members.find(keys, "Twin"), "ambiguous"); + assertNull(Members.find(keys, "Nobody")); + assertNull(Members.find(Set.of(Members.key(twinA)), "Steve"), "online, but not a member here"); + assertNull(Members.find(null, "x")); + assertNull(Members.find(keys, null)); + + assertEquals(adaKey, Members.findOnline("Steve")); + assertEquals(adaKey, Members.findOnline("Ada Lovelace")); + assertEquals(Members.key(twinA), Members.findOnline(Members.key(twinA))); + assertNull(Members.findOnline("Twin")); + assertNull(Members.findOnline(null)); + assertEquals(adaKey, Members.ofAccountName("Steve")); + assertNull(Members.ofAccountName(null)); + } + } + + @Test + void findOnlineRefusesAmbiguousCharacterNames() { + try (var identity = new SharedIdentity()) { + Player one = identity.connect("One"); + Player two = identity.connect("Two"); + identity.play(one, identity.character(one.getUniqueId(), "Twin")); + identity.play(two, identity.character(two.getUniqueId(), "Twin")); + assertNull(Members.findOnline("Twin")); + Player sender = mock(Player.class); + assertNull(Members.resolveOnline(sender, "Twin")); + verify(sender).sendMessage("§cSeveral characters share that name. Use their Minecraft name."); + assertNull(Members.resolveOnline(sender, "Nobody")); + verify(sender).sendMessage("§cNo online player found by that character or Minecraft name."); + assertEquals(one, Members.resolveOnline(sender, "One")); + assertNull(Members.resolveOnline(null, "Nobody")); + } + } + + @Test + void resolveOnlineFindsAUniqueCharacterName() { + try (var identity = new SharedIdentity()) { + Player one = identity.connect("One"); + identity.play(one, identity.character(one.getUniqueId(), "Solo")); + assertEquals(one, Members.resolveOnline(null, "\"solo\"")); + } + } + + @Test + void anOfflineAccountStandsForItsLegacyHeir() { + try (var identity = new SharedIdentity()) { + UUID account = identity.account("Steve"); + CharacterRef heir = identity.character(account, "Ada"); + assertNull(Members.ofAccountOrHeir("Steve")); + identity.heirs.put(account, heir); + assertEquals(Members.key(heir), Members.ofAccountOrHeir("Steve")); + assertNull(Members.ofAccountOrHeir("Unknown")); + assertNull(Members.ofAccountOrHeir(null)); + Player steve = identity.connect("Alex"); + CharacterRef bea = identity.character(steve.getUniqueId(), "Bea"); + identity.play(steve, bea); + assertEquals(Members.key(bea), Members.ofAccountOrHeir("Alex")); + identity.unregister(); + assertNull(Members.ofAccountOrHeir("Steve")); + } + } + + @Test + void noServerMeansNobodyIsOnline() { + try (var bukkit = mockStatic(Bukkit.class)) { + assertNull(Members.findOnline("Steve")); + assertNull(Members.ofAccountOrHeir("Steve")); + assertNull(Members.accountName(Members.key(UUID.randomUUID()))); + bukkit.when(Bukkit::getServer).thenThrow(new IllegalStateException("no server")); + assertNull(Members.findOnline("Steve")); + assertNull(Members.ofAccountOrHeir("Steve")); + } + } + + @Test + void keysAreLowerCaseCharacterIds() { + UUID id = UUID.randomUUID(); + assertEquals(id.toString(), Members.key(id)); + assertNull(Members.key((UUID) null)); + assertNull(Members.key((CharacterRef) null)); + assertNull(Members.key((Player) null)); + assertEquals(id, Members.characterId(id.toString().toUpperCase())); + assertNull(Members.characterId("1-1-1-1-1")); + assertNull(Members.characterId("Steve")); + assertNull(Members.characterId(null)); + assertTrue(Members.isQuarantined("quarantine:q1")); + assertFalse(Members.isQuarantined(null)); + assertTrue(Members.online(null).isEmpty()); + assertTrue(Members.online(" ").isEmpty()); + assertTrue(Members.session(null).isEmpty()); + assertFalse(Members.isCurrent(null)); + } + + @Test + void completionsOfferTheUntypedRestOfAName() { + assertEquals(List.of(), Members.completions(List.of("Ada"), new String[] {"invite"})); + assertEquals(List.of("Lovelace"), Members.completions(java.util.Arrays.asList("Ada Lovelace", null, "Bea"), + new String[] {"invite", "ada", "lo"})); + assertEquals(List.of("Ada"), Members.completions(List.of("§6Ada", "Ada"), new String[] {"invite", "a"})); + } + + @Test + void theResolverCanBeReplacedAndRestored() { + Members.Resolver custom = mock(Members.Resolver.class); + Player player = mock(Player.class); + when(custom.key(player)).thenReturn("custom"); + Members.setResolver(custom); + assertEquals("custom", Members.key(player)); + Members.setResolver(null); + try (var identity = new SharedIdentity()) { + assertNull(Members.key(player)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/SharedLeaderCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/SharedLeaderCharactersTest.java index a7dba492..4381ea61 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/SharedLeaderCharactersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/SharedLeaderCharactersTest.java @@ -4,265 +4,68 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; -import org.bukkit.plugin.ServicePriority; -import org.bukkit.plugin.SimpleServicesManager; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import net.tfminecraft.tlibs.character.CharacterIdentity; -import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.simplefactions.testsupport.SharedIdentity; import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterStatus; -import net.tfminecraft.tlibs.character.Characters; class SharedLeaderCharactersTest { - @Test - void readyLeaderUsesCanonicalNameRatherThanMaskOrAlias() { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - fixture.activate(leader, UUID.randomUUID(), "Grunk the Bold"); - when(fixture.provider.displayName(leader)).thenReturn("A hooded traveller"); - - assertEquals("A hooded traveller", Characters.displayName(leader)); - clearInvocations(fixture.provider); - assertEquals(new LeaderCharacters.Remembered("Grunk the Bold", "rushork"), - LeaderCharacters.resolve("rushork", null, null)); - verify(fixture.provider, never()).displayName(any()); - verify(fixture.plugin, never()).getDataFolder(); - verify(fixture.plugins, never()).getPlugin(anyString()); - } - } - - @Test - void pendingOnlineLeaderKeepsOnlyTheirRememberedNameWithoutOfflineFallback() { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - fixture.lastPlayed.put(leader.getUniqueId(), - info(leader.getUniqueId(), UUID.randomUUID(), "Old directory name")); - - assertEquals(LeaderCharacters.Remembered.NONE, - LeaderCharacters.resolve("rushork", null, null)); - assertEquals(new LeaderCharacters.Remembered("Remembered", "rushork"), - LeaderCharacters.resolve("rushork", "Remembered", "RUSHORK")); - assertEquals(LeaderCharacters.Remembered.NONE, - LeaderCharacters.resolve("rushork", "Another ruler", "someoneelse")); - verify(fixture.provider, never()).lastPlayed(any()); - } - } - - @Test - void sharedLookupSeesProviderArrivalAndLossWithoutReinstallingTheProbe() { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - fixture.activate(leader, UUID.randomUUID(), "Grunk"); - fixture.unregister(); - assertEquals(LeaderCharacters.Remembered.NONE, - LeaderCharacters.resolve("rushork", "Remembered", "rushork")); - - fixture.register(); - assertEquals("Grunk", LeaderCharacters.resolve("rushork", null, null).name()); - fixture.unregister(); - assertEquals(LeaderCharacters.Remembered.NONE, - LeaderCharacters.resolve("rushork", "Grunk", "rushork")); - } - } - - @Test - void onlineLookupRejectsForeignSessionsAndMismatchedOrMissingSnapshots() { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - UUID owner = leader.getUniqueId(); - UUID characterId = UUID.randomUUID(); - CharacterSession session = fixture.activate(leader, characterId, "Grunk"); - fixture.sessions.put(owner, - new CharacterSession(new CharacterRef(UUID.randomUUID(), characterId), 100)); - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - - fixture.sessions.put(owner, session); - fixture.infos.put(characterId, info(UUID.randomUUID(), characterId, "Someone else's name")); - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - fixture.infos.put(characterId, info(owner, UUID.randomUUID(), "Wrong character")); - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - fixture.infos.remove(characterId); - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - } + @AfterEach + void reset() { + LeaderCharacters.reset(); } @Test - void snapshotCallbackCannotPublishAnOldGenerationAfterAToBToA() { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - UUID characterId = UUID.randomUUID(); - CharacterSession original = fixture.activate(leader, characterId, "Original A"); - CharacterInfo originalInfo = fixture.infos.get(characterId); - when(fixture.provider.info(characterId)).thenAnswer(call -> { - fixture.activate(leader, UUID.randomUUID(), "B"); - fixture.activate(leader, characterId, "Fresh A"); - return Optional.of(originalInfo); - }); - - assertEquals("Remembered", LeaderCharacters.resolve("rushork", "Remembered", "rushork").name()); - verify(fixture.provider).isCurrent(original); - assertFalse(Characters.isCurrent(original)); + void leaderKeyNamesItsOwnCharacterEvenWhileOffline() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + UUID account = identity.account("rushork"); + CharacterRef grunk = identity.character(account, "§6Grunk the Bold"); + identity.character(account, "Another character of the same player"); + String key = Members.key(grunk); + + assertEquals(new LeaderCharacters.Remembered("Grunk the Bold", key), + LeaderCharacters.resolve(key, null, null)); + verify(identity.provider, never()).lastPlayed(any()); + verify(identity.provider, never()).displayName(any()); } } @Test - void snapshotCallbackCannotPublishAfterDisconnectOrConnectionReplacement() { - for (boolean disconnect : new boolean[] {true, false}) { - try (var fixture = new Fixture()) { - Player leader = fixture.player("rushork"); - UUID owner = leader.getUniqueId(); - UUID characterId = UUID.randomUUID(); - CharacterSession original = fixture.activate(leader, characterId, "Grunk"); - CharacterInfo snapshot = fixture.infos.get(characterId); - when(fixture.provider.info(characterId)).thenAnswer(call -> { - if (disconnect) when(leader.isOnline()).thenReturn(false); - else fixture.connections.put(owner, mock(Player.class)); - return Optional.of(snapshot); - }); - - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - assertTrue(Characters.isCurrent(original), "Provider token alone cannot prove the connection"); - when(leader.isOnline()).thenReturn(true); - fixture.connections.put(owner, leader); - doReturn(Optional.of(snapshot)).when(fixture.provider).info(characterId); - assertEquals("Grunk", LeaderCharacters.resolve("rushork", null, null).name()); - } + void deadLeaderStillShowsTheirName() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + CharacterRef fallen = identity.character(identity.account("rushork"), "Grunk", CharacterStatus.DEAD); + assertEquals("Grunk", LeaderCharacters.resolve(Members.key(fallen), null, null).name()); } } @Test - void offlineLeaderUsesCachedOwnerAndLastPlayedEvenWhenTheCharacterIsDead() { - try (var fixture = new Fixture()) { - UUID owner = fixture.cached("rushork"); - fixture.lastPlayed.put(owner, new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), - "Grunk remembered", CharacterStatus.DEAD, null, Set.of(), 60, Instant.EPOCH)); - - assertEquals("Grunk remembered", LeaderCharacters.resolve("rushork", null, null).name()); - fixture.bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); - verify(fixture.plugin, never()).getDataFolder(); - verify(fixture.provider, never()).currentSession(any()); + void unknownCharacterKeepsOnlyItsOwnRememberedName() { + try (var identity = new SharedIdentity()) { + LeaderCharacters.useSharedIdentity(); + String key = Members.key(UUID.randomUUID()); + assertEquals(LeaderCharacters.Remembered.NONE, LeaderCharacters.resolve(key, null, null)); + assertEquals(new LeaderCharacters.Remembered("Remembered", key), + LeaderCharacters.resolve(key, "Remembered", key)); + assertEquals(LeaderCharacters.Remembered.NONE, + LeaderCharacters.resolve(key, "Another ruler", Members.key(UUID.randomUUID()))); } } @Test - void uncachedUnknownAndForeignOfflineResultsCannotInventAName() { - try (var fixture = new Fixture()) { - assertNull(LeaderCharacters.resolve("uncached", null, null).name()); - verify(fixture.provider, never()).lastPlayed(any()); - UUID owner = fixture.cached("rushork"); - assertEquals("Remembered", LeaderCharacters.resolve("rushork", "Remembered", "rushork").name()); - fixture.lastPlayed.put(owner, info(UUID.randomUUID(), UUID.randomUUID(), "Wrong owner")); - assertNull(LeaderCharacters.resolve("rushork", null, null).name()); - fixture.bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); - } - } - - static CharacterInfo info(UUID owner, UUID characterId, String name) { - return new CharacterInfo(new CharacterRef(owner, characterId), name, - CharacterStatus.ALIVE, null, Set.of(), 0, Instant.EPOCH); - } - - /** Real facade and registry; only Bukkit's server boundary and provider data are controlled. */ - static final class Fixture implements AutoCloseable { - final MockedStatic bukkit = mockStatic(Bukkit.class); - final SimpleServicesManager services = new SimpleServicesManager(); - final Plugin plugin = mock(Plugin.class); - final PluginManager plugins = mock(PluginManager.class); - final CharacterIdentity provider = mock(CharacterIdentity.class); - final Map sessions = new HashMap<>(); - final Map infos = new HashMap<>(); - final Map lastPlayed = new HashMap<>(); - final Map online = new HashMap<>(); - final Map connections = new HashMap<>(); - final Map cached = new HashMap<>(); - private long generation; - - Fixture() { - Server server = mock(Server.class); - when(server.getPluginManager()).thenReturn(plugins); - when(server.getServicesManager()).thenReturn(services); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - bukkit.when(Bukkit::getServicesManager).thenReturn(services); - bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); - bukkit.when(() -> Bukkit.getPlayerExact(anyString())) - .thenAnswer(call -> online.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))) - .thenAnswer(call -> connections.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())) - .thenAnswer(call -> cached.get(call.getArgument(0))); - when(provider.currentSession(any())).thenAnswer(call -> { - Player player = call.getArgument(0); - return Optional.ofNullable(sessions.get(player.getUniqueId())); - }); - when(provider.isCurrent(any())).thenAnswer(call -> { - CharacterSession session = call.getArgument(0); - return session != null && session.equals(sessions.get(session.ref().ownerId())); - }); - when(provider.info(any())).thenAnswer(call -> Optional.ofNullable(infos.get(call.getArgument(0)))); - when(provider.lastPlayed(any())).thenAnswer(call -> Optional.ofNullable(lastPlayed.get(call.getArgument(0)))); - register(); + void providerLossForgetsNamesUntilItReturns() { + try (var identity = new SharedIdentity()) { LeaderCharacters.useSharedIdentity(); - } - - Player player(String name) { - Player player = mock(Player.class); - UUID owner = UUID.randomUUID(); - when(player.getUniqueId()).thenReturn(owner); - when(player.getName()).thenReturn(name); - when(player.isOnline()).thenReturn(true); - online.put(name, player); - connections.put(owner, player); - return player; - } - - UUID cached(String name) { - UUID owner = UUID.randomUUID(); - OfflinePlayer player = mock(OfflinePlayer.class); - when(player.getUniqueId()).thenReturn(owner); - cached.put(name, player); - return owner; - } - - CharacterSession activate(Player player, UUID characterId, String name) { - UUID owner = player.getUniqueId(); - CharacterSession session = new CharacterSession(new CharacterRef(owner, characterId), ++generation); - sessions.put(owner, session); - infos.put(characterId, info(owner, characterId, name)); - return session; - } - - void register() { - services.register(CharacterIdentity.class, provider, plugin, ServicePriority.Normal); - } - - void unregister() { - services.unregister(CharacterIdentity.class, provider); - } - - @Override public void close() { - try { - unregister(); - LeaderCharacters.reset(); - } finally { - bukkit.close(); - } + String key = Members.key(identity.character(identity.account("rushork"), "Grunk")); + identity.unregister(); + assertEquals(LeaderCharacters.Remembered.NONE, LeaderCharacters.resolve(key, "Grunk", key)); + identity.register(); + assertEquals("Grunk", LeaderCharacters.resolve(key, null, null).name()); } } } diff --git a/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java new file mode 100644 index 00000000..532e2214 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java @@ -0,0 +1,493 @@ +package net.tfminecraft.simplefactions.migration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; + +class CharacterMigrationTest { + @TempDir + Path root; + + private final Logger logger = mock(Logger.class); + private final Map accounts = new HashMap<>(); + private final Map heirs = new HashMap<>(); + private int heirLookups; + + private final CharacterMigration.Heirs fakeHeirs = new CharacterMigration.Heirs() { + @Override + public Optional account(String name) { + return Optional.ofNullable(accounts.get(name.toLowerCase(java.util.Locale.ROOT))); + } + + @Override + public Optional heir(UUID account) { + heirLookups++; + return Optional.ofNullable(heirs.get(account)); + } + }; + + /** An account with a reviewed heir; returns the heir's member key. */ + private String player(String... names) { + UUID account = UUID.randomUUID(); + for (String name : names) accounts.put(name.toLowerCase(java.util.Locale.ROOT), account); + CharacterRef heir = new CharacterRef(account, UUID.randomUUID()); + heirs.put(account, heir); + return Members.key(heir); + } + + /** An account the server knows, but with no reviewed heir. */ + private UUID heirless(String name) { + UUID account = UUID.randomUUID(); + accounts.put(name.toLowerCase(java.util.Locale.ROOT), account); + return account; + } + + private CharacterMigration migration() { + return new CharacterMigration(root, fakeHeirs, logger); + } + + private void write(String store, String json) throws IOException { + Path file = root.resolve(store); + Files.createDirectories(file.getParent()); + Files.writeString(file, json); + } + + private JsonObject read(String store) throws IOException { + return JsonParser.parseString(Files.readString(root.resolve(store))).getAsJsonObject(); + } + + private static List strings(JsonElement array) { + return array.getAsJsonArray().asList().stream().map(JsonElement::getAsString).toList(); + } + + private static final String FACTION = """ + { + "id": "rome", "leader": "Caesar", "leader character": "Gaius", "leader character of": "Caesar", + "guilds": [ + {"id": "rome", "leader": "Caesar", "members": ["Caesar", "Brutus", "OldBrutus", "Ghost"], + "dividendEligible": ["Caesar", "Brutus", "OldBrutus"], + "citizen taxes": {"Brutus": 2.5, "OldBrutus": 1.5, "Ghost": 4.0}, + "deposits today": {"Caesar": 10}, + "ledger last day": {"CITIZENS": {"Brutus": 1.0, "oldbrutus": 2.0}, "GUILD_TAXES": {"smiths": 3.0}, + "DEPOSITS": {"Caesar": 5.0}}, + "ledger lifetime": {"CITIZENS": {"Ghost": 7.0}}, + "company": {"enlisted": ["Brutus", "OldBrutus"], "activeWageOverrides": {"Brutus": 5.0, "OldBrutus": 9.0}, + "peacetimeWageOverrides": {"Ghost": 1.0}, "pendingWages": {"Brutus": 3.0, "OldBrutus": 4.0}}}, + {"id": "smiths", "leader": "Ghost", "members": ["Ghost", "Cassius"], "leader character": "Spooky"} + ], + "governmentData": { + "council members": ["Brutus", "Cassius"], + "eligible voters": ["Caesar", "Brutus", "OldBrutus"], + "election candidates": {"LEADER": ["Brutus", "OldBrutus"]}, + "election votes": {"LEADER": {"Caesar": "Brutus", "Brutus": "Cassius", "OldBrutus": "Caesar"}}, + "previous votes": {"COUNCIL": {"Brutus": 2, "OldBrutus": 3}}, + "proposals": ["Brutus:law:economy:free_trade", "Cassius:action:CHANGE_LEADER:Brutus", "broken", "Ghost:tax:CITIZENS:x:5.0"], + "movements": [ + {"id": "m1", "leader": "Cassius", "supporters": {"citizens": ["Cassius", "Brutus"], "guilds": ["smiths"]}, + "causes": [{"leader": "Cassius", "members": {"citizens": ["Cassius"]}, + "proposal": {"proposer": "Cassius", "action key": "CHANGE_LEADER", "target": "Brutus"}}, + {"leader": "Brutus", "proposal": {"proposer": "Brutus", "action key": "END_WAR", "target": "gaul"}}]} + ] + } + } + """; + + @Test + void migratesEveryNameFieldOfARealmAndMergesNamesOfOneAccount() throws IOException { + String caesar = player("Caesar"); + String brutus = player("Brutus", "OldBrutus"); + String cassius = player("Cassius"); + write("Data/rome.json", FACTION); + + CharacterMigration.Result result = migration().run(); + assertEquals(1, result.migrated()); + + JsonObject rome = read("Data/rome.json"); + assertEquals(caesar, rome.get("leader").getAsString()); + assertFalse(rome.has("leader character")); + assertFalse(rome.has("leader character of")); + JsonObject base = rome.getAsJsonArray("guilds").get(0).getAsJsonObject(); + JsonObject smiths = rome.getAsJsonArray("guilds").get(1).getAsJsonObject(); + String ghost = smiths.get("leader").getAsString(); + assertTrue(Members.isQuarantined(ghost)); + // The quarantined leader of the smiths no longer also sits in the realm's own guild. + assertEquals(List.of(caesar, brutus), strings(base.get("members"))); + assertEquals(List.of(caesar, brutus), strings(base.get("dividendEligible"))); + assertEquals(4.0, base.getAsJsonObject("citizen taxes").get(brutus).getAsDouble()); + assertEquals(4.0, base.getAsJsonObject("citizen taxes").get(ghost).getAsDouble()); + assertEquals(10, base.getAsJsonObject("deposits today").get(caesar).getAsLong()); + JsonObject lastDay = base.getAsJsonObject("ledger last day"); + assertEquals(3.0, lastDay.getAsJsonObject("CITIZENS").get(brutus).getAsDouble()); + assertEquals(3.0, lastDay.getAsJsonObject("GUILD_TAXES").get("smiths").getAsDouble()); + assertEquals(5.0, lastDay.getAsJsonObject("DEPOSITS").get(caesar).getAsDouble()); + assertEquals(7.0, base.getAsJsonObject("ledger lifetime").getAsJsonObject("CITIZENS").get(ghost).getAsDouble()); + JsonObject company = base.getAsJsonObject("company"); + assertEquals(List.of(brutus), strings(company.get("enlisted"))); + assertEquals(5.0, company.getAsJsonObject("activeWageOverrides").get(brutus).getAsDouble()); + assertEquals(1.0, company.getAsJsonObject("peacetimeWageOverrides").get(ghost).getAsDouble()); + assertEquals(7.0, company.getAsJsonObject("pendingWages").get(brutus).getAsDouble()); + assertNotNull(base.get("incarnation").getAsString()); + + assertEquals(List.of(ghost, cassius), strings(smiths.get("members"))); + assertFalse(smiths.has("leader character")); + assertNotEquals(base.get("incarnation"), smiths.get("incarnation")); + + JsonObject government = rome.getAsJsonObject("governmentData"); + assertEquals(List.of(brutus, cassius), strings(government.get("council members"))); + assertEquals(List.of(caesar, brutus), strings(government.get("eligible voters"))); + assertEquals(List.of(brutus), strings(government.getAsJsonObject("election candidates").get("LEADER"))); + JsonObject ballots = government.getAsJsonObject("election votes").getAsJsonObject("LEADER"); + assertEquals(brutus, ballots.get(caesar).getAsString()); + assertEquals(cassius, ballots.get(brutus).getAsString()); + assertEquals(2, ballots.size()); + assertEquals(5, government.getAsJsonObject("previous votes").getAsJsonObject("COUNCIL").get(brutus).getAsLong()); + assertEquals(List.of(brutus + ":law:economy:free_trade", cassius + ":action:CHANGE_LEADER:" + brutus, + "broken", ghost + ":tax:CITIZENS:x:5.0"), strings(government.get("proposals"))); + JsonObject movement = government.getAsJsonArray("movements").get(0).getAsJsonObject(); + assertEquals(cassius, movement.get("leader").getAsString()); + assertEquals(List.of(cassius, brutus), strings(movement.getAsJsonObject("supporters").get("citizens"))); + assertEquals(List.of("smiths"), strings(movement.getAsJsonObject("supporters").get("guilds"))); + JsonObject cause = movement.getAsJsonArray("causes").get(0).getAsJsonObject(); + assertEquals(cassius, cause.get("leader").getAsString()); + assertEquals(List.of(cassius), strings(cause.getAsJsonObject("members").get("citizens"))); + assertEquals(cassius, cause.getAsJsonObject("proposal").get("proposer").getAsString()); + assertEquals(brutus, cause.getAsJsonObject("proposal").get("target").getAsString()); + JsonObject warEnd = movement.getAsJsonArray("causes").get(1).getAsJsonObject(); + assertEquals("gaul", warEnd.getAsJsonObject("proposal").get("target").getAsString()); + + Quarantine quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + assertEquals(1, quarantine.records().size()); + Quarantine.Record record = quarantine.records().iterator().next(); + assertEquals(Quarantine.NAME, record.kind); + assertEquals("Ghost", record.original); + assertEquals("Data/rome.json", record.store); + assertEquals(ghost, record.token); + assertTrue(record.holdsLeader()); + assertTrue(record.occurrences.stream().anyMatch(o -> o.path.equals("$.guilds[smiths].leader"))); + assertTrue(record.occurrences.stream().anyMatch(o -> o.path.equals("$.guilds[rome].citizen taxes[Ghost]") + && o.fragment.getAsJsonObject().get("Ghost").getAsDouble() == 4.0)); + + assertArrayEquals(FACTION.getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("migrations/backup/Data/rome.json"))); + MigrationManifest manifest = MigrationManifest.load(root.resolve(CharacterMigration.MANIFEST)); + assertTrue(manifest.complete); + assertEquals(MigrationManifest.DONE, manifest.entry("Data/rome.json").state); + assertEquals(MigrationFiles.sha256(FACTION), manifest.entry("Data/rome.json").hash); + } + + @Test + void incarnationsAreStableAcrossARedo() throws IOException { + player("Caesar", "Brutus", "OldBrutus", "Cassius", "Ghost"); + write("Data/rome.json", FACTION); + migration().run(); + String first = read("Data/rome.json").getAsJsonArray("guilds").get(0).getAsJsonObject().get("incarnation").getAsString(); + Files.writeString(root.resolve("Data/rome.json"), FACTION); + Files.delete(root.resolve(CharacterMigration.MANIFEST)); + migration().run(); + assertEquals(first, read("Data/rome.json").getAsJsonArray("guilds").get(0).getAsJsonObject().get("incarnation").getAsString()); + } + + @Test + void keepsAnExistingIncarnationAndLeavesKeysAndTokensAlone() throws IOException { + String key = Members.key(UUID.randomUUID()); + write("Data/gaul.json", "{\"id\":\"gaul\",\"leader\":\"" + key + "\",\"guilds\":[{\"id\":\"gaul\",\"incarnation\":\"fixed\"," + + "\"leader\":\"" + key.toUpperCase() + "\",\"members\":[\"quarantine:q1\"]}]}"); + migration().run(); + JsonObject gaul = read("Data/gaul.json"); + JsonObject guild = gaul.getAsJsonArray("guilds").get(0).getAsJsonObject(); + assertEquals("fixed", guild.get("incarnation").getAsString()); + assertEquals(key, guild.get("leader").getAsString()); + assertEquals(List.of("quarantine:q1"), strings(guild.get("members"))); + assertEquals(0, heirLookups); + } + + @Test + void aCharacterInSeveralGuildsOfARealmKeepsOnlyOne() throws IOException { + String caesar = player("Caesar"); + String brutus = player("Brutus", "OldBrutus"); + String cassius = player("Cassius", "Cass"); + write("Data/rome.json", """ + {"id":"rome","leader":"Caesar","guilds":[ + {"id":"rome","leader":"Caesar","members":["Caesar","Brutus","Cass", 5]}, + {"id":"smiths","leader":"Cassius","members":["Cassius","OldBrutus"]}, + {"id":"bakers","leader":"Brutus","members":["Brutus","Cass"]}, + {"id":"empty"}]} + """); + migration().run(); + JsonArray guilds = read("Data/rome.json").getAsJsonArray("guilds"); + assertEquals(List.of(caesar), strings(guilds.get(0).getAsJsonObject().get("members")).subList(0, 1)); + assertEquals(2, guilds.get(0).getAsJsonObject().getAsJsonArray("members").size()); + assertEquals(List.of(cassius), strings(guilds.get(1).getAsJsonObject().get("members"))); + assertEquals(List.of(brutus), strings(guilds.get(2).getAsJsonObject().get("members"))); + org.mockito.Mockito.verify(logger, org.mockito.Mockito.atLeastOnce()) + .warning(org.mockito.ArgumentMatchers.contains("several guilds")); + } + + @Test + void migratesWarsLoginsAndVehicleStoresAndLeavesCombatFilesAlone() throws IOException { + String brutus = player("Brutus", "OldBrutus"); + String cassius = player("Cassius"); + UUID orphanAccount = heirless("Orphan"); + CharacterRef brutusHeir = heirs.get(accounts.get("brutus")); + write("Wars/war_1.json", "{\"id\":1,\"civilWarWantedLeaderName\":\"Brutus\",\"civilWarMemberMoves\":" + + "[{\"player\":\"Cassius\",\"originGuildId\":\"smiths\"},{\"player\":\"Orphan\"}],\"battleVotes\":{\"x\":[1]}}"); + write("Wars/notes.txt", "ignored"); + write("Cache/logins.json", "{\"brutus\":4,\"oldbrutus\":1,\"cassius\":2}"); + write("Cache/vehicle_fees.json", "{\"lastOwners\":{\"v1\":\"Brutus\",\"v2\":\"Orphan\"},\"paidBuilds\":[{\"payerUuid\":\"" + + brutusHeir.ownerId() + "\",\"amount\":5},{\"payerUuid\":\"" + orphanAccount + "\",\"amount\":2},{\"payerUuid\":\"nope\"},{}]}"); + write("Cache/vehicles_registry.json", "[{\"playerUuid\":\"" + brutusHeir.ownerId() + "\",\"vehicleUuid\":\"v1\"}," + + "{\"vehicleUuid\":\"v3\"}, 7]"); + String warband = "{\"id\":\"w\",\"leaderId\":\"" + UUID.randomUUID() + "\"}"; + write("Warbands/warband_w.json", warband); + write("Battles/battle_b.json", "{\"id\":\"b\"}"); + + migration().run(); + + JsonObject war = read("Wars/war_1.json"); + assertEquals(brutus, war.get("civilWarWantedLeaderName").getAsString()); + JsonArray moves = war.getAsJsonArray("civilWarMemberMoves"); + assertEquals(cassius, moves.get(0).getAsJsonObject().get("player").getAsString()); + assertTrue(Members.isQuarantined(moves.get(1).getAsJsonObject().get("player").getAsString())); + JsonObject logins = read("Cache/logins.json"); + assertEquals(1.0, logins.get(brutus).getAsDouble()); + assertEquals(2, logins.get(cassius).getAsInt()); + JsonObject fees = read("Cache/vehicle_fees.json"); + assertEquals(brutus, fees.getAsJsonObject("lastOwners").get("v1").getAsString()); + assertTrue(Members.isQuarantined(fees.getAsJsonObject("lastOwners").get("v2").getAsString())); + JsonArray builds = fees.getAsJsonArray("paidBuilds"); + assertEquals(brutusHeir.characterId().toString(), builds.get(0).getAsJsonObject().get("payerUuid").getAsString()); + assertEquals(orphanAccount.toString(), builds.get(1).getAsJsonObject().get("payerUuid").getAsString()); + assertEquals("nope", builds.get(2).getAsJsonObject().get("payerUuid").getAsString()); + JsonArray registry = JsonParser.parseString(Files.readString(root.resolve("Cache/vehicles_registry.json"))).getAsJsonArray(); + assertEquals(brutusHeir.characterId().toString(), registry.get(0).getAsJsonObject().get("playerUuid").getAsString()); + assertEquals(warband, Files.readString(root.resolve("Warbands/warband_w.json"))); + + Quarantine quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + Quarantine.Record account = quarantine.records().stream() + .filter(record -> Quarantine.ACCOUNT.equals(record.kind)).findFirst().orElseThrow(); + assertEquals(orphanAccount.toString(), account.token); + assertEquals("Cache/vehicle_fees.json", account.store); + assertFalse(account.holdsLeader()); + MigrationManifest manifest = MigrationManifest.load(root.resolve(CharacterMigration.MANIFEST)); + assertEquals(MigrationManifest.DONE, manifest.entry("Warbands/warband_w.json").state); + assertEquals(MigrationManifest.DONE, manifest.entry("Battles/battle_b.json").state); + } + + @Test + void anInterruptedStoreIsRestoredFromItsBackupAndRedone() throws IOException { + String caesar = player("Caesar"); + String original = "{\"id\":\"rome\",\"leader\":\"Caesar\",\"guilds\":[]}"; + write("Data/rome.json", original); + write("migrations/backup/Data/rome.json", original); + MigrationManifest manifest = new MigrationManifest(); + manifest.mark("Data/rome.json", MigrationManifest.IN_PROGRESS, "migrations/backup/Data/rome.json", + MigrationFiles.sha256(original)); + manifest.mark("Data/done.json", MigrationManifest.DONE, "migrations/backup/Data/done.json", "x"); + manifest.save(root.resolve(CharacterMigration.MANIFEST)); + // A half-finished earlier attempt already rewrote the store. + write("Data/rome.json", "{\"id\":\"rome\",\"leader\":\"" + Members.key(UUID.randomUUID()) + "\"}"); + write("Data/done.json", "{\"id\":\"done\",\"leader\":\"Caesar\"}"); + + CharacterMigration.Result result = migration().run(); + + assertEquals(1, result.migrated()); + assertEquals(caesar, read("Data/rome.json").get("leader").getAsString()); + assertEquals("Caesar", read("Data/done.json").get("leader").getAsString()); + org.mockito.Mockito.verify(logger).warning(org.mockito.ArgumentMatchers.contains("Restored Data/rome.json")); + } + + @Test + void anInterruptedStoreWithoutAGoodBackupStopsTheMigration() throws IOException { + write("Data/rome.json", "{\"id\":\"rome\"}"); + MigrationManifest manifest = new MigrationManifest(); + manifest.mark("Data/rome.json", MigrationManifest.IN_PROGRESS, "migrations/backup/Data/rome.json", "abc"); + manifest.save(root.resolve(CharacterMigration.MANIFEST)); + assertThrows(IOException.class, () -> migration().run()); + write("migrations/backup/Data/rome.json", "{\"id\":\"other\"}"); + assertThrows(IOException.class, () -> migration().run()); + assertFalse(MigrationManifest.load(root.resolve(CharacterMigration.MANIFEST)).complete); + } + + @Test + void aCompletedMigrationNeverRunsAgain() throws IOException { + player("Caesar"); + write("Data/rome.json", "{\"id\":\"rome\",\"leader\":\"Caesar\"}"); + CharacterMigration migration = migration(); + assertFalse(migration.complete()); + migration.run(); + assertTrue(migration.complete()); + String migrated = Files.readString(root.resolve("Data/rome.json")); + write("Data/new.json", "{\"id\":\"new\",\"leader\":\"Caesar\"}"); + assertEquals(new CharacterMigration.Result(0, 0, 0, 0), migration.run()); + assertEquals(migrated, Files.readString(root.resolve("Data/rome.json"))); + assertEquals("Caesar", read("Data/new.json").get("leader").getAsString()); + } + + @Test + void anUnreadableStoreIsLeftAsItIsAndReported() throws IOException { + write("Data/broken.json", "{not json"); + write("Data/list.json", "[1,2]"); + CharacterMigration.Result result = migration().run(); + assertEquals(1, result.skipped()); + assertEquals("{not json", Files.readString(root.resolve("Data/broken.json"))); + assertEquals(MigrationManifest.UNREADABLE, + MigrationManifest.load(root.resolve(CharacterMigration.MANIFEST)).entry("Data/broken.json").state); + } + + @Test + void aFreshServerCompletesWithNothingToDo() throws IOException { + assertEquals(new CharacterMigration.Result(0, 0, 0, 0), migration().run()); + assertTrue(migration().complete()); + } + + @Test + void staffAssignmentsReplaceTheTokenEverywhereAtTheNextStart() throws IOException { + String caesar = player("Caesar"); + String brutus = player("Brutus"); + write("Data/rome.json", """ + {"id":"rome","leader":"Ghost","guilds":[ + {"id":"rome","leader":"Ghost","members":["Ghost","Brutus"],"citizen taxes":{"Ghost":2.0,"Brutus":1.0}, + "company":{"pendingWages":{"Ghost":3.0},"activeWageOverrides":{"Ghost":2.0,"Brutus":7.0}}}], + "governmentData":{"election votes":{"LEADER":{"Ghost":"Brutus","Brutus":"Ghost"}}, + "previous votes":{"LEADER":{"Ghost":2,"Brutus":1}},"election candidates":{"LEADER":["Ghost","Brutus"]}}} + """); + write("Cache/logins.json", "{\"ghost\":5,\"brutus\":3}"); + migration().run(); + Path quarantineFile = root.resolve(CharacterMigration.QUARANTINE); + Quarantine quarantine = Quarantine.load(quarantineFile); + assertEquals(2, quarantine.records().size()); + for (Quarantine.Record record : quarantine.records()) record.assigned = brutus; + quarantine.save(); + + CharacterMigration.Result result = migration().run(); + + assertEquals(2, result.assignmentsApplied()); + JsonObject rome = read("Data/rome.json"); + assertEquals(brutus, rome.get("leader").getAsString()); + JsonObject base = rome.getAsJsonArray("guilds").get(0).getAsJsonObject(); + assertEquals(List.of(brutus), strings(base.get("members"))); + assertEquals(3.0, base.getAsJsonObject("citizen taxes").get(brutus).getAsDouble()); + assertEquals(3.0, base.getAsJsonObject("company").getAsJsonObject("pendingWages").get(brutus).getAsDouble()); + assertEquals(2.0, base.getAsJsonObject("company").getAsJsonObject("activeWageOverrides").get(brutus).getAsDouble()); + JsonObject government = rome.getAsJsonObject("governmentData"); + assertEquals(brutus, government.getAsJsonObject("election votes").getAsJsonObject("LEADER").get(brutus).getAsString()); + assertEquals(3, government.getAsJsonObject("previous votes").getAsJsonObject("LEADER").get(brutus).getAsLong()); + assertEquals(List.of(brutus), strings(government.getAsJsonObject("election candidates").get("LEADER"))); + assertEquals(3, read("Cache/logins.json").get(brutus).getAsInt()); + assertNotEquals(caesar, brutus); + Quarantine after = Quarantine.load(quarantineFile); + assertTrue(after.records().stream().allMatch(record -> record.applied)); + Quarantine.Record first = after.records().iterator().next(); + assertTrue(Files.exists(root.resolve("migrations/backup/assign-" + first.id + "/" + first.store))); + assertEquals(0, migration().run().assignmentsApplied()); + } + + @Test + void anAssignmentWhoseStoreIsGoneOrNoLongerHoldsTheTokenIsStillMarkedApplied() throws IOException { + Path quarantineFile = root.resolve(CharacterMigration.QUARANTINE); + write("Data/rome.json", "{\"id\":\"rome\"}"); + migration().run(); + Quarantine quarantine = Quarantine.load(quarantineFile); + Quarantine.Record gone = new Quarantine.Record(); + gone.id = "qgone"; + gone.store = "Data/gone.json"; + gone.token = "quarantine:qgone"; + gone.original = "Gone"; + gone.assigned = Members.key(UUID.randomUUID()); + Quarantine.Record moved = new Quarantine.Record(); + moved.id = "qmoved"; + moved.store = "Data/rome.json"; + moved.token = "quarantine:qmoved"; + moved.original = "Moved"; + moved.assigned = gone.assigned; + Quarantine.Record open = new Quarantine.Record(); + open.id = "qopen"; + open.store = "Data/rome.json"; + open.token = "quarantine:qopen"; + open.original = "Open"; + quarantine.merge(List.of(gone, moved, open)); + quarantine.save(); + CharacterMigration.Result result = migration().run(); + assertEquals(2, result.assignmentsApplied()); + assertEquals(1, result.quarantined()); + assertFalse(Files.exists(root.resolve("migrations/backup/assign-qmoved/Data/rome.json"))); + } + + @Test + void aRedoneStoreKeepsEarlierAssignments() throws IOException { + Quarantine quarantine = Quarantine.load(root.resolve("q.json")); + Quarantine.Record record = new Quarantine.Record(); + record.id = "q1"; + record.assigned = "abc"; + record.applied = true; + quarantine.merge(List.of(record)); + Quarantine.Record again = new Quarantine.Record(); + again.id = "q1"; + quarantine.merge(List.of(again)); + assertEquals("abc", quarantine.get("q1").assigned); + assertTrue(quarantine.get("q1").applied); + assertNull(quarantine.get(null)); + assertNull(quarantine.get("missing")); + } + + @Test + void unreadableManifestsAndQuarantinesAreErrors() throws IOException { + write(CharacterMigration.MANIFEST, "{oops"); + assertThrows(IOException.class, () -> migration().run()); + write(CharacterMigration.MANIFEST, "null"); + assertThrows(IOException.class, () -> migration().complete()); + Files.delete(root.resolve(CharacterMigration.MANIFEST)); + write(CharacterMigration.QUARANTINE, "{oops"); + assertThrows(IOException.class, () -> migration().run()); + write(CharacterMigration.QUARANTINE, "null"); + assertThrows(IOException.class, () -> Quarantine.load(root.resolve(CharacterMigration.QUARANTINE))); + } + + @Test + void replaceMergesByTheFieldsMeaning() { + assertEquals(StoreMapper.Merge.MIN, CharacterMigration.mergeFor("")); + assertEquals(StoreMapper.Merge.SUM, CharacterMigration.mergeFor("/guilds/pendingWages")); + assertEquals(StoreMapper.Merge.SUM, CharacterMigration.mergeFor("/governmentData/previous votes/LEADER")); + assertEquals(StoreMapper.Merge.FIRST, CharacterMigration.mergeFor("/governmentData/election votes/LEADER")); + assertTrue(CharacterMigration.memberList("/guilds/members")); + assertTrue(CharacterMigration.memberList("/governmentData/election candidates/LEADER")); + assertFalse(CharacterMigration.memberList("/governmentData/proposals")); + JsonElement tree = JsonParser.parseString("{\"a\":[\"t\",\"t\",1],\"b\":{\"x\":\"y\"}}"); + assertTrue(CharacterMigration.replace(tree, "t", "k", "")); + assertEquals("{\"a\":[\"k\",\"k\",1],\"b\":{\"x\":\"y\"}}", tree.toString()); + assertFalse(CharacterMigration.replace(tree, "zzz", "k", "")); + assertEquals(new com.google.gson.JsonPrimitive(1.5), StoreMapper.merge( + new com.google.gson.JsonPrimitive(1), new com.google.gson.JsonPrimitive(0.5), StoreMapper.Merge.SUM)); + assertEquals(new com.google.gson.JsonPrimitive("a"), StoreMapper.merge( + new com.google.gson.JsonPrimitive("a"), new com.google.gson.JsonPrimitive(2), StoreMapper.Merge.SUM)); + } + + @Test + void characterKeysAreLowerCaseCharacterIds() { + UUID id = UUID.randomUUID(); + assertEquals(id.toString(), CharacterMigration.characterKey(" " + id.toString().toUpperCase() + " ")); + assertNull(CharacterMigration.characterKey("Brutus")); + assertNull(CharacterMigration.characterKey(null)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/migration/QuarantineCommandsTest.java b/src/test/java/net/tfminecraft/simplefactions/migration/QuarantineCommandsTest.java new file mode 100644 index 00000000..8303c32c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/migration/QuarantineCommandsTest.java @@ -0,0 +1,161 @@ +package net.tfminecraft.simplefactions.migration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.testsupport.SharedIdentity; +import net.tfminecraft.tlibs.character.CharacterRef; + +class QuarantineCommandsTest { + @TempDir + Path root; + + private SimpleFactions previous; + private final CommandSender staff = mock(CommandSender.class); + + @BeforeEach + void plugin() { + previous = SimpleFactions.plugin; + SimpleFactions plugin = mock(SimpleFactions.class); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + SimpleFactions.plugin = plugin; + when(staff.hasPermission("simplefactions.admin")).thenReturn(true); + } + + @AfterEach + void restore() { + SimpleFactions.plugin = previous; + } + + private Quarantine.Record record(String id, boolean leader) throws IOException { + Quarantine quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + Quarantine.Record record = new Quarantine.Record(); + record.id = id; + record.store = "Data/rome.json"; + record.kind = Quarantine.NAME; + record.original = "Ghost"; + record.token = Members.QUARANTINED + id; + record.occurrences.add(new Quarantine.Occurrence(leader ? "$.guilds[rome].leader" : "$.guilds[rome].members[1]", + new com.google.gson.JsonPrimitive("Ghost"))); + quarantine.merge(List.of(record)); + quarantine.save(); + return record; + } + + @Test + void onlyStaffMayUseIt() { + assertTrue(QuarantineCommands.matches(new String[] {"admin", "QUARANTINE"})); + assertFalse(QuarantineCommands.matches(new String[] {"admin"})); + assertFalse(QuarantineCommands.matches(new String[] {"list", "quarantine"})); + CommandSender player = mock(CommandSender.class); + assertTrue(QuarantineCommands.handle(player, new String[] {"admin", "quarantine", "list"})); + verify(player).sendMessage("§a[SimpleFactions]§c You do not have access to this command"); + } + + @Test + void listsRecordsWithTheirState() throws IOException { + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "list"}); + verify(staff).sendMessage("§aNothing is quarantined."); + + record("q1", true); + Quarantine.Record second = record("q2", false); + second.occurrences.add(second.occurrences.getFirst()); + Quarantine quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + quarantine.get("q2").assigned = "abc"; + quarantine.merge(List.of(second)); + quarantine.get("q2").assigned = "abc"; + Quarantine.Record third = new Quarantine.Record(); + third.id = "q3"; + third.store = "Cache/logins.json"; + third.kind = Quarantine.NAME; + third.original = "Old"; + third.assigned = "def"; + third.applied = true; + quarantine.merge(List.of(third)); + quarantine.get("q3").assigned = "def"; + quarantine.get("q3").applied = true; + quarantine.save(); + + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "list"}); + verify(staff).sendMessage("§eQuarantined faction records:"); + verify(staff).sendMessage(contains("q1 §7name §fGhost §7in Data/rome.json (1 place, holds a leader's seat) §cunresolved")); + verify(staff).sendMessage(contains("q2 §7name §fGhost §7in Data/rome.json (2 places) §6assigned to abc, applies at the next restart")); + verify(staff).sendMessage(contains("q3 §7name §fOld §7in Cache/logins.json (0 places) §agiven to def")); + } + + @Test + void assignsACharacterForTheNextStart() throws IOException { + record("q1", true); + try (var identity = new SharedIdentity()) { + CharacterRef ada = identity.character(identity.account("Steve"), "Ada"); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "q1", + ada.characterId().toString().toUpperCase()}); + verify(staff).sendMessage("§aRecord q1 (Ghost) goes to Ada at the next restart, before factions load."); + assertEquals(Members.key(ada), + Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)).get("q1").assigned); + } + } + + @Test + void refusesUnknownRecordsCharactersAndFinishedRecords() throws IOException { + record("q1", false); + try (var identity = new SharedIdentity()) { + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "nope", "x"}); + verify(staff).sendMessage("§cNo quarantine record nope"); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "q1", "Steve"}); + verify(staff).sendMessage("§cThat is not a character ID"); + String unknown = UUID.randomUUID().toString(); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "q1", unknown}); + verify(staff).sendMessage("§cNo character with ID " + unknown); + Quarantine quarantine = Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)); + quarantine.get("q1").assigned = "done"; + quarantine.get("q1").applied = true; + quarantine.save(); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "q1", unknown}); + verify(staff).sendMessage("§cRecord q1 was already given to done"); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "what"}); + verify(staff).sendMessage("§cUsage: /faction admin quarantine list | assign "); + } + } + + @Test + void reportsAnUnreadableOrUnwritableQuarantine() throws IOException { + Files.createDirectories(root.resolve("migrations")); + Files.writeString(root.resolve(CharacterMigration.QUARANTINE), "{oops"); + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "list"}); + verify(staff).sendMessage(startsWith("§cCannot read the quarantine: ")); + + Files.delete(root.resolve(CharacterMigration.QUARANTINE)); + record("q1", false); + try (var identity = new SharedIdentity()) { + CharacterRef ada = identity.character(identity.account("Steve"), "Ada"); + // A directory where the temporary file would go makes the save fail. + Path migrations = root.resolve("migrations"); + migrations.toFile().setWritable(false); + try { + QuarantineCommands.handle(staff, new String[] {"admin", "quarantine", "assign", "q1", ada.characterId().toString()}); + } finally { + migrations.toFile().setWritable(true); + } + verify(staff).sendMessage("§cCannot save the quarantine; nothing changed"); + assertNull(Quarantine.load(root.resolve(CharacterMigration.QUARANTINE)).get("q1").assigned); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/SharedIdentity.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/SharedIdentity.java new file mode 100644 index 00000000..5851e05a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/SharedIdentity.java @@ -0,0 +1,165 @@ +package net.tfminecraft.simplefactions.testsupport; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.SimpleServicesManager; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; + +/** + * The real TLibs character facade over a mocked provider, with Bukkit's + * server boundary stubbed, and {@link Members} reading from it. Accounts and + * characters are created by the test. + */ +public final class SharedIdentity implements AutoCloseable { + public final MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class); + public final Server server = mock(Server.class); + public final SimpleServicesManager services = new SimpleServicesManager(); + public final Plugin plugin = mock(Plugin.class); + public final PluginManager plugins = mock(PluginManager.class); + public final CharacterIdentity provider = mock(CharacterIdentity.class); + public final Map sessions = new HashMap<>(); + public final Map infos = new HashMap<>(); + public final Map heirs = new HashMap<>(); + public final Map online = new HashMap<>(); + public final Map connections = new HashMap<>(); + public final Map cached = new HashMap<>(); + public boolean heirsReady = true; + private long generation; + + public SharedIdentity() { + when(server.getPluginManager()).thenReturn(plugins); + when(server.getServicesManager()).thenReturn(services); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> new ArrayList<>(connections.values())); + bukkit.when(() -> Bukkit.getPlayerExact(anyString())).thenAnswer(call -> online.get(call.getArgument(0))); + bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenAnswer(call -> connections.get(call.getArgument(0))); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())).thenAnswer(call -> cached.get(call.getArgument(0))); + bukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenAnswer(call -> { + OfflinePlayer account = mock(OfflinePlayer.class); + UUID id = call.getArgument(0); + for (Map.Entry entry : cached.entrySet()) { + if (id.equals(entry.getValue().getUniqueId())) when(account.getName()).thenReturn(entry.getKey()); + } + return account; + }); + when(provider.currentSession(any())).thenAnswer(call -> { + Player player = call.getArgument(0); + return Optional.ofNullable(sessions.get(player.getUniqueId())); + }); + when(provider.isCurrent(any())).thenAnswer(call -> { + CharacterSession session = call.getArgument(0); + return session != null && session.equals(sessions.get(session.ref().ownerId())); + }); + when(provider.info(any())).thenAnswer(call -> Optional.ofNullable(infos.get(call.getArgument(0)))); + when(provider.characters(any())).thenAnswer(call -> { + UUID owner = call.getArgument(0); + List list = new ArrayList<>(); + for (CharacterInfo info : infos.values()) if (info.ref().ownerId().equals(owner)) list.add(info); + return Optional.of(list); + }); + when(provider.legacyHeir(any())).thenAnswer(call -> Optional.ofNullable(heirs.get(call.getArgument(0)))); + when(provider.legacyHeirsReady()).thenAnswer(call -> heirsReady); + register(); + Members.useSharedIdentity(); + } + + /** A known account: cached by name, offline. */ + public UUID account(String name) { + UUID owner = UUID.nameUUIDFromBytes(("account:" + name).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + OfflinePlayer player = mock(OfflinePlayer.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn(name); + cached.put(name, player); + return owner; + } + + /** A living character of {@code owner}. */ + public CharacterRef character(UUID owner, String name, String... traits) { + return character(owner, name, CharacterStatus.ALIVE, traits); + } + + public CharacterRef character(UUID owner, String name, CharacterStatus status, String... traits) { + CharacterRef ref = new CharacterRef(owner, UUID.randomUUID()); + infos.put(ref.characterId(), new CharacterInfo(ref, name, status, null, Set.of(traits), 0, Instant.EPOCH)); + return ref; + } + + /** An online connection for an account, playing nothing yet. */ + public Player connect(String accountName) { + UUID owner = account(accountName); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn(accountName); + when(player.isOnline()).thenReturn(true); + online.put(accountName, player); + connections.put(owner, player); + return player; + } + + /** Starts a new session of {@code ref} for its online account. */ + public CharacterSession play(Player player, CharacterRef ref) { + CharacterSession session = new CharacterSession(ref, ++generation); + sessions.put(player.getUniqueId(), session); + return session; + } + + public void stopPlaying(Player player) { + sessions.remove(player.getUniqueId()); + } + + public static String key(CharacterRef ref) { + return Members.key(ref); + } + + public void register() { + services.register(CharacterIdentity.class, provider, plugin, ServicePriority.Normal); + } + + public void unregister() { + services.unregister(CharacterIdentity.class, provider); + } + + public Collection players() { + return connections.values(); + } + + @Override + public void close() { + try { + unregister(); + NameKeyedMembers.install(); + } finally { + bukkit.close(); + } + } +} From 624b8875edac796831fa1884c1c5beefcdfe7800 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:24:44 +0000 Subject: [PATCH 07/35] refactor: key menus and tab completion by character Menus, creators and tab completion use member keys: lookups by the current character, names shown through Members, heads from the account that plays the character. Plugin menus capture the opener's character session and close with a short message when clicked after a switch. Co-Authored-By: Claude Opus 5.5 --- .../managers/InventoryManager.java | 64 +++++- .../CampaignInstallationPickView.java | 2 +- .../inventory/CampaignRaidLaunchView.java | 3 +- .../managers/inventory/CompanyCreator.java | 11 +- .../managers/inventory/CompanyView.java | 8 +- .../managers/inventory/ContractView.java | 6 +- .../managers/inventory/ElectionCreator.java | 12 +- .../managers/inventory/ElectionView.java | 16 +- .../managers/inventory/EspionageView.java | 22 +-- .../managers/inventory/FactionCreator.java | 14 +- .../managers/inventory/GovernmentCreator.java | 26 ++- .../managers/inventory/GovernmentView.java | 28 +-- .../managers/inventory/GuildCreator.java | 9 +- .../managers/inventory/MemberHeads.java | 18 ++ .../managers/inventory/MovementCreator.java | 18 +- .../managers/inventory/MovementView.java | 38 ++-- .../managers/inventory/ReportedMenus.java | 6 +- .../managers/inventory/VehicleFeeView.java | 4 +- .../simplefactions/utils/TabCompletion.java | 57 +++--- .../InventoryManagerRegressionTest.java | 8 +- .../InventoryManagerStaleMenuTest.java | 184 ++++++++++++++++++ .../managers/TabCompletionCoverageTest.java | 49 ++++- .../inventory/CharacterKeyFixture.java | 89 +++++++++ .../inventory/FactionMenusCoverageTest.java | 4 - .../GovernmentMenusCoverageTest.java | 4 - .../inventory/MemberNamesInMenusTest.java | 81 ++++++++ 26 files changed, 622 insertions(+), 159 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/MemberHeads.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerStaleMenuTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/managers/inventory/CharacterKeyFixture.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/managers/inventory/MemberNamesInMenusTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index 23412cc5..db725175 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -10,6 +10,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -18,11 +20,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.persistence.PersistentDataType; @@ -97,6 +102,7 @@ import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; public class InventoryManager implements Listener{ @@ -112,7 +118,11 @@ public class InventoryManager implements Listener{ public HashMap dividendChange = new HashMap<>(); public HashMap donationChange = new HashMap<>(); public HashMap slotChanges = new HashMap<>(); + /** The character session each open menu belongs to, by account. */ + private final Map> menuSessions = new HashMap<>(); + private static final String CONFIRM_TITLE = "§7Confirm Action"; + InventoryUpdater updater = new InventoryUpdater(this); public InventoryUpdater getUpdater() { @@ -529,7 +539,7 @@ public void taxChat(Player p, AsyncPlayerChatEvent e) { p.sendMessage("§4Type 'cancel' to cancel."); return; } - Proposal proposal = new Proposal(p.getName(), f.getGovernment()); + Proposal proposal = new Proposal(Members.key(p), f.getGovernment()); TaxLawChange tax = new TaxLawChange(change.getTarget(), change.getId(), amount); proposal.setTaxProposal(tax); taxChange.remove(p); @@ -560,18 +570,18 @@ private void submitRateProposal(Player p, Faction f, Proposal proposal) { } else if(gov.canProposeOrStartMovement(p)) { Movement movement = gov.getMovementByMember(Members.key(p)); if(movement != null) { - movement.createCause(p.getName(), proposal); + movement.createCause(Members.key(p), proposal); p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); return; } - if (net.tfminecraft.simplefactions.war.civilwar.CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) { + if (net.tfminecraft.simplefactions.war.civilwar.CivilWarHostMovementRules.blocksHostGuildStart(f, Members.key(p))) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.ONE_PROVINCE_HOST_GUILD); return; } - gov.startMovement(p.getName(), proposal); + gov.startMovement(Members.key(p), proposal); p.sendTitle("", "§cMovement Started", 20, 80, 20); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!"); @@ -620,7 +630,7 @@ public void feeChat(Player p, AsyncPlayerChatEvent e) { p.sendMessage("§4Type 'cancel' to cancel."); return; } - Proposal proposal = new Proposal(p.getName(), f.getGovernment()); + Proposal proposal = new Proposal(Members.key(p), f.getGovernment()); proposal.setFeeProposal(new FeeChange(kind, change.getVehicleTypeId(), amount)); feeChange.remove(p); submitRateProposal(p, f, proposal); @@ -831,7 +841,7 @@ public void slotChat(Player p, AsyncPlayerChatEvent e) { return; } MercenaryResult result = company.getContractHandler() - .proposeSlots(contract.getId(), p.getName(), slots, System.currentTimeMillis()); + .proposeSlots(contract.getId(), Members.key(p), slots, System.currentTimeMillis()); slotChanges.remove(p); p.sendMessage((result.ok() ? "§a" : "§c") + result.message()); p.playSound(p, result.ok() ? Sound.ENTITY_PLAYER_LEVELUP : Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); @@ -1059,6 +1069,10 @@ public ItemStack createBackButton(SFGUI gui) { @EventHandler public void dragInWarGui(InventoryDragEvent e) { + if (e.getWhoClicked() instanceof Player p && closeIfStale(p, e.getView())) { + e.setCancelled(true); + return; + } Inventory inv = e.getView().getTopInventory(); // Menu contents are replaced on redraw. Keep player items out of every menu, // including holderless confirmations, while allowing drags within their own inventory. @@ -1074,6 +1088,38 @@ public void clearPendingOnQuit(PlayerQuitEvent e) { confirming.remove(e.getPlayer()); } slotChanges.remove(e.getPlayer()); + menuSessions.remove(e.getPlayer().getUniqueId()); + } + + @EventHandler + public void captureMenuSession(InventoryOpenEvent e) { + if (e.getPlayer() instanceof Player p && isMenu(e.getView())) { + menuSessions.put(p.getUniqueId(), Members.session(p)); + } + } + + @EventHandler + public void releaseMenuSession(InventoryCloseEvent e) { + menuSessions.remove(e.getPlayer().getUniqueId()); + } + + private static boolean isMenu(InventoryView view) { + return isPluginMenu(view.getTopInventory()) || CONFIRM_TITLE.equals(view.getTitle()); + } + + /** + * Closes a menu opened for a character the player is no longer playing. A + * menu opened before its session was captured takes the current one. + */ + private boolean closeIfStale(Player p, InventoryView view) { + if (!isMenu(view)) return false; + Optional current = Members.session(p); + Optional opened = menuSessions.computeIfAbsent(p.getUniqueId(), id -> current); + if (opened.isPresent() && Members.isCurrent(opened.get()) && opened.equals(current)) return false; + menuSessions.remove(p.getUniqueId()); + p.closeInventory(); + p.sendMessage("§cThat menu belonged to your previous character."); + return true; } private static boolean isPluginMenu(Inventory inv) { @@ -1096,6 +1142,10 @@ public static boolean isTopInventoryClick(int rawSlot, int topSize) { @EventHandler public void clickButton(InventoryClickEvent e) { Player p = (Player) e.getWhoClicked(); + if (closeIfStale(p, e.getView())) { + e.setCancelled(true); + return; + } Inventory inv = e.getView().getTopInventory(); if (inv.getHolder() instanceof SFInventoryHolder && ((SFInventoryHolder) inv.getHolder()).getType() == SFGUI.PLAYER_LEDGER_VIEW) { @@ -1691,7 +1741,7 @@ public void clickButton(InventoryClickEvent e) { p.closeInventory(); return; } - MercenaryResult result = MercenaryCompanyService.requestFormation(guild, p.getName(), name); + MercenaryResult result = MercenaryCompanyService.requestFormation(guild, Members.key(p), name); p.sendMessage((result.ok() ? "§a" : "§c") + result.message()); if (!result.ok()) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java index a4b5611b..b31e7e0a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java @@ -177,7 +177,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player player) { String installationName = installation != null ? installation.getName() : installationId; InstallationPickToggleResult result = BattleInstallationPickService.togglePick( - war, viewerFaction, player.getName(), installationId); + war, viewerFaction, Members.key(player), installationId); player.sendMessage(toggleMessage(result, installationName)); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java index 86730dbc..185b38e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java @@ -7,7 +7,6 @@ import java.time.Instant; import java.util.List; -import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.entity.Player; @@ -269,7 +268,7 @@ private void broadcastRaidCalled(War war, Faction launcher, Installation target) String message = CampaignRaidMessages.buildRaidCalledMessage(launcher, target, raid.getId()); Side side = war.getSide(launcher); for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { - Player online = Bukkit.getPlayerExact(memberName); + Player online = Members.player(memberName); if (online != null && online.isOnline()) { online.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java index 9acb27a1..a53caf14 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import java.util.ArrayList; import java.util.List; @@ -61,7 +63,7 @@ public List buildFormingLore(MercenaryCompany company) { List lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#baa875Being founded")); lore.add("§7Ready in: §e" + TimeFormatter.formatTime(company.getFormationRemaining())); - lore.add("§7Leader: §e" + company.getLeader()); + lore.add("§7Leader: §e" + Members.display(company.getLeader())); lore.add(" "); lore.add(StringFormatter.formatHex("#877e7cOpens with one slot, costing " + Formatter.formatMoney(Cache.mercenarySlotUpkeep) + "d a day")); @@ -100,7 +102,7 @@ public List buildFoundConfirmLore(Guild guild) { public List buildCompanyLore(Guild guild, MercenaryCompany company) { List lore = new ArrayList<>(); - lore.add("§7Leader: §e" + company.getLeader()); + lore.add("§7Leader: §e" + Members.display(company.getLeader())); lore.add("§7Home: §e" + HomeSettlementNames.of(guild)); lore.add("§7Slots: §e" + company.getFilledSlots() + "/" + company.getSlots()); lore.add("§7Reputation: " + company.getReputationString()); @@ -232,7 +234,7 @@ public List buildSlotLore(String occupant) { lore.add(StringFormatter.formatHex("#877e7cAn empty slot blocks expansion")); lore.add(StringFormatter.formatHex("#877e7cClick to remove this unused slot")); } else { - lore.add("§7Mercenary: §e" + occupant); + lore.add("§7Mercenary: §e" + Members.display(occupant)); lore.add(StringFormatter.formatHex("#877e7cDismiss from Roster before removing a slot")); } lore.add("§7Upkeep: §e" + Formatter.formatMoney(Cache.mercenarySlotUpkeep) + "d"); @@ -291,7 +293,8 @@ public ItemStack createExpandButton(MercenaryCompany company) { public ItemStack createRosterItem(MercenaryCompany company, String member, boolean leader) { ItemStack item = new ItemStack(Material.PLAYER_HEAD, 1); ItemMeta meta = item.getItemMeta(); - meta.setDisplayName("§e" + member); + meta.setDisplayName("§e" + Members.display(member)); + MemberHeads.apply(meta, member); List lore = new ArrayList<>(); lore.add("§7Company: §e" + company.getName()); if (leader) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java index 2fb06c8d..8b95dc9d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java @@ -213,13 +213,13 @@ private void clickSlots( int visibleSlots = Math.min(company.getSlots(), LAST_SLOT_BUTTON - FIRST_SLOT_BUTTON + 1); int slotIndex = company.getSlots() - visibleSlots + slot - FIRST_SLOT_BUTTON; if (slotIndex < company.getSlots() && slotIndex >= company.getEnlisted().size()) { - report(p, MercenaryCompanyService.removeSlot(guild, p.getName())); + report(p, MercenaryCompanyService.removeSlot(guild, Members.key(p))); slotsView(p, guild, inventory); } return; } if (e.getSlot() == EXPAND_BUTTON) { - report(p, MercenaryCompanyService.expand(guild, p.getName())); + report(p, MercenaryCompanyService.expand(guild, Members.key(p))); slotsView(p, guild, inventory); return; } @@ -235,7 +235,7 @@ private void clickRoster( InventoryClickEvent e, Inventory inventory, Player p, Guild guild, MercenaryCompany company) { String member = stringKey(e.getCurrentItem()); if (member == null) return; - report(p, MercenaryCompanyService.kick(guild, p.getName(), member)); + report(p, MercenaryCompanyService.kick(guild, Members.key(p), member)); rosterView(p, guild, inventory); } @@ -250,7 +250,7 @@ private void clickUpgrade( } String id = stringKey(e.getCurrentItem()); if (id == null) return; - report(p, MercenaryCompanyService.upgrade(guild, p.getName(), id)); + report(p, MercenaryCompanyService.upgrade(guild, Members.key(p), id)); companyUpgradeView(p, guild, inventory); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java index b3aef9db..e28e2991 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java @@ -179,7 +179,7 @@ private void clickDetail( long now = System.currentTimeMillis(); if (e.getSlot() == CHANGE_SLOTS_BUTTON && company.isLeader(Members.key(p))) { if (contract.hasPendingSlots(now)) { - report(p, company.getContractHandler().withdrawSlots(contractId, p.getName(), now)); + report(p, company.getContractHandler().withdrawSlots(contractId, Members.key(p), now)); detailView(p, guild, inventory, contractId); } else { inv.beginSlotChange(p, guild, contract); @@ -189,9 +189,9 @@ private void clickDetail( if (!contract.hasPendingSlots(now) || !canSign(p, contract)) return; switch (e.getSlot()) { case ACCEPT_SLOTS_BUTTON -> report(p, company.getContractHandler() - .acceptSlots(contractId, contract.getHirer(), p.getName(), now)); + .acceptSlots(contractId, contract.getHirer(), Members.key(p), now)); case DECLINE_SLOTS_BUTTON -> report(p, company.getContractHandler() - .declineSlots(contractId, contract.getHirer(), p.getName(), now)); + .declineSlots(contractId, contract.getHirer(), Members.key(p), now)); default -> { return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java index ca316a72..36ddfb0e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionCreator.java @@ -42,22 +42,22 @@ public ItemStack createCandidateTypeItem(Player p, Faction f, Candidate c) { } else { lore.add(StringFormatter.formatHex("#67cc64Candidates:")); for(String candidate : candidates) { - lore.add(StringFormatter.formatHex("§f- #e3d5a1"+candidate + " §7(" + Represents.represents(f, candidate) +"§7)")); + lore.add(StringFormatter.formatHex("§f- #e3d5a1"+Members.display(candidate) + " §7(" + Represents.represents(f, candidate) +"§7)")); } } lore.add(""); - if(e.isCandiate(c, p.getName())) { + if(e.isCandiate(c, Members.key(p))) { // Already a candidate if(!e.isActive()) { lore.add(StringFormatter.formatHex("#ba7872Click to withdraw")); } - } else if(!e.canBeCandidate(c, p.getName())) { + } else if(!e.canBeCandidate(c, Members.key(p))) { e.applyReasons(lore, c, p); } else { lore.add(StringFormatter.formatHex("#28ed70Click to sign up")); } if(gov.hasElection() && e.canVote(Members.key(p))){ - if(!e.hasVoted(c, p.getName())) { + if(!e.hasVoted(c, Members.key(p))) { lore.add(StringFormatter.formatHex("#28ed70Click to view candidates")); } else { lore.add(StringFormatter.formatHex("#ab483fYou have already voted for a candidate")); @@ -75,8 +75,8 @@ public ItemStack createCandidateItem(Faction f, Candidate candidateType, String ItemStack item = new ItemStack(org.bukkit.Material.PLAYER_HEAD); SkullMeta skull = (SkullMeta) item.getItemMeta(); skull.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - skull.setOwningPlayer(org.bukkit.Bukkit.getOfflinePlayer(candidateName)); - skull.setDisplayName(StringFormatter.formatHex("#84c468" + candidateName)); + MemberHeads.apply(skull, candidateName); + skull.setDisplayName(StringFormatter.formatHex("#84c468" + Members.display(candidateName))); List lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#67cc64" + Represents.represents(f, candidateName))); skull.setLore(lore); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java index 855bb9fa..ffb33ced 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java @@ -105,17 +105,17 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Candidate c = Candidate.valueOf(id); Election election = f.getGovernment().getElection(); if(election.isActive()) { - if(election.canVote(Members.key(p)) && !election.hasVoted(c, p.getName())) { + if(election.canVote(Members.key(p)) && !election.hasVoted(c, Members.key(p))) { votingView(p, f, c); } } else { - if(election.isCandiate(c, p.getName())) { - election.removeCandidate(c, p.getName()); + if(election.isCandiate(c, Members.key(p))) { + election.removeCandidate(c, Members.key(p)); p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); p.sendMessage("§aWithdrawn from candidacy for "+c.getName()); electionView(p, f); - } else if(election.canBeCandidate(c, p.getName())) { - election.addCandidate(c, p.getName()); + } else if(election.canBeCandidate(c, Members.key(p))) { + election.addCandidate(c, Members.key(p)); p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); p.sendMessage("§aSigned up as a candidate for "+c.getName()); electionView(p, f); @@ -132,11 +132,11 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { try { Candidate type = Candidate.valueOf(cid); if(election.canVote(Members.key(p)) && election.isActive()) { - if(!election.hasVoted(type, p.getName()) && + if(!election.hasVoted(type, Members.key(p)) && election.getCandidates(type).contains(candidateName)) { - election.addVote(type, p.getName(), candidateName); + election.addVote(type, Members.key(p), candidateName); p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); - p.sendMessage("§aVoted for " + candidateName + " as " + type.getName()); + p.sendMessage("§aVoted for " + Members.display(candidateName) + " as " + type.getName()); p.closeInventory(); return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 29194aa2..72f8aaca 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -3,7 +3,6 @@ import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.espionage.EspionageService; -import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.espionage.IntelligenceLedger; import net.tfminecraft.simplefactions.espionage.SpecialPosition; import net.tfminecraft.simplefactions.espionage.EspionageConfig; @@ -76,7 +75,7 @@ static ItemStack reportHeader(Player viewer, IntelligenceReport report) { var spy = observer == null ? null : observer.getEspionage().getSpymaster(); boolean missingSpymaster = !EspionageService.hasSpymaster(observer); String last = missingSpymaster ? "\u00a77The Spymaster's office stands vacant; no findings reach your court." - : "\u00a77These are today\u2019s findings, delivered by your spymaster, " + CharacterNames.of(spy.playerName) + "."; + : "\u00a77These are today\u2019s findings, delivered by your spymaster, " + Members.display(spy.playerName) + "."; List lore = new ArrayList<>(List.of("\u00a77Report quality: \u00a7e" + (missingSpymaster ? "Absent" : report == null ? IntelligenceTier.UNKNOWN.label() : report.qualityLabel()), report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last)); @@ -163,12 +162,12 @@ public static ItemStack factionItem(Player viewer, Faction target, MenuItemType return switch (type) { case GOVERNMENT -> { ItemStack head = styled(Material.PLAYER_HEAD, "#93c9a7Government:", - "#9c9775§l" + target.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, target.getLeader(), target), + "#9c9775§l" + target.getRulerTitle() + ": #c2bea7" + Members.display(viewer, target.getLeader()), "#85c265Administrative Power§7: §e" + value(report, "Administrative power", ""), "#85c265Stability: " + IntelligenceReport.stabilityRange(report), " ", "#b8ae61Ruling System: #d4c9ae" + target.getGovernmentString()); SkullMeta meta = (SkullMeta) head.getItemMeta(); - meta.setOwningPlayer(Bukkit.getOfflinePlayer(target.getLeader())); + MemberHeads.apply(meta, target.getLeader()); head.setItemMeta(meta); yield head; } @@ -221,7 +220,7 @@ public static ItemStack foreignPositionsItem(Player viewer, Faction faction) { for (var office : SpecialPosition.values()) { var holder = EspionageService.canViewExact(viewer, faction) ? faction.getEspionage().holder(office) : null; String name = EspionageService.canViewExact(viewer, faction) - ? holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName) + ? holder == null ? "Vacant" : Members.display(viewer, holder.playerName) : report == null ? "Unknown" : report.officeHolder(office); String aptitude = EspionageService.canViewExact(viewer, faction) ? Integer.toString(switch (office) { @@ -253,7 +252,7 @@ public static void positions(Player viewer, Faction faction, InventoryManager ma for (SpecialPosition office : SpecialPosition.values()) { var holder = office == SpecialPosition.SPYMASTER ? EspionageService.spymaster(faction) : faction.getEspionage().holder(office); ItemStack option = item(Material.ENDER_EYE, office.label(), - "\u00a77Office holder: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), + "\u00a77Office holder: " + (holder == null ? "Vacant" : Members.display(viewer, holder.playerName)), "\u00a7aClick to inspect this office."); var meta = option.getItemMeta(); meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "special-office"), @@ -271,7 +270,7 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana 27, "\u00a77Spymaster's Office"); SpecialPositionAssignment holder = EspionageService.spymaster(faction); long now = System.currentTimeMillis(); - ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), + ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : Members.display(viewer, holder.playerName)), "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", buildUpLine(holder, now), holder == null ? "§7Falls to the faction leader once they have an active character." @@ -378,17 +377,18 @@ private static ItemStack conduct(boolean offense, int reduction) { private static void candidates(Player viewer, Faction faction, InventoryManager manager, int page) { if (!EspionageService.isOwn(viewer, faction) || !faction.isLeader(Members.key(viewer))) return; List members = faction.getMembers().stream().distinct() - .filter(name -> EspionageService.eligible(faction, name)).sorted(String.CASE_INSENSITIVE_ORDER).toList(); + .filter(name -> EspionageService.eligible(faction, name)) + .sorted(java.util.Comparator.comparing(Members::display, String.CASE_INSENSITIVE_ORDER)).toList(); int safePage = Math.max(0, Math.min(page, Math.max(0, (members.size() - 1) / 45))); Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_SELECT, safePage), 54, "§7Appoint Spymaster"); int start = safePage * 45; for (int index = start; index < Math.min(start + 45, members.size()); index++) { String name = members.get(index); - ItemStack head = item(Material.PLAYER_HEAD, CharacterNames.display(viewer, name), + ItemStack head = item(Material.PLAYER_HEAD, Members.display(viewer, name), "§7Guild: " + net.tfminecraft.simplefactions.utils.Represents.represents(faction, name), buildUpTerms(), - Bukkit.getPlayerExact(name) == null ? "§8Must be online with an active character." : "§aClick to appoint as Spymaster."); + !Members.isOnline(name) ? "§8Must be online with an active character." : "§aClick to appoint as Spymaster."); var meta = head.getItemMeta(); meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "spy-candidate"), PersistentDataType.STRING, name); @@ -468,7 +468,7 @@ else if (slot == 11 || slot == 15) { else if (event.getCurrentItem() != null && event.getCurrentItem().hasItemMeta()) { String candidate = event.getCurrentItem().getItemMeta().getPersistentDataContainer() .get(new NamespacedKey(SimpleFactions.plugin, "spy-candidate"), PersistentDataType.STRING); - if (candidate != null && EspionageService.appoint(viewer, faction, Bukkit.getPlayerExact(candidate))) { + if (candidate != null && EspionageService.appoint(viewer, faction, Members.player(candidate))) { spymasterOffice(viewer, faction, manager); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 6d0dd38c..e9bea4e7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -3,14 +3,12 @@ import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.espionage.EspionageService; -import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.util.LegacyModelData; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; -import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; @@ -95,7 +93,7 @@ public List listLore(Player p, Faction f) { if(realmSize > 0 && realmSize-f.getProvinces().size() > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize+" #a39ba8("+(realmSize-f.getProvinces().size())+" from subjects)")); else if(realmSize > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize)); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); + lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); lore.add(StringFormatter.formatHex("#b8ae61Culture: #d4c9ae"+f.getCulture())); lore.add(StringFormatter.formatHex("#b8ae61Religion: #d4c9ae"+f.getReligion())); @@ -294,8 +292,8 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); - m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); + m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); + MemberHeads.apply(m, f.getLeader()); List lore = new ArrayList(); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); m.setLore(lore); @@ -305,10 +303,10 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); m.setDisplayName(StringFormatter.formatHex("#93c9a7Government:")); - m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); + MemberHeads.apply(m, f.getLeader()); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; @@ -408,7 +406,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); List memberNames = new ArrayList<>(f.getMembers()); - memberNames.removeIf(name -> name.equalsIgnoreCase(f.getLeader())); + memberNames.removeIf(name -> name.equals(f.getLeader())); memberNames.add(0, f.getLeader()); m.setDisplayName(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+memberNames.size())); List lore = net.tfminecraft.simplefactions.espionage.RosterLore.faction(p, f).stream().map(StringFormatter::formatHex).toList(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index 9ee81d0b..465617cb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java @@ -69,7 +69,7 @@ public ItemStack createGovernmentItem(Player p, Faction f) { m.setDisplayName(StringFormatter.formatHex("#93c9a7Government:")); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.espionage.CharacterNames.display(p, f.getLeader(), f))); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; @@ -98,7 +98,7 @@ public ItemStack createCouncilItem(Faction f) { if(gov.getCouncil().getCurrentSize() > 0) { lore.add(StringFormatter.formatHex("#93c9a7Members:")); for(String member : gov.getCouncilMembers()) { - lore.add(StringFormatter.formatHex("#d4bb98- "+member + " §7("+Represents.represents(f, member)+")")); + lore.add(StringFormatter.formatHex("#d4bb98- "+Members.display(member) + " §7("+Represents.represents(f, member)+")")); } } m.setLore(lore); @@ -146,7 +146,7 @@ public ItemStack createElectionItem(Player p, Faction f) { color = "#c74d32"; // ineligible } - lore.add(formatElectionLine(i++, name, votes, totalVotes, color)); + lore.add(formatElectionLine(i++, Members.display(name), votes, totalVotes, color)); } } @@ -175,7 +175,7 @@ public ItemStack createElectionItem(Player p, Faction f) { color = "#c74d32"; // ineligible } - lore.add(formatElectionLine(i++, name, votes, totalVotes, color)); + lore.add(formatElectionLine(i++, Members.display(name), votes, totalVotes, color)); } } } @@ -456,7 +456,7 @@ public ItemStack createTaxTypeItem(Player p, Faction f, TaxTarget target, boolea else lore.add(StringFormatter.formatHex("#28ed70Click to specific rates")); } else { Government gov = f.getGovernment(); - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); proposal.setTaxProposal(new TaxLawChange(target, "all", 50)); if(gov.canProposeOrStartMovement(p) && gov.canBeProposed(proposal)) { lore.add(StringFormatter.formatHex("#525d5dCurrent Rate: #e3d5a1"+f.getTaxRate(target, null, false)+"%"+ " §8(§7"+f.getTaxRate(target, null, true)+"% effective§8)")); @@ -514,7 +514,7 @@ public ItemStack createSpecificTaxItem(Player p, Faction f, String id, TaxTarget lore.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a"+baseRate+"%#3f4040)")); } Government gov = f.getGovernment(); - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); proposal.setTaxProposal(new TaxLawChange(target, id, 50)); if(gov.canProposeOrStartMovement(p) && gov.canBeProposed(proposal)) lore.add(StringFormatter.formatHex("#28ed70Click to propose a change")); else lore.add(StringFormatter.formatHex("#89504eAnother proposal is active for this target.")); @@ -619,9 +619,7 @@ public ItemStack createCouncilMemberItem(Player player, Faction f, int slot) { } else { item = new ItemStack(Material.PLAYER_HEAD); ItemMeta skullMeta = item.getItemMeta(); - if(skullMeta instanceof org.bukkit.inventory.meta.SkullMeta) { - ((org.bukkit.inventory.meta.SkullMeta) skullMeta).setOwner(memberName); - } + MemberHeads.apply(skullMeta, memberName); item.setItemMeta(skullMeta); } @@ -638,7 +636,7 @@ public ItemStack createCouncilMemberItem(Player player, Faction f, int slot) { } m.setLore(lore); } else { - m.setDisplayName(StringFormatter.formatHex("#93c9a7" + memberName)); + m.setDisplayName(StringFormatter.formatHex("#93c9a7" + Members.display(memberName))); List lore = new ArrayList<>(); // Display wealth and ranking @@ -685,16 +683,14 @@ public ItemStack createPotentialMemberItem(Player player, Faction f, String memb ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemMeta skullMeta = item.getItemMeta(); - if(skullMeta instanceof org.bukkit.inventory.meta.SkullMeta) { - ((org.bukkit.inventory.meta.SkullMeta) skullMeta).setOwner(member); - } + MemberHeads.apply(skullMeta, member); item.setItemMeta(skullMeta); ItemMeta m = item.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#93c9a7" + member)); + m.setDisplayName(StringFormatter.formatHex("#93c9a7" + Members.display(member))); List lore = new ArrayList<>(); // Display wealth and ranking @@ -739,7 +735,7 @@ public ItemStack createCurrentProposalItem(Player p, Faction f, Proposal proposa m.setDisplayName(StringFormatter.formatHex(proposal.isLawProposal() ? "#93c9a7Law Proposal" : proposal.isFeeProposal() ? "#93c9a7Fee Proposal" : "#93c9a7Tax Proposal")); List lore = new ArrayList(); - lore.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7"+proposal.getProposer())); + lore.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7"+Members.display(proposal.getProposer()))); LoreWriter.applyProposalLore(proposal, lore, p, f, m); m.setLore(lore); item.setItemMeta(m); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 1e554b17..f484f7b9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -77,7 +77,7 @@ public void governmentView(Player player, Faction f, Inventory i) { i.setItem(24, creator.createProposalListItem(player, f)); i.setItem(33, creator.createMovementListItem(player, f)); Government gov = f.getGovernment(); - if(gov.getCouncil().canBeMember(player.getName(), true, true)) i.setItem(21, creator.createToggleRefuseButton(player, f)); + if(gov.getCouncil().canBeMember(Members.key(player), true, true)) i.setItem(21, creator.createToggleRefuseButton(player, f)); if(gov.hasElections()) i.setItem(14, creator.createElectionItem(player, f)); if(gov.getCouncil().canHostSession() && Members.is(player, f.getLeader())) { i.setItem(23, creator.createStartCouncilButton(player, f)); @@ -371,7 +371,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Government gov = f.getGovernment(); // Foreign viewers see this menu too; only those offered the button may use it. if(!EspionageService.canViewExact(p, f) - || !gov.getCouncil().canBeMember(p.getName(), true, true)) return; + || !gov.getCouncil().canBeMember(Members.key(p), true, true)) return; gov.getCouncil().toggleRefuse(Members.key(p)); governmentView(p, f, inventory); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -518,7 +518,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } Government gov = f.getGovernment(); double cost = law.getCost()*law.getCompatibility(f.getLawHandler().getGroup(group).getCurrent().getId()); - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); proposal.setLawProposal(law); if(!gov.hasCouncil() && f.isLeader(Members.key(p))) { if(gov.getPower() < cost) { @@ -554,18 +554,18 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } - movement.createCause(p.getName(), proposal); + movement.createCause(Members.key(p), proposal); p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); return; } - if (CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) { + if (CivilWarHostMovementRules.blocksHostGuildStart(f, Members.key(p))) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage(CivilWarCopy.ONE_PROVINCE_HOST_GUILD); return; } - gov.startMovement(p.getName(), proposal); + gov.startMovement(Members.key(p), proposal); p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); @@ -595,7 +595,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { return; } - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); PoliticalAction politicalAction = PoliticalActionLoader.getByAction(action); proposal.setPoliticalActionProposal(politicalAction); @@ -606,18 +606,18 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } - movement.createCause(p.getName(), proposal); + movement.createCause(Members.key(p), proposal); p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); return; } - if (CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) { + if (CivilWarHostMovementRules.blocksHostGuildStart(f, Members.key(p))) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage(CivilWarCopy.ONE_PROVINCE_HOST_GUILD); return; } - gov.startMovement(p.getName(), proposal); + gov.startMovement(Members.key(p), proposal); p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); @@ -864,7 +864,7 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); return; } - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); PoliticalAction politicalAction = PoliticalActionLoader.getByAction(action); proposal.setPoliticalActionProposal(politicalAction); proposal.setTarget(warId); @@ -892,18 +892,18 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } - movement.createCause(p.getName(), proposal); + movement.createCause(Members.key(p), proposal); p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); return; } - if (CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) { + if (CivilWarHostMovementRules.blocksHostGuildStart(f, Members.key(p))) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage(CivilWarCopy.ONE_PROVINCE_HOST_GUILD); return; } - gov.startMovement(p.getName(), proposal); + gov.startMovement(Members.key(p), proposal); p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); governmentView(p, f, null); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 789ba892..3ec7363e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -1,9 +1,10 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.espionage.EspionageAccess; import net.tfminecraft.simplefactions.espionage.IntelligenceLedger; import net.tfminecraft.simplefactions.espionage.EspionageService; -import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import net.tfminecraft.simplefactions.util.LegacyModelData; @@ -13,7 +14,6 @@ import java.util.List; import java.util.Map; -import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; @@ -160,7 +160,7 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); m.setDisplayName(StringFormatter.formatHex("#9c9775§lLeader: #c2bea7"+ReportedMenus.guildLeader(p, guild))); - if (!ReportedMenus.guildLeader(p, guild).endsWith("Unknown")) m.setOwningPlayer(Bukkit.getOfflinePlayer(guild.getLeader())); + if (!ReportedMenus.guildLeader(p, guild).endsWith("Unknown")) MemberHeads.apply(m, guild.getLeader()); i.setItemMeta(m); } else if(t.equals(MenuItemType.WEALTH)) { i = new ItemStack(Material.GOLD_NUGGET, 1); @@ -734,8 +734,9 @@ private static void addLedgerSourceSection(List lore, String header, Map int count = 0; for (var e : LedgerHistory.descending(amounts)) { if (e.getValue() <= 0) continue; + // Citizen and deposit rows are member keys; guild and faction names pass through unchanged. lore.add(StringFormatter.formatHex( - "#d4c9ae" + e.getKey() + "#d4c9ae" + Members.display(e.getKey()) + "#7a706a: #7fbd73+" + String.format("%.2f", e.getValue()) + "d" )); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MemberHeads.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MemberHeads.java new file mode 100644 index 00000000..8e456c3c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MemberHeads.java @@ -0,0 +1,18 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import org.bukkit.Bukkit; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; + +import net.tfminecraft.simplefactions.identity.Members; + +/** Skull faces for members: the head of the account that plays the character. */ +public final class MemberHeads { + + private MemberHeads() {} + + public static void apply(ItemMeta meta, String key) { + if (!(meta instanceof SkullMeta skull)) return; + Members.accountId(key).ifPresent(id -> skull.setOwningPlayer(Bukkit.getOfflinePlayer(id))); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java index 67c75b22..349e9872 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java @@ -45,7 +45,7 @@ public ItemStack createMovementLeaderItem(Player p, Movement movement) { } m.setDisplayName(StringFormatter.formatHex("#c5e0e3Movement Leader")); List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + (movement.hasLeader() ? movement.getLeader() : "None"))); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + (movement.hasLeader() ? Members.display(movement.getLeader()) : "None"))); lore.add(" "); lore.add(StringFormatter.formatHex("#7a7a7aThe leader coordinates the movement")); lore.add(StringFormatter.formatHex("#7a7a7aand represents its interests.")); @@ -159,11 +159,11 @@ public ItemStack createCausesButton(Movement movement) { public ItemStack createCauseItem(Cause cause, Player p, Faction f) { ItemStack item = new ItemStack(Material.WRITABLE_BOOK); ItemMeta m = item.getItemMeta(); - m.setDisplayName(StringFormatter.formatHex("#c5e0e3"+(cause.hasLeader() ? cause.getLeader()+"'s Cause" : "Disorganized Cause"))); + m.setDisplayName(StringFormatter.formatHex("#c5e0e3"+(cause.hasLeader() ? Members.display(cause.getLeader())+"'s Cause" : "Disorganized Cause"))); List lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#9c9775Action: #c2bea7" + cause.getAction().getDisplay())); if (cause.hasLeader()) { - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + cause.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + Members.display(cause.getLeader()))); } else { lore.add(StringFormatter.formatHex("#c74d32No Leader")); } @@ -200,7 +200,7 @@ public ItemStack createCauseLeaderItem(Player p, Cause cause) { m.setDisplayName(StringFormatter.formatHex("#c5e0e3Cause Leader")); List lore = new ArrayList<>(); if (cause.hasLeader()) { - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + cause.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + Members.display(cause.getLeader()))); } else { lore.add(StringFormatter.formatHex("#c74d32No Leader Assigned")); } @@ -306,7 +306,7 @@ public ItemStack createEndMovementConfirmItem(Movement movement) { public ItemStack createMovementListItem(Movement movement) { ItemStack item = new ItemStack(Material.BLAZE_POWDER); ItemMeta m = item.getItemMeta(); - m.setDisplayName(StringFormatter.formatHex("#d1743b" + movement.getLeader() + "'s Movement ")); + m.setDisplayName(StringFormatter.formatHex("#d1743b" + Members.display(movement.getLeader()) + "'s Movement ")); List lore = new ArrayList<>(); appendMovementSummaryLore(lore, movement); lore.add(" "); @@ -318,7 +318,7 @@ public ItemStack createMovementListItem(Movement movement) { } private void appendMovementSummaryLore(List lore, Movement movement) { - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + movement.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7" + Members.display(movement.getLeader()))); lore.add(StringFormatter.formatHex("#9c9775Organization: #c2bea7" + Formatter.formatDouble(movement.getOrganization()) + "/"+movement.getMaxOrganization()+" §7(" + (movement.getOrganizationGain() > 0 ? "+" : "#d65c5c") + movement.getOrganizationGain()) + " §7per day)"); lore.add(StringFormatter.formatHex("#c45749Power: #d87638"+movement.getPower()+"% #7a7a7aof " + movement.getFaction().getName())); @@ -509,7 +509,7 @@ public ItemStack createAvailableCauseSlot(Movement movement, int index) { public ItemStack createDemandItem(Cause cause, Movement movement) { ItemStack item = new ItemStack(Material.PAPER); ItemMeta m = item.getItemMeta(); - m.setDisplayName(StringFormatter.formatHex("#c5e0e3Demand: " + (cause.hasLeader() ? cause.getLeader() : "Leaderless"))); + m.setDisplayName(StringFormatter.formatHex("#c5e0e3Demand: " + (cause.hasLeader() ? Members.display(cause.getLeader()) : "Leaderless"))); List lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#9c9775Action: #c2bea7" + cause.getAction().getDisplay())); lore.add(" "); @@ -622,7 +622,7 @@ public ItemStack createTargetButton(Player p, Cause cause) { List lore = new ArrayList<>(); if (proposal.hasTarget()) { - lore.add(StringFormatter.formatHex("#9c9775Target: #c2bea7" + proposal.getTarget())); + lore.add(StringFormatter.formatHex("#9c9775Target: #c2bea7" + Members.display(proposal.getTarget()))); } else { lore.add(StringFormatter.formatHex("#c74d32No Target Selected")); lore.add(StringFormatter.formatHex("#d65c5c-5 Organization Gain Penalty")); @@ -657,7 +657,7 @@ public ItemStack createPotentialTargetItem(Player player, Faction f, String memb ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemMeta m = item.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#c5e0e3" + member)); + m.setDisplayName(StringFormatter.formatHex("#c5e0e3" + Members.display(member))); List lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#7a7a7aSelect this player as the")); lore.add(StringFormatter.formatHex("#7a7a7atarget for leadership change.")); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java index 57808f74..b3f5746a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java @@ -80,7 +80,7 @@ public Cause displayedCause(Inventory inventory, Movement movement) { public void movementView(Player player, Faction f, Movement movement, Inventory i) { boolean open = i == null; if (i == null) { - i = Bukkit.createInventory(new SFInventoryHolder(movement.getId(), SFGUI.MOVEMENT_VIEW), 54, "Movement: " + movement.getLeader()); + i = Bukkit.createInventory(new SFInventoryHolder(movement.getId(), SFGUI.MOVEMENT_VIEW), 54, "Movement: " + Members.display(movement.getLeader())); } i.clear(); @@ -451,7 +451,7 @@ else if (slot == 19) { } } } - Player factionLeader = Bukkit.getPlayer(f.getLeader()); + Player factionLeader = Members.player(f.getLeader()); if (factionLeader != null && factionLeader.isOnline()) { LogManager.movement( "SEND_DEMANDS movementId=%s faction=%s power=%.1f", @@ -460,7 +460,7 @@ else if (slot == 19) { movement.getPower()); demandsView(factionLeader, f, movement, null); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1, 1.5f); - p.sendMessage(StringFormatter.formatHex("§7Demands sent to " + f.getLeader())); + p.sendMessage(StringFormatter.formatHex("§7Demands sent to " + Members.display(f.getLeader()))); } else { p.sendMessage(StringFormatter.formatHex("§cThe faction leader must be online to send demands!")); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); @@ -481,7 +481,7 @@ else if (slot == 25) { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); return; } - Player movementLeader = Bukkit.getPlayer(movement.getLeader()); + Player movementLeader = Members.player(movement.getLeader()); if (movementLeader == null || !movementLeader.isOnline()) { p.sendMessage(StringFormatter.formatHex("§cThe movement leader must be online to demand a disband.")); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); @@ -494,7 +494,7 @@ else if (slot == 25) { movement.getPower()); crackdownView(movementLeader, f, movement, null); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1, 1.5f); - p.sendMessage(StringFormatter.formatHex("§7Disband demand sent to " + movement.getLeader())); + p.sendMessage(StringFormatter.formatHex("§7Disband demand sent to " + Members.display(movement.getLeader()))); } // Phase buttons (slots 28-31) else if (slot >= 28 && slot <= 31) { @@ -654,9 +654,9 @@ private void handleDemandsViewClick(InventoryClickEvent e, Movement movement, Pl // Notify movement leader if online if (movement.hasLeader()) { - Player movementLeader = Bukkit.getPlayer(movement.getLeader()); + Player movementLeader = Members.player(movement.getLeader()); if (movementLeader != null && movementLeader.isOnline()) { - movementLeader.sendMessage(StringFormatter.formatHex("§a" + f.getLeader() + " has accepted your demands!")); + movementLeader.sendMessage(StringFormatter.formatHex("§a" + Members.name(p) + " has accepted your demands!")); } } @@ -684,9 +684,9 @@ else if (slot == 33) { // Notify movement leader if online if (movement.hasLeader()) { - Player movementLeader = Bukkit.getPlayer(movement.getLeader()); + Player movementLeader = Members.player(movement.getLeader()); if (movementLeader != null && movementLeader.isOnline()) { - movementLeader.sendMessage(StringFormatter.formatHex("§c" + f.getLeader() + " has declined your demands!")); + movementLeader.sendMessage(StringFormatter.formatHex("§c" + Members.name(p) + " has declined your demands!")); movementLeader.sendMessage(StringFormatter.formatHex("§7A civil war has begun!")); } } @@ -709,9 +709,9 @@ private void handleCrackdownViewClick(InventoryClickEvent e, Movement movement, movement.getId(), f.getId(), movement.getPower()); - Player factionLeader = Bukkit.getPlayer(f.getLeader()); + Player factionLeader = Members.player(f.getLeader()); if (factionLeader != null && factionLeader.isOnline()) { - factionLeader.sendMessage(StringFormatter.formatHex("§a" + p.getName() + " disbanded their movement.")); + factionLeader.sendMessage(StringFormatter.formatHex("§a" + Members.name(p) + " disbanded their movement.")); } f.getGovernment().endMovement(movement); p.closeInventory(); @@ -730,9 +730,9 @@ private void handleCrackdownViewClick(InventoryClickEvent e, Movement movement, p.sendMessage(StringFormatter.formatHex("§cYou have refused to disband.")); p.sendMessage(StringFormatter.formatHex("§7A civil war has begun!")); p.playSound(p.getLocation(), "entity.ender_dragon.growl", 1, 0.8f); - Player factionLeader = Bukkit.getPlayer(f.getLeader()); + Player factionLeader = Members.player(f.getLeader()); if (factionLeader != null && factionLeader.isOnline()) { - factionLeader.sendMessage(StringFormatter.formatHex("§c" + p.getName() + " refused to disband!")); + factionLeader.sendMessage(StringFormatter.formatHex("§c" + Members.name(p) + " refused to disband!")); factionLeader.sendMessage(StringFormatter.formatHex("§7A civil war has begun!")); } p.closeInventory(); @@ -752,17 +752,17 @@ private boolean canPlayerCreateCause(Player p, Movement movement) { } private Object getSupporterObject(Player p, Movement movement) { - String playerName = p.getName(); + String key = Members.key(p); // Check if player is in supporters as a citizen - if (movement.getSupporters().getCitizens().contains(playerName)) { - return playerName; + if (movement.getSupporters().getCitizens().contains(key)) { + return key; } // Check if player's guild is a supporter - Member relation = movement.getFaction().getRelationToFaction(playerName); + Member relation = movement.getFaction().getRelationToFaction(key); if (relation == Member.GUILD_LEADER || relation == Member.GUILD_MEMBER) { - Guild guild = FactionManager.getGuildByMember(playerName); + Guild guild = FactionManager.getGuildByMember(key); if (guild != null && movement.getSupporters().getGuilds().contains(guild)) { return guild; } @@ -770,7 +770,7 @@ private Object getSupporterObject(Player p, Movement movement) { // Check if player's faction is a supporter if (relation == Member.VASSAL_LEADER || relation == Member.VASSAL_MEMBER) { - Faction faction = FactionManager.getByMember(playerName); + Faction faction = FactionManager.getByMember(key); if (faction != null && movement.getSupporters().getFactions().contains(faction)) { return faction; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java index b33fe1a6..2507e025 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -45,7 +47,7 @@ public static ItemStack mask(ItemStack template, String title, List lore public static String guildLeader(Player viewer, Guild guild) { if (EspionageService.canViewExact(viewer, guild.getFaction()) || guild.getFaction().isLeader(guild.getLeader())) - return CharacterNames.display(viewer, guild.getLeader(), guild.getFaction()); + return Members.display(viewer, guild.getLeader()); var report = EspionageService.report(viewer, guild.getFaction()); var names = report == null ? List.of() : report.details("guild-leader", "guild-leader:" + guild.getId()); return names.isEmpty() ? "\u00a77Unknown" : names.getFirst(); @@ -129,7 +131,7 @@ public static void government(Inventory inventory, Player viewer, Faction factio prepare(inventory); var report = EspionageService.report(viewer, faction); inventory.setItem(10, mask(new ItemStack(Material.WRITABLE_BOOK), "#93c9a7Government:", List.of( - "#9c9775" + faction.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, faction.getLeader(), faction), + "#9c9775" + faction.getRulerTitle() + ": #c2bea7" + Members.display(viewer, faction.getLeader()), "#85c265Administrative Power: " + (report == null ? "Unknown" : report.display("Administrative power")), "", "#b8ae61Ruling System: #d4c9ae" + faction.getGovernmentString()))); inventory.setItem(11, mask(IconGetter.getIconOrDefault("stability", Material.GREEN_DYE), diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java index 6fdca0b2..393da586 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; @@ -212,7 +214,7 @@ private void startInput(Player p, Faction f, FeeKind kind, String vehicleTypeId) private static boolean canPropose(Player p, Faction f, FeeKind kind, String vehicleTypeId) { Government gov = f.getGovernment(); - Proposal proposal = new Proposal(p.getName(), gov); + Proposal proposal = new Proposal(Members.key(p), gov); proposal.setFeeProposal(new FeeChange(kind, vehicleTypeId, 0.0)); return gov.canProposeOrStartMovement(p) && gov.canBeProposed(proposal); } diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index ceeaab8a..1716cc9d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -82,11 +82,11 @@ private List completeCompany(Player p, String[] args) { completions.add("1"); return completions; } - if(args.length == 2 && args[0].equalsIgnoreCase("kick")) { + if(args.length >= 2 && args[0].equalsIgnoreCase("kick")) { if(guild != null && guild.getCompany() != null) { - completions.addAll(guild.getCompany().getEnlisted()); + completions.addAll(Members.display(guild.getCompany().getEnlisted())); } - return filtered(completions, args[1]); + return Members.completions(completions, args); } if(args.length == 2 && args[0].equalsIgnoreCase("invite")) { for(Player online : Bukkit.getOnlinePlayers()) { @@ -120,6 +120,11 @@ private List completeMercenaries(String[] args) { return completions; } + /** Drops the subcommand, so a member name that starts at the third word completes like one at the second. */ + private static String[] afterFirst(String[] args) { + return java.util.Arrays.copyOfRange(args, 1, args.length); + } + private List filtered(List completions, String prefix) { String lower = prefix == null ? "" : prefix.toLowerCase(); completions.removeIf(s -> !s.toLowerCase().startsWith(lower)); @@ -454,44 +459,45 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le List completions = new ArrayList(); for(Player p : Bukkit.getOnlinePlayers()) { completions.add(p.getName()); - completions.add(net.tfminecraft.simplefactions.espionage.CharacterNames.of(p.getName())); + completions.add(Members.name(p)); } - return net.tfminecraft.simplefactions.espionage.CharacterNames.inviteCompletions(completions, args); + return Members.completions(completions, args); } - } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ + } else if(cmd.getName().equalsIgnoreCase("guild") && args.length >= 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ Player p = (Player) sender; Guild g = FactionManager.getGuildByLeader(Members.key(p)); List completions = new ArrayList(); if(g != null && !g.isBase()) { for(String member : g.getMembers()) { - if(!member.equalsIgnoreCase(g.getLeader())) completions.add(member); + if(!member.equals(g.getLeader())) completions.add(Members.display(member)); } } - return completions; + return Members.completions(completions, args); } - } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ + } else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ Player p = (Player) sender; Faction f = FactionManager.getByMember(Members.key(p)); List completions = new ArrayList(); if(f != null) { - completions.addAll(f.getMembers()); - completions.remove(f.getLeader()); + for(String member : f.getMembers()) { + if(!member.equals(f.getLeader())) completions.add(Members.display(member)); + } } - return completions; + return Members.completions(completions, args); } - } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("setleader")){ + } else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 2 && args[0].equalsIgnoreCase("setleader")){ if(sender instanceof Player){ Player p = (Player) sender; Faction f = FactionManager.getByMember(Members.key(p)); List completions = new ArrayList(); if(f != null) { for(String member : f.getMembers()) { - if(f.canBecomeLeader(member)) completions.add(member); + if(f.canBecomeLeader(member)) completions.add(Members.display(member)); } } - return completions; + return Members.completions(completions, args); } } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("rename")){ if(sender instanceof Player){ @@ -555,7 +561,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } else if(eitherCommand(cmd) && args.length == 2 && (args[0].equalsIgnoreCase("join") || args[0].equalsIgnoreCase("decline"))){ if(sender instanceof Player p){ - return InviteCommands.inviteIds(cmd.getName(), p.getName(), args[1]); + return InviteCommands.inviteIds(cmd.getName(), Members.key(p), args[1]); } } else if(eitherCommand(cmd) && args.length == 2 && args[0].equalsIgnoreCase("accept")){ if(sender instanceof Player){ @@ -589,26 +595,26 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } } return completions; - } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 3 && args[0].equalsIgnoreCase("forceleader")){ + } else if(cmd.getName().equalsIgnoreCase("guild") && args.length >= 3 && args[0].equalsIgnoreCase("forceleader")){ List completions = new ArrayList(); Guild g = FactionManager.getGuildByString(args[1]); if(g != null) { for(String member : g.getMembers()) { - if(!g.isLeader(member) && member.toLowerCase().startsWith(args[2].toLowerCase())) completions.add(member); + if(!g.isLeader(member)) completions.add(Members.display(member)); } } - return completions; - } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 3 && args[0].equalsIgnoreCase("forceleader")){ + return Members.completions(completions, afterFirst(args)); + } else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 3 && args[0].equalsIgnoreCase("forceleader")){ if(sender instanceof Player){ List completions = new ArrayList(); Faction f = FactionManager.getByString(args[1]); if(f != null) { for(String member : f.getMembers()) { - if(f.canBecomeLeader(member)) completions.add(member); + if(f.canBecomeLeader(member)) completions.add(Members.display(member)); } } - return completions; + return Members.completions(completions, afterFirst(args)); } } else if (cmd.getName().equalsIgnoreCase("faction") && Cache.provincesEnabled @@ -705,9 +711,10 @@ else if (cmd.getName().equalsIgnoreCase("faction") && args.length == 3 && args[0 if (f != null) { for (Player pl : Bukkit.getOnlinePlayers()) { String name = pl.getName(); - if (name.toLowerCase().startsWith(args[2].toLowerCase()) - && !f.getMembers().contains(name) - && FactionManager.getByMember(name) == null) { + String key = Members.key(pl); + if (key != null && name.toLowerCase().startsWith(args[2].toLowerCase()) + && !f.getMembers().contains(key) + && FactionManager.getByMember(key) == null) { completions.add(name); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java index 4f13a349..3810f9cf 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java @@ -338,9 +338,15 @@ private void verifyRedraw() { } } + private static Player named(String name) { + Player player = mock(Player.class); + when(player.getName()).thenReturn(name); + return player; + } + private static final class NavigationFixture { private final InventoryManager manager = new InventoryManager(); - private final Player player = mock(Player.class); + private final Player player = named("Viewer"); private final Faction faction = mock(Faction.class); private final Movement movement = mock(Movement.class); private final Inventory inventory = mock(Inventory.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerStaleMenuTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerStaleMenuTest.java new file mode 100644 index 00000000..cf998c4f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerStaleMenuTest.java @@ -0,0 +1,184 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Set; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.managers.inventory.CharacterKeyFixture; +import net.tfminecraft.simplefactions.managers.inventory.WarView; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** Menus belong to the character session that opened them. */ +@SuppressWarnings("deprecation") +class InventoryManagerStaleMenuTest { + private static final String STALE = "§cThat menu belonged to your previous character."; + + private GuiTestFixture gui; + private MockedStatic factions; + private CharacterKeyFixture characters; + private InventoryManager manager; + private Player player; + + @BeforeEach + void setUp() { + gui = new GuiTestFixture(); + factions = mockStatic(FactionManager.class); + characters = CharacterKeyFixture.install(); + manager = new InventoryManager(); + manager.warView = mock(WarView.class); + player = gui.player("Ryan"); + } + + @AfterEach + void close() { + factions.close(); + gui.close(); + } + + @Test + void aMenuFromThePreviousCharacterClosesAndRunsNoAction() { + characters.play(player, "Aldric"); + open(warList()); + characters.play(player, "Bryn"); + + InventoryClickEvent click = gui.click(player, 0); + manager.clickButton(click); + + assertTrue(click.isCancelled()); + verify(player).closeInventory(); + verify(player).sendMessage(STALE); + verify(manager.warView, never()).click(any(), any(), any()); + } + + @Test + void theSameSessionKeepsUsingTheMenu() { + characters.play(player, "Aldric"); + open(warList()); + + manager.clickButton(gui.click(player, 0)); + manager.clickButton(gui.click(player, 0)); + + verify(manager.warView, times(2)).click(any(), any(), eq(player)); + verify(player, never()).sendMessage(STALE); + } + + @Test + void anEndedSessionWithNoNewCharacterIsStale() { + characters.play(player, "Aldric"); + open(warList()); + characters.end(player); + + manager.clickButton(gui.click(player, 0)); + + verify(player).sendMessage(STALE); + verify(manager.warView, never()).click(any(), any(), any()); + } + + @Test + void aMenuOpenedWithoutACharacterNeverActs() { + open(warList()); + + manager.clickButton(gui.click(player, 0)); + + verify(player).sendMessage(STALE); + verify(manager.warView, never()).click(any(), any(), any()); + } + + @Test + void aMenuOpenedBeforeCaptureTakesTheSessionOfItsFirstClick() { + characters.play(player, "Aldric"); + player.openInventory(warList()); + + manager.clickButton(gui.click(player, 0)); + verify(manager.warView).click(any(), any(), eq(player)); + + characters.play(player, "Bryn"); + manager.clickButton(gui.click(player, 0)); + verify(player).sendMessage(STALE); + verify(manager.warView, times(1)).click(any(), any(), any()); + } + + @Test + void closingOrQuittingForgetsTheCapturedSession() { + characters.play(player, "Aldric"); + open(warList()); + manager.releaseMenuSession(new InventoryCloseEvent(player.getOpenInventory())); + characters.play(player, "Bryn"); + manager.clickButton(gui.click(player, 0)); + + open(warList()); + manager.clearPendingOnQuit(new PlayerQuitEvent(player, "quit")); + characters.play(player, "Cora"); + manager.clickButton(gui.click(player, 0)); + + verify(manager.warView, times(2)).click(any(), any(), eq(player)); + verify(player, never()).sendMessage(STALE); + } + + @Test + void confirmationsAreCheckedAndOtherInventoriesAreNot() { + characters.play(player, "Aldric"); + manager.confirmView(player, mock(Faction.class), "war_declare", "1"); + manager.captureMenuSession(new InventoryOpenEvent(player.getOpenInventory())); + characters.play(player, "Bryn"); + InventoryClickEvent confirm = gui.click(player, 11); + manager.clickButton(confirm); + assertTrue(confirm.isCancelled()); + verify(player).sendMessage(STALE); + + Inventory chest = gui.inventory(null, 27, "Chest"); + player.openInventory(chest); + manager.captureMenuSession(new InventoryOpenEvent(player.getOpenInventory())); + characters.play(player, "Cora"); + InventoryClickEvent ordinary = gui.click(player, 0); + manager.clickButton(ordinary); + assertFalse(ordinary.isCancelled()); + verify(player, times(1)).sendMessage(STALE); + } + + @Test + void draggingInAStaleMenuIsCancelled() { + characters.play(player, "Aldric"); + open(warList()); + characters.play(player, "Bryn"); + + InventoryView view = player.getOpenInventory(); + InventoryDragEvent drag = mock(InventoryDragEvent.class); + when(drag.getWhoClicked()).thenReturn(player); + when(drag.getView()).thenReturn(view); + when(drag.getRawSlots()).thenReturn(Set.of(60)); + manager.dragInWarGui(drag); + + verify(drag).setCancelled(true); + verify(player).sendMessage(STALE); + } + + private Inventory warList() { + Inventory inventory = gui.inventory(new SFInventoryHolder("realm", SFGUI.WAR_LIST), 54, "Wars"); + inventory.setItem(0, new ItemStack(Material.PAPER)); + return inventory; + } + + private void open(Inventory inventory) { + player.openInventory(inventory); + manager.captureMenuSession(new InventoryOpenEvent(player.getOpenInventory())); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java index 3c2522d0..09d1cb2c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java @@ -8,11 +8,12 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.UUID; import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.army.Military; import net.tfminecraft.simplefactions.army.Regiment; import net.tfminecraft.simplefactions.diplomacy.RelationType; -import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.managers.inventory.CharacterKeyFixture; import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.espionage.EspionageConfig; import net.tfminecraft.simplefactions.guild.Guild; @@ -479,12 +480,9 @@ void memberAndLeadershipSuggestionsExcludeTheCurrentLeaderAndIneligibleSuccessor void invitationCompletionsKeepOnlyTheUnfinishedSuffixOfACharacterName() { Player alice = gui.player("Alice"); when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(alice)); - try (MockedStatic characters = - mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { - characters.when(() -> CharacterNames.of("Alice")).thenReturn("Lady Alice"); - assertEquals(List.of("Alice"), complete("faction", "invite", "Lady", "A")); - assertEquals(List.of("Alice"), complete("guild", "invite", "Al")); - } + CharacterKeyFixture.install().play(alice, "Lady Alice"); + assertEquals(List.of("Alice"), complete("faction", "invite", "Lady", "A")); + assertEquals(List.of("Alice"), complete("guild", "invite", "Al")); } @ParameterizedTest @@ -660,6 +658,43 @@ private void installationChoices() { factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(realm); } + @Test + void memberSuggestionsOfferCharacterNamesAndCompleteTheirLastWords() { + CharacterKeyFixture characters = CharacterKeyFixture.install(); + String leader = characters.play(player, "Lord Aldric"); + String alice = characters.play(gui.player("Alice"), "Alice Fairweather"); + String bob = characters.offline(UUID.randomUUID(), "Bob Stone"); + List members = List.of(leader, alice, bob); + when(realm.getLeader()).thenReturn(leader); + when(realm.getMembers()).thenAnswer(call -> new ArrayList<>(members)); + when(realm.canBecomeLeader(alice)).thenReturn(true); + when(guild.getLeader()).thenReturn(leader); + when(guild.getMembers()).thenAnswer(call -> new ArrayList<>(members)); + when(guild.isLeader(leader)).thenReturn(true); + factions.when(() -> FactionManager.getByMember(leader)).thenReturn(realm); + factions.when(() -> FactionManager.getGuildByLeader(leader)).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + + assertEquals(List.of("Alice Fairweather", "Bob Stone"), complete("faction", "kick", "")); + assertEquals(List.of("Fairweather"), complete("guild", "kick", "Alice", "F")); + assertEquals(List.of("Alice Fairweather"), complete("faction", "setleader", "a")); + MercenaryCompany company = mock(MercenaryCompany.class); + when(company.getEnlisted()).thenReturn(List.of(bob)); + when(guild.getCompany()).thenReturn(company); + assertEquals(List.of("Stone"), complete("company", "kick", "Bob", "")); + + admin(); + assertEquals(List.of("Fairweather"), complete("faction", "forceleader", "realm", "Alice", "")); + assertEquals( + List.of("Alice Fairweather", "Bob Stone"), + complete("guild", "forceleader", "merchants", "")); + Player stranger = gui.player("Stranger"); + Player free = gui.player("Free"); + characters.play(free, "Free Folk"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(stranger, free)); + assertEquals(List.of("Free"), complete("faction", "forcejoin", "realm", "")); + } + private void admin() { when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true); } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/CharacterKeyFixture.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/CharacterKeyFixture.java new file mode 100644 index 00000000..56bddf58 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/CharacterKeyFixture.java @@ -0,0 +1,89 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import org.bukkit.entity.Player; + +/** + * Real character keys for menu tests: each mocked player plays a named character whose key is a + * character UUID, and can switch to another character. The global extension restores the + * name-keyed resolver after each test. + */ +public final class CharacterKeyFixture implements Members.Resolver { + private final Map sessions = new HashMap<>(); + private final Map infos = new HashMap<>(); + private final Map online = new HashMap<>(); + private final Set ended = new HashSet<>(); + private long nextSession = 1; + + public static CharacterKeyFixture install() { + CharacterKeyFixture fixture = new CharacterKeyFixture(); + Members.setResolver(fixture); + return fixture; + } + + /** Starts a new character for the player, ending any current one, and returns its key. */ + public String play(Player player, String name) { + end(player); + CharacterRef ref = new CharacterRef(player.getUniqueId(), UUID.randomUUID()); + sessions.put(player.getUniqueId(), new CharacterSession(ref, nextSession++)); + String key = Members.key(ref); + infos.put(key, info(ref, name)); + online.put(key, player); + return key; + } + + /** A character that exists but is not being played. */ + public String offline(UUID owner, String name) { + CharacterRef ref = new CharacterRef(owner, UUID.randomUUID()); + String key = Members.key(ref); + infos.put(key, info(ref, name)); + return key; + } + + public void end(Player player) { + CharacterSession previous = sessions.remove(player.getUniqueId()); + if (previous == null) return; + ended.add(previous); + online.remove(Members.key(previous.ref())); + } + + @Override + public Optional session(Player player) { + return Optional.ofNullable(sessions.get(player.getUniqueId())); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return !ended.contains(session); + } + + @Override + public String key(Player player) { + return session(player).map(session -> Members.key(session.ref())).orElse(null); + } + + @Override + public Optional info(String key) { + return Optional.ofNullable(infos.get(key)); + } + + @Override + public Optional online(String key) { + return Optional.ofNullable(online.get(key)); + } + + private static CharacterInfo info(CharacterRef ref, String name) { + return new CharacterInfo(ref, name, CharacterStatus.ALIVE, "human", Set.of(), 0L, Instant.EPOCH); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java index 7068d053..964b96a9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java @@ -14,7 +14,6 @@ import net.tfminecraft.simplefactions.enums.RankType; import net.tfminecraft.simplefactions.enums.Rules; import net.tfminecraft.simplefactions.enums.SFGUI; -import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.espionage.EspionageAccess; import net.tfminecraft.simplefactions.espionage.EspionageService; import net.tfminecraft.simplefactions.espionage.IntelligenceReport; @@ -96,9 +95,6 @@ void setup() { intelligence.when(() -> EspionageService.canViewExact(any(), any())).thenReturn(true); intelligence.when(() -> EspionageService.canViewCovert(any(), any())).thenReturn(true); intelligence.when(() -> EspionageService.isOwn(player, faction)).thenReturn(true); - scoped(CharacterNames.class) - .when(() -> CharacterNames.display(any(), anyString(), any())) - .thenAnswer(call -> call.getArgument(1)); scoped(RosterLore.class) .when(() -> RosterLore.faction(any(), any())) .thenReturn(List.of("Leader", "Alice")); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java index 44dd277f..9a27d2ad 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java @@ -15,7 +15,6 @@ import net.tfminecraft.simplefactions.enums.SFGUI; import net.tfminecraft.simplefactions.enums.Scope; import net.tfminecraft.simplefactions.enums.Stance; -import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.espionage.EspionageService; import net.tfminecraft.simplefactions.government.Council; import net.tfminecraft.simplefactions.government.Government; @@ -154,9 +153,6 @@ void setup() { wealth .when(() -> Wealth.topWealth(any(), eq(true))) .thenReturn(List.of("Alice", "Bob", "Cara")); - scoped(CharacterNames.class) - .when(() -> CharacterNames.display(any(), anyString(), any())) - .thenAnswer(call -> call.getArgument(1)); scoped(PoliticalActionLoader.class) .when(() -> PoliticalActionLoader.getByAction(any())) .thenAnswer(call -> new PoliticalAction(call.getArgument(0))); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MemberNamesInMenusTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MemberNamesInMenusTest.java new file mode 100644 index 00000000..d2e1f112 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MemberNamesInMenusTest.java @@ -0,0 +1,81 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.utils.Represents; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** Menus print character names for member keys and show the playing account's head. */ +@SuppressWarnings("deprecation") +class MemberNamesInMenusTest { + private final UUID owner = UUID.randomUUID(); + private GuiTestFixture gui; + private CharacterKeyFixture characters; + private String aldric; + + @BeforeEach + void setUp() { + gui = new GuiTestFixture(); + characters = CharacterKeyFixture.install(); + aldric = characters.offline(owner, "Aldric Vane"); + } + + @AfterEach + void close() { + gui.close(); + } + + @Test + void skullsTakeTheHeadOfTheAccountThatPlaysTheCharacter() { + OfflinePlayer account = mock(OfflinePlayer.class); + when(Bukkit.getOfflinePlayer(owner)).thenReturn(account); + SkullMeta skull = mock(SkullMeta.class); + + MemberHeads.apply(skull, aldric); + MemberHeads.apply(skull, "not-a-character"); + MemberHeads.apply(mock(ItemMeta.class), aldric); + + verify(skull, times(1)).setOwningPlayer(account); + } + + @Test + void companyRosterAndSlotsNameTheCharacter() { + MercenaryCompany company = mock(MercenaryCompany.class); + when(company.getName()).thenReturn("Blades"); + CompanyCreator creator = new CompanyCreator(); + + ItemStack roster = creator.createRosterItem(company, aldric, true); + + assertEquals("§eAldric Vane", roster.getItemMeta().getDisplayName()); + assertTrue(creator.buildSlotLore(aldric).contains("§7Mercenary: §eAldric Vane")); + } + + @Test + void leadershipTargetsAndCandidatesNameTheCharacter() { + Faction faction = mock(Faction.class); + ItemStack target = + new MovementCreator().createPotentialTargetItem(gui.player("Ryan"), faction, aldric, 0); + assertTrue(target.getItemMeta().getDisplayName().contains("Aldric Vane")); + + try (MockedStatic represents = mockStatic(Represents.class)) { + represents.when(() -> Represents.represents(any(), any())).thenReturn("Artisans"); + ItemStack candidate = new ElectionCreator().createCandidateItem(faction, Candidate.LEADER, aldric); + assertTrue(candidate.getItemMeta().getDisplayName().contains("Aldric Vane")); + } + } +} From 2062fde561a8ca37e7c3598e858aa76e8282bbe0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:34:36 +0000 Subject: [PATCH 08/35] wip: update core tests for member keys --- .../managers/CommandManager.java | 29 +++++++++++-------- .../simplefactions/migration/StoreMapper.java | 5 +++- .../PluginLifecycleCoverageTest.java | 20 ++++++++++--- .../guild/GuildKickCommandTest.java | 9 +++--- .../managers/CommandManagerCoverageTest.java | 22 +++++++------- .../managers/FactionManagerCoverageTest.java | 28 +++++++++++------- .../PlayerEventsLifecycleCoverageTest.java | 18 +++++++----- .../managers/PlayerManagerCoverageTest.java | 4 +++ .../testsupport/NameKeyedMembers.java | 9 ++++++ 9 files changed, 96 insertions(+), 48 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 3713d645..2d89b8dc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -73,6 +73,11 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String () -> dispatchCommand(sender, cmd, label, args)); } + /** The arguments from {@code from} on, joined: character names can have spaces. */ + static String rest(String[] args, int from) { + return String.join(" ", Arrays.copyOfRange(args, from, args.length)); + } + private boolean dispatchCommand(CommandSender sender, Command cmd, String label, String[] args) { if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("tradegraph")) return net.tfminecraft.simplefactions.guild.network.TradeGraphCommand.handle(sender, args); @@ -286,7 +291,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, g.kick(Members.key(p)); p.sendMessage("§aLeft "+g.getName()); return true; - } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("kick") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("kick") && args.length >= 2) { Guild guild = FactionManager.getGuildByLeader(Members.key(p)); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); @@ -296,7 +301,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cThis is the base guild, use /faction kick instead"); return true; } - String kicked = Members.find(guild.getMembers(), args[1]); + String kicked = Members.find(guild.getMembers(), rest(args, 1)); if(kicked == null) { p.sendMessage("§cPlayer is not a member"); return true; @@ -484,7 +489,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aGuild colour updated to §f" + rgb); } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setleader") && args.length >= 2) { Guild g = FactionManager.getGuildByMember(Members.key(p)); if(g == null) { p.sendMessage("§cYou need to have a guild to change leader"); @@ -494,7 +499,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cOnly the leader can set a new leader!"); return true; } - String heir = Members.find(g.getMembers(), args[1]); + String heir = Members.find(g.getMembers(), rest(args, 1)); if(heir == null) { p.sendMessage("§cPlayer is not in the guild"); return true; @@ -510,7 +515,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("forceleader") && args.length == 3) { + } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("forceleader") && args.length >= 3) { if(!Permissions.isAdmin(sender)) { p.sendMessage("§a[SimpleFactions]§c You do not have access to this command"); return true; @@ -524,7 +529,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cThis is the base guild, use /faction forceleader instead"); return true; } - String member = Members.find(g.getMembers(), args[2]); + String member = Members.find(g.getMembers(), rest(args, 2)); if(member == null) { p.sendMessage("§cPlayer is not in the guild"); return true; @@ -877,7 +882,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, InventoryManager i = new InventoryManager(); i.factionView(p, f); return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("kick") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("kick") && args.length >= 2) { Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to have a faction to kick someone"); @@ -887,7 +892,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cOnly the leader can dismiss members!"); return true; } - String kicked = Members.find(f.getMembers(), args[1]); + String kicked = Members.find(f.getMembers(), rest(args, 1)); if(kicked != null && kicked.equals(f.getLeader())) { p.sendMessage("§cYou cannot dismiss the leader!"); return true; @@ -904,7 +909,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§aKicked "+Members.display(kicked)); Members.online(kicked).ifPresent(pl -> pl.sendMessage("§a"+Members.name(p)+ " kicked you from "+f.getName())); return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setleader") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("setleader") && args.length >= 2) { Faction f = FactionManager.getByMember(Members.key(p)); if(f == null) { p.sendMessage("§cYou need to have a faction to change leader"); @@ -918,7 +923,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cCannot set leader in a democracy!"); return true; } - String heir = Members.find(f.getMembers(), args[1]); + String heir = Members.find(f.getMembers(), rest(args, 1)); if(heir == null) { p.sendMessage("§cPlayer is not in the faction"); return true; @@ -1414,7 +1419,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§aRemoved " + amount + " " + slotWord + " from " + regimentName); } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forceleader") && args.length == 3) { + } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forceleader") && args.length >= 3) { if(!Permissions.isAdmin(sender)) { p.sendMessage("§a[SimpleFactions]§c You do not have access to this command"); return true; @@ -1424,7 +1429,7 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } - String heir = Members.find(f.getMembers(), args[2]); + String heir = Members.find(f.getMembers(), rest(args, 2)); if(heir == null) { p.sendMessage("§cPlayer is not in the faction"); return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java b/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java index 9fe6c111..bd3e21ad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java @@ -337,7 +337,10 @@ private void forgetLeaderCharacter(JsonObject object) { String name(String raw, String path, JsonElement fragment) { if (Members.isQuarantined(raw)) return raw; UUID asKey = Members.characterId(raw); - if (asKey != null) return Members.key(asKey); + if (asKey != null) { + changed |= !Members.key(asKey).equals(raw); + return Members.key(asKey); + } Optional key = names.computeIfAbsent(raw.toLowerCase(Locale.ROOT), lower -> heirs.account(raw) .flatMap(this::heirKey)); changed = true; diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 366e2514..8560237f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -210,18 +210,26 @@ void setup() throws Exception { RpCharactersChatIntegration.class, VfBuildersConstructionFreeze.class, LeaderCharacterListener.class)) scoped(type); + // Character identity is ready and the data was migrated earlier, unless a test says otherwise. + scoped(net.tfminecraft.tlibs.character.Characters.class) + .when(net.tfminecraft.tlibs.character.Characters::available) + .thenReturn(true); + scoped(net.tfminecraft.tlibs.character.Characters.class) + .when(net.tfminecraft.tlibs.character.Characters::legacyHeirsReady) + .thenReturn(true); + disk.write("migrations/character-v1.json", "{\"complete\":true,\"stores\":{}}"); SimpleFactions.plugin = plugin; } @Test void incompatibleCharacterApiDisablesBeforeOpeningOrSavingState() { scoped(net.tfminecraft.tlibs.character.Characters.class) - .when(net.tfminecraft.tlibs.character.Characters::available) - .thenThrow(new NoClassDefFoundError("CharacterIdentity")); + .when(net.tfminecraft.tlibs.character.Characters::legacyHeirsReady) + .thenThrow(new NoSuchMethodError("legacyHeirsReady")); plugin.onEnable(); plugin.onDisable(); verify(plugins).disablePlugin(plugin); - verify(logger).severe(contains("TLibs 2.3.0")); + verify(logger).severe(contains("TLibs 2.4.0")); verify(plugin, never()).getConfig(); scoped(BattlePersistenceService.class).verifyNoInteractions(); scoped(InactivityService.class).verifyNoInteractions(); @@ -955,8 +963,10 @@ void paperClassLoaderConstructsTheRealPluginAndItsServiceGraph() throws Exceptio assertNotNull(sessions); assertSame( sessions, constructed.getClass().getMethod("getSessionManager").invoke(constructed)); + // Never enabled, so shutting down touches nothing. constructed.onDisable(); - scoped(BattleManager.class).verify(BattleManager::shutdown); + scoped(BattleManager.class).verify(BattleManager::shutdown, never()); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll, never()); } } @@ -1050,6 +1060,8 @@ void ownerAndMaintenancePersistenceUsesTheExposedLiveStores() throws Exception { queries .when(() -> VehicleOwnershipQueries.allPersonalVehicles(registry)) .thenReturn(List.of(personal, unowned)); + // The fee follows the character the owning account plays. + domain.player("Bob"); plugin.recordVehicleOwners(); fees.load(); assertEquals("Bob", fees.getLastOwner("personal")); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildKickCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildKickCommandTest.java index 04671d72..082149cd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildKickCommandTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildKickCommandTest.java @@ -37,8 +37,9 @@ void leaderRemovesAMemberAndNotifiesThem() { assertTrue(new CommandManager().onCommand(leader, command, "guild", new String[] {"kick", "steve"})); - verify(guild).kick("steve"); - verify(leader).sendMessage("§aKicked steve"); + // The typed name finds the member by character name; the stored key is what leaves. + verify(guild).kick("Steve"); + verify(leader).sendMessage("§aKicked Steve"); verify(member).sendMessage("§aLeader kicked you from Smiths"); } } @@ -90,7 +91,7 @@ void leaderCannotKickThemselves() { assertTrue(new CommandManager().onCommand(leader, command, "guild", new String[] {"kick", "leader"})); verify(leader).sendMessage("§cYou cannot dismiss the leader!"); - bukkit.verifyNoInteractions(); + verify(guild, never()).kick(org.mockito.ArgumentMatchers.anyString()); } } @@ -107,7 +108,7 @@ void unknownNameIsRejected() { assertTrue(new CommandManager().onCommand(leader, command, "guild", new String[] {"kick", "Alex"})); verify(leader).sendMessage("§cPlayer is not a member"); - bukkit.verifyNoInteractions(); + verify(guild, never()).kick(org.mockito.ArgumentMatchers.anyString()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index 501eef5e..e72e9333 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -23,7 +23,7 @@ import net.tfminecraft.simplefactions.diplomacy.RelationType; import net.tfminecraft.simplefactions.enums.Rules; import net.tfminecraft.simplefactions.enums.Stance; -import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.events.FactionCreateEvent; import net.tfminecraft.simplefactions.events.FactionDeleteEvent; @@ -1124,6 +1124,10 @@ void guildLeaderChangesCheckMembershipAndNotifyOnlyGuildMembers() { @Test void administrativeJoinRefusesExistingMembershipAndNotifiesAfterAdding() { admin(); + assertTrue(run("faction", "forcejoin", "realm", "Nobody")); + verify(player).sendMessage(contains("No online character or character ID matches Nobody")); + Player alice = gui.player("Alice"), bob = gui.player("Bob"), visitor = gui.player("Visitor"); + online(alice, bob, visitor); assertTrue(run("faction", "forcejoin", "realm", "Alice")); verify(player).sendMessage(contains("already in the faction")); Faction other = mock(Faction.class); @@ -1131,8 +1135,6 @@ void administrativeJoinRefusesExistingMembershipAndNotifiesAfterAdding() { assertTrue(run("faction", "forcejoin", "realm", "Bob")); verify(player).sendMessage(contains("already in a faction")); factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(null); - Player alice = gui.player("Alice"), visitor = gui.player("Visitor"); - online(alice, visitor); assertTrue(run("faction", "forcejoin", "realm", "Bob")); verify(faction).addMember("Bob"); verify(alice).sendMessage(contains("Bob joined")); @@ -1800,11 +1802,11 @@ void factionInvitationsRequireLeadershipCapacityAndAnUnalignedInvitee() { verify(player).sendMessage(contains("Only the leader")); when(faction.getLeader()).thenReturn("Leader"); int oldMaximum = Cache.maxMembers; - try (MockedStatic characters = mockStatic(CharacterNames.class)) { + try (MockedStatic characters = mockStatic(Members.class, CALLS_REAL_METHODS)) { assertTrue(run("faction", "invite", "Lady", "Alice")); verify(faction, never()).invite(anyString()); Player alice = gui.player("Alice"); - characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(alice); + characters.when(() -> Members.resolveOnline(player, "Lady Alice")).thenReturn(alice); when(faction.isMember("Alice")).thenReturn(true); assertTrue(run("faction", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("already a member")); @@ -1841,14 +1843,14 @@ void guildInvitationsRejectSelfDuplicatesAndProtectedMemberships() { assertTrue(run("guild", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("use /faction invite")); when(guild.isBase()).thenReturn(false); - try (MockedStatic characters = mockStatic(CharacterNames.class)) { + try (MockedStatic characters = mockStatic(Members.class, CALLS_REAL_METHODS)) { assertTrue(run("guild", "invite", "Lady", "Alice")); verify(guild, never()).invite(anyString()); - characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(player); + characters.when(() -> Members.resolveOnline(player, "Lady Alice")).thenReturn(player); assertTrue(run("guild", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("Cannot invite yourself")); Player alice = gui.player("Alice"); - characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(alice); + characters.when(() -> Members.resolveOnline(player, "Lady Alice")).thenReturn(alice); when(guild.isInvited("Alice")).thenReturn(true); assertTrue(run("guild", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("already invited")); @@ -1888,8 +1890,8 @@ void closedBordersBlockOutsideGuildInvitesButAllowTheFactionLeader() { guildLeader(); when(faction.hasFactionRule(Rules.CLOSED_BORDERS)).thenReturn(true); Player alice = gui.player("Alice"); - try (MockedStatic characters = mockStatic(CharacterNames.class)) { - characters.when(() -> CharacterNames.resolveOnline(player, "Alice")).thenReturn(alice); + try (MockedStatic characters = mockStatic(Members.class, CALLS_REAL_METHODS)) { + characters.when(() -> Members.resolveOnline(player, "Alice")).thenReturn(alice); assertTrue(run("guild", "invite", "Alice")); verify(player).sendMessage(contains("closed borders")); verify(guild, never()).invite("Alice"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index a995472f..d6d3fb77 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -128,7 +128,7 @@ private Faction faction(String id, String leader) { List members = new ArrayList<>(List.of(leader)); when(faction.getMembers()).thenReturn(members); when(faction.isMember(anyString())) - .thenAnswer(call -> members.stream().anyMatch(name -> name.equalsIgnoreCase(call.getArgument(0)))); + .thenAnswer(call -> members.contains(call.getArgument(0))); doAnswer(call -> members.add(call.getArgument(0))).when(faction).addMember(anyString()); when(faction.getProvinces()).thenReturn(new ArrayList<>()); when(faction.getTitles()).thenReturn(new ArrayList<>()); @@ -155,7 +155,7 @@ private Guild guild(Faction faction, String id, String leader) { when(guild.getName()).thenReturn(id); when(guild.getFaction()).thenReturn(faction); when(guild.getLeader()).thenReturn(leader); - when(guild.isLeader(anyString())).thenAnswer(call -> leader.equalsIgnoreCase(call.getArgument(0))); + when(guild.isLeader(anyString())).thenAnswer(call -> leader.equals(call.getArgument(0))); when(guild.getMembers()).thenReturn(new ArrayList<>(List.of(leader))); when(guild.getType()).thenReturn(GuildLoader.map.get("guild")); faction.getGuildHandler().getGuilds().add(guild); @@ -243,7 +243,7 @@ void deletedWantedLeaderCauseCannotRetargetTheNextProposal() { } @Test - void managerLookupsPreserveCaseInsensitivityAndRepairLegacyLeaderMembership() { + void managerLookupsMatchMemberKeysExactlyAndRepairLegacyLeaderMembership() { Faction home = faction("Home", "Alice"); Faction other = faction("Other", "Bob"); Guild traders = guild(home, "Traders", "Carol"); @@ -252,21 +252,27 @@ void managerLookupsPreserveCaseInsensitivityAndRepairLegacyLeaderMembership() { home.getProvinces().add(4); when(home.hasTitle(title)).thenReturn(true); assertSame(home, FactionManager.getByString("HOME")); - assertSame(home, FactionManager.getByLeader("aLiCe")); - assertSame(home, FactionManager.getByMember("ALICE")); + assertSame(home, FactionManager.getByLeader("Alice")); + assertNull(FactionManager.getByLeader("aLiCe"), "member keys never match by case"); + assertSame(home, FactionManager.getByMember("Alice")); + assertNull(FactionManager.getByMember("ALICE")); home.getMembers().clear(); - assertSame(home, FactionManager.getByMember("aLiCe")); - assertEquals(List.of("aLiCe"), home.getMembers()); + assertSame(home, FactionManager.getByMember("Alice")); + assertEquals(List.of("Alice"), home.getMembers()); assertSame(home, FactionManager.getByRGB("home-RGB")); assertSame(home, FactionManager.getByProvince(4)); assertSame(home, FactionManager.getTitleOwner(title)); assertSame(traders, FactionManager.getGuildByString("TRADERS")); - assertSame(traders, FactionManager.getGuildByLeader("cARoL")); - assertSame(traders, FactionManager.getGuildByMember("dAVE")); + assertSame(traders, FactionManager.getGuildByLeader("Carol")); + assertNull(FactionManager.getGuildByLeader("cARoL")); + assertNull(FactionManager.getGuildByLeader(null)); + assertSame(traders, FactionManager.getGuildByMember("Dave")); + assertNull(FactionManager.getGuildByMember("dAVE")); assertTrue(FactionManager.guildExists("traders")); assertFalse(FactionManager.guildExists("missing")); assertNull(FactionManager.getByString("missing")); assertNull(FactionManager.getByLeader("missing")); + assertNull(FactionManager.getByLeader(null)); assertNull(FactionManager.getByMember("missing")); assertNull(FactionManager.getByMember(null)); assertNull(FactionManager.getByRGB("missing")); @@ -292,7 +298,9 @@ void joiningGuildIsAllowedForUnaffiliatedAndRealmMembersButNotGuildLeaders() { Guild guild = guild(home, "guild", "Bob"); guild.getMembers().add("Carol"); assertTrue(FactionManager.canJoinGuild(fixture.player("Dave"))); - assertFalse(FactionManager.canJoinGuild(fixture.player("BOB"))); + assertFalse(FactionManager.canJoinGuild(fixture.player("Bob"))); + Player stranger = mock(Player.class); + assertFalse(FactionManager.canJoinGuild(stranger), "no character, no guild"); assertFalse(FactionManager.canJoinGuild(fixture.player("Carol"))); when(guild.getType()).thenReturn(GuildLoader.map.get("realm")); assertTrue(FactionManager.canJoinGuild(fixture.player("Carol"))); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java index e30c461f..92ea4920 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java @@ -42,10 +42,14 @@ void close() { @Test void readySessionsImmediatelyRefreshOnlyTheMatchingRealmAndGuildLeader() { - var realm = fixture.saved("realm", "Alice"); + UUID aliceCharacter = UUID.randomUUID(); + UUID bobCharacter = UUID.randomUUID(); + String aliceKey = aliceCharacter.toString(); + String bobKey = bobCharacter.toString(); + var realm = fixture.saved("realm", aliceKey); realm.getOrCreateMainGuild(); - var guild = fixture.guild(realm, "traders", "Bob"); - var names = new HashMap<>(Map.of("Alice", "Lady Alice", "Bob", "Master Bob")); + var guild = fixture.guild(realm, "traders", bobKey); + var names = new HashMap<>(Map.of(aliceKey, "Lady Alice", bobKey, "Master Bob")); LeaderCharacters.setProbe(names::get); var identity = mock(CharacterIdentity.class); Bukkit.getServicesManager().register(CharacterIdentity.class, identity, fixture.ui.plugin, @@ -54,21 +58,21 @@ void readySessionsImmediatelyRefreshOnlyTheMatchingRealmAndGuildLeader() { var listener = new LeaderCharacterListener(); Player alice = fixture.player("Alice"); listener.onCharacterStart(new CharacterSessionStartEvent(alice, - new CharacterSession(new CharacterRef(alice.getUniqueId(), UUID.randomUUID()), 1))); + new CharacterSession(new CharacterRef(alice.getUniqueId(), aliceCharacter), 1))); assertTrue(fixture.ui.tasks.isEmpty()); assertEquals("Lady Alice", realm.getLeaderCharacter()); assertNull(guild.getLeaderCharacter()); verify(fixture.map, times(1)).markLeaderNamesChanged(); Player bob = fixture.player("Bob"); listener.onCharacterStart(new CharacterSessionStartEvent(bob, - new CharacterSession(new CharacterRef(bob.getUniqueId(), UUID.randomUUID()), 2))); + new CharacterSession(new CharacterRef(bob.getUniqueId(), bobCharacter), 2))); assertTrue(fixture.ui.tasks.isEmpty()); assertEquals("Master Bob", guild.getLeaderCharacter()); verify(fixture.map, times(2)).markLeaderNamesChanged(); LeaderCharacterListener.refresh("alice"); verify(fixture.map, times(2)).markLeaderNamesChanged(); - names.put("Alice", "Queen Alice"); - names.put("Bob", "Guildmaster Bob"); + names.put(aliceKey, "Queen Alice"); + names.put(bobKey, "Guildmaster Bob"); LeaderCharacterListener.refresh(null); assertEquals("Queen Alice", realm.getLeaderCharacter()); assertEquals("Guildmaster Bob", guild.getLeaderCharacter()); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java index 7e150b1e..759d5dbd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java @@ -294,6 +294,8 @@ void acceptingALoanConservesFundsRecordsOneDebtAndProducesASignedReceipt(String if (!notification.equals("absent")) { when(Bukkit.getPlayer("Leader")).thenReturn(leader); when(leader.isOnline()).thenReturn(!notification.equals("offline")); + } else { + fixture.online.remove("Leader"); } ItemStack book = loanBook(3, borrower); if (notification.equals("no_expiry")) @@ -519,6 +521,8 @@ void acceptingMercenaryServiceActivatesTheOfferAndCreatesASignedAgreement(String if (!notification.equals("absent")) { when(Bukkit.getPlayer("Captain")).thenReturn(captain); when(captain.isOnline()).thenReturn(notification.equals("online")); + } else { + fixture.online.remove("Captain"); } var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); assertTrue(offer.ok(), offer.message()); diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java index 7628d8c4..ef58da80 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java @@ -75,6 +75,15 @@ public Optional online(String key) { // No server in this test. } } + if (player == null) { + try { + for (Player online : Bukkit.getOnlinePlayers()) { + if (online != null && key.equals(online.getName())) return Optional.of(online); + } + } catch (RuntimeException ignored) { + // No server in this test. + } + } return Optional.ofNullable(player); } From 375b8a8e26375977e209b4bfc8788567addcf056 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:36:01 +0000 Subject: [PATCH 09/35] test: waiting state, readiness and migration at start --- .../PluginLifecycleCoverageTest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 8560237f..4db82a14 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -1150,6 +1150,137 @@ void trackChangesInvalidateRoutesBeforeRecalculationAndAReadFailureClearsTheSnap } } + private void identityReady(boolean ready) { + scoped(net.tfminecraft.tlibs.character.Characters.class) + .when(net.tfminecraft.tlibs.character.Characters::legacyHeirsReady) + .thenReturn(ready); + } + + private org.bukkit.command.CommandExecutor firstExecutor(String command) { + var executor = org.mockito.ArgumentCaptor.forClass(org.bukkit.command.CommandExecutor.class); + verify(commands.get(command), atLeastOnce()).setExecutor(executor.capture()); + return executor.getAllValues().getFirst(); + } + + private void enableEvent(String name) throws Exception { + Plugin enabled = mock(Plugin.class); + when(enabled.getName()).thenReturn(name); + for (Listener listener : new ArrayList<>(listeners)) { + if (listener.getClass().getEnclosingClass() != SimpleFactions.class) continue; + var method = listener.getClass().getMethod("onPluginEnable", PluginEnableEvent.class); + method.setAccessible(true); + method.invoke(listener, new PluginEnableEvent(enabled)); + } + } + + @Test + void waitingForIdentityLoadsTicksAndSavesNothingAndCommandsSaySo() throws Exception { + identityReady(false); + Map saved = unloadedSavedState(); + plugin.onEnable(); + + assertFalse(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); + assertFalse(plugin.isStarted()); + verify(logger).severe(contains("Waiting for character identity")); + assertEquals(1, listeners.size(), "only the identity watcher listens"); + assertEquals(1, domain.ui.repeatingTasks.size(), "only the identity poll ticks"); + verify(dependency("db", Database.class), never()).loadFactions(); + verify(dependency("factionManager", FactionManager.class), never()).run(); + for (String name : SimpleFactions.COMMANDS) { + var executor = firstExecutor(name); + var sender = domain.player("Waiter"); + assertTrue(executor.onCommand(sender, commands.get(name), name, new String[0])); + verify(sender, atLeastOnce()).sendMessage("§eFactions are starting up; try again in a moment."); + var completer = org.mockito.ArgumentCaptor.forClass(org.bukkit.command.TabCompleter.class); + verify(commands.get(name)).setTabCompleter(completer.capture()); + assertEquals(List.of(), completer.getValue().onTabComplete(sender, commands.get(name), name, new String[0])); + } + // Not ready yet: neither the poll nor another plugin's enable starts anything. + domain.ui.repeatingTasks.getFirst().run(); + enableEvent("RPCharacters"); + verify(dependency("factionManager", FactionManager.class), never()).run(); + + plugin.onDisable(); + verify(dependency("sessionManager", SessionManager.class), never()).end(); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll, never()); + scoped(InactivityService.class).verify(InactivityService::save, never()); + verify(dependency("db", Database.class), never()).saveFaction(any()); + assertSavedStateUnchanged(saved); + assertFalse(Files.exists(disk.root.resolve("migrations/quarantine.json"))); + } + + @Test + void identityArrivingByPollStartsEverythingOnceAndAnnouncesReadiness() throws Exception { + identityReady(false); + plugin.onEnable(); + Runnable poll = domain.ui.repeatingTasks.getFirst(); + identityReady(true); + poll.run(); + + assertTrue(plugin.isStarted()); + assertTrue(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); + verify(dependency("factionManager", FactionManager.class)).run(); + verify(plugins).callEvent(any(net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent.class)); + poll.run(); + enableEvent("RPCharacters"); + verify(dependency("factionManager", FactionManager.class)).run(); + + plugin.onDisable(); + assertFalse(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); + verify(dependency("sessionManager", SessionManager.class)).end(); + } + + @Test + void identityArrivingWithRpCharactersStartsEverything() throws Exception { + identityReady(false); + plugin.onEnable(); + identityReady(true); + enableEvent("SomethingElse"); + verify(dependency("factionManager", FactionManager.class), never()).run(); + enableEvent("RPCharacters"); + verify(dependency("factionManager", FactionManager.class)).run(); + assertTrue(plugin.isStarted()); + } + + @Test + void aFailedMigrationKeepsFactionsOfflineWithoutRetrying() throws Exception { + disk.write("migrations/character-v1.json", "{broken"); + plugin.onEnable(); + verify(logger).log(eq(java.util.logging.Level.SEVERE), contains("Cannot migrate faction data"), any(Throwable.class)); + assertFalse(plugin.isStarted()); + plugin.start(); + verify(dependency("factionManager", FactionManager.class), never()).run(); + plugin.onDisable(); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll, never()); + } + + @Test + void startMigratesStoredNamesBeforeAnythingLoads() throws Exception { + Files.delete(disk.root.resolve("migrations/character-v1.json")); + String heir = UUID.randomUUID().toString(); + UUID account = UUID.randomUUID(); + disk.write("Data/rome.json", "{\"id\":\"rome\",\"leader\":\"Caesar\",\"guilds\":[{\"id\":\"rome\"," + + "\"leader\":\"Caesar\",\"members\":[\"Caesar\",\"Ghost\"]}]}"); + plugin.useMigrationHeirs(new net.tfminecraft.simplefactions.migration.CharacterMigration.Heirs() { + @Override + public Optional account(String name) { + return name.equals("Caesar") ? Optional.of(account) : Optional.empty(); + } + + @Override + public Optional heir(UUID id) { + return Optional.of(new net.tfminecraft.tlibs.character.CharacterRef(id, UUID.fromString(heir))); + } + }); + org.mockito.InOrder order = inOrder(logger, dependency("db", Database.class)); + plugin.onEnable(); + order.verify(logger).info(contains("1 stores migrated")); + order.verify(dependency("db", Database.class)).loadFactions(); + verify(logger).warning(contains("1 quarantined faction records need staff")); + assertTrue(Files.readString(disk.root.resolve("Data/rome.json")).contains(heir)); + assertTrue(plugin.isStarted()); + } + @Test void lateDependencyEnableEventsRegisterVehicleAndChatWithoutAnotherIdentityBootstrap() throws Exception { From f76b3056ab74e1dbe0f37a5a880e7f7aee068c8f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:40:52 +0000 Subject: [PATCH 10/35] feat: mark guilds whose leader is still quarantined --- .../simplefactions/guild/Guild.java | 6 +++++ .../guild/GuildUnresolvedLeaderTest.java | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/GuildUnresolvedLeaderTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 8af07128..02973662 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -499,6 +499,12 @@ public boolean isLeader(String p) { return currentLeader != null && currentLeader.equals(p); } public boolean isLeader(Player p) { return isLeader(Members.key(p)); } + + /** + * True while the leader's seat is held by a migration quarantine record: + * nothing may pass the seat on or disband the guild until staff assign it. + */ + public boolean hasUnresolvedLeader() { return Members.isQuarantined(getLeader()); } public Map getBranches() { return branches; } public Branch getBranch(int i) { return branches.getOrDefault(i, null); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildUnresolvedLeaderTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildUnresolvedLeaderTest.java new file mode 100644 index 00000000..c32dfa91 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildUnresolvedLeaderTest.java @@ -0,0 +1,22 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +class GuildUnresolvedLeaderTest { + @Test + void aQuarantinedLeaderSeatIsUnresolved() { + Guild guild = mock(Guild.class); + when(guild.hasUnresolvedLeader()).thenCallRealMethod(); + when(guild.getLeader()).thenReturn("quarantine:q1a2b3c4d5e6f"); + assertTrue(guild.hasUnresolvedLeader()); + when(guild.getLeader()).thenReturn("6e846e46-699e-4131-9e9a-3e68a6dc4b08"); + assertFalse(guild.hasUnresolvedLeader()); + when(guild.getLeader()).thenReturn(null); + assertFalse(guild.hasUnresolvedLeader()); + } +} From 43f20f97f053d59ea4a29cef4fb6d5787cb5cdac Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:51:18 +0000 Subject: [PATCH 11/35] test: cover characterless commands, API readiness and migration edges --- .../api/SimpleFactionsApiTest.java | 35 +++++++++++ .../simplefactions/identity/MembersTest.java | 6 ++ .../managers/CommandManagerCoverageTest.java | 63 +++++++++++++++++++ .../migration/CharacterMigrationTest.java | 59 +++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java diff --git a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java new file mode 100644 index 00000000..2d427c72 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java @@ -0,0 +1,35 @@ +package net.tfminecraft.simplefactions.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent; + +class SimpleFactionsApiTest { + @Test + void readyOnlyOnceThePluginHasStarted() { + SimpleFactions previous = SimpleFactions.plugin; + try { + SimpleFactions.plugin = null; + assertFalse(SimpleFactionsApi.ready()); + SimpleFactions plugin = mock(SimpleFactions.class); + SimpleFactions.plugin = plugin; + assertFalse(SimpleFactionsApi.ready()); + when(plugin.isStarted()).thenReturn(true); + assertTrue(SimpleFactionsApi.ready()); + } finally { + SimpleFactions.plugin = previous; + } + } + + @Test + void theReadyEventHasItsOwnHandlers() { + SimpleFactionsReadyEvent event = new SimpleFactionsReadyEvent(); + assertNotNull(event.getHandlers()); + assertSame(SimpleFactionsReadyEvent.getHandlerList(), event.getHandlers()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java index 5429a336..9b6b6ff2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java @@ -95,6 +95,12 @@ void staffSeeWhichAccountPlaysACharacter() { assertEquals("Unknown", Members.display(staff, Members.key(UUID.randomUUID()))); assertEquals("Steve", Members.accountName(Members.key(ada))); assertNull(Members.accountName(Members.key(UUID.randomUUID()))); + identity.bukkit.when(() -> Bukkit.getOfflinePlayer(org.mockito.ArgumentMatchers.any(UUID.class))) + .thenThrow(new IllegalStateException("no user cache")); + assertNull(Members.accountName(Members.key(ada))); + identity.bukkit.when(Bukkit::getServer).thenReturn(null); + assertNull(Members.accountName(Members.key(ada))); + identity.bukkit.when(Bukkit::getServer).thenReturn(identity.server); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index e72e9333..16c2c5c2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -1786,6 +1786,69 @@ void settingPowerForAMissingFactionReportsTheId() { verify(player).sendMessage("§cNo faction by the id missing"); } + @Test + void aPlayerWithoutACharacterIsAStrangerToEveryCommand() { + Player stranger = gui.player("Stranger"); + when(stranger.getName()).thenReturn(null); + Command guildCommand = mock(Command.class); + when(guildCommand.getName()).thenReturn("guild"); + assertTrue(commands.onCommand(stranger, guildCommand, "guild", new String[] {"menu"})); + assertTrue(commands.onCommand(stranger, guildCommand, "guild", new String[] {"join", "merchants"})); + verify(stranger, times(2)).sendMessage(CommandManager.NO_CHARACTER); + verify(guild, never()).consumeInvite(any()); + } + + @Test + void staffWithoutACharacterCannotFoundARealm() { + Player staff = gui.player("Staff"); + when(staff.getName()).thenReturn(null); + when(staff.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + Command factionCommand = mock(Command.class); + when(factionCommand.getName()).thenReturn("faction"); + try (MockedStatic names = mockStatic(DisplayNameGate.class); + MockedConstruction created = mockConstruction(Faction.class)) { + names.when(() -> DisplayNameGate.check(any(), any(), anyString())).thenReturn(DisplayNameGate.Result.OK); + assertTrue(commands.onCommand(staff, factionCommand, "faction", new String[] {"create", "New_Realm"})); + assertTrue(created.constructed().isEmpty()); + } + verify(staff).sendMessage(CommandManager.NO_CHARACTER); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void invitingAPlayerWithoutACharacterIsRefused(String name) { + if (name.equals("guild")) guildLeader(); + else member(); + Player alice = gui.player("Alice"); + when(alice.getName()).thenReturn(null); + try (MockedStatic characters = mockStatic(Members.class, CALLS_REAL_METHODS)) { + characters.when(() -> Members.resolveOnline(player, "Alice")).thenReturn(alice); + assertTrue(run(name, "invite", "Alice")); + } + verify(player).sendMessage("§cThat player has not chosen a character yet"); + verify(faction, never()).invite(any()); + verify(guild, never()).invite(any()); + } + + @Test + void quarantineCommandsAreRoutedBeforeAnyPlayerCheck() { + CommandSender console = mock(CommandSender.class); + try (MockedStatic quarantine = + mockStatic(net.tfminecraft.simplefactions.migration.QuarantineCommands.class, CALLS_REAL_METHODS)) { + quarantine + .when(() -> net.tfminecraft.simplefactions.migration.QuarantineCommands.handle(eq(console), any())) + .thenReturn(true); + assertTrue(run(console, "faction", "admin", "quarantine", "list")); + quarantine.verify(() -> net.tfminecraft.simplefactions.migration.QuarantineCommands.handle(eq(console), any())); + } + } + + @Test + void multiWordCharacterNamesAreJoined() { + assertEquals("Ada Lovelace", CommandManager.rest(new String[] {"kick", "Ada", "Lovelace"}, 1)); + assertEquals("", CommandManager.rest(new String[] {"kick"}, 1)); + } + private boolean run(String commandName, String... args) { Command command = mock(Command.class); when(command.getName()).thenReturn(commandName); diff --git a/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java index 532e2214..e61c39d4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java @@ -483,6 +483,65 @@ void replaceMergesByTheFieldsMeaning() { new com.google.gson.JsonPrimitive("a"), new com.google.gson.JsonPrimitive(2), StoreMapper.Merge.SUM)); } + @Test + void aRealmFileWithoutItsOwnGuildStillMigrates() throws IOException { + String caesar = player("Caesar"); + write("Data/rome.json", "{\"id\":\"rome\",\"leader\":\"Caesar\",\"guilds\":[{\"id\":\"smiths\",\"leader\":\"Caesar\"," + + "\"members\":[\"Caesar\"]}]}"); + write("Data/nameless.json", "{\"guilds\":[{\"leader\":\"Caesar\"}]}"); + migration().run(); + assertEquals(caesar, read("Data/rome.json").getAsJsonArray("guilds").get(0).getAsJsonObject().get("leader").getAsString()); + assertEquals(caesar, read("Data/nameless.json").getAsJsonArray("guilds").get(0).getAsJsonObject().get("leader").getAsString()); + } + + @Test + void sharedHeirsAskDenarEconomyThenTheUserCacheThenTheIdentityProvider() { + try (var identity = new net.tfminecraft.simplefactions.testsupport.SharedIdentity(); + var names = org.mockito.Mockito.mockStatic(net.tfminecraft.denareconomy.accounts.OfflineModifier.class)) { + UUID known = UUID.randomUUID(); + names.when(() -> net.tfminecraft.denareconomy.accounts.OfflineModifier.playerId("Known")).thenReturn(known); + assertEquals(Optional.of(known), CharacterMigration.SHARED.account("Known")); + UUID cached = identity.account("Cached"); + assertEquals(Optional.of(cached), CharacterMigration.SHARED.account("Cached")); + assertEquals(Optional.empty(), CharacterMigration.SHARED.account("Nobody")); + names.when(() -> net.tfminecraft.denareconomy.accounts.OfflineModifier.playerId("Cached")) + .thenThrow(new NoClassDefFoundError("DenarEconomy")); + assertEquals(Optional.of(cached), CharacterMigration.SHARED.account("Cached")); + CharacterRef heir = identity.character(cached, "Heir"); + identity.heirs.put(cached, heir); + assertEquals(Optional.of(heir), CharacterMigration.SHARED.heir(cached)); + assertEquals(Optional.empty(), CharacterMigration.SHARED.heir(known)); + } + } + + @Test + void writesFallBackWhenTheFilesystemCannotMoveAtomically() throws IOException { + Path target = root.resolve("nested/file.json"); + try (var files = org.mockito.Mockito.mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("move") + && java.util.Arrays.asList((java.nio.file.CopyOption[]) call.getRawArguments()[2]) + .contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) { + throw new java.nio.file.AtomicMoveNotSupportedException("a", "b", "no atomic moves here"); + } + return call.callRealMethod(); + })) { + MigrationFiles.write(target, "{}"); + } + assertEquals("{}", Files.readString(target)); + try (var stream = Files.list(target.getParent())) { + assertEquals(1, stream.count(), "no temporary file is left behind"); + } + } + + @Test + void aMissingHashAlgorithmIsAnError() { + try (var digests = org.mockito.Mockito.mockStatic(java.security.MessageDigest.class)) { + digests.when(() -> java.security.MessageDigest.getInstance("SHA-256")) + .thenThrow(new java.security.NoSuchAlgorithmException("none")); + assertThrows(IllegalStateException.class, () -> MigrationFiles.sha256("x")); + } + } + @Test void characterKeysAreLowerCaseCharacterIds() { UUID id = UUID.randomUUID(); From e91ff54e16d99cf527a5737283a3d6a5f208606a Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:52:49 +0000 Subject: [PATCH 12/35] test: every declared command waits for identity --- .../simplefactions/PluginLifecycleCoverageTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 4db82a14..71500a72 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -1173,6 +1173,16 @@ private void enableEvent(String name) throws Exception { } } + @Test + void everyDeclaredCommandWaitsForIdentity() throws Exception { + try (var input = SimpleFactions.class.getClassLoader().getResourceAsStream("plugin.yml")) { + var yaml = YamlConfiguration.loadConfiguration(new java.io.InputStreamReader(input)); + assertEquals( + yaml.getConfigurationSection("commands").getKeys(false), + new java.util.HashSet<>(SimpleFactions.COMMANDS)); + } + } + @Test void waitingForIdentityLoadsTicksAndSavesNothingAndCommandsSaySo() throws Exception { identityReady(false); From eec0e0a337a5c120d467ea1807246e1651cdfc36 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:53:12 +0000 Subject: [PATCH 13/35] refactor: key misc areas by character Vehicles, mercenaries, espionage, inactivity, prestige, chat resolvers, ledger and utils use member keys. Mercenary eligibility reads the enrolled character's own trait. CharacterNames gives way to Members. Co-Authored-By: Claude Opus 5.5 --- .../espionage/CharacterNames.java | 85 ------- .../espionage/EspionageCommands.java | 17 +- .../espionage/EspionageService.java | 35 ++- .../espionage/OfficeCharacters.java | 4 +- .../espionage/ReportDetails.java | 5 +- .../simplefactions/espionage/RosterLore.java | 11 +- .../espionage/SpecialPositionAssignment.java | 8 + .../guild/income/GuildDonation.java | 3 +- .../simplefactions/guild/income/Ledger.java | 1 + .../guild/income/LedgerHistory.java | 4 +- .../inactivity/InactivityService.java | 16 +- .../InstallationProtectionListener.java | 3 +- .../handler/InstallationHandler.java | 12 +- .../chat/OrgChatMemberCollector.java | 16 +- .../managers/MercenaryCommandManager.java | 24 +- .../simplefactions/map/MapSystem.java | 2 +- .../mercenary/company/MercenaryCompany.java | 63 ++--- .../company/MercenaryCompanyService.java | 11 +- .../company/MercenaryEligibility.java | 120 +++------ .../mercenary/company/MercenaryInvites.java | 34 ++- .../mercenary/company/WageSettings.java | 8 +- .../mercenary/contract/ContractBook.java | 3 +- .../mercenary/contract/ContractHandler.java | 5 +- .../contract/MercenaryEngagements.java | 4 +- .../mercenary/contract/MercenaryMarket.java | 3 +- .../mercenary/stat/MercenaryStatService.java | 3 +- .../player/PlayerEconomyManager.java | 7 +- .../prestige/MemberPlaytime.java | 35 +-- .../settlement/handler/SettlementHandler.java | 4 +- .../simplefactions/tiers/Title.java | 4 +- .../simplefactions/utils/FactionCleanup.java | 37 ++- .../simplefactions/utils/MemberAccounts.java | 27 ++ .../simplefactions/utils/Wealth.java | 15 +- .../vehicles/VehicleFactionCommands.java | 38 ++- .../BattleVehicleEligibilityService.java | 2 + .../berth/FactionVehicleGiveService.java | 17 +- .../berth/FactionVehicleReleaseListener.java | 8 +- .../berth/FactionVehicleReleaseService.java | 11 +- .../berth/InstallationVehicleOwnerSync.java | 16 +- .../berth/VehicleTransferConsentService.java | 13 +- .../berth/VehicleTransferListener.java | 5 +- .../vehicles/fees/VehicleFeeService.java | 17 +- .../vehicles/fees/VehicleFeeStore.java | 7 +- .../fees/VehicleReclaimFeeListener.java | 7 +- .../fees/VehicleRegistrationFeeListener.java | 18 +- .../handover/VehicleHandoverListener.java | 7 +- .../handover/VehicleHandoverService.java | 19 +- .../maintenance/DenarEconomyPlayerBank.java | 20 +- .../maintenance/VehicleUpkeepProjection.java | 5 +- .../maintenance/VehicleUpkeepService.java | 6 +- .../registry/VehicleOwnershipQueries.java | 44 ++++ .../CharacterBoundaryCoverageTest.java | 31 ++- .../espionage/CharacterKeyedMembers.java | 69 +++++ .../CharacterNameResolutionTest.java | 224 ++++------------- .../espionage/EspionageBypassTest.java | 5 +- .../espionage/EspionageMenusCoverageTest.java | 24 +- .../EspionageOperationsCoverageTest.java | 59 +++-- .../espionage/EspionagePermissionsTest.java | 25 +- .../espionage/EspionageTestFixtures.java | 27 +- .../espionage/GuildIntelligenceTest.java | 5 +- .../espionage/IntelligenceBrowsingTest.java | 5 +- .../espionage/IntelligencePolicyTest.java | 12 +- .../espionage/OfficeAppointmentsTest.java | 13 +- .../OfficeOptionalIntegrationsTest.java | 13 +- .../espionage/OfficePersistenceTest.java | 9 +- .../espionage/OfficeVacancyTest.java | 20 +- .../espionage/ReportPresentationTest.java | 4 +- .../RosterAndLedgerPresentationTest.java | 8 +- .../espionage/UnguardedFactionTest.java | 14 +- .../espionage/VassalIntelligenceTest.java | 19 +- .../inactivity/InactivityCoverageTest.java | 39 +-- .../InstallationProtectionCoverageTest.java | 6 +- .../IntegrationBoundaryCoverageTest.java | 27 +- .../chat/OrgChatMemberCollectorTest.java | 2 +- .../MercenaryCommandsCoverageTest.java | 9 +- .../MercenaryCompanyFormationTest.java | 3 +- .../company/MercenaryEnlistmentTest.java | 3 +- .../SharedMercenaryCharactersTest.java | 238 +++++------------- .../mercenary/company/WageSettingsTest.java | 7 +- ...MercenaryContractBoundaryCoverageTest.java | 13 +- .../ExternalIntegrationsCoverageTest.java | 29 +-- .../stat/MercenaryStatServiceTest.java | 2 +- .../prestige/MemberPlaytimeTest.java | 195 +++----------- .../utils/FactionCleanupCoverageTest.java | 18 +- .../utils/FactionEconomicsCoverageTest.java | 8 +- .../SharedDomainUtilitiesCoverageTest.java | 25 ++ .../vehicles/VehicleCommandRouteTest.java | 2 + .../vehicles/VehicleCommandsCoverageTest.java | 2 +- .../VehicleInteractionsCoverageTest.java | 13 +- .../VehicleTransferConsentServiceTest.java | 2 +- .../registry/VehicleOwnerKeysTest.java | 138 ++++++++++ 91 files changed, 1079 insertions(+), 1177 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/utils/MemberAccounts.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/CharacterKeyedMembers.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java deleted file mode 100644 index 747eac78..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java +++ /dev/null @@ -1,85 +0,0 @@ -package net.tfminecraft.simplefactions.espionage; - -import org.bukkit.Bukkit; -import net.tfminecraft.tlibs.character.Characters; - -/** Visible online names and cached-account directory names, without disk or network reads. */ -public final class CharacterNames { - private CharacterNames() {} - - public static String display(org.bukkit.entity.Player viewer, String account) { - String character = of(account); - return EspionageService.bypasses(viewer) ? character + " \u00a77(" + account + ")" : character; - } - - public static String display(org.bukkit.entity.Player viewer, String account, - net.tfminecraft.simplefactions.objects.Faction faction) { - if (!Characters.available() || EspionageService.canViewExact(viewer, faction)) - return display(viewer, account); - return forForeign(account); - } - - /** A missing active character must not expose account identity in foreign reports. */ - public static String forForeign(String account) { - String name = activeName(account); - return name == null ? "Unknown" : name; - } - - /** Exact account names take precedence; ambiguous character names never pick a player arbitrarily. */ - public static org.bukkit.entity.Player resolveOnline(org.bukkit.entity.Player sender, String input) { - String query = input.strip(); - if (query.length() >= 2 && query.startsWith("\"") && query.endsWith("\"")) - query = query.substring(1, query.length() - 1).strip(); - var result = resolve(query, Bukkit.getOnlinePlayers(), player -> of(player.getName())); - if (result.ambiguous()) sender.sendMessage("\u00a7cSeveral characters share that name. Use their Minecraft name."); - else if (result.player() == null) sender.sendMessage("\u00a7cNo online player found by that character or Minecraft name."); - return result.player(); - } - - static Resolution resolve(String query, java.util.Collection players, - java.util.function.Function names) { - for (var player : players) if (player.getName().equalsIgnoreCase(query)) return new Resolution(player, false); - var matches = players.stream().filter(player -> org.bukkit.ChatColor.stripColor(names.apply(player)) - .equalsIgnoreCase(query)).toList(); - return new Resolution(matches.size() == 1 ? matches.getFirst() : null, matches.size() > 1); - } - - record Resolution(org.bukkit.entity.Player player, boolean ambiguous) {} - - /** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */ - public static java.util.List inviteCompletions(java.util.Collection names, String[] args) { - if (args.length < 2) return java.util.List.of(); - int skip = args.length - 2; - String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); - return names.stream().filter(java.util.Objects::nonNull).map(org.bukkit.ChatColor::stripColor).distinct() - .filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length())) - .map(name -> name.split(" ")) - .filter(words -> words.length > skip) - .map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length))) - .toList(); - } - - public static String of(String playerName) { - String name = activeName(playerName); - return name == null ? playerName : name; - } - - private static String activeName(String playerName) { - if (playerName == null) return "Unknown"; - if (!Characters.available()) return playerName; - var player = Bukkit.getPlayerExact(playerName); - if (player != null) { - var session = Characters.currentSession(player).orElse(null); - if (session == null || !session.ref().ownerId().equals(player.getUniqueId())) return null; - // Plain text, like offline names: callers colour names themselves and reports persist them. - String name = org.bukkit.ChatColor.stripColor(Characters.displayName(player)); - return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player - && Characters.isCurrent(session) ? name : null; - } - var cached = Bukkit.getOfflinePlayerIfCached(playerName); - if (cached == null) return null; - return Characters.lastPlayed(cached.getUniqueId()) - .filter(info -> info.ref().ownerId().equals(cached.getUniqueId())) - .map(net.tfminecraft.tlibs.character.CharacterInfo::name).orElse(null); - } -} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java index 1ddd054a..78234a01 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -5,7 +5,6 @@ import java.util.ArrayList; import java.util.List; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -68,7 +67,7 @@ public static boolean handle(Player player, String[] args) { else usage(player); } else { - var candidate = CharacterNames.resolveOnline(player, String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); + var candidate = Members.resolveOnline(player, String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); if (candidate != null) EspionageService.appoint(player, faction, candidate); } } else if (args[0].equalsIgnoreCase("spymaster") && args.length == 4 && args[1].equalsIgnoreCase("sabotage") @@ -88,6 +87,14 @@ private static void usage(Player player) { player.sendMessage("§7/faction spymaster share "); } + /** Online members who could hold the office, completed by character name. */ + private static List candidates(net.tfminecraft.simplefactions.objects.Faction faction, String[] args) { + List names = new ArrayList<>(); + for (String member : faction.getMembers()) + if (Members.isOnline(member) && EspionageService.eligible(faction, member)) names.add(Members.display(member)); + return Members.completions(names, args); + } + public static List complete(Player player, String[] args) { List choices = new ArrayList<>(); var faction = FactionManager.getByMember(Members.key(player)); @@ -97,8 +104,7 @@ public static List complete(Player player, String[] args) { if (args.length == 2) { if (faction.isLeader(Members.key(player))) { choices.add("remove"); - for (String name : faction.getMembers()) - if (Bukkit.getPlayerExact(name) != null && EspionageService.eligible(faction, name)) choices.add(name); + choices.addAll(candidates(faction, args)); } if (isHolder) choices.add("sabotage"); if (isHolder && EspionageConfig.sharingAllowed()) choices.add("share"); @@ -108,6 +114,9 @@ public static List complete(Player player, String[] args) { } else if (isHolder && args[1].equalsIgnoreCase("share")) { if (args.length == 3) choices.addAll(List.of("overlord", "vassals", "allies")); if (args.length == 4) choices.addAll(List.of("none", "rumours", "broad", "reliable", "detailed")); + } else if (faction.isLeader(Members.key(player))) { + // Later words of a character name with spaces. + choices.addAll(candidates(faction, args)); } String prefix = args[args.length - 1]; choices.removeIf(choice -> !choice.regionMatches(true, 0, prefix, 0, prefix.length())); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index f434081c..7b09e831 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -44,14 +44,14 @@ public static void loadAptitudes(java.nio.file.Path file) throws java.io.IOExcep characterAptitudes = registry; } - public static boolean eligible(Faction faction, String playerName) { - return faction.isMember(playerName) || faction.isLeader(playerName); + public static boolean eligible(Faction faction, String member) { + return member != null && (faction.isMember(member) || faction.isLeader(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.playerName) && !deadCharacter(holder) - && !(holder.automatic && !faction.isLeader(holder.playerName)); + return holder != null && eligible(faction, holder.memberKey()) && !deadCharacter(holder) + && !(holder.automatic && !faction.isLeader(holder.memberKey())); } /** New factions and every later vacancy fall to the leader; it waits for their active character. */ @@ -61,7 +61,7 @@ public static void initializeFounder(Faction faction) { private static void initializeFounder(Faction faction, java.util.Set dirty) { if (faction.getEspionage() == null || faction.getLeader() == null || org.bukkit.Bukkit.getServer() == null) return; - var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader()); + var founder = Members.player(faction.getLeader()); var session = OfficeCharacters.currentSession(founder); String characterId = session == null ? null : session.ref().characterId().toString(); for (SpecialPosition office : SpecialPosition.values()) { @@ -71,7 +71,7 @@ private static void initializeFounder(Faction faction, java.util.Set di continue; } var assignment = new SpecialPositionAssignment(); - assignment.playerName = faction.getLeader(); + assignment.playerName = founder.getName(); assignment.playerId = session.ref().ownerId(); int aptitude = 0; try { @@ -132,7 +132,7 @@ public static int effectiveAptitude(Faction faction, SpecialPositionAssignment h /** A new holder builds up to their permanent aptitude; a solo leader keeps only part of it. */ static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder, long now) { - if (holder == null || !eligible(faction, holder.playerName)) return 0; + if (holder == null || !eligible(faction, holder.memberKey())) return 0; // The epsilon keeps 1 - 0.1 * 3 from flooring a whole result one point low. return (int) Math.floor(holder.aptitude * EspionageMath.buildUp(holder.appointedAt, now) * positionMultiplier(positionsHeld(faction, holder)) + 1e-9); @@ -140,11 +140,12 @@ static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder, /** Offices held by the same person; an assignment not saved yet still counts as one. */ public static int positionsHeld(Faction faction, SpecialPositionAssignment holder) { - if (holder == null || holder.playerName == null || faction.getEspionage() == null) return 1; + String member = holder == null ? null : holder.memberKey(); + if (member == null || faction.getEspionage() == null) return 1; int held = 0; for (SpecialPosition office : SpecialPosition.values()) { var other = faction.getEspionage().holder(office); - if (other != null && holder.playerName.equalsIgnoreCase(other.playerName)) held++; + if (other != null && member.equals(other.memberKey())) held++; } return Math.max(1, held); } @@ -205,7 +206,7 @@ private static SpecialPositionAssignment spymaster(Faction faction, java.util.Se boolean changed = false; SpecialPositionAssignment holder = state.getSpymaster(); if (holder != null && !validHolder(faction, holder)) { - boolean died = eligible(faction, holder.playerName) && deadCharacter(holder); + boolean died = eligible(faction, holder.memberKey()) && deadCharacter(holder); state.removeSpymaster(); if (died) state.waiveAppointmentWait(SpecialPosition.SPYMASTER); changed = true; @@ -238,10 +239,8 @@ public static void characterDied(net.tfminecraft.tlibs.character.CharacterRef re boolean matching = holder != null && ref.ownerId().equals(holder.playerId) && ref.characterId().equals(OfficeCharacters.characterUuid(holder.characterId)) && Math.max(holder.appointedAt, holder.characterBoundAt) <= at.toEpochMilli(); - var leader = faction.getLeader() == null ? null - : org.bukkit.Bukkit.getOfflinePlayerIfCached(faction.getLeader()); boolean pending = state.isPendingFounder(SpecialPosition.SPYMASTER) - && leader != null && ref.ownerId().equals(leader.getUniqueId()) + && Members.key(ref).equals(faction.getLeader()) && ref.characterId().equals(OfficeCharacters.characterUuid( state.pendingFounderCharacter(SpecialPosition.SPYMASTER))) && state.pendingFounderBoundAt(SpecialPosition.SPYMASTER) <= at.toEpochMilli(); @@ -560,11 +559,11 @@ static void captureMembers(IntelligenceReport report, Faction target, int margin // The rolled sample is a prefix of the shared roster, so it still holds if sharing is switched off. var rolled = prefix(shuffled, rosterFraction(margin)); var sample = report.exact("roster") ? prefix(shuffled, 1) : rolled; - report.members = sample.stream().map(name -> CharacterNames.forForeign(name) + " §7— " + report.members = sample.stream().map(name -> Members.display(name) + " §7— " + net.tfminecraft.simplefactions.utils.Represents.represents(target, name)).toList(); for (var guild : target.getGuildHandler().getGuilds()) { report.guildMembers.put(guild.getId(), sample.stream().filter(guild::isMember) - .filter(name -> !guild.isLeader(name)).map(CharacterNames::forForeign).toList()); + .filter(name -> !guild.isLeader(name)).map(Members::display).toList()); } report.roster = new java.util.ArrayList<>(); for (var guild : target.getGuildHandler().getGuilds()) { @@ -572,12 +571,12 @@ static void captureMembers(IntelligenceReport report, Faction target, int margin if (target.isLeader(name)) continue; var offices = java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = target.getEspionage().holder(office); - return holder != null && holder.playerName.equalsIgnoreCase(name); + return holder != null && name.equals(holder.memberKey()); }).toList(); boolean knownLeader = guild.isLeader(name) && report.allows("guild-leader"); boolean knownOffice = !offices.isEmpty() && report.allows("office-holder"); if (!knownLeader && !knownOffice && (!report.allows("guild-members") || !sample.contains(name))) continue; - report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), rolled.contains(name), + report.roster.add(new IntelligenceReport.RosterMember(Members.display(name), guild.getId(), guild.getName(), rolled.contains(name), knownLeader, knownOffice ? offices : java.util.List.of())); } } @@ -621,7 +620,7 @@ static void captureOffices(IntelligenceReport report, Faction target, RandomGene for (var office : SpecialPosition.values()) { var holder = target.getEspionage().holder(office); if (report.allows("office-holder")) report.officeHolders.put(office, - holder == null ? "Vacant" : CharacterNames.forForeign(holder.playerName)); + holder == null ? "Vacant" : Members.display(holder.memberKey())); String key = IntelligenceReport.officeAptitudeKey(office); if (!report.allows(key)) continue; double aptitude = office == SpecialPosition.SPYMASTER ? effectiveAptitude(target, holder) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java index 09ae9f5d..fd6418d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java @@ -5,6 +5,7 @@ import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterStatus; import net.tfminecraft.tlibs.character.Characters; @@ -36,8 +37,7 @@ static boolean isCurrent(Player player, CharacterSession session) { static boolean isDead(UUID owner, String characterId) { UUID id = characterUuid(characterId); if (owner == null || id == null) return false; - return Characters.info(id).filter(info -> info.ref().ownerId().equals(owner) - && info.ref().characterId().equals(id)) + return Members.info(Members.key(id)).filter(info -> info.ref().ownerId().equals(owner)) .map(info -> info.status() == CharacterStatus.DEAD).orElse(false); } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java index 2da4498d..d64b5f60 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.espionage; import java.util.List; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; /** Non-numeric intelligence is frozen with the same faction-wide daily report. */ @@ -14,7 +15,7 @@ static void capture(IntelligenceReport report, Faction target) { if (upgrade.hasMaxLevel()) report.maximums.put(IntelligenceLedger.key(guild, "Upgrade:" + upgrade.getId()), (long) upgrade.getMaxLevel()); if (report.allows("office-holder")) report.details.put("leader-offices", java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = target.getEspionage().holder(office); - return holder != null && target.isLeader(holder.playerName); + return holder != null && target.isLeader(holder.memberKey()); }).map(SpecialPosition::label).toList()); if (report.allows("training")) { report.details.put("training", target.getMilitary().getQueue().stream() @@ -31,7 +32,7 @@ static void capture(IntelligenceReport report, Faction target) { report.details.put("construction", pending == null ? List.of() : List.of((pending.isUpgrade() ? "Upgrading " : "Building ") + pending.getName())); } if (report.allows("guild-leader")) for (var guild : target.getGuildHandler().getGuilds()) - report.details.put("guild-leader:" + guild.getId(), List.of(CharacterNames.forForeign(guild.getLeader()))); + report.details.put("guild-leader:" + guild.getId(), List.of(Members.display(guild.getLeader()))); VehicleIntelligence.captureTypes(report, target); if (report.allows("upgrades")) for (var guild : target.getGuildHandler().getGuilds()) report.details.put("upgrading:" + guild.getId(), guild.getUpgradeQueue().stream() diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java index 19592d6c..7fb938c6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -5,6 +5,7 @@ import java.util.List; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; /** One roster format for owned, staff and estimated views. Never sort foreign guilds by exact wealth. */ @@ -19,12 +20,12 @@ private static List build(Player viewer, Faction faction, Guild only) { var report = exact ? null : EspionageService.report(viewer, faction); List lore = new ArrayList<>(); String title = faction.getRulerTitle(); - lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + CharacterNames.display(viewer, faction.getLeader(), faction)); + lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + Members.display(viewer, faction.getLeader())); List leaderOffices = exact ? java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = faction.getEspionage().holder(office); - return holder != null && holder.playerName.equalsIgnoreCase(faction.getLeader()); + return holder != null && faction.isLeader(holder.memberKey()); }).map(SpecialPosition::label).toList() : report == null ? List.of() : report.details("office-holder", "leader-offices"); - for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader(), faction)); + for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + Members.display(viewer, faction.getLeader())); List guilds = new ArrayList<>(only == null ? faction.getGuildHandler().getGuilds() : List.of(only)); guilds.sort(Comparator.comparing((Guild guild) -> !guild.isBase()).thenComparing(Comparator.comparing((Guild guild) -> { var wealth = exact ? guild.getWealth() : EspionageService.visibleValue(viewer, faction, @@ -34,10 +35,10 @@ private static List build(Player viewer, Faction faction, Guild only) { for (Guild guild : guilds) { List entries; if (exact) entries = guild.getMembers().stream().filter(name -> !faction.isLeader(name)).map(name -> - new IntelligenceReport.RosterMember(CharacterNames.display(viewer, name), guild.getId(), guild.getName(), true, guild.isLeader(name), + new IntelligenceReport.RosterMember(Members.display(viewer, name), guild.getId(), guild.getName(), true, guild.isLeader(name), java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = faction.getEspionage().holder(office); - return holder != null && holder.playerName.equalsIgnoreCase(name); + return holder != null && name.equals(holder.memberKey()); }).toList())).toList(); else entries = report == null || report.roster == null ? List.of() : report.roster.stream() .filter(entry -> entry.guildId().equals(guild.getId())) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java index 29ebf425..a351f689 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java @@ -2,8 +2,11 @@ import java.util.UUID; +import net.tfminecraft.simplefactions.identity.Members; + public class SpecialPositionAssignment { public UUID playerId; + /** Account name at binding, kept for older saves; membership uses {@link #memberKey()}. */ public String playerName; public String characterId; // Character binding time, independent of an automatic founder's immediate full aptitude. @@ -19,4 +22,9 @@ public class SpecialPositionAssignment { public boolean isHolder(UUID id) { return id != null && id.equals(playerId); } + + /** Member key of the bound character; null while no character is bound. */ + public String memberKey() { + return Members.key(OfficeCharacters.characterUuid(characterId)); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java index 8ff759b2..00b21fde 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java @@ -5,6 +5,7 @@ import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.identity.Members; /** * A guild's daily gift to its realm treasury. The amount is what the realm receives. @@ -41,7 +42,7 @@ public static void notifyCancelled(Guild guild, double sent) { if (leaderName == null || leaderName.isBlank() || Bukkit.getServer() == null) { return; } - Player leader = Bukkit.getPlayerExact(leaderName); + Player leader = Members.player(leaderName); if (leader == null) { return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 52970f71..1c043c38 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -39,6 +39,7 @@ public class Ledger { private Guild guild; + // Keyed by the earning character's member key. private final Map citizenTaxes = new HashMap<>(); private final Map loanPayments = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java index 6aab969d..2ccc6cc5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java @@ -8,7 +8,8 @@ import net.tfminecraft.simplefactions.utils.Formatter; /** - * Last-day and lifetime totals behind the ledger view icons, keyed by payer name. This is a + * Last-day and lifetime totals behind the ledger view icons, keyed by payer: a member key for + * citizens and deposits, a realm or guild name otherwise (show them with Members.display). This is a * record of what already moved; settlement never reads it, so it cannot change a bank. */ public final class LedgerHistory { @@ -27,6 +28,7 @@ public enum Source { // Deposits land in the bank straight away, so they are gathered here until the day ends. private final Map depositsToday = new HashMap<>(); + /** {@code name} is the depositor's member key, or a label such as "Compensation". */ public void addDeposit(String name, double amount) { if (name == null || name.isBlank() || amount <= 0) return; depositsToday.merge(name, amount, Double::sum); diff --git a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java index b3e002bd..11aae9b6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java @@ -22,6 +22,7 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.utils.FactionCleanup; import net.tfminecraft.simplefactions.war.civilwar.wartime.CivilWarBorderLock; +import net.tfminecraft.simplefactions.identity.Members; /** * Tracks guild output penalties and faction prestige penalties, and sheds land @@ -126,7 +127,8 @@ public static boolean isFullyInactive(List members) { public static void onLogin(Player player) { if (player == null) return; ensureLoaded(); - String name = player.getName(); + String name = Members.key(player); + if (name == null) return; boolean wasInactive = InactivityRules.isInactive(FactionCleanup.daysOffline(name)); FactionCleanup.ping(name); if (!wasInactive) return; @@ -343,16 +345,8 @@ private static void writeFrom(Map clocks, Map onlinePlayersNamed(Collection memberNames) { + /** Players currently playing one of the member characters. */ + static List onlinePlayersNamed(Collection members) { if (Bukkit.getServer() == null) { return List.of(); } - return onlinePlayersNamed(memberNames, Bukkit::getPlayerExact); + return onlinePlayersNamed(members, Members::player); } static List onlinePlayersNamed(Collection memberNames, Function lookup) { @@ -28,17 +30,13 @@ static List onlinePlayersNamed(Collection memberNames, Function< } List online = new ArrayList<>(); - Set seen = new HashSet<>(); + Set seen = new HashSet<>(); for (String memberName : memberNames) { if (memberName == null || memberName.isBlank()) { continue; } - String normalized = memberName.toLowerCase(Locale.ROOT); - if (!seen.add(normalized)) { - continue; - } Player player = lookup.apply(memberName); - if (player != null && player.isOnline()) { + if (player != null && player.isOnline() && seen.add(player)) { online.add(player); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java index 854a19c3..912097eb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java @@ -52,22 +52,22 @@ public boolean onCommand(CommandSender sender, Command command, String label, St switch (sub) { case "found" -> found(p, args); case "invite" -> { - if (args.length != 2) { + if (args.length < 2) { p.sendMessage("§cUsage: /company invite "); return true; } - MercenaryInvites.invite(p, args[1]); + MercenaryInvites.invite(p, rest(args)); } case "accept" -> MercenaryInvites.accept(p); case "decline" -> MercenaryInvites.decline(p); case "kick" -> { - if (args.length != 2) { + if (args.length < 2) { p.sendMessage("§cUsage: /company kick "); return true; } - report(p, MercenaryCompanyService.kick(leaderGuild(p), p.getName(), args[1])); + kick(p, rest(args)); } - case "expand" -> report(p, MercenaryCompanyService.expand(leaderGuild(p), p.getName())); + case "expand" -> report(p, MercenaryCompanyService.expand(leaderGuild(p), Members.key(p))); case "draft" -> draft(p); case "offer" -> { if (args.length < 2) { @@ -94,7 +94,7 @@ private void found(Player p, String[] args) { return; } Guild guild = leaderGuild(p); - MercenaryResult check = MercenaryCompanyService.canFound(guild, p.getName(), name); + MercenaryResult check = MercenaryCompanyService.canFound(guild, Members.key(p), name); if (!check.ok()) { report(p, check); return; @@ -103,6 +103,18 @@ private void found(Player p, String[] args) { FactionManager.getInv().confirmCompanyFoundView(p, guild, name); } + /** Typed names may be character names with spaces. */ + private static String rest(String[] args) { + return String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + } + + /** Matches the typed name against the roster; an unknown name is refused by name. */ + private void kick(Player p, String input) { + MercenaryCompany company = company(p); + String member = company == null ? null : Members.find(company.getEnlisted(), input); + report(p, MercenaryCompanyService.kick(leaderGuild(p), Members.key(p), member == null ? input : member)); + } + /** {@code /mercenaries} opens the hiring hall; {@code list} prints it in chat. */ private boolean market(Player p, String[] args) { if (args.length > 0 && args[0].equalsIgnoreCase("list")) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java index c3d23dc4..4aa51b88 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java @@ -277,7 +277,7 @@ private void claimProvince( return; } if(stolen) { - Player leader = Bukkit.getPlayerExact(owner.getLeader()); + Player leader = Members.player(owner.getLeader()); if(leader != null) leader.sendMessage(f.getName()+" §cclaimed one of your provinces since you lacked the prestige to hold it!"); InstallationTransferService.transfer(owner, f, province); unclaim(null, owner, province); diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index 45ae493b..2f33412f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -6,8 +6,7 @@ import java.util.List; import java.util.Map; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.army.MilitaryExpansion; @@ -179,7 +178,7 @@ public String getLeader() { public boolean isLeader(String player) { String leader = getLeader(); - return leader != null && player != null && leader.equalsIgnoreCase(player); + return leader != null && leader.equals(player); } public String getName() { @@ -351,10 +350,7 @@ public List getEnlisted() { } public boolean isEnlisted(String player) { - for (String s : enlisted) { - if (s.equalsIgnoreCase(player)) return true; - } - return false; + return player != null && enlisted.contains(player); } public boolean enlist(String player) { @@ -376,16 +372,11 @@ public void enlistLeader() { /** Frees the slot, which in turn freezes expansion until it is filled again. */ public boolean kick(String player) { - for (int i = 0; i < enlisted.size(); i++) { - if (enlisted.get(i).equalsIgnoreCase(player)) { - enlisted.remove(i); - // Every roster or slot change funnels through the one guarded check, so no - // caller has to know which of the two can put the company under its promise. - ContractTerminationService.checkSlotCommitments(this); - return true; - } - } - return false; + if (player == null || !enlisted.remove(player)) return false; + // Every roster or slot change funnels through the one guarded check, so no + // caller has to know which of the two can put the company under its promise. + ContractTerminationService.checkSlotCommitments(this); + return true; } /* ===================================================== @@ -556,12 +547,12 @@ private void tickGrowth() { } /** - * The company disbands only when every player on it — the guild leader and - * each enlisted fighter — has no alive character with the mercenary trait. - * A character they are not currently playing still counts, including one - * stored for an offline account. A player who can be checked and has none - * is dismissed. A player who cannot be checked is left in place, and that - * uncertainty keeps the company from disbanding. + * The company disbands only when no character on it — the guild leader and + * each enlisted fighter — is an alive character with the mercenary trait. + * Each character is judged on its own; another character on the same account + * does not count. A character that can be read and fails is dismissed. One + * that cannot be read is left in place, and that uncertainty keeps the + * company from disbanding. * * @return true when this company was disbanded and the rest of the tick must stop */ @@ -587,8 +578,8 @@ boolean enforceCharacterGate() { disband(); for (String player : players) { if (isLeader(player)) { - tell(player, "§c" + companyName + " disbanded because nobody in the company" - + " has a living character with the mercenary trait."); + tell(player, "§c" + companyName + " disbanded because no character in the company" + + " is a living mercenary."); } else { tell(player, "§c" + companyName + " has disbanded."); } @@ -596,19 +587,19 @@ boolean enforceCharacterGate() { return true; } for (String member : new ArrayList<>(enlisted)) { - if (!containsIgnoreCase(withoutMercenary, member)) continue; + if (!withoutMercenary.contains(member)) continue; kick(member); tell(member, "§cYou were dismissed from " + name - + ". You have no living character with the mercenary trait."); + + ". This character is not a living mercenary."); if (!isLeader(member)) { - tell(getLeader(), "§c" + member - + " was dismissed from the company. They have no living character with the mercenary trait."); + tell(getLeader(), "§c" + Members.display(member) + + " was dismissed from the company. They are not a living mercenary."); } } return false; } - /** Guild leader plus enlisted fighters, with case-insensitive duplicates removed. */ + /** Guild leader plus enlisted fighters, without duplicates. */ private List playersInCompany() { List players = new ArrayList<>(); addPlayer(players, getLeader()); @@ -618,17 +609,10 @@ private List playersInCompany() { private static void addPlayer(List players, String name) { if (name == null || name.isBlank()) return; - if (containsIgnoreCase(players, name)) return; + if (players.contains(name)) return; players.add(name); } - private static boolean containsIgnoreCase(List names, String name) { - for (String existing : names) { - if (existing.equalsIgnoreCase(name)) return true; - } - return false; - } - /** * Closes every live contract as a company-side breach, drops open offers, and * detaches from the host guild. Pending wages die with the company. @@ -648,8 +632,7 @@ public void disband() { private static void tell(String player, String message) { if (player == null || message == null || SimpleFactions.plugin == null) return; - Player online = Bukkit.getPlayerExact(player); - if (online != null) online.sendMessage(message); + Members.online(player).ifPresent(online -> online.sendMessage(message)); } private void tickSlotQueue() { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java index 200419fb..dd21d921 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java @@ -7,6 +7,7 @@ import net.tfminecraft.simplefactions.army.Regiment; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.utils.Formatter; @@ -101,12 +102,12 @@ public static MercenaryResult canInvite(Guild guild, String actor, String target return MercenaryResult.deny("Your company is still being founded."); } if (company.isEnlisted(target)) { - return MercenaryResult.deny(target + " already serves in your company."); + return MercenaryResult.deny(Members.display(target) + " already serves in your company."); } if (!company.hasFreeSlot()) { return MercenaryResult.deny("Every slot is already filled."); } - return MercenaryResult.ok("Invite sent to " + target + "."); + return MercenaryResult.ok("Invite sent to " + Members.display(target) + "."); } public static MercenaryResult canJoin(MercenaryCompany company, String player) { @@ -158,13 +159,13 @@ public static MercenaryResult kick(Guild guild, String actor, String player) { return MercenaryResult.deny("Only the guild leader can dismiss mercenaries."); } if (!company.isEnlisted(player)) { - return MercenaryResult.deny(player + " does not serve in your company."); + return MercenaryResult.deny(Members.display(player) + " does not serve in your company."); } if (company.hasFreeSlot()) { return MercenaryResult.deny("Fill every slot before dismissing a mercenary."); } company.kick(player); - return MercenaryResult.ok(player + " was dismissed from the company."); + return MercenaryResult.ok(Members.display(player) + " was dismissed from the company."); } /* ===================================================== @@ -224,7 +225,7 @@ public static MercenaryResult upgrade(Guild guild, String actor, String upgradeI private static boolean isGuildLeader(Guild guild, String player) { String leader = guild.getLeader(); - return leader != null && player != null && leader.equalsIgnoreCase(player); + return leader != null && leader.equals(player); } private static String money(double amount) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibility.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibility.java index d77aed25..8b6d154c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibility.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibility.java @@ -1,49 +1,38 @@ package net.tfminecraft.simplefactions.mercenary.company; -import java.util.List; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; - +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.character.CharacterInfo; -import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterStatus; -import net.tfminecraft.tlibs.character.Characters; /** - * Character-trait gate on mercenary work. Factions stay player-based. Founding - * and joining use the active character's {@code mercenary} trait. Remaining in a - * company uses every alive character the player has, including one they are not - * currently playing. + * Character-trait gate on mercenary work. Every question is about one + * character, named by its member key: founding, joining and staying in a + * company all need that character's own {@code mercenary} trait. Another + * character on the same account does not count. * *

The default probe is open for standalone callers and tests. Production uses * {@link #useSharedIdentity()} to query the current character provider on every call. * The company tick treats {@link Status#UNKNOWN} as "leave them alone" - * and {@link Status#INELIGIBLE} as no living mercenary. + * and {@link Status#INELIGIBLE} as not a living mercenary. */ public final class MercenaryEligibility { public enum Status { - /** Their characters could not be read. The tick does not act on them. */ + /** The character could not be read. The tick does not act on it. */ UNKNOWN, ELIGIBLE, INELIGIBLE } public interface Probe { - Probe OPEN = player -> Status.ELIGIBLE; + Probe OPEN = member -> Status.ELIGIBLE; - /** Active character, for founding and joining. */ - Status check(String player); + /** Whether the character with this member key is a living mercenary. */ + Status check(String member); - /** - * Any alive character with the mercenary trait. Defaults to - * {@link #check} so a probe that only knows one answer keeps it. - */ - default Status livingMercenary(String player) { - return check(player); + /** Kept for the company tick; defaults to {@link #check}. */ + default Status livingMercenary(String member) { + return check(member); } } @@ -66,18 +55,19 @@ public static void reset() { probe = Probe.OPEN; } - public static Status check(String player) { - if (player == null || player.isBlank()) return Status.INELIGIBLE; - return probe.check(player); + /** A null or blank key is a player without a character, who may not do mercenary work. */ + public static Status check(String member) { + if (member == null || member.isBlank()) return Status.INELIGIBLE; + return probe.check(member); } /** - * Whether {@code player} has an alive character with the mercenary trait. - * {@link Status#UNKNOWN} means the characters could not be read. + * Whether the character with this key is alive and has the mercenary trait. + * {@link Status#UNKNOWN} means the character could not be read. */ - public static Status livingMercenary(String player) { - if (player == null || player.isBlank()) return Status.INELIGIBLE; - return probe.livingMercenary(player); + public static Status livingMercenary(String member) { + if (member == null || member.isBlank()) return Status.INELIGIBLE; + return probe.livingMercenary(member); } /** False while the open probe is installed, so tests and unconfigured servers skip the tick. */ @@ -85,71 +75,31 @@ public static boolean isEnforced() { return probe != Probe.OPEN; } - /** Whether a player may found a mercenary company. */ - public static boolean canCreate(String player) { - return check(player) != Status.INELIGIBLE; + /** Whether a character may found a mercenary company. */ + public static boolean canCreate(String member) { + return check(member) != Status.INELIGIBLE; } - /** Whether a player may enlist in a mercenary company. */ - public static boolean canJoin(String player) { - return check(player) != Status.INELIGIBLE; + /** Whether a character may enlist in a mercenary company. */ + public static boolean canJoin(String member) { + return check(member) != Status.INELIGIBLE; } private static final class SharedIdentityProbe implements Probe { @Override - public Status check(String player) { - if (Bukkit.getServer() == null || !Characters.available()) return Status.UNKNOWN; - Player online = Bukkit.getPlayerExact(player); - if (online == null) return Status.UNKNOWN; - - CharacterSession session = Characters.currentSession(online).orElse(null); - if (session == null || !online.getUniqueId().equals(session.ref().ownerId())) { - return Status.INELIGIBLE; - } - CharacterInfo character = Characters.info(session.ref().characterId()).orElse(null); - if (!readable(character, online.getUniqueId()) - || !session.ref().equals(character.ref()) || !livingMercenary(character)) { - return Status.INELIGIBLE; - } - return online.isOnline() && Bukkit.getPlayer(online.getUniqueId()) == online - && Characters.isCurrent(session) ? Status.ELIGIBLE : Status.INELIGIBLE; + public Status check(String member) { + CharacterInfo character = Members.info(member).orElse(null); + if (!readable(character)) return Status.UNKNOWN; + return character.status() == CharacterStatus.ALIVE && character.traits().contains(TRAIT_ID) + ? Status.ELIGIBLE : Status.INELIGIBLE; } - @Override - public Status livingMercenary(String player) { - if (Bukkit.getServer() == null || !Characters.available()) return Status.UNKNOWN; - Player online = Bukkit.getPlayerExact(player); - OfflinePlayer account = online != null ? online : Bukkit.getOfflinePlayerIfCached(player); - if (account == null || account.getUniqueId() == null) return Status.UNKNOWN; - - UUID ownerId = account.getUniqueId(); - List characters = Characters.characters(ownerId).orElse(null); - if (characters == null || !Characters.available()) return Status.UNKNOWN; - boolean unreadable = false; - for (CharacterInfo character : characters) { - if (!readable(character, ownerId)) { - unreadable = true; - } else if (livingMercenary(character)) { - return Status.ELIGIBLE; - } - } - return unreadable ? Status.UNKNOWN : Status.INELIGIBLE; - } - - private static boolean readable(CharacterInfo character, UUID ownerId) { - if (character == null || character.ref() == null - || !ownerId.equals(character.ref().ownerId()) || character.ref().characterId() == null - || character.status() == null || character.traits() == null) { - return false; - } + private static boolean readable(CharacterInfo character) { + if (character == null || character.status() == null || character.traits() == null) return false; for (String trait : character.traits()) { if (trait == null) return false; } return true; } - - private static boolean livingMercenary(CharacterInfo character) { - return character.status() == CharacterStatus.ALIVE && character.traits().contains(TRAIT_ID); - } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java index 470617fb..ec4567bb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java @@ -2,7 +2,6 @@ import net.tfminecraft.simplefactions.identity.Members; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.guild.Guild; @@ -19,15 +18,20 @@ private MercenaryInvites() { public static void invite(Player leader, String targetName) { Guild guild = net.tfminecraft.simplefactions.managers.FactionManager .getGuildByLeader(Members.key(leader)); + // Tells the leader when nobody online matches the name. + Player target = Members.resolveOnline(leader, targetName); + if (target == null) { + return; + } + String targetKey = Members.key(target); MercenaryResult check = MercenaryCompanyService - .canInvite(guild, leader.getName(), targetName); + .canInvite(guild, Members.key(leader), targetKey); if (!check.ok()) { leader.sendMessage("§c" + check.message()); return; } - Player target = Bukkit.getPlayerExact(targetName); - if (target == null) { - leader.sendMessage("§cCould not find the player " + targetName); + if (targetKey == null) { + leader.sendMessage("§c" + target.getName() + " has no character to enlist."); return; } MercenaryCompany company = guild.getCompany(); @@ -36,8 +40,8 @@ public static void invite(Player leader, String targetName) { if (RequestManager.getRequest(target) != invitation) { return; } - leader.sendMessage("§aInvited " + target.getName() + " to " + company.getName() + "."); - target.sendMessage("§a" + leader.getName() + " offers you a slot in " + leader.sendMessage("§aInvited " + Members.name(target) + " to " + company.getName() + "."); + target.sendMessage("§a" + Members.name(leader) + " offers you a slot in " + company.getName() + "."); target.sendMessage("§aType /company accept to sign on, or /company decline to refuse."); } @@ -54,17 +58,14 @@ public static void accept(Player player) { player.sendMessage("§cThat company invite has expired."); return; } - MercenaryResult result = MercenaryCompanyService.join(company, player.getName()); + MercenaryResult result = MercenaryCompanyService.join(company, Members.key(player)); player.sendMessage((result.ok() ? "§a" : "§c") + result.message()); RequestManager.remove(player); if (!result.ok()) { return; } - String leader = request.getCompany().getLeader(); - Player leaderPlayer = leader == null ? null : Bukkit.getPlayerExact(leader); - if (leaderPlayer != null) { - leaderPlayer.sendMessage("§a" + player.getName() + " signed on with the company."); - } + Members.online(request.getCompany().getLeader()).ifPresent(leaderPlayer -> + leaderPlayer.sendMessage("§a" + Members.name(player) + " signed on with the company.")); } public static void decline(Player player) { @@ -75,11 +76,8 @@ public static void decline(Player player) { } RequestManager.remove(player); player.sendMessage("§aYou turned down " + request.getCompany().getName() + "."); - String leader = request.getCompany().getLeader(); - Player leaderPlayer = leader == null ? null : Bukkit.getPlayerExact(leader); - if (leaderPlayer != null) { - leaderPlayer.sendMessage("§c" + player.getName() + " turned down the company."); - } + Members.online(request.getCompany().getLeader()).ifPresent(leaderPlayer -> + leaderPlayer.sendMessage("§c" + Members.name(player) + " turned down the company.")); } private static MercenaryInviteRequest pending(Player player) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java index 4be1b8ae..85d154d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java @@ -9,7 +9,7 @@ * guild: an active wage that is a share of what the slot earns under contract, * and a flat peacetime wage paid whether or not there is a contract. * - *

Each kind has a company-wide base and an optional per-player override, so a + *

Each kind has a company-wide base and an optional override per member key, so a * veteran can be kept on better terms than a new recruit without giving everyone * the raise. */ @@ -114,14 +114,14 @@ private static double clampPercent(double percent) { private static void put(Map map, String player, Double value) { if (player == null || player.isBlank()) return; if (value == null) { - map.remove(player.toLowerCase(java.util.Locale.ROOT)); + map.remove(player); return; } - map.put(player.toLowerCase(java.util.Locale.ROOT), value); + map.put(player, value); } private static Double get(Map map, String player) { if (player == null) return null; - return map.get(player.toLowerCase(java.util.Locale.ROOT)); + return map.get(player); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractBook.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractBook.java index 2cc08399..80873ac0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractBook.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractBook.java @@ -11,6 +11,7 @@ import net.tfminecraft.simplefactions.keys.Keys; import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; import net.tfminecraft.simplefactions.mercenary.company.MercenaryReputationCalculator; +import net.tfminecraft.simplefactions.identity.Members; import net.md_5.bungee.api.ChatColor; /** @@ -143,7 +144,7 @@ public static String signaturePage(MercenaryContract contract) { + "\n" + "Company:\n" + "-------------------\n" - + contract.getCompany().getLeader() + "\n" + + Members.display(contract.getCompany().getLeader()) + "\n" + "\n" + "Hirer:\n" + "-------------------\n" diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java index 3b22f76c..f0a0c0ef 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java @@ -151,7 +151,7 @@ public Offer offer(Faction hirer, ContractKind kind, ContractTerms terms, long n public MercenaryResult acceptAtHall(String contractId, Faction hirer, Player signer) { MercenaryResult permission = MercenaryMarket.canSign(company, signer); if (!permission.ok()) return permission; - return accept(contractId, hirer, signer.getName()); + return accept(contractId, hirer, Members.key(signer)); } public MercenaryResult accept(String contractId, Faction hirer, String signer) { @@ -394,8 +394,7 @@ public List tickAmendments(long now) { private static void tell(String player, String message) { if (player == null || message == null || SimpleFactions.plugin == null) return; - Player online = Bukkit.getPlayerExact(player); - if (online != null) online.sendMessage(message); + Members.online(player).ifPresent(online -> online.sendMessage(message)); } private static void tellGovernment(Faction hirer, String message) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java index bfef86c0..947f46e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java @@ -5,10 +5,10 @@ import java.util.List; import java.util.UUID; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.MemberAccounts; import net.tfminecraft.simplefactions.utils.PostSettlementPayouts.PlayerUuidLookup; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.simplefactions.war.battle.warband.Warband; @@ -46,7 +46,7 @@ public static PlayerUuidLookup uuidLookup() { } public static PlayerUuidLookup bukkitUuids() { - return OfflineModifier::playerId; + return MemberAccounts::accountId; } public static List on(War war, Side side) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarket.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarket.java index 274d556e..f4b97d07 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarket.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryMarket.java @@ -7,6 +7,7 @@ import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.rest.RestServer; @@ -79,7 +80,7 @@ public static MercenaryResult canSign(MercenaryCompany company, Player signer) { } MercenaryResult local = isLocal(company, RestServer.getProvince(signer)); if (!local.ok()) return local; - return hasAuthority(company, signer.getName()); + return hasAuthority(company, Members.key(signer)); } /** diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java index c5d05179..3718530e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompanies; import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.identity.Members; /** * Company upgrades only touch a player while they fight a battle as a hired @@ -64,7 +65,7 @@ public static MercenaryStatPlan planFor(String player, Collection guilds) /** True when the player is carrying company buffs after this call. */ public static boolean apply(Player player) { if (player == null) return false; - MercenaryStatPlan plan = planFor(player.getName()); + MercenaryStatPlan plan = planFor(Members.key(player)); if (plan.isEmpty()) { clear(player); return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java b/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java index 3ec4b415..5af8246d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java @@ -4,9 +4,9 @@ import java.util.Map; import java.util.UUID; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.player.income.PlayerLedger; +import net.tfminecraft.simplefactions.utils.MemberAccounts; public final class PlayerEconomyManager { private final Map ledgers = new HashMap<>(); @@ -22,8 +22,9 @@ public PlayerLedger getLedger(UUID playerUuid) { return ledgers.computeIfAbsent(playerUuid, ignored -> new PlayerLedger()); } - public PlayerLedger getLedger(String playerName) { - UUID uuid = OfflineModifier.playerId(playerName); + /** Ledger of the account a member's money reaches. */ + public PlayerLedger getLedger(String member) { + UUID uuid = MemberAccounts.accountId(member); if (uuid == null) { return new PlayerLedger(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/prestige/MemberPlaytime.java b/src/main/java/net/tfminecraft/simplefactions/prestige/MemberPlaytime.java index b60e4706..7148964a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/prestige/MemberPlaytime.java +++ b/src/main/java/net/tfminecraft/simplefactions/prestige/MemberPlaytime.java @@ -4,13 +4,7 @@ import java.util.HashMap; import java.util.Map; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; - -import net.tfminecraft.tlibs.character.CharacterInfo; -import net.tfminecraft.tlibs.character.CharacterSession; -import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.simplefactions.identity.Members; /** * The playtime half of the Members prestige term. Headcount alone rewards mass @@ -31,8 +25,8 @@ public interface Probe { /** Knows nobody, so the whole term stays out of the way. */ Probe NONE = player -> null; - /** Online seconds for this player, or null when the player is unknown. */ - Integer secondsFor(String player); + /** Online seconds for this member's character, or null when it is unknown. */ + Integer secondsFor(String member); } private static Probe probe = Probe.NONE; @@ -50,28 +44,15 @@ public static void reset() { probe = Probe.NONE; } - /** Uses ready online characters and cached offline accounts' last-played characters. */ + /** Reads each member's own character, online or not. */ public static void useSharedIdentity() { setProbe(MemberPlaytime::sharedSecondsFor); } - private static Integer sharedSecondsFor(String name) { - if (Bukkit.getServer() == null || !Characters.available()) return null; - Player online = Bukkit.getPlayerExact(name); - CharacterInfo info; - if (online != null) { - CharacterSession session = Characters.currentSession(online).orElse(null); - if (session == null || !session.ref().ownerId().equals(online.getUniqueId())) return null; - info = Characters.info(session.ref().characterId()).orElse(null); - if (info == null || !info.ref().equals(session.ref()) || !online.isOnline() - || Bukkit.getPlayer(online.getUniqueId()) != online || !Characters.isCurrent(session)) return null; - } else { - OfflinePlayer offline = Bukkit.getOfflinePlayerIfCached(name); - if (offline == null) return null; - info = Characters.lastPlayed(offline.getUniqueId()).orElse(null); - if (info == null || !info.ref().ownerId().equals(offline.getUniqueId())) return null; - } - return (int) Math.min(Integer.MAX_VALUE, Math.max(0L, info.playtimeSeconds())); + private static Integer sharedSecondsFor(String member) { + return Members.info(member) + .map(info -> (int) Math.min(Integer.MAX_VALUE, Math.max(0L, info.playtimeSeconds()))) + .orElse(null); } /** False while the default probe is installed, so unconfigured servers can tell. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java b/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java index 5e08c754..d32a8008 100644 --- a/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java @@ -6,7 +6,6 @@ import java.util.List; import java.util.Map; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.database.SettlementData; @@ -17,6 +16,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; public class SettlementHandler { @@ -326,7 +326,7 @@ void dissolve(Settlement s) { enqueueMapUpdate(); - Player leader = Bukkit.getPlayerExact(faction.getLeader()); + Player leader = Members.player(faction.getLeader()); if (leader != null) { leader.sendMessage("§cSettlement §f" + settlementName + " §chas been destroyed"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java b/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java index 633c5ced..5f16895e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java +++ b/src/main/java/net/tfminecraft/simplefactions/tiers/Title.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.List; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import com.google.gson.JsonArray; @@ -12,6 +11,7 @@ import net.tfminecraft.simplefactions.loaders.TitleLoader; import net.tfminecraft.simplefactions.managers.TitleManager; import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; public class Title { @@ -107,7 +107,7 @@ public void destroy(Faction f, List ownedProvinces, List ownedTi Title parent = TitleManager.getParent(this); ownedTitles.remove(this); f.removeTitle(this); - Player p = Bukkit.getPlayerExact(f.getLeader()); + Player p = Members.player(f.getLeader()); if(p != null && p.isOnline()) { p.sendMessage("§cYou lost the title §e"+name+" §cdue to a lack of provinces or required titles"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java index 3a81f757..87cd4ae3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java @@ -8,18 +8,17 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.logging.Level; import org.bukkit.Bukkit; -import org.bukkit.entity.Player; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.inactivity.InactivityRules; import net.tfminecraft.simplefactions.objects.Faction; @@ -29,7 +28,8 @@ public class FactionCleanup { private static Map<String, Integer> offlineDays; /** - * Counts a day offline for members who are not on the server. Members who + * Counts a day offline for members (keyed by character) whose character is + * not being played. Members who * reach {@link InactivityRules#DAYS_UNTIL_INACTIVE} stay in the guild and * are marked inactive. They are not kicked, and a lone inactive leader * does not dissolve the faction. @@ -44,9 +44,8 @@ public static void advanceOfflineDays(List<Faction> factions) { if (faction == null) continue; for (String member : faction.getMembers()) { if (!InactivityRules.isTrackedMember(member)) continue; - String key = member.toLowerCase(Locale.ROOT); - offlineDays.putIfAbsent(key, 0); - members.add(key); + offlineDays.putIfAbsent(member, 0); + members.add(member); } } } @@ -63,7 +62,7 @@ public static void advanceOfflineDays(List<Faction> factions) { if (members.contains(name) && !InactivityRules.isInactive(before) && InactivityRules.isInactive(after)) { - log(name + " is inactive after " + after + " days offline and stays in their guild"); + log(Members.display(name) + " (" + name + ") is inactive after " + after + " days offline and stays in their guild"); } } @@ -73,18 +72,18 @@ public static void advanceOfflineDays(List<Faction> factions) { } } - public static int daysOffline(String username) { - if (username == null) return 0; + public static int daysOffline(String member) { + if (member == null) return 0; if (!ensureLoaded()) return 0; - Integer days = offlineDays.get(username.toLowerCase(Locale.ROOT)); + Integer days = offlineDays.get(member); return days == null ? 0 : days; } - public static void ping(String username) { - if (username == null || username.isBlank()) return; + public static void ping(String member) { + if (member == null || member.isBlank()) return; try { if (!ensureLoaded()) return; - offlineDays.put(username.toLowerCase(Locale.ROOT), 0); + offlineDays.put(member, 0); saveOfflineDays(offlineDays); } catch (IOException e) { e.printStackTrace(); @@ -104,16 +103,8 @@ private static boolean ensureLoaded() { } } - private static boolean isOnline(String name) { - try { - if (Bukkit.getServer() == null || name == null) return false; - for (Player player : Bukkit.getOnlinePlayers()) { - if (player != null && player.getName().equalsIgnoreCase(name)) return true; - } - } catch (Throwable ignored) { - return false; - } - return false; + private static boolean isOnline(String member) { + return Bukkit.getServer() != null && Members.isOnline(member); } private static Map<String, Integer> loadOfflineDays() throws IOException { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/MemberAccounts.java b/src/main/java/net/tfminecraft/simplefactions/utils/MemberAccounts.java new file mode 100644 index 00000000..7a496b0c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/utils/MemberAccounts.java @@ -0,0 +1,27 @@ +package net.tfminecraft.simplefactions.utils; + +import java.util.UUID; + +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.simplefactions.identity.Members; + +/** + * The account that money for a member reaches until the economy keys by + * character. Member keys map to the account playing that character; any + * other string is still looked up as a Minecraft name. + */ +public final class MemberAccounts { + private MemberAccounts() {} + + public static UUID accountId(String member) { + if (member == null || member.isBlank()) return null; + if (Members.characterId(member) != null) return Members.accountId(member).orElse(null); + return OfflineModifier.playerId(member); + } + + /** The account behind a character ID; any other UUID is taken to be an account already. */ + public static UUID accountOf(UUID id) { + if (id == null) return null; + return Members.accountId(Members.key(id)).orElse(id); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java index 5544bf06..965a7246 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java @@ -4,8 +4,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.denareconomy.accounts.OfflineModifier; @@ -13,12 +15,13 @@ import net.tfminecraft.simplefactions.managers.RelationManager; public class Wealth { + /** Pouch, bank and a share of their guild; the member's account holds the money until part C. */ public static double wealth(String player) { - double wealth = OfflineModifier.balance(player, Accounts.POUCH); - wealth += OfflineModifier.balance(player, Accounts.BANK); + double wealth = balance(player, Accounts.POUCH); + wealth += balance(player, Accounts.BANK); Guild guild = FactionManager.getGuildByMember(player); if(guild != null) { - if(guild.getLeader().equalsIgnoreCase(player) && guild.isBase()) { + if(guild.getLeader().equals(player) && guild.isBase()) { wealth += guild.getWealth(); } else if(!guild.isBase()) { wealth += guild.getWealth()/(double)guild.getMembers().size(); @@ -27,6 +30,12 @@ public static double wealth(String player) { return wealth; } + private static double balance(String member, Accounts account) { + if (Members.characterId(member) == null) return OfflineModifier.balance(member, account); + UUID id = Members.accountId(member).orElse(null); + return id == null ? 0 : OfflineModifier.balance(id, account); + } + public static List<String> topWealth(Faction faction, boolean includeVassals) { List<String> top = new ArrayList<>(); Map<String, Double> wealthMap = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java index 31f8a389..4b9374e6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java @@ -16,7 +16,6 @@ import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseMessages; import net.tfminecraft.simplefactions.vehicles.berth.VehicleInstallationLockService; import net.tfminecraft.simplefactions.vehicles.berth.VehicleTransferMessages; -import org.bukkit.Bukkit; import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePaySessionManager.VehicleMaintenancePaySession; import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceMessages; import org.bukkit.entity.Player; @@ -96,13 +95,17 @@ public static void armGive(Player player, String targetName) { player.sendMessage(FactionVehicleReleaseMessages.giveUsage()); return; } - if (targetName.equalsIgnoreCase(player.getName())) { + // Tells the leader when nobody online matches the name. + Player target = Members.resolveOnline(player, targetName); + if (target == null) { + return; + } + if (target.getUniqueId().equals(player.getUniqueId())) { player.sendMessage(FactionVehicleReleaseMessages.giveSelf()); return; } - Player target = Bukkit.getPlayerExact(targetName); - if (target == null || !target.isOnline()) { - player.sendMessage(FactionVehicleReleaseMessages.recipientOffline(targetName)); + if (!target.isOnline()) { + player.sendMessage(FactionVehicleReleaseMessages.recipientOffline(Members.name(target))); return; } SimpleFactions plugin = SimpleFactions.getInstance(); @@ -117,7 +120,7 @@ public static void armGive(Player player, String targetName) { target.getName(), target.getUniqueId(), System.currentTimeMillis() + timeoutMillis)); - player.sendMessage(FactionVehicleReleaseMessages.giveArmed(target.getName())); + player.sendMessage(FactionVehicleReleaseMessages.giveArmed(Members.name(target))); } /** Any player can hand one of their personal vehicles to another online player. */ @@ -126,13 +129,17 @@ public static void armHandover(Player player, String targetName) { player.sendMessage(VehicleHandoverMessages.usage()); return; } - if (targetName.equalsIgnoreCase(player.getName())) { + // Tells the owner when nobody online matches the name. + Player target = Members.resolveOnline(player, targetName); + if (target == null) { + return; + } + if (target.getUniqueId().equals(player.getUniqueId())) { player.sendMessage(VehicleHandoverMessages.self()); return; } - Player target = Bukkit.getPlayerExact(targetName); - if (target == null || !target.isOnline()) { - player.sendMessage(VehicleHandoverMessages.recipientOffline(targetName)); + if (!target.isOnline()) { + player.sendMessage(VehicleHandoverMessages.recipientOffline(Members.name(target))); return; } SimpleFactions plugin = SimpleFactions.getInstance(); @@ -145,7 +152,7 @@ public static void armHandover(Player player, String targetName) { target.getName(), target.getUniqueId(), System.currentTimeMillis() + timeoutMillis)); - player.sendMessage(VehicleHandoverMessages.armed(target.getName())); + player.sendMessage(VehicleHandoverMessages.armed(Members.name(target))); } private static void clearOtherVehicleSessions(SimpleFactions plugin, Player player) { @@ -267,7 +274,7 @@ public static String handoverTarget(String[] args) { || !args[1].equalsIgnoreCase("handover")) { return null; } - return args.length >= 3 ? args[2] : ""; + return target(args); } /** @@ -281,7 +288,12 @@ public static String giveTarget(String[] args) { || !args[1].equalsIgnoreCase("give")) { return null; } - return args.length >= 3 ? args[2] : ""; + return target(args); + } + + /** Character names may have spaces, so the target is every word after the subcommand. */ + private static String target(String[] args) { + return args.length >= 3 ? String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)) : ""; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java index 596587e7..07d92901 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java @@ -27,6 +27,7 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.warband.WarbandVehicleRules; import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.utils.MemberAccounts; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; public final class BattleVehicleEligibilityService { @@ -137,6 +138,7 @@ public static Player resolveNotifyPlayer(ActiveVehicle vehicle, PlayerVehicleReg } return registry.getByVehicleUuid(vehicle.getUUID()) .map(PlayerVehicleRecord::getPlayerUuid) + .map(MemberAccounts::accountOf) .map(Bukkit::getPlayer) .orElse(null); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java index 42361fc3..60559f06 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java @@ -10,6 +10,7 @@ import net.tfminecraft.simplefactions.objects.request.VehicleGiveConsentRequest; import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Outcome; import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Status; +import net.tfminecraft.simplefactions.identity.Members; public final class FactionVehicleGiveService { private final FactionVehicleReleaseService releaseService; @@ -29,14 +30,14 @@ public void offer(Player leader, Player recipient, Faction faction, String vehic vehicleTypeId, recipient.getUniqueId(), leader.getUniqueId(), - leader.getName()); + Members.key(leader)); RequestManager.addRequest(leader, recipient, request); // addRequest refuses when the recipient is already considering a request, even one for this vehicle. if (RequestManager.getRequest(recipient) != request) { return; } - recipient.sendMessage(FactionVehicleReleaseMessages.givePrompt(leader.getName(), vehicleTypeId)); - leader.sendMessage(FactionVehicleReleaseMessages.giveSent(recipient.getName())); + recipient.sendMessage(FactionVehicleReleaseMessages.givePrompt(Members.name(leader), vehicleTypeId)); + leader.sendMessage(FactionVehicleReleaseMessages.giveSent(Members.name(recipient))); } public void acceptRequest(Player recipient) { @@ -53,7 +54,7 @@ public void acceptRequest(Player recipient) { } Faction faction = FactionManager.getByString(req.getFactionId()); if (faction == null || faction.getLeader() == null - || !faction.getLeader().equalsIgnoreCase(req.getLeaderName())) { + || !faction.getLeader().equals(req.getLeaderName())) { String expired = FactionVehicleReleaseMessages.giveExpired(); recipient.sendMessage(expired); notifyLeader(req, expired); @@ -66,7 +67,7 @@ public void acceptRequest(Player recipient) { recipient.getName(), req.getVehicleUuid()); if (outcome.status() != Status.OK) { - String message = FactionVehicleReleaseMessages.forGive(outcome, recipient.getName()); + String message = FactionVehicleReleaseMessages.forGive(outcome, Members.name(recipient)); if (message != null) { recipient.sendMessage(message); notifyLeader(req, message); @@ -75,10 +76,10 @@ public void acceptRequest(Player recipient) { } SimpleFactions plugin = SimpleFactions.getInstance(); if (plugin != null) { - plugin.recordVehicleOwner(req.getVehicleUuid(), recipient.getName()); + plugin.recordVehicleOwner(req.getVehicleUuid(), Members.key(recipient)); } - recipient.sendMessage(FactionVehicleReleaseMessages.giveSuccessRecipient(req.getLeaderName())); - notifyLeader(req, FactionVehicleReleaseMessages.giveSuccessLeader(recipient.getName())); + recipient.sendMessage(FactionVehicleReleaseMessages.giveSuccessRecipient(Members.display(req.getLeaderName()))); + notifyLeader(req, FactionVehicleReleaseMessages.giveSuccessLeader(Members.name(recipient))); } public void notifyExpired(VehicleGiveConsentRequest req, Player recipient) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java index a10b4c52..a86b0754 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java @@ -61,10 +61,10 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - Outcome outcome = releaseService.take(faction, leader.getName(), vehicle.getUUID()); + Outcome outcome = releaseService.take(faction, Members.key(leader), vehicle.getUUID()); SimpleFactions plugin = SimpleFactions.getInstance(); if (outcome.status() == Status.OK && plugin != null) { - plugin.recordVehicleOwner(vehicle.getUUID(), leader.getName()); + plugin.recordVehicleOwner(vehicle.getUUID(), Members.key(leader)); } String message = FactionVehicleReleaseMessages.forTake(outcome); if (message != null) { @@ -92,9 +92,9 @@ private void handleGive( } Outcome outcome = releaseService.evaluateGive( - faction, leader.getName(), recipient.getName(), vehicleUuid); + faction, Members.key(leader), recipient.getName(), vehicleUuid); if (outcome.status() != Status.OK) { - String message = FactionVehicleReleaseMessages.forGive(outcome, recipient.getName()); + String message = FactionVehicleReleaseMessages.forGive(outcome, Members.name(recipient)); if (message != null) { leader.sendMessage(message); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java index 2b1e97fe..fe74cba8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java @@ -66,16 +66,17 @@ public enum Status { public record Outcome(Status status, CanBuildResult slotFailure, String vehicleTypeId) {} - public Outcome take(Faction faction, String leaderName, String vehicleUuid) { - return release(faction, leaderName, leaderName, vehicleUuid, null, true); + /** {@code leader} is the leader's member key; recipients are account names, as VehicleFramework owners are. */ + public Outcome take(Faction faction, String leader, String vehicleUuid) { + return release(faction, leader, VehicleOwnershipQueries.accountNameOf(leader), vehicleUuid, null, true); } public Outcome takeFromInstallation( Faction faction, - String leaderName, + String leader, String installationId, String vehicleUuid) { - return release(faction, leaderName, leaderName, vehicleUuid, installationId, true); + return release(faction, leader, VehicleOwnershipQueries.accountNameOf(leader), vehicleUuid, installationId, true); } public Outcome give(Faction faction, String leaderName, String recipientName, String vehicleUuid) { @@ -104,7 +105,7 @@ private Outcome release( || vehicleUuid.isBlank()) { return outcome(Status.NOT_FACTION_VEHICLE, null, null); } - if (faction.getLeader() == null || !faction.getLeader().equalsIgnoreCase(leaderName)) { + if (faction.getLeader() == null || !faction.getLeader().equals(leaderName)) { return outcome(Status.NOT_LEADER, null, null); } if (recipientName == null || recipientName.isBlank()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java index 8450d60c..82132c5d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java @@ -8,6 +8,7 @@ import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; public final class InstallationVehicleOwnerSync { @@ -17,11 +18,16 @@ public InstallationVehicleOwnerSync(PlayerVehicleRegistry registry) { this.registry = registry; } + /** + * VehicleFramework names the leader's account. Null while that account cannot be + * read, so callers leave the owner as it is. + */ public static String expectedOwner(Faction faction) { if (faction == null || faction.getLeader() == null) { return "player_none"; } - return "player_" + faction.getLeader(); + String account = VehicleOwnershipQueries.accountNameOf(faction.getLeader()); + return account == null ? null : "player_" + account; } public void applyLeaderOwner(ActiveVehicle vehicle, Faction faction) { @@ -35,7 +41,10 @@ public void applyLeaderOwner(net.tfminecraft.vehicleframework.data.OwnerData own if (ownerData == null || faction == null) { return; } - ownerData.setOwner(expectedOwner(faction)); + String expected = expectedOwner(faction); + if (expected != null) { + ownerData.setOwner(expected); + } } public void syncIfBerthed(ActiveVehicle vehicle) { @@ -85,6 +94,9 @@ private static void applyExpectedOwner( return; } String expected = expectedOwner(faction); + if (expected == null) { + return; + } String current = ownerData.getOwner(); if (!expected.equalsIgnoreCase(current) || isLegacyFactionOwner(current)) { ownerData.setOwner(expected); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java index a11b887a..a5d4bb36 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java @@ -62,8 +62,8 @@ public boolean trySendConsentRequest(Player leader, Player owner, Faction factio RequestManager.addRequest(leader, owner, request); if (RequestManager.getRequest(owner) != request) return false; owner.sendMessage(VehicleTransferMessages.consentPrompt( - leader.getName(), vehicle.getId(), installation.getName())); - leader.sendMessage(VehicleTransferMessages.consentSent(owner.getName())); + Members.name(leader), vehicle.getId(), installation.getName())); + leader.sendMessage(VehicleTransferMessages.consentSent(Members.name(owner))); return true; } @@ -80,8 +80,8 @@ public boolean trySendPoolConsentRequest(Player leader, Player owner, Faction fa vehicle.getId(), owner.getUniqueId(), leader.getUniqueId(), true); RequestManager.addRequest(leader, owner, request); if (RequestManager.getRequest(owner) != request) return false; - owner.sendMessage(VehicleTransferMessages.poolConsentPrompt(leader.getName(), vehicle.getId())); - leader.sendMessage(VehicleTransferMessages.consentSent(owner.getName())); + owner.sendMessage(VehicleTransferMessages.poolConsentPrompt(Members.name(leader), vehicle.getId())); + leader.sendMessage(VehicleTransferMessages.consentSent(Members.name(owner))); return true; } @@ -138,7 +138,8 @@ public void acceptRequest(Player owner) { return; } - if (!installationVehicleService.tryRegister(installation, vehicle, faction, req.getOwnerUuid())) { + if (!installationVehicleService.tryRegister( + installation, vehicle, faction, VehicleOwnershipQueries.characterId(owner))) { String message = VehicleTransferMessages.saveFailed(); owner.sendMessage(message); notifyProposer(req, message); @@ -168,7 +169,7 @@ private void acceptPoolRequest(Player owner, VehicleTransferConsentRequest req, return; } - if (!poolService.tryRegister(faction, vehicle, req.getOwnerUuid())) { + if (!poolService.tryRegister(faction, vehicle, VehicleOwnershipQueries.characterId(owner))) { String message = VehicleTransferMessages.saveFailed(); owner.sendMessage(message); notifyProposer(req, message); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java index 52f60cd5..e0b63003 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java @@ -97,7 +97,8 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - if (!installationVehicleService.tryRegister(installation, vehicle, faction, leader.getUniqueId())) { + if (!installationVehicleService.tryRegister( + installation, vehicle, faction, VehicleOwnershipQueries.characterId(leader))) { leader.sendMessage(VehicleTransferMessages.saveFailed()); event.setCancelled(true); return; @@ -177,7 +178,7 @@ private void handlePoolTransfer( return; } - if (!poolService.tryRegister(faction, vehicle, leader.getUniqueId())) { + if (!poolService.tryRegister(faction, vehicle, VehicleOwnershipQueries.characterId(leader))) { leader.sendMessage(VehicleTransferMessages.saveFailed()); event.setCancelled(true); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java index 18605077..b66ef6ea 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java @@ -11,6 +11,7 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.player.income.PlayerCashflow; import net.tfminecraft.simplefactions.player.income.PlayerLedger; +import net.tfminecraft.simplefactions.utils.MemberAccounts; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; @@ -34,13 +35,13 @@ public static void setForTests(Function<String, Faction> lookup, PlayerBank bank playerBank = bank == null ? DenarEconomyPlayerBank.INSTANCE : bank; } - /** The charge this player owes for this vehicle type, or null when they owe nothing. */ - public static Quote quote(FeeKind kind, String payerName, String vehicleTypeId) { - if (kind == null || payerName == null || payerName.isBlank() || vehicleTypeId == null) { + /** The charge this member owes for this vehicle type, or null when they owe nothing. */ + public static Quote quote(FeeKind kind, String payer, String vehicleTypeId) { + if (kind == null || payer == null || payer.isBlank() || vehicleTypeId == null) { return null; } - Faction faction = factionLookup.apply(payerName); - if (faction == null || isLeader(faction, payerName)) { + Faction faction = factionLookup.apply(payer); + if (faction == null || isLeader(faction, payer)) { return null; } double rate = faction.getVehicleFeeHandler().getChargedRate(kind, vehicleTypeId); @@ -51,8 +52,8 @@ public static Quote quote(FeeKind kind, String payerName, String vehicleTypeId) return new Quote(kind, vehicleTypeId, faction, rate, amount); } - private static boolean isLeader(Faction faction, String playerName) { - return faction.getLeader() != null && faction.getLeader().equalsIgnoreCase(playerName); + private static boolean isLeader(Faction faction, String payer) { + return faction.getLeader() != null && faction.getLeader().equals(payer); } /** @@ -116,7 +117,7 @@ private static void recordPlayer(UUID payerUuid, FeeKind kind, double amount) { if (SimpleFactions.getInstance() == null) { return; } - PlayerLedger ledger = SimpleFactions.getPlayerEconomyManager().getLedger(payerUuid); + PlayerLedger ledger = SimpleFactions.getPlayerEconomyManager().getLedger(MemberAccounts.accountOf(payerUuid)); if (ledger != null) { ledger.add(kind == FeeKind.VEHICLE_TAX ? PlayerCashflow.VEHICLE_TAX : PlayerCashflow.VEHICLE_FEES, amount); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java index 4febe614..91fdbc0b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java @@ -35,11 +35,12 @@ public String getLastOwner(String vehicleUuid) { return vehicleUuid == null ? null : lastOwners.get(vehicleUuid); } - public void setLastOwner(String vehicleUuid, String playerName) { - if (vehicleUuid == null || playerName == null || playerName.isBlank()) { + /** {@code owner} is the member key of the character that last owned the vehicle. */ + public void setLastOwner(String vehicleUuid, String owner) { + if (vehicleUuid == null || owner == null || owner.isBlank()) { return; } - lastOwners.put(vehicleUuid, playerName); + lastOwners.put(vehicleUuid, owner); } public void forgetVehicle(String vehicleUuid) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java index 820031ed..1a7509d7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java @@ -6,6 +6,7 @@ import org.bukkit.event.Listener; import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.utils.Permissions; import net.tfminecraft.simplefactions.vehicles.VehicleRemovals; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; @@ -38,7 +39,7 @@ public void onVehicleOwnerClaimed(VehicleOwnerClaimedEvent event) { return; } String lastOwner = store.getLastOwner(vehicle.getUUID()); - if (lastOwner == null || lastOwner.equalsIgnoreCase(player.getName()) || Permissions.isAdmin(player)) { + if (lastOwner == null || Members.is(player, lastOwner) || Permissions.isAdmin(player)) { return; } Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, lastOwner, vehicle.getId()); @@ -50,7 +51,7 @@ public void onVehicleOwnerClaimed(VehicleOwnerClaimedEvent event) { if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) { event.setCancelled(true); confirmations.ask(player.getUniqueId(), target, quote.amount(), now); - player.sendMessage(VehicleFeeMessages.claimConfirm(quote, lastOwner)); + player.sendMessage(VehicleFeeMessages.claimConfirm(quote, Members.display(lastOwner))); return; } if (!VehicleFeeService.collect(VehicleRegistrationFeeListener.payerUuid(player), quote)) { @@ -66,7 +67,7 @@ public void onClaimed(VehicleOwnerClaimedEvent event) { if (event.getPlayer() == null || event.getVehicle() == null) { return; } - store.setLastOwner(event.getVehicle().getUUID(), event.getPlayer().getName()); + store.setLastOwner(event.getVehicle().getUUID(), Members.key(event.getPlayer())); saver.run(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index b2b8864e..fdb3551c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -11,6 +11,8 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.utils.MemberAccounts; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; @@ -47,7 +49,7 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { return; } String vehicleTypeId = VehicleIntegrationListener.resolveVehicleTypeId(blueprint); - Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, player.getName(), vehicleTypeId); + Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, Members.key(player), vehicleTypeId); if (quote == null) { return; } @@ -87,7 +89,7 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { SimpleFactions.getInstance().getLogger().warning("Registration fee refund short by " + shortfall + " for " + build.payerUuid() + " from faction " + build.factionId()); } - Player payer = Bukkit.getPlayer(build.payerUuid()); + Player payer = Bukkit.getPlayer(MemberAccounts.accountOf(build.payerUuid())); if (payer != null && payer.isOnline()) { if (refunded > 0.0) { payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded)); @@ -100,11 +102,13 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVehicleConstruct(VehicleConstructEvent event) { - boolean changed = store.takePaidBuild(stationKey(event.getStation())) != null; + PaidBuild paid = store.takePaidBuild(stationKey(event.getStation())); + boolean changed = paid != null; if (event.getVehicle() != null && event.getConstructorUuid() != null) { + // The builder's character; an offline builder is known only by the fee they paid. String owner = event.getConstructor() != null - ? event.getConstructor().getName() - : VehicleOwnershipQueries.resolvePlayerName(event.getConstructorUuid()); + ? Members.key(event.getConstructor()) + : paid == null ? null : Members.key(paid.payerUuid()); if (owner != null) { store.setLastOwner(event.getVehicle().getUUID(), owner); changed = true; @@ -115,9 +119,9 @@ public void onVehicleConstruct(VehicleConstructEvent event) { } } + /** The paying character; its account is charged. */ static UUID payerUuid(Player player) { - UUID resolved = VehicleFeeService.resolve(player.getName()); - return resolved != null ? resolved : player.getUniqueId(); + return VehicleOwnershipQueries.characterId(player); } static String stationKey(ActiveStation station) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java index 9d7cc365..5e8ad662 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java @@ -13,6 +13,7 @@ import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService.Outcome; import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService.Status; import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverSessionManager.Session; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -51,19 +52,19 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { } Outcome outcome = service.evaluate(owner.getName(), recipient.getName(), vehicle.getUUID()); if (outcome.status() != Status.OK) { - owner.sendMessage(VehicleHandoverMessages.forOutcome(outcome, recipient.getName())); + owner.sendMessage(VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); if (outcome.status() != Status.NOT_OWNER) { sessions.clear(owner.getUniqueId()); } return; } - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, owner.getName(), outcome.vehicleTypeId()); + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, Members.key(owner), outcome.vehicleTypeId()); if (quote != null) { long now = System.currentTimeMillis(); String target = "handover:" + vehicle.getUUID() + ":" + recipient.getUniqueId(); if (!confirmations.confirm(owner.getUniqueId(), target, quote.amount(), now)) { confirmations.ask(owner.getUniqueId(), target, quote.amount(), now); - owner.sendMessage(VehicleFeeMessages.handoverConfirm(quote, recipient.getName())); + owner.sendMessage(VehicleFeeMessages.handoverConfirm(quote, Members.name(recipient))); return; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java index 2cb46d5d..7954dd8c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -63,7 +63,7 @@ public VehicleHandoverService(PlayerVehicleRegistry registry, VehicleFeeStore st this.ownerAssigner = ownerAssigner; } - /** Whether the owner may hand this vehicle to the recipient right now. */ + /** Whether the owner may hand this vehicle to the recipient right now; both are account names, as VehicleFramework stores them. */ public Outcome evaluate(String ownerName, String recipientName, String vehicleUuid) { String typeId = personalVehicleType(ownerName, vehicleUuid); if (typeId == null) { @@ -72,7 +72,7 @@ public Outcome evaluate(String ownerName, String recipientName, String vehicleUu if (!VehiclesConfigLoader.isKnownType(typeId)) { return new Outcome(Status.UNKNOWN_TYPE, null, typeId); } - if (FactionCampaignBattleLock.blocks(FactionManager.getByMember(ownerName))) { + if (FactionCampaignBattleLock.blocks(FactionManager.getByMember(VehicleOwnershipQueries.ownerKey(ownerName)))) { return new Outcome(Status.IN_BATTLE, null, typeId); } CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipientName, typeId, registry); @@ -109,8 +109,8 @@ public void offer(Player owner, Player recipient, String vehicleUuid, String veh if (RequestManager.getRequest(recipient) != request) { return; } - recipient.sendMessage(VehicleHandoverMessages.prompt(owner.getName(), vehicleTypeId)); - owner.sendMessage(VehicleHandoverMessages.sent(recipient.getName())); + recipient.sendMessage(VehicleHandoverMessages.prompt(Members.name(owner), vehicleTypeId)); + owner.sendMessage(VehicleHandoverMessages.sent(Members.name(recipient))); } public void acceptRequest(Player recipient) { @@ -127,10 +127,11 @@ public void acceptRequest(Player recipient) { } Outcome outcome = evaluate(req.getOwnerName(), recipient.getName(), req.getVehicleUuid()); if (outcome.status() != Status.OK) { - tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, recipient.getName())); + tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); return; } - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, req.getOwnerName(), outcome.vehicleTypeId()); + String ownerKey = VehicleOwnershipQueries.ownerKey(req.getOwnerName()); + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerKey, outcome.vehicleTypeId()); if (!Objects.equals(req.getVehicleTypeId(), outcome.vehicleTypeId()) || !req.matchesFee(quote)) { tellBoth(req, recipient, VehicleHandoverMessages.feeChanged()); return; @@ -153,15 +154,15 @@ public void acceptRequest(Player recipient) { tellBoth(req, recipient, VehicleHandoverMessages.ownershipUnavailable()); return; } - store.setLastOwner(req.getVehicleUuid(), recipient.getName()); + store.setLastOwner(req.getVehicleUuid(), Members.key(recipient)); saver.run(); - recipient.sendMessage(VehicleHandoverMessages.successRecipient(req.getOwnerName())); + recipient.sendMessage(VehicleHandoverMessages.successRecipient(Members.display(ownerKey))); Player owner = Bukkit.getPlayer(req.getOwnerUuid()); if (owner != null && owner.isOnline()) { if (quote != null) { owner.sendMessage(VehicleFeeMessages.handoverPaid(quote)); } - owner.sendMessage(VehicleHandoverMessages.successOwner(recipient.getName())); + owner.sendMessage(VehicleHandoverMessages.successOwner(Members.name(recipient))); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java index 93a23dbe..635678c5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java @@ -4,8 +4,10 @@ import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.simplefactions.utils.MemberAccounts; public final class DenarEconomyPlayerBank { + /** Payer IDs may be character IDs (vehicle registry and fees); money reaches their account. */ public interface PlayerBank { double getBankBalance(UUID playerUuid); @@ -13,8 +15,8 @@ public interface PlayerBank { boolean depositToBank(UUID playerUuid, double amount); - /** Account id for this name, whether or not they are logged in. */ - UUID resolve(String playerName); + /** Account id for this member key (or legacy name), whether or not they are logged in. */ + UUID resolve(String member); } public interface PlayerPouch { @@ -31,33 +33,33 @@ public static final class Impl implements PlayerBank, PlayerPouch { private Impl() {} @Override - public UUID resolve(String playerName) { - return OfflineModifier.playerId(playerName); + public UUID resolve(String member) { + return MemberAccounts.accountId(member); } @Override public double getBankBalance(UUID playerUuid) { - return OfflineModifier.balance(playerUuid, Accounts.BANK); + return OfflineModifier.balance(MemberAccounts.accountOf(playerUuid), Accounts.BANK); } @Override public boolean withdrawFromBank(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerUuid, Accounts.BANK, -amount); + return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.BANK, -amount); } @Override public boolean depositToBank(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerUuid, Accounts.BANK, amount); + return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.BANK, amount); } @Override public double getPouchBalance(UUID playerUuid) { - return OfflineModifier.balance(playerUuid, Accounts.POUCH); + return OfflineModifier.balance(MemberAccounts.accountOf(playerUuid), Accounts.POUCH); } @Override public boolean withdrawFromPouch(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerUuid, Accounts.POUCH, -amount); + return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.POUCH, -amount); } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java index f55b9c56..e6538111 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java @@ -37,16 +37,17 @@ public static double projectedDailyUpkeep(String playerName, PlayerVehicleRegist return total; } - /** Vehicle tax the player's faction will charge with tomorrow's upkeep. */ + /** Vehicle tax the owner's faction will charge with tomorrow's upkeep; {@code playerName} is the account. */ public static double projectedDailyTax(String playerName, PlayerVehicleRegistry registry) { if (playerName == null || playerName.isBlank()) { return 0.0; } double total = 0.0; + String owner = VehicleOwnershipQueries.ownerKey(playerName); for (OwnedVehicleSummary vehicle : VehicleOwnershipQueries.personalVehicles(playerName, registry)) { VehicleFeeService.Quote quote = - VehicleFeeService.quote(FeeKind.VEHICLE_TAX, playerName, vehicle.getTypeId()); + VehicleFeeService.quote(FeeKind.VEHICLE_TAX, owner, vehicle.getTypeId()); if (quote != null) { total += quote.amount(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java index a8752e7f..a0343d51 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java @@ -24,6 +24,7 @@ import net.tfminecraft.simplefactions.player.PlayerEconomyManager; import net.tfminecraft.simplefactions.player.income.PlayerCashflow; import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; public final class VehicleUpkeepService { @@ -75,7 +76,8 @@ public void processDailyUpkeep() { continue; } // Tax is charged with upkeep, so a player who misses one misses both. - Quote tax = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, playerName, vehicle.getTypeId()); + Quote tax = VehicleFeeService.quote( + FeeKind.VEHICLE_TAX, VehicleOwnershipQueries.ownerKey(playerName), vehicle.getTypeId()); chargePlayer(playerUuid, upkeep, tax, vehicle.getTypeId(), vehicle.getUuid(), now); } chargeFactionVehicles(now); @@ -225,7 +227,7 @@ private void markFactionUnpaid( if (faction == null || faction.getLeader() == null || Bukkit.getServer() == null) { return; } - Player leader = Bukkit.getPlayerExact(faction.getLeader()); + Player leader = Members.player(faction.getLeader()); if (leader != null && leader.isOnline()) { leader.sendMessage( "§cCould not pay faction vehicle upkeep (" diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java index 1fcf82a1..37d714f0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java @@ -10,9 +10,11 @@ import org.bukkit.entity.Player; import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.tlibs.character.Characters; public final class VehicleOwnershipQueries { public interface OwnedInventory { @@ -96,6 +98,48 @@ public static UUID resolvePlayerUuid(String playerName) { return OfflineModifier.playerId(playerName); } + /** + * The member behind a VehicleFramework owner name. Owner tags still name accounts + * (stage 6), so this is the character being played, else the account's last-played + * character. Without a server or character provider the name itself is the key. + */ + public static String ownerKey(String accountName) { + if (accountName == null || accountName.isBlank()) { + return null; + } + if (Bukkit.getServer() == null) { + return accountName; + } + Player online = Bukkit.getPlayerExact(accountName); + if (online != null) { + return Members.key(online); + } + if (!Bukkit.isPrimaryThread() || !Characters.available()) { + return accountName; + } + UUID account = OfflineModifier.playerId(accountName); + if (account == null) { + return null; + } + return Characters.lastPlayed(account) + .filter(info -> account.equals(info.ref().ownerId())) + .map(info -> Members.key(info.ref())) + .orElse(null); + } + + /** The character a new registry row or fee is recorded for; the account without a character. */ + public static UUID characterId(Player player) { + return Members.session(player).map(session -> session.ref().characterId()).orElse(player.getUniqueId()); + } + + /** The account name VehicleFramework uses for a member's vehicles; a non-key is already a name. */ + public static String accountNameOf(String member) { + if (member == null || member.isBlank()) { + return null; + } + return Members.characterId(member) == null ? member : Members.accountName(member); + } + public static String resolvePlayerName(UUID playerUuid) { if (playerUuid == null || Bukkit.getServer() == null) { return null; diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java index f527940e..73863515 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java @@ -207,15 +207,15 @@ void trainingDetailsRemainTheDailySnapshotAfterTheLiveQueueChanges() { @Test void ownRosterCombinesCurrentGuildLeadershipAndOfficeWithoutDuplicatingTheFactionLeader() { try (var fixture = new FactionDomainFixture()) { - var leader = fixture.player("Alice"); - var home = fixture.saved("home", "Alice"); - home.addMember("Ordinary"); - var artisans = fixture.guild(home, "Artisans", "Boris"); - artisans.addMember("Clerk"); - var appointment = new SpecialPositionAssignment(); - appointment.playerName = "Boris"; - appointment.playerId = fixture.player("Boris").getUniqueId(); - appointment.characterId = "boris-character"; + var members = CharacterKeyedMembers.install(); + var leader = members.add(fixture.player("Alice")); + for (String name : List.of("Ordinary", "Clerk")) members.add(fixture.player(name)); + var home = fixture.saved("home", EspionageTestFixtures.key("Alice")); + home.addMember(EspionageTestFixtures.key("Ordinary")); + var artisans = fixture.guild(home, "Artisans", EspionageTestFixtures.key("Boris")); + artisans.addMember(EspionageTestFixtures.key("Clerk")); + var appointment = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Boris"); + appointment.playerId = members.add(fixture.player("Boris")).getUniqueId(); home.getEspionage().appoint(appointment, 80); var lines = RosterLore.faction(leader, home); assertEquals(1, lines.stream().filter(line -> line.contains("Alice")).count()); @@ -246,10 +246,11 @@ void ownRosterCombinesCurrentGuildLeadershipAndOfficeWithoutDuplicatingTheFactio void nestedGuiCommandsRestoreTheOuterObserversIntelligenceContext(boolean nestedFails) { try (var fixture = new FactionDomainFixture(); var database = mockConstruction(Database.class)) { - var alice = fixture.player("Alice"); - var bob = fixture.player("Bob"); - var first = fixture.saved("first", "Alice"); - var second = fixture.saved("second", "Bob"); + var members = CharacterKeyedMembers.install(); + var alice = members.add(fixture.player("Alice")); + var bob = members.add(fixture.player("Bob")); + var first = fixture.saved("first", EspionageTestFixtures.key("Alice")); + var second = fixture.saved("second", EspionageTestFixtures.key("Bob")); appointLeader(first, alice); appointLeader(second, bob); assertNull(EspionageService.report(alice, second)); @@ -284,10 +285,8 @@ void nestedGuiCommandsRestoreTheOuterObserversIntelligenceContext(boolean nested } private void appointLeader(Faction faction, Player leader) { - var appointment = new SpecialPositionAssignment(); + var appointment = EspionageTestFixtures.bind(new SpecialPositionAssignment(), leader.getName()); appointment.playerId = leader.getUniqueId(); - appointment.playerName = leader.getName(); - appointment.characterId = leader.getName() + "-character"; faction.getEspionage().assignFounder(SpecialPosition.SPYMASTER, appointment, 80); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterKeyedMembers.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterKeyedMembers.java new file mode 100644 index 00000000..03eebc37 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterKeyedMembers.java @@ -0,0 +1,69 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; + +/** + * Keys each test player by the character {@link EspionageTestFixtures#characterOf(String)} + * gives their name, so office holders and faction members share one key. Registered + * players are online and their character carries their name. + */ +final class CharacterKeyedMembers implements Members.Resolver { + private final Map<String, Player> players = new HashMap<>(); + + static CharacterKeyedMembers install() { + CharacterKeyedMembers resolver = new CharacterKeyedMembers(); + Members.setResolver(resolver); + return resolver; + } + + Player add(Player player) { + players.put(EspionageTestFixtures.key(player.getName()), player); + return player; + } + + void remove(Player player) { + players.remove(EspionageTestFixtures.key(player.getName())); + } + + @Override + public Optional<CharacterSession> session(Player player) { + if (player.getName() == null) return Optional.empty(); + return Optional.of(new CharacterSession( + new CharacterRef(player.getUniqueId(), EspionageTestFixtures.characterOf(player.getName())), 1)); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return true; + } + + @Override + public String key(Player player) { + return player.getName() == null ? null : EspionageTestFixtures.key(player.getName()); + } + + @Override + public Optional<CharacterInfo> info(String key) { + Player player = players.get(key); + if (player == null) return Optional.empty(); + return Optional.of(new CharacterInfo(new CharacterRef(player.getUniqueId(), + EspionageTestFixtures.characterOf(player.getName())), player.getName(), + CharacterStatus.ALIVE, null, Set.of(), 0, null)); + } + + @Override + public Optional<Player> online(String key) { + return Optional.ofNullable(players.get(key)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index f0a0645f..6864e64a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -2,201 +2,77 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; + import java.util.List; import java.util.UUID; -import net.tfminecraft.simplefactions.espionage.OfficeOptionalIntegrationsTest.IdentityFixture; -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.CharacterSession; -import net.tfminecraft.tlibs.character.CharacterStatus; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -class CharacterNameResolutionTest { - @org.junit.jupiter.api.BeforeEach void guardEverything() { net.tfminecraft.simplefactions.testsupport.EspionageModes.guardEverything(); } - @org.junit.jupiter.api.AfterEach void resetEspionage() { net.tfminecraft.simplefactions.testsupport.EspionageModes.reset(); } - - @Test void foreignFallbackHidesAccountsOnlyWhenSharedIdentityIsAvailable() { - var account = "NoActiveCharacterAccount"; - var viewer = player("ForeignViewer"); - var faction = mock(net.tfminecraft.simplefactions.objects.Faction.class); - EspionageTestFixtures.protect(faction); - try (var f = new IdentityFixture(account)) { - assertEquals("Unknown", CharacterNames.forForeign(account)); - assertEquals("Unknown", CharacterNames.display(viewer, account, faction)); - assertEquals(account, CharacterNames.of(account), "Invitations keep account fallback"); - when(faction.isMember("ForeignViewer")).thenReturn(true); - assertEquals(account, CharacterNames.display(viewer, account, faction)); - when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); - assertEquals(account + " \u00a77(" + account + ")", CharacterNames.display(viewer, account, faction)); - f.registered = false; - assertEquals(account, CharacterNames.forForeign(account), "Servers without a provider keep account names"); - } - } +import net.tfminecraft.simplefactions.espionage.OfficeOptionalIntegrationsTest.IdentityFixture; +import net.tfminecraft.simplefactions.identity.Members; - @Test void onlineNamesUseLiveAliasesWithoutReadingCanonicalDirectoryNames() { - try (var f = new IdentityFixture()) { - var alice = f.activate(UUID.randomUUID()); - f.alias = "A Hooded Stranger"; - assertEquals(f.alias, CharacterNames.of(f.account)); - assertEquals(f.alias, CharacterNames.forForeign(f.account)); - f.activate(UUID.randomUUID()); - f.alias = "Bob's Mask"; - assertEquals(f.alias, CharacterNames.forForeign(f.account)); - f.activate(alice.ref().characterId()); - f.alias = "Alice's New Mask"; - assertEquals(f.alias, CharacterNames.of(f.account)); - verify(f.provider, never()).info(any()); - verify(f.provider, never()).lastPlayed(any()); - } - } +/** Names espionage shows and resolves now come from member keys through {@link Members}. */ +class CharacterNameResolutionTest { + @BeforeEach void guardEverything() { net.tfminecraft.simplefactions.testsupport.EspionageModes.guardEverything(); } + @AfterEach void resetEspionage() { net.tfminecraft.simplefactions.testsupport.EspionageModes.reset(); } - @Test void onlineNamesDropRpCharactersColouringSoMenusAndReportsKeepTheirOwn() { - try (var f = new IdentityFixture()) { - f.activate(UUID.randomUUID()); - // RPCharacters' white reset followed by a two-colour hex gradient. - f.alias = "\u00a7f\u00a7x\u00a7f\u00a74\u00a7e\u00a74\u00a7a\u00a7aLa\u00a7x\u00a7c\u00a72\u00a7b\u00a7e\u00a7a\u00a77dy \u00a7oRaven"; - assertEquals("Lady Raven", CharacterNames.of(f.account)); - assertEquals("Lady Raven", CharacterNames.forForeign(f.account)); - var viewer = player("Staff"); + @Test void reportsShowCharacterNamesAndNeverFallBackToAccounts() { + try (var f = new IdentityFixture("Account").shared()) { + var viewer = player("ForeignViewer"); + UUID id = UUID.randomUUID(); + String key = Members.key(id); + assertEquals("Unknown", Members.display(key), "An unreadable character never shows its account"); + f.infos.put(id, OfficeOptionalIntegrationsTest.info(new net.tfminecraft.tlibs.character.CharacterRef(f.owner, id), + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE)); + String name = Members.display(key); + assertNotEquals("Account", name); + assertEquals(name, Members.display(viewer, key)); + OfflinePlayer account = mock(OfflinePlayer.class); + when(account.getName()).thenReturn("Account"); + f.bukkit.when(() -> Bukkit.getOfflinePlayer(f.owner)).thenReturn(account); when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); - assertEquals("Lady Raven \u00a77(" + f.account + ")", CharacterNames.display(viewer, f.account)); - } - } - - @Test void providerArrivalAndRemovalTakeEffectWithoutANameCache() { - try (var f = new IdentityFixture()) { - f.registered = false; - assertEquals(f.account, CharacterNames.forForeign(f.account)); - f.registered = true; - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - assertEquals(f.account, CharacterNames.of(f.account)); - f.activate(UUID.randomUUID()); - assertEquals(f.alias, CharacterNames.forForeign(f.account)); + assertEquals(name + " §7(Account)", Members.display(viewer, key), "Staff see who plays it"); f.registered = false; - assertEquals(f.account, CharacterNames.forForeign(f.account)); - assertEquals(f.account, CharacterNames.of(f.account)); - assertEquals("Unknown", CharacterNames.forForeign(null)); + assertEquals("Unknown", Members.display(key)); } } - @Test void pendingOrWrongOwnerOnlineSessionCannotFallBackToLastPlayedName() { - try (var f = new IdentityFixture()) { - UUID characterId = UUID.randomUUID(); - f.lastPlayed.put(f.owner, OfficeOptionalIntegrationsTest.info(new CharacterRef(f.owner, characterId), CharacterStatus.ALIVE)); - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - assertEquals(f.account, CharacterNames.of(f.account)); - f.token = new CharacterSession(new CharacterRef(UUID.randomUUID(), characterId), 1); - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - verify(f.provider, never()).displayName(any()); - verify(f.provider, never()).lastPlayed(any()); + @Test void onlineCandidatesResolveByCharacterOrAccountNameAndRefuseAmbiguity() { + try (var f = new IdentityFixture("Account").shared()) { + var sender = player("Sender"); + var session = f.activate(UUID.randomUUID()); + String name = Members.name(f.player); + assertSame(f.player, Members.resolveOnline(sender, " \"" + name.toLowerCase() + "\" ")); + assertSame(f.player, Members.resolveOnline(sender, "Account")); + assertEquals(Members.key(session.ref()), Members.findOnline(name)); + assertNull(Members.resolveOnline(sender, "No Such Player")); + verify(sender).sendMessage(contains("No online player found")); + var twin = mock(Player.class); + when(twin.getName()).thenReturn(name); + f.bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> List.of(f.player, twin)); + assertNull(Members.resolveOnline(sender, name.toUpperCase())); + verify(sender).sendMessage(contains("Several characters share that name")); } } - @ParameterizedTest - @ValueSource(strings={"switch-back", "provider-lost", "reconnect", "offline"}) - void aliasCallbackCannotPublishANameFromAnEndedSession(String change) { - try (var f = new IdentityFixture()) { - var original = f.activate(UUID.randomUUID()); - when(f.provider.displayName(f.player)).thenAnswer(call -> { - switch (change) { - case "switch-back" -> { - f.activate(UUID.randomUUID()); - f.activate(original.ref().characterId()); - } - case "provider-lost" -> f.registered = false; - case "reconnect" -> { - Player replacement = mock(Player.class); - when(replacement.getUniqueId()).thenReturn(f.owner); - when(replacement.isOnline()).thenReturn(true); - f.connection = replacement; - } - case "offline" -> when(f.player.isOnline()).thenReturn(false); - default -> throw new AssertionError(change); - } - return "Old Mask"; - }); - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - verify(f.provider, never()).info(any()); - verify(f.provider, never()).lastPlayed(any()); - } + @Test void completionReturnsOnlyTheCurrentWordAndGuardsPastTheEndOfAName() { + var names = List.of("Lady Raven", "Lady Rose", "Lady", "Account", "Lady Raven"); + assertEquals(List.of("Lady Raven", "Lady Rose", "Lady"), Members.completions(names, new String[]{"spymaster", "La"})); + assertEquals(List.of("Raven", "Rose"), Members.completions(names, new String[]{"spymaster", "Lady", "R"})); + assertTrue(Members.completions(names, new String[]{"spymaster", "Lady", "Raven", ""}).isEmpty()); + assertEquals(List.of("Raven"), Members.completions( + List.of("§6Lady §eRaven"), new String[]{"spymaster", "Lady", "R"})); } - @Test void cachedOfflineIdentityUsesOwnerCheckedLastPlayedWithoutNetworkLookup() { - try (var f = new IdentityFixture()) { - f.connection = null; - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - assertEquals(f.account, CharacterNames.of(f.account)); - verify(f.provider, never()).lastPlayed(any()); - OfflinePlayer cached = mock(OfflinePlayer.class); - when(cached.getUniqueId()).thenReturn(f.owner); - f.bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(f.account)).thenReturn(cached); - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - var ref = new CharacterRef(f.owner, UUID.randomUUID()); - f.lastPlayed.put(f.owner, OfficeOptionalIntegrationsTest.info(ref, CharacterStatus.DEAD)); - assertEquals("Canonical Secret", CharacterNames.forForeign(f.account)); - assertEquals("Canonical Secret", CharacterNames.of(f.account)); - f.lastPlayed.put(f.owner, OfficeOptionalIntegrationsTest.info(new CharacterRef(UUID.randomUUID(), ref.characterId()), CharacterStatus.ALIVE)); - assertEquals("Unknown", CharacterNames.forForeign(f.account)); - assertEquals(f.account, CharacterNames.of(f.account)); - f.bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); - verify(f.provider, never()).info(any()); - verify(f.provider, never()).displayName(any()); - } - } - @org.junit.jupiter.api.Test - void invitationCompletionReturnsOnlyCurrentTokenAndGuardsPastEndOfName() { - var names = java.util.List.of("Lady Raven", "Lady Rose", "Lady", "Account", "Lady Raven"); - assertEquals(java.util.List.of("Lady Raven", "Lady Rose", "Lady"), - CharacterNames.inviteCompletions(names, new String[]{"invite", "La"})); - assertEquals(java.util.List.of("Raven", "Rose"), - CharacterNames.inviteCompletions(names, new String[]{"invite", "Lady", "R"})); - assertEquals(java.util.List.of("Raven", "Rose"), - CharacterNames.inviteCompletions(names, new String[]{"invite", "lady", ""})); - assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Lady", "Raven", ""}).isEmpty()); - assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Account", "", ""}).isEmpty()); - assertEquals(java.util.List.of("Account"), CharacterNames.inviteCompletions(names, new String[]{"invite", "acc"})); - assertEquals(java.util.List.of("Lady Raven"), CharacterNames.inviteCompletions( - java.util.List.of("\u00a76Lady \u00a7eRaven"), new String[]{"invite", "La"})); - assertEquals(java.util.List.of("Raven"), CharacterNames.inviteCompletions( - java.util.List.of("\u00a76Lady \u00a7eRaven"), new String[]{"invite", "Lady", "R"})); - } - private Player player(String name) { + private static Player player(String name) { var player = mock(Player.class); when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); return player; } - @Test - void resolvesFullCharacterNamesAndExactAccountsWithoutPrefixMatches() { - var first = player("FirstAccount"); - var second = player("SecondAccount"); - var players = List.of(first, second); - java.util.function.Function<Player, String> names = player -> player == first ? "\u00a7aLady Rowan Ash" : "Lord Vale"; - assertSame(first, CharacterNames.resolve("lady rowan ash", players, names).player()); - assertSame(second, CharacterNames.resolve("secondaccount", players, names).player()); - assertNull(CharacterNames.resolve("Lady Rowan", players, names).player()); - assertNull(CharacterNames.resolve("First", players, names).player()); - } - @Test - void duplicateCharactersRequireAnAccountAndAccountsTakePrecedence() { - var first = player("Rowan"); - var second = player("OtherAccount"); - var players = List.of(first, second); - assertTrue(CharacterNames.resolve("Shared Name", players, ignored -> "Shared Name").ambiguous()); - assertNull(CharacterNames.resolve("Shared Name", players, ignored -> "Shared Name").player()); - assertSame(first, CharacterNames.resolve("Rowan", players, ignored -> "Rowan").player()); - } - @Test - void bypassDisplaysBothNamesWhileNormalViewsDisplayCharacterOnly() { - var viewer = player("Viewer"); - try (var names = mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { - names.when(() -> CharacterNames.of("Account")).thenReturn("Lady Rowan Ash"); - assertEquals("Lady Rowan Ash", CharacterNames.display(viewer, "Account")); - when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); - assertEquals("Lady Rowan Ash \u00a77(Account)", CharacterNames.display(viewer, "Account")); - } - } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java index 3a76afb5..088db37e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java @@ -21,11 +21,10 @@ class EspionageBypassTest { private void protect(Faction faction) { var state = new EspionageState(); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Spy"); state.appoint(holder, 50); when(faction.getEspionage()).thenReturn(state); - when(faction.isMember("Spy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java index c7168ba7..46e0d0c5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java @@ -51,20 +51,22 @@ class EspionageMenusCoverageTest { private MockedStatic<TLibs> items; private MockedStatic<net.tfminecraft.rpcharacters.calendar.FantasyCalendar> calendar; private MockedConstruction<Database> databases; + private CharacterKeyedMembers members; @BeforeEach void setup() throws Exception { fixture = new FactionDomainFixture(); - home = fixture.saved("home", "Leader"); + members = CharacterKeyedMembers.install(); + home = fixture.saved("home", EspionageTestFixtures.key("Leader")); home.getOrCreateMainGuild(); - home.addMember("Spy"); - target = fixture.saved("target", "ForeignLeader"); + home.addMember(EspionageTestFixtures.key("Spy")); + target = fixture.saved("target", EspionageTestFixtures.key("ForeignLeader")); target.getOrCreateMainGuild(); - guild = fixture.guild(target, "merchants", "Baron"); - leader = fixture.player("Leader"); - spy = fixture.player("Spy"); - foreignLeader = fixture.player("ForeignLeader"); - visitor = fixture.player("Visitor"); + guild = fixture.guild(target, "merchants", EspionageTestFixtures.key("Baron")); + leader = members.add(fixture.player("Leader")); + spy = members.add(fixture.player("Spy")); + foreignLeader = members.add(fixture.player("ForeignLeader")); + visitor = members.add(fixture.player("Visitor")); characters = mockStatic(OfficeCharacters.class); characters.when(() -> OfficeCharacters.currentSession(any())) .thenAnswer(call -> session(call.getArgument(0))); @@ -413,15 +415,15 @@ void candidatePagesClampAndRevalidateLeadershipWhenTheMenuIsClicked() { click(leader, 53); assertEquals(SFGUI.SPYMASTER_VIEW, kind(leader)); click(leader, 11); - home.setLeader("Spy"); + home.setLeader(EspionageTestFixtures.key("Spy")); click(leader, 0); verify(leader).closeInventory(); } @Test void selectingAnOnlineCharacterAppointsAndRemovalReturnsTheOfficeToTheLeader() { - home.addMember("NewSpy"); - Player candidate = fixture.player("NewSpy"); + home.addMember(EspionageTestFixtures.key("NewSpy")); + Player candidate = members.add(fixture.player("NewSpy")); EspionageView.spymasterOffice(leader, home, manager); click(leader, 11); int candidateSlot = -1; diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java index bc7a7f48..90a2ed5d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java @@ -36,6 +36,7 @@ import net.tfminecraft.simplefactions.map.provinces.Province; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.ItemAPI; import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; @@ -144,14 +145,14 @@ void close() throws Exception { @Test void offlineNamesUseAuthoritativeDirectoryWithoutReadingPluginFiles() throws Exception { + Members.useSharedIdentity(); Path folder = directory.resolve("data/characterdata").resolve(accountId("Offline").toString()); Files.createDirectories(folder); Files.writeString(folder.resolve("old.json"), "{"); UUID id = UUID.randomUUID(); offlineInfos.put(id, new CharacterInfo(new CharacterRef(accountId("Offline"), id), "Lady Rowan", CharacterStatus.DEAD, null, Set.of(), 0, null)); - assertEquals("Lady Rowan", CharacterNames.forForeign("Offline")); - assertEquals("Lady Rowan", CharacterNames.of("Offline")); + assertEquals("Lady Rowan", Members.display(Members.key(id))); assertEquals("{", Files.readString(folder.resolve("old.json"))); } @@ -160,16 +161,16 @@ void quotedCharacterResolutionRejectsAmbiguityAndExactAccountsStillTakePrecedenc throws Exception { offices(); when(characters.get("Candidate").getName()).thenReturn("§aLady Rowan"); - assertSame(candidate, CharacterNames.resolveOnline(leader, " \"lady rowan\" ")); + assertSame(candidate, Members.resolveOnline(leader, " \"lady rowan\" ")); Player duplicate = characterPlayer("Duplicate"); when(characters.get("Duplicate").getName()).thenReturn("Lady Rowan"); - assertNull(CharacterNames.resolveOnline(leader, "Lady Rowan")); + assertNull(Members.resolveOnline(leader, "Lady Rowan")); verify(leader).sendMessage(contains("Several characters")); - assertSame(candidate, CharacterNames.resolveOnline(leader, "candidate")); - assertSame(duplicate, CharacterNames.resolveOnline(leader, "Duplicate")); - assertNull(CharacterNames.resolveOnline(leader, "No Such Player")); + assertSame(candidate, Members.resolveOnline(leader, "Candidate")); + assertSame(duplicate, Members.resolveOnline(leader, "Duplicate")); + assertNull(Members.resolveOnline(leader, "No Such Player")); verify(leader).sendMessage(contains("No online player")); - assertEquals("Lady Rowan", CharacterNames.forForeign("Candidate")); + assertEquals("Lady Rowan", Members.display(key("Candidate"))); } @Test @@ -177,11 +178,11 @@ void unknownDirectoryKeepsForeignIdentityHiddenAndDoesNotCacheOldNames() { UUID id = UUID.randomUUID(); offlineInfos.put(id, new CharacterInfo(new CharacterRef(accountId("Offline"), id), "Current", CharacterStatus.ALIVE, null, Set.of(), 0, null)); - assertEquals("Current", CharacterNames.of("Offline")); + Members.useSharedIdentity(); + assertEquals("Current", Members.display(Members.key(id))); offlineInfos.clear(); - assertEquals("Unknown", CharacterNames.forForeign("Offline")); - assertEquals("Offline", CharacterNames.of("Offline")); - assertEquals("Unknown", CharacterNames.forForeign(null)); + assertEquals("Unknown", Members.display(Members.key(id))); + assertEquals("Unknown", Members.display((String) null)); } @Test @@ -206,6 +207,7 @@ void founderStorageFailureKeepsRetryIntentAndNeverInstallsAnUnsavedOffice() thro offices(); home.getEspionage().removeSpymaster(); activate("Leader", "replacement-character"); + home.setLeader(characterId("replacement-character")); Files.delete(aptitudeFile); Files.createDirectory(aptitudeFile); EspionageService.initializeFounder(home); @@ -250,6 +252,7 @@ void reportRefreshPersistsFounderRetryIntentWithoutInventingAnIntelligenceReport offices(); home.getEspionage().removeSpymaster(); activate("Leader", "replacement-character"); + home.setLeader(characterId("replacement-character")); Files.delete(aptitudeFile); Files.createDirectory(aptitudeFile); int before = databases.constructed().size(); @@ -273,17 +276,17 @@ void publicReportRefreshCapturesRealGuildArmyAndOfficeStateOncePerDay() throws E settings.set("espionage.checks.aptitude-multiplier", 2); EspionageConfig.load(settings); home.getEspionage().getSpymaster().aptitude = 100; - Faction target = fixture.saved("target", "Foreign"); + Faction target = fixture.saved("target", key("Foreign")); target.getOrCreateMainGuild(); - target.addMember("Ordinary"); + target.addMember(key("Ordinary")); Player foreign = characterPlayer("Foreign"); characterPlayer("Ordinary"); YamlConfiguration upgradeData = new YamlConfiguration(); upgradeData.set("upgrade.allowed-types", List.of("guild")); UpgradeLoader.map.put( "workshop", new Upgrade("workshop", upgradeData.getConfigurationSection("upgrade"))); - Guild guild = fixture.guild(target, "merchants", "Baron"); - guild.addMember("Trader"); + Guild guild = fixture.guild(target, "merchants", key("Baron")); + guild.addMember(key("Trader")); Player baron = characterPlayer("Baron"); characterPlayer("Trader"); fixture.provinceData.put(37, new Province(37, "PLAINS", 50)); @@ -476,12 +479,17 @@ void completionListsOnlyAuthorizedOnlineAppointmentsAndPrivateConductChoices() t home.addMember("OfflineMember"); assertTrue( EspionageCommands.complete(leader, new String[] {"spymaster", ""}) - .containsAll(List.of("remove", "Leader", "Candidate", "sabotage"))); + .containsAll(List.of("remove", "Character Leader", "Character Candidate", "sabotage"))); assertFalse( EspionageCommands.complete(leader, new String[] {"spymaster", ""}) .contains("OfflineMember")); assertEquals( - List.of("Candidate"), EspionageCommands.complete(leader, new String[] {"spymaster", "Ca"})); + List.of("Character Candidate"), + EspionageCommands.complete(leader, new String[] {"spymaster", "Character C"})); + assertEquals( + List.of("Candidate"), + EspionageCommands.complete(leader, new String[] {"spymaster", "Character", "Ca"}), + "Later words of a name with spaces complete on their own"); assertTrue(EspionageCommands.complete(candidate, new String[] {"spymaster", ""}).isEmpty()); assertTrue(EspionageCommands.complete(visitor, new String[] {"spymaster", ""}).isEmpty()); assertTrue(EspionageCommands.complete(leader, new String[] {"positions", ""}).isEmpty()); @@ -580,7 +588,10 @@ void staleAttributeReadNeverPersistsARollOrBindsTheReturnedCharacter(String oper boolean founder = operation.equals("founder"); String name = founder ? "Leader" : "Candidate"; activate(name, "unrolled"); - if (founder) home.getEspionage().removeSpymaster(); + if (founder) { + home.getEspionage().removeSpymaster(); + home.setLeader(characterId("unrolled")); + } else home.addMember(characterId("unrolled")); SpecialPositionAssignment previous = home.getEspionage().getSpymaster(); String stored = Files.readString(aptitudeFile); AtomicBoolean switched = new AtomicBoolean(); @@ -690,9 +701,10 @@ private void offices() throws Exception { when(player.getUniqueId()).thenReturn(call.getArgument(0)); return player; }); - home = fixture.saved("home", "Leader"); + Members.useSharedIdentity(); + home = fixture.saved("home", key("Leader")); home.getOrCreateMainGuild(); - home.addMember("Candidate"); + home.addMember(key("Candidate")); leader = characterPlayer("Leader"); candidate = characterPlayer("Candidate"); visitor = characterPlayer("Visitor"); @@ -738,6 +750,11 @@ private void activate(String name, String label) { sessions.put(accountId(name), new CharacterSession(new CharacterRef(accountId(name), id), ++generation)); } + /** Member key of the named test player's first character. */ + private static String key(String name) { + return characterId("character-" + name); + } + private static String characterId(String label) { return UUID.nameUUIDFromBytes(label.getBytes(StandardCharsets.UTF_8)).toString(); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index 274540b5..dfc903e3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -64,8 +64,7 @@ void sabotageRequiresTheOfficeHoldersUuidEvenForLeadersAndMembers() { var state = new EspionageState(); var assignment = new SpecialPositionAssignment(); assignment.playerId = UUID.randomUUID(); - assignment.playerName = "Spy"; - assignment.characterId = "character"; + EspionageTestFixtures.bind(assignment, "Spy"); state.appoint(assignment, () -> 60); when(faction.getEspionage()).thenReturn(state); when(faction.isMember(anyString())).thenReturn(true); @@ -170,8 +169,7 @@ void leaderIsAlwaysEligibleAndOneOfficeKeepsFullAptitude() { assertTrue(EspionageService.eligible(faction, "Spy")); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Leader"; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Leader"); state.assignFounder(SpecialPosition.SPYMASTER, holder, 79); assertEquals(1, EspionageService.positionsHeld(faction, holder)); assertEquals(79, EspionageService.effectiveAptitude(faction, holder)); @@ -202,15 +200,14 @@ void eachExtraOfficeCostsTheConfiguredShareOfAptitude() { @Test void leaderKeepsOfficeWhenAnotherMemberJoins() { Faction faction = mock(Faction.class); - when(faction.isLeader("Leader")).thenReturn(true); - when(faction.getLeader()).thenReturn("Leader"); - when(faction.getMembers()).thenReturn(java.util.List.of("Leader")); + when(faction.isLeader(EspionageTestFixtures.key("Leader"))).thenReturn(true); + when(faction.getLeader()).thenReturn(EspionageTestFixtures.key("Leader")); + when(faction.getMembers()).thenReturn(java.util.List.of(EspionageTestFixtures.key("Leader"))); var state = new EspionageState(); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Leader"; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Leader"); state.assignFounder(SpecialPosition.SPYMASTER, holder, 100); when(faction.getEspionage()).thenReturn(state); - when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "GuildMember")); + when(faction.getMembers()).thenReturn(java.util.List.of(EspionageTestFixtures.key("Leader"), "GuildMember")); try (var databases = mockConstruction(Database.class)) { assertSame(holder, EspionageService.spymaster(faction)); assertTrue(EspionageService.hasSpymaster(faction)); @@ -224,19 +221,17 @@ void defaultHoldingFollowsANewLeaderButAppointmentsStay() { Faction faction = mock(Faction.class); when(faction.getLeader()).thenReturn("NewLeader"); when(faction.isLeader("NewLeader")).thenReturn(true); - when(faction.isMember("OldLeader")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("OldLeader"))).thenReturn(true); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); - var appointed = new SpecialPositionAssignment(); - appointed.playerName = "OldLeader"; + var appointed = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "OldLeader"); state.appoint(appointed, 80); try (var databases = mockConstruction(Database.class)) { assertSame(appointed, EspionageService.spymaster(faction), "A deliberate appointment survives a change of leader"); assertTrue(databases.constructed().isEmpty()); } state.removeSpymaster(); - var automatic = new SpecialPositionAssignment(); - automatic.playerName = "OldLeader"; + var automatic = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "OldLeader"); state.assignFounder(SpecialPosition.SPYMASTER, automatic, 80); assertFalse(EspionageService.hasSpymaster(faction)); try (var databases = mockConstruction(Database.class)) { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java index bc2beac9..d4ed18df 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java @@ -1,17 +1,38 @@ package net.tfminecraft.simplefactions.espionage; import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; final class EspionageTestFixtures { private EspionageTestFixtures() {} + /** A stable character ID for a test name; offices match members by this key. */ + static UUID characterOf(String name) { + return UUID.nameUUIDFromBytes(("character-" + name).getBytes(StandardCharsets.UTF_8)); + } + + /** The member key of {@link #characterOf(String)}. */ + static String key(String name) { + return Members.key(characterOf(name)); + } + + /** Binds the holder to the named test character. */ + static SpecialPositionAssignment bind(SpecialPositionAssignment holder, String name) { + holder.playerName = name; + holder.characterId = characterOf(name).toString(); + return holder; + } + static void protect(Faction faction) { var state = new EspionageState(); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + var holder = bind(new SpecialPositionAssignment(), "Spy"); state.appoint(holder, 50); when(faction.getEspionage()).thenReturn(state); - when(faction.isMember("Spy")).thenReturn(true); + when(faction.isMember(key("Spy"))).thenReturn(true); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java index c68a5c62..9629c987 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java @@ -67,8 +67,9 @@ void guildRostersReuseTheFactionSampleAndRemainStableAcrossSerialization() { when(guild.isLeader("Account0")).thenReturn(true); var report = new IntelligenceReport(); report.quality = "Detailed estimates"; - try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { - names.when(() -> CharacterNames.of(anyString())).thenAnswer(call -> "Character " + call.getArgument(0).toString().substring(7)); + try (var names = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS); + var represents = mockStatic(Represents.class)) { + names.when(() -> net.tfminecraft.simplefactions.identity.Members.display(anyString())).thenAnswer(call -> "Character " + call.getArgument(0).toString().substring(7)); represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); EspionageService.captureMembers(report, target, 65, new Random(2)); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java index 73c265a4..555b1b4b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -36,10 +36,9 @@ private Faction faction(String id) { when(faction.getId()).thenReturn(id); when(faction.getFoundedAt()).thenReturn(1L); when(faction.getEspionage()).thenReturn(new EspionageState()); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Spy"); faction.getEspionage().appoint(holder, 50); - when(faction.isMember("Spy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); when(faction.getMembers()).thenReturn(List.of()); when(faction.getGuildHandler().getGuilds()).thenReturn(List.of()); when(faction.getMilitary().getRegiments()).thenReturn(List.of()); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java index f9ed481c..3e951367 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java @@ -59,13 +59,13 @@ class IntelligencePolicyTest { var target = mock(Faction.class); var state = new EspionageState(); when(target.getEspionage()).thenReturn(state); - when(target.isMember("Account")).thenReturn(true); - when(target.getMembers()).thenReturn(List.of("Account")); - var holder = new SpecialPositionAssignment(); - holder.playerName = "Account"; + String account = EspionageTestFixtures.key("Account"); + when(target.isMember(account)).thenReturn(true); + when(target.getMembers()).thenReturn(List.of(account)); + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Account"); state.appoint(holder, 80); - try (var names = mockStatic(CharacterNames.class)) { - names.when(() -> CharacterNames.forForeign("Account")).thenReturn("Lady Raven"); + try (var names = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + names.when(() -> net.tfminecraft.simplefactions.identity.Members.display(account)).thenReturn("Lady Raven"); for (int margin : new int[]{30, 65, 100}) { var report = EspionageService.createReport(Map.of(), margin, new Random(1)); EspionageService.captureOffices(report, target, new Random(1)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java index 9b6944a0..8fe5dbc0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -47,6 +47,9 @@ void vacantOfficeInALargerFactionFallsToTheLeadersCharacterAtFullAptitude() thro String characterId = UUID.randomUUID().toString(); var session = new CharacterSession(new CharacterRef(ownerId, UUID.fromString(characterId)), 1); when(leader.getUniqueId()).thenReturn(ownerId); + when(leader.getName()).thenReturn("Leader"); + // The office holder is judged by its character's key, which the leader's seat must name. + when(faction.isLeader(net.tfminecraft.simplefactions.identity.Members.key(UUID.fromString(characterId)))).thenReturn(true); var registry = mock(CharacterAptitudes.class); when(registry.aptitude(eq(characterId), any())).thenReturn(72); var field = EspionageService.class.getDeclaredField("characterAptitudes"); @@ -61,6 +64,7 @@ void vacantOfficeInALargerFactionFallsToTheLeadersCharacterAtFullAptitude() thro characters.when(() -> OfficeCharacters.currentSession(leader)).thenReturn(session); characters.when(() -> OfficeCharacters.isCurrent(leader, session)).thenReturn(true); characters.when(() -> OfficeCharacters.requireCurrent(leader, session)).thenCallRealMethod(); + characters.when(() -> OfficeCharacters.characterUuid(any())).thenCallRealMethod(); var holder = EspionageService.spymaster(faction); assertNotNull(holder); assertEquals("Leader", holder.playerName); @@ -77,10 +81,7 @@ void vacantOfficeInALargerFactionFallsToTheLeadersCharacterAtFullAptitude() thro } private SpecialPositionAssignment assignment(String name) { - var assignment = new SpecialPositionAssignment(); - assignment.playerName = name; - assignment.characterId = UUID.nameUUIDFromBytes(name.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString(); - return assignment; + return EspionageTestFixtures.bind(new SpecialPositionAssignment(), name); } @Test @@ -133,8 +134,8 @@ void anotherAppointmentWaitsForTheCooldownEvenAfterRemoval() { void newHolderBuildsUpToFullAptitudeOverTheConfiguredDays() { long day = 86_400_000L; var faction = mock(Faction.class); - when(faction.isMember("Spy")).thenReturn(true); - when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader", EspionageTestFixtures.key("Spy"))); var holder = assignment("Spy"); new EspionageState().appoint(holder, 80, 10 * day); assertEquals(10 * day, holder.appointedAt); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java index 4d8df196..4ad38ad4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java @@ -46,7 +46,8 @@ class OfficeOptionalIntegrationsTest { assertNull(state.getSpymaster()); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); var holder = new SpecialPositionAssignment(); - holder.playerId = f.owner; holder.characterId = "founder"; holder.playerName = "Founder"; + holder.playerId = f.owner; holder.characterId = UUID.randomUUID().toString(); holder.playerName = "Founder"; + when(faction.isLeader(holder.memberKey())).thenReturn(true); state.assignFounder(SpecialPosition.SPYMASTER, holder, 80); assertSame(holder, EspionageService.spymaster(faction)); assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); @@ -156,7 +157,7 @@ void roleplayModelMustBelongToTheCapturedCharacter(String missing) { } @Test void onlyExactOwnerAndCanonicalDeadIdentityRevokesAnOffice() { - try (var f = new IdentityFixture()) { + try (var f = new IdentityFixture().shared()) { UUID id = UUID.fromString("abcdefab-cdef-abcd-efab-cdefabcdefab"); var ref = new CharacterRef(f.owner, id); assertFalse(OfficeCharacters.isDead(null, id.toString())); @@ -218,7 +219,15 @@ static final class IdentityFixture implements AutoCloseable { bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); bukkit.when(() -> Bukkit.getPlayer(owner)).thenAnswer(call -> connection); bukkit.when(() -> Bukkit.getPlayerExact(account)).thenAnswer(call -> connection); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> java.util.List.of(connection)); + } + /** Members read this fixture's provider instead of keying players by name. */ + IdentityFixture shared() { + net.tfminecraft.simplefactions.identity.Members.useSharedIdentity(); + return this; } + /** The member key of the active character. */ + String key() { return net.tfminecraft.simplefactions.identity.Members.key(token.ref()); } CharacterSession activate(UUID characterId) { token = new CharacterSession(new CharacterRef(owner, characterId), ++generation); infos.put(characterId, info(token.ref(), CharacterStatus.ALIVE)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 9206d899..c2291ea1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -35,7 +35,7 @@ class OfficePersistenceTest { when(faction.getEspionage()).thenReturn(state); when(actor.getName()).thenReturn("Leader"); when(faction.isLeader("Leader")).thenReturn(true); - when(faction.isMember("Spy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.remove(actor, faction)); assertSame(holder, state.getSpymaster()); @@ -61,6 +61,7 @@ class OfficePersistenceTest { when(faction.getLeader()).thenReturn("Leader"); when(actor.getName()).thenReturn("Leader"); when(faction.isLeader("Leader")).thenReturn(true); + when(faction.isLeader(EspionageTestFixtures.key("Leader"))).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.remove(actor, faction)); assertSame(leader, state.getSpymaster()); @@ -103,7 +104,7 @@ class OfficePersistenceTest { when(faction.getEspionage()).thenReturn(state); when(actor.getName()).thenReturn("Spy"); when(actor.getUniqueId()).thenReturn(holder.playerId); - when(faction.isMember("Spy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.setSabotage(actor, faction, true, 100)); assertFalse(EspionageService.setSabotage(actor, faction, false, 75)); @@ -286,10 +287,8 @@ private static final class ReadyFounder implements AutoCloseable { } private static SpecialPositionAssignment holder(String name) { - var holder = new SpecialPositionAssignment(); - holder.playerName = name; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), name); holder.playerId = UUID.randomUUID(); - holder.characterId = name; return holder; } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index 5e380685..5b3cb9e6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -29,6 +29,7 @@ import net.tfminecraft.tlibs.character.CharacterStatus; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.identity.Members; class OfficeVacancyTest { @AfterEach void defaults() { EspionageConfig.load(new YamlConfiguration()); } @@ -43,8 +44,8 @@ class OfficeVacancyTest { assertEquals(-10, penalties.getFirst().getModifier()); assertEquals("Vacant Spymaster", penalties.getFirst().getName()); } - var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; - when(faction.isMember("Spy")).thenReturn(true); + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "Spy"); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); state.appoint(holder, 80); assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); state.removeSpymaster(); @@ -62,12 +63,12 @@ class OfficeVacancyTest { var faction = mock(Faction.class); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); - var holder = new SpecialPositionAssignment(); holder.playerName = "FormerSpy"; + var holder = EspionageTestFixtures.bind(new SpecialPositionAssignment(), "FormerSpy"); state.appoint(holder, 80); state.addUnrest(SpecialPosition.SPYMASTER, 10, 7, 1000); assertEquals(2, EspionageService.stabilityModifiers(faction, 1000).size()); assertEquals(1, EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).size()); - when(faction.isMember("FormerSpy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("FormerSpy"))).thenReturn(true); assertTrue(EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).isEmpty()); } @@ -121,7 +122,7 @@ class OfficeVacancyTest { } } - @Test void pendingFounderRequiresKnownCachedOwnerAndExactUuidAndRetriesFailedSave() { + @Test void pendingFounderRequiresTheLeadersCharacterAndExactUuidAndRetriesFailedSave() { try (var fixture = new DeathFixture()) { String savedId = fixture.ref.characterId().toString().toUpperCase(java.util.Locale.ROOT); fixture.state.pendingFounder(SpecialPosition.SPYMASTER, savedId); @@ -374,7 +375,7 @@ private static final class DeathFixture implements AutoCloseable { final MockedConstruction<Database> databases; EspionageState state = new EspionageState(); boolean saveSucceeds = true; - private OfflinePlayer cachedFounder; + private String leader = "Founder"; DeathFixture() { Server server = mock(Server.class); @@ -385,10 +386,9 @@ private static final class DeathFixture implements AutoCloseable { bukkit.when(Bukkit::getServicesManager).thenReturn(services); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); bukkit.when(() -> Bukkit.getPlayer(ref.ownerId())).thenReturn(owner); - bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("Founder")).thenAnswer(call -> cachedFounder); when(owner.getUniqueId()).thenReturn(ref.ownerId()); when(owner.getName()).thenReturn("Founder"); - when(faction.getLeader()).thenReturn("Founder"); + when(faction.getLeader()).thenAnswer(call -> leader); when(faction.getEspionage()).thenAnswer(call -> state); factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); databases = mockConstruction(Database.class, (database, context) -> @@ -405,9 +405,9 @@ SpecialPositionAssignment appoint(UUID ownerId, String characterId, long at) { return holder; } + /** Makes the faction leader the dying character, or another character when the owner differs. */ void cacheFounder(UUID ownerId) { - cachedFounder = mock(OfflinePlayer.class); - when(cachedFounder.getUniqueId()).thenReturn(ownerId); + leader = ownerId.equals(ref.ownerId()) ? Members.key(ref) : Members.key(UUID.randomUUID()); } @Override public void close() { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java index 25c4fb43..c5a3e4d1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -93,13 +93,13 @@ class ReportPresentationTest { new IntelligenceReport.RosterMember("Secret Spy", "poor", "Poor Guild", false, false, List.of(SpecialPosition.SPYMASTER)), new IntelligenceReport.RosterMember("Guild Baron", "rich", "Rich Guild", false, true, List.of())); try (var service = mockStatic(EspionageService.class); - var names = mockStatic(CharacterNames.class, CALLS_REAL_METHODS); + var names = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS); var identities = mockStatic(Characters.class)) { identities.when(Characters::available).thenReturn(false); service.when(() -> EspionageService.report(viewer, faction)).thenReturn(report); service.when(() -> EspionageService.visibleValue(eq(viewer), eq(faction), anyString(), any())) .thenAnswer(call -> call.getArgument(2).toString().contains("rich") ? 100.0 : 10.0); - names.when(() -> CharacterNames.display(viewer, "Leader")).thenReturn("King Rowan"); + names.when(() -> net.tfminecraft.simplefactions.identity.Members.display(viewer, "Leader")).thenReturn("King Rowan"); var lore = RosterLore.faction(viewer, faction); String text = String.join("\n", lore); assertTrue(text.startsWith("#e8c55a\u00a7lLeader:")); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java index 52f5877d..7f4861dd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java @@ -15,19 +15,19 @@ class RosterAndLedgerPresentationTest { var viewer = mock(Player.class); var faction = mock(Faction.class, RETURNS_DEEP_STUBS); when(faction.getEspionage()).thenReturn(new EspionageState()); var realm = mock(Guild.class); var rich = mock(Guild.class); var poor = mock(Guild.class); - when(faction.getLeader()).thenReturn("LeaderAccount"); when(faction.getRulerTitle()).thenReturn("Elder"); + when(faction.getRulerTitle()).thenReturn("Elder"); when(faction.getGuildHandler().getGuilds()).thenReturn(List.of(poor, rich, realm)); when(realm.isBase()).thenReturn(true); when(realm.getName()).thenReturn("Main Realm"); when(realm.getFaction()).thenReturn(faction); when(rich.getName()).thenReturn("Rich Guild"); when(rich.getWealth()).thenReturn(1000.0); when(poor.getName()).thenReturn("Poor Guild"); when(poor.getWealth()).thenReturn(100.0); - try (var identity = new OfficeOptionalIntegrationsTest.IdentityFixture("LeaderAccount"); + try (var identity = new OfficeOptionalIntegrationsTest.IdentityFixture("LeaderAccount").shared(); var service = mockStatic(EspionageService.class)) { identity.activate(java.util.UUID.randomUUID()); - identity.alias = "Rowan"; + when(faction.getLeader()).thenReturn(identity.key()); service.when(() -> EspionageService.canViewExact(viewer, faction)).thenReturn(true); String text = String.join("\n", RosterLore.faction(viewer, faction)); - assertTrue(text.startsWith("#e8c55a\u00a7lElder: #f4e4aaRowan")); + assertTrue(text.startsWith("#e8c55a\u00a7lElder: #f4e4aaCanonical Secret"), "The leader's character name"); assertTrue(text.indexOf("Main Realm") < text.indexOf("Rich Guild")); assertTrue(text.indexOf("Rich Guild") < text.indexOf("Poor Guild")); assertFalse(text.contains("Capital")); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java index 5c085bbf..88342d48 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java @@ -36,10 +36,9 @@ private Faction faction(String id) { private SpecialPositionAssignment appoint(Faction faction, String name, int aptitude) { var holder = new SpecialPositionAssignment(); - holder.playerName = name; + EspionageTestFixtures.bind(holder, name); holder.playerId = UUID.randomUUID(); - holder.characterId = "character"; - when(faction.isMember(name)).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key(name))).thenReturn(true); faction.getEspionage().appoint(holder, aptitude); return holder; } @@ -88,9 +87,10 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti var target = faction("target"); var holder = appoint(target, "Spy", 50); try (var characters = mockStatic(OfficeCharacters.class); var databases = mockConstruction(Database.class)) { - when(target.isMember("Spy")).thenReturn(false); + characters.when(() -> OfficeCharacters.characterUuid(any())).thenCallRealMethod(); + when(target.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(false); assertTrue(EspionageService.canViewExact(viewer, target)); - when(target.isMember("Spy")).thenReturn(true); + when(target.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); assertTrue(EspionageService.canViewExact(viewer, target)); assertSame(holder, target.getEspionage().getSpymaster()); @@ -159,9 +159,9 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti factions.when(() -> FactionManager.getByMember("ForeignViewer")).thenReturn(observer); factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, target))); assertSame(cached, EspionageService.report(viewer, target)); - when(observer.isMember("ObserverSpy")).thenReturn(false); + when(observer.isMember(EspionageTestFixtures.key("ObserverSpy"))).thenReturn(false); assertNull(EspionageService.report(viewer, target), "Ineligible observers cannot read cached findings"); - when(observer.isMember("ObserverSpy")).thenReturn(true); + when(observer.isMember(EspionageTestFixtures.key("ObserverSpy"))).thenReturn(true); try (var characters = mockStatic(OfficeCharacters.class)) { characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); assertNull(EspionageService.report(viewer, target), "Dead observers cannot read cached findings"); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index 4dd2069a..bf56c652 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.utils.Represents; +import net.tfminecraft.simplefactions.identity.Members; class VassalIntelligenceTest { @AfterEach void restoreDefaults() { EspionageConfig.load(new YamlConfiguration()); } @@ -94,19 +95,19 @@ class VassalIntelligenceTest { when(target.getGuildHandler().getGuilds()).thenReturn(List.of()); var state = new EspionageState(); var holder = new SpecialPositionAssignment(); - holder.playerName = "Account1"; + EspionageTestFixtures.bind(holder, "Account1"); state.appoint(holder, 70); when(target.getEspionage()).thenReturn(state); when(target.isMember(anyString())).thenReturn(true); var report = EspionageService.createReport(Map.of(), -50, IntelligenceTier.DETAILED, new Random(2)); - try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { - names.when(() -> CharacterNames.forForeign(anyString())).thenAnswer(call -> call.getArgument(0)); + try (var names = mockStatic(Members.class, CALLS_REAL_METHODS); var represents = mockStatic(Represents.class)) { + names.when(() -> Members.display(anyString())).thenAnswer(call -> call.getArgument(0)); represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); EspionageService.captureMembers(report, target, -50, new Random(2)); EspionageService.captureOffices(report, target, new Random(2)); } assertEquals(10, report.members.size()); - assertEquals("Account1", report.officeHolder(SpecialPosition.SPYMASTER)); + assertEquals(EspionageTestFixtures.key("Account1"), report.officeHolder(SpecialPosition.SPYMASTER)); assertEquals("70", report.display(IntelligenceReport.officeAptitudeKey(SpecialPosition.SPYMASTER))); } @@ -121,8 +122,8 @@ class VassalIntelligenceTest { when(guild.getMembers()).thenReturn(members); when(guild.isMember(anyString())).thenReturn(true); var report = EspionageService.createReport(Map.of(), -50, IntelligenceTier.RUMOURS, new Random(3)); - try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { - names.when(() -> CharacterNames.forForeign(anyString())).thenAnswer(call -> call.getArgument(0)); + try (var names = mockStatic(Members.class, CALLS_REAL_METHODS); var represents = mockStatic(Represents.class)) { + names.when(() -> Members.display(anyString())).thenAnswer(call -> call.getArgument(0)); represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); EspionageService.captureMembers(report, target, -50, new Random(3)); } @@ -191,7 +192,7 @@ class VassalIntelligenceTest { Player spy = mock(Player.class), member = mock(Player.class); var state = new EspionageState(); var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + EspionageTestFixtures.bind(holder, "Spy"); holder.playerId = UUID.randomUUID(); state.appoint(holder, 60); when(faction.getId()).thenReturn("faction"); @@ -233,7 +234,7 @@ class VassalIntelligenceTest { Player spy = mock(Player.class); var state = new EspionageState(); var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + EspionageTestFixtures.bind(holder, "Spy"); holder.playerId = UUID.randomUUID(); state.appoint(holder, 60); state.share(SharingPartner.VASSALS, IntelligenceTier.RUMOURS); @@ -252,7 +253,7 @@ class VassalIntelligenceTest { Player spy = mock(Player.class); var state = new EspionageState(); var holder = new SpecialPositionAssignment(); - holder.playerName = "Spy"; + EspionageTestFixtures.bind(holder, "Spy"); holder.playerId = UUID.randomUUID(); state.appoint(holder, 60); when(faction.getId()).thenReturn("faction"); diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java index 96e3645b..b8e02fce 100644 --- a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java @@ -183,24 +183,24 @@ void missingNullAndPartialSavedStatesUseDefaultsAndClampPersistedPenalties() thr @Test void inactivityMembershipSkipsPlaceholdersAndUsesCurrentOnlinePresence() throws Exception { - history(Map.of("alice", 21, "bob", 20, "cara", 35)); + history(Map.of("Alice", 21, "Bob", 20, "Cara", 35)); assertFalse(InactivityService.isMemberInactive(null)); assertFalse(InactivityService.isMemberInactive(" ")); assertFalse(InactivityService.isMemberInactive("DUMMY_guard")); - assertTrue(InactivityService.isMemberInactive("ALICE")); + assertTrue(InactivityService.isMemberInactive("Alice")); + assertFalse(InactivityService.isMemberInactive("ALICE"), "Member keys are never folded"); assertFalse(InactivityService.isMemberInactive("Bob")); assertFalse(InactivityService.isFullyInactive(null)); assertFalse(InactivityService.isFullyInactive(List.of("dummy_guard"))); assertFalse(InactivityService.isFullyInactive(List.of("Alice", "Bob"))); assertTrue( InactivityService.isFullyInactive(Arrays.asList(null, "dummy_guard", "Alice", "Cara"))); - Player online = fixture.player("aLiCe"); - when(Bukkit.getOnlinePlayers()).thenAnswer(call -> Arrays.asList(null, online)); - assertFalse(InactivityService.isMemberInactive("ALICE")); + fixture.player("Alice"); + assertFalse(InactivityService.isMemberInactive("Alice")); when(Bukkit.getServer()).thenReturn(null); assertTrue(InactivityService.isMemberInactive("Alice")); when(Bukkit.getServer()).thenReturn(fixture.ui.server); - when(Bukkit.getOnlinePlayers()).thenThrow(new IllegalStateException("server stopping")); + fixture.online.remove("Alice"); assertTrue(InactivityService.isMemberInactive("Alice")); } @@ -211,7 +211,7 @@ void armingAndDueTicksApplyOneStepWithoutKickingMembersOrChangingTheirBanks() th Guild guild = fixture.guild(faction, "merchants", "Bob"); base.getBank().deposit(37.0); guild.getBank().deposit(19.0); - history(Map.of("alice", 21, "bob", 22)); + history(Map.of("Alice", 21, "Bob", 22)); InactivityService.armAll(null, 1000); InactivityService.armAll(Arrays.asList(null, faction), 1000); assertEquals( @@ -240,7 +240,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", 21, "Bob", 22)); state(armed.getOrCreateMainGuild().getId(), 0, armed.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(null); @@ -263,7 +263,7 @@ void dueSweepArmsUntrackedInactiveGroupsAndSkipsGroupsStillWaiting() throws Exce void activityReconciliationClearsBothPenaltiesAndRefreshesDerivedOutput() throws Exception { Faction faction = fixture.saved("home", "Alice"); Guild guild = faction.getOrCreateMainGuild(); - history(Map.of("alice", 20)); + history(Map.of("Alice", 20)); state(guild.getId(), 30, faction.getId(), 40, 1); InactivityService.load(); InactivityService.tickIfDue(List.of(faction)); @@ -286,7 +286,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", 22, "Bob", 30, "Stranger", 21, "Cara", 20)); state(guild.getId(), 35, faction.getId(), 40, 1); var snapshot = saved(); InactivityService.SavedClock baseClock = new InactivityService.SavedClock(); @@ -297,7 +297,7 @@ void loginClearsOnlyTheReturningMembersGuildAndItsFactionAndPersistsActivity() t InactivityService.load(); Player bob = fixture.player("Bob"); InactivityService.onLogin(bob); - assertEquals(0, FactionCleanup.daysOffline("bob")); + assertEquals(0, FactionCleanup.daysOffline("Bob")); assertEquals(0, InactivityService.guildPercent(guild)); assertEquals(0, InactivityService.prestigePercent(faction)); assertEquals(45, InactivityService.guildPercent(base)); @@ -310,13 +310,16 @@ void loginClearsOnlyTheReturningMembersGuildAndItsFactionAndPersistsActivity() t verify(fixture.provinces).recalculate(); assertFalse(saved().guilds.containsKey(guild.getId())); InactivityService.onLogin(null); + Player noCharacter = mock(Player.class); + InactivityService.onLogin(noCharacter); + verify(noCharacter, never()).sendMessage(anyString()); Player active = fixture.player("Cara"); InactivityService.onLogin(active); verify(active, never()).sendMessage(anyString()); Player stranger = fixture.player("Stranger"); InactivityService.onLogin(stranger); verify(stranger).sendMessage("§aYou are active again."); - history(Map.of("bob", 21)); + history(Map.of("Bob", 21)); InactivityService.onLogin(bob); verify(bob).sendMessage("§aYou are active again."); } @@ -336,7 +339,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", 21, "Bob", 21)); state(guild.getId(), 0, faction.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(List.of(faction)); @@ -362,7 +365,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", 21)); state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); InactivityService.load(); InactivityService.tickIfDue(List.of(faction)); @@ -385,7 +388,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", 21)); state(guild.getId(), 10, faction.getId(), 20, 1); InactivityService.load(); doThrow(new IllegalStateException("province refresh unavailable")) @@ -436,7 +439,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", 21)); state(guild.getId(), 10, faction.getId(), 20, 1); InactivityService.load(); var file = disk.root.resolve("Cache/inactivity.json"); @@ -465,7 +468,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", 21, "Bob", 21)); InactivityService.armAll(List.of(failing, next), 0); String failedGuildName = failing.getOrCreateMainGuild().getName(); Logger logger = fixture.ui.plugin.getLogger(); @@ -483,7 +486,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", 21)); state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); InactivityService.load(); SimpleFactions previous = SimpleFactions.plugin; diff --git a/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java index 65d06844..72e2aa0a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java @@ -257,11 +257,11 @@ void eitherStaffPermissionBypassesBlockBreakPlaceAndPassengerProtection(String p @ValueSource(strings = {"break", "place"}) void theOwningFactionsLeaderAndMembersBuildInsideTheirInstallationAtPeace(String eventType) { Location inside = new Location(fixture.ui.world, 0, 64, 0); - player = fixture.player("NEUTRAL"); + player = fixture.player(neutral.getLeader()); assertFalse(protectedAt(eventType, inside, neutral), "The leader counts as a member"); neutral.addMember("Builder"); - player = fixture.player("builder"); - assertFalse(protectedAt(eventType, inside, neutral), "Membership ignores name case"); + player = fixture.player("Builder"); + assertFalse(protectedAt(eventType, inside, neutral), "Members build too"); verify(player, never()).sendMessage(anyString()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java index 3ae0db5a..dddb2fea 100644 --- a/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java @@ -28,6 +28,7 @@ import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.vfbuilders.api.ConstructionFreeze; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -168,25 +169,29 @@ void leaderNamesUseSharedReadyCharactersWithoutRpcEnablementOrDiskLookup() { } @Test - void playtimeIntegrationUsesSharedOfflineSnapshotAndBecomesUnknownOnProviderLoss() { + void playtimeIntegrationReadsTheMembersOwnCharacterAndBecomesUnknownOnProviderLoss() { SimpleServicesManager services = sharedServices(); CharacterIdentity provider = mock(CharacterIdentity.class); + Members.useSharedIdentity(); MemberPlaytime.useSharedIdentity(); - assertNull(MemberPlaytime.secondsFor("Offline")); UUID owner = UUID.randomUUID(); - OfflinePlayer offline = mock(OfflinePlayer.class); - when(offline.getUniqueId()).thenReturn(owner); - when(Bukkit.getOfflinePlayerIfCached("Offline")).thenReturn(offline); - CharacterInfo info = new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), - "Last character", CharacterStatus.DEAD, null, Set.of(), 7200, Instant.EPOCH); - when(provider.lastPlayed(owner)).thenReturn(Optional.of(info)); + UUID characterId = UUID.randomUUID(); + String member = Members.key(characterId); + assertNull(MemberPlaytime.secondsFor(member)); + CharacterInfo info = new CharacterInfo(new CharacterRef(owner, characterId), + "Older character", CharacterStatus.DEAD, null, Set.of(), 7200, Instant.EPOCH); + CharacterInfo lastPlayed = new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), + "Last character", CharacterStatus.ALIVE, null, Set.of(), 60, Instant.EPOCH); + when(provider.info(characterId)).thenReturn(Optional.of(info)); + when(provider.lastPlayed(owner)).thenReturn(Optional.of(lastPlayed)); services.register(CharacterIdentity.class, provider, rig.ui.plugin, ServicePriority.Normal); try { - assertEquals(7200, MemberPlaytime.secondsFor("Offline")); - verify(provider).lastPlayed(owner); + assertEquals(7200, MemberPlaytime.secondsFor(member), "Not the account's last-played character"); + assertNull(MemberPlaytime.secondsFor("Offline"), "A name is not a character"); + verify(provider, never()).lastPlayed(any()); verify(Bukkit.getPluginManager(), never()).isPluginEnabled("RPCharacters"); services.unregister(CharacterIdentity.class, provider); - assertNull(MemberPlaytime.secondsFor("Offline")); + assertNull(MemberPlaytime.secondsFor(member)); } finally { services.unregister(CharacterIdentity.class, provider); MemberPlaytime.reset(); diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollectorTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollectorTest.java index 17703f55..d1b54c18 100644 --- a/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollectorTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollectorTest.java @@ -31,7 +31,7 @@ void offlineNamesAreSkipped() { } @Test - void onlineMatchingNamesIncludedCaseInsensitivelyOnce() { + void eachOnlinePlayerIsIncludedOnce() { Player alice = onlinePlayer(); Map<String, Player> lookup = new HashMap<>(); lookup.put("alice", alice); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java index 0829fdab..4ecb3104 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java @@ -273,7 +273,14 @@ void noInviteAndOfflineInviteRefusalsKeepTheRosterUnchanged() { verify(recruit).sendMessage(contains("no company invite to decline")); assertTrue(company.adminAdjustSlots(1).ok()); run(captain, "invite", "Offline"); - verify(captain).sendMessage(contains("Could not find the player Offline")); + verify(captain).sendMessage(contains("No online player found")); + Player newcomer = fixture.player("Newcomer"); + try (var members = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + members.when(() -> net.tfminecraft.simplefactions.identity.Members.key(newcomer)).thenReturn(null); + run(captain, "invite", "Newcomer"); + } + verify(captain).sendMessage(contains("Newcomer has no character to enlist")); + assertFalse(RequestManager.hasRequest(newcomer)); assertEquals(List.of("Captain"), company.getEnlisted()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java index d3b2fa04..249239cb 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java @@ -202,7 +202,8 @@ void leadershipFollowsTheGuildLeader() { MercenaryCompany company = fixture.company(); assertEquals("Ivar", company.getLeader()); - assertTrue(company.isLeader("ivar")); + assertTrue(company.isLeader("Ivar")); + assertFalse(company.isLeader("ivar"), "Member keys match exactly"); fixture.setLeader("Sigrun"); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEnlistmentTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEnlistmentTest.java index ec61afb8..c34063fc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEnlistmentTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEnlistmentTest.java @@ -213,7 +213,8 @@ void lookupsFindCompaniesByMemberAndName() { List<Guild> guilds = List.of(fixture.guild); company.enlist("Sigrun"); - assertSame(company, MercenaryCompanies.findByMember("sigrun", guilds)); + assertSame(company, MercenaryCompanies.findByMember("Sigrun", guilds)); + assertNull(MercenaryCompanies.findByMember("sigrun", guilds)); assertNull(MercenaryCompanies.findByMember("Bjorn", guilds)); assertSame(company, MercenaryCompanies.findByName("hired blades", guilds)); assertNull(MercenaryCompanies.findByName("Iron Wolves", guilds)); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/SharedMercenaryCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/SharedMercenaryCharactersTest.java index db27c644..4a1d1aa6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/SharedMercenaryCharactersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/SharedMercenaryCharactersTest.java @@ -6,16 +6,13 @@ import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.Server; -import org.bukkit.entity.Player; import org.bukkit.plugin.ServicesManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,25 +22,20 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.character.CharacterIdentity; import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterStatus; -import net.tfminecraft.tlibs.character.Characters; +/** The company gate reads the enrolled character's own trait through the shared provider. */ class SharedMercenaryCharactersTest { private final UUID owner = UUID.randomUUID(); private final UUID characterId = UUID.randomUUID(); - private final Map<String, Player> online = new HashMap<>(); - private final Map<UUID, Player> connections = new HashMap<>(); - private final Map<String, OfflinePlayer> cached = new HashMap<>(); + private final String key = Members.key(characterId); private final Map<UUID, CharacterInfo> infos = new HashMap<>(); - private final Map<UUID, List<CharacterInfo>> directory = new HashMap<>(); private final CharacterIdentity provider = mock(CharacterIdentity.class); private CharacterIdentity registered; - private CharacterSession session; - private Player player; private MockedStatic<Bukkit> bukkit; @BeforeEach @@ -54,20 +46,9 @@ void setUp() { ServicesManager services = mock(ServicesManager.class); bukkit.when(Bukkit::getServicesManager).thenReturn(services); when(services.load(CharacterIdentity.class)).thenAnswer(call -> registered); - bukkit.when(() -> Bukkit.getPlayerExact(anyString())).thenAnswer(call -> online.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenAnswer(call -> connections.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())).thenAnswer(call -> cached.get(call.getArgument(0))); - player = mock(Player.class); - when(player.getName()).thenReturn("Sigrun"); - when(player.getUniqueId()).thenReturn(owner); - when(player.isOnline()).thenReturn(true); - online.put("Sigrun", player); - connections.put(owner, player); - when(provider.currentSession(player)).thenAnswer(call -> Optional.ofNullable(session)); - when(provider.isCurrent(any())).thenAnswer(call -> call.getArgument(0).equals(session)); when(provider.info(any())).thenAnswer(call -> Optional.ofNullable(infos.get(call.getArgument(0)))); - when(provider.characters(any())).thenAnswer(call -> Optional.ofNullable(directory.get(call.getArgument(0)))); registered = provider; + Members.useSharedIdentity(); MercenaryEligibility.useSharedIdentity(); } @@ -78,203 +59,114 @@ void close() { } @Test - void providerArrivalAndLossKeepOptionalGameplayOpenButPendingCharactersClosed() { + void providerLossLeavesOptionalGameplayOpenButANoCharacterPlayerClosed() { registered = null; - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - assertTrue(MercenaryEligibility.canCreate("Sigrun")); - assertTrue(MercenaryEligibility.canJoin("Sigrun")); + infos.put(characterId, character(owner, characterId, CharacterStatus.ALIVE, "mercenary")); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check(key)); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary(key)); + assertTrue(MercenaryEligibility.canCreate(key)); + assertTrue(MercenaryEligibility.canJoin(key)); registered = provider; - assertFalse(MercenaryEligibility.canCreate("Sigrun")); - assertFalse(MercenaryEligibility.canJoin("Sigrun")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - CharacterInfo mercenary = character(owner, characterId, CharacterStatus.ALIVE, "MERCENARY"); - activate(mercenary); - directory.put(owner, List.of(mercenary)); - assertTrue(MercenaryEligibility.canCreate("Sigrun")); - assertTrue(MercenaryEligibility.canJoin("Sigrun")); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - - registered = null; - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - assertTrue(MercenaryEligibility.canCreate("Sigrun")); - bukkit.when(Bukkit::getServer).thenReturn(null); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); + assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.check(key)); + assertFalse(MercenaryEligibility.canCreate(null), "A player without a character is not a mercenary"); + assertFalse(MercenaryEligibility.canJoin(" ")); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary(null)); } @ParameterizedTest @EnumSource(CharacterStatus.class) - void onlyAnOwnedAliveMercenaryCanFoundJoinOrCountAsLiving(CharacterStatus status) { - CharacterInfo character = character(owner, characterId, status, "mercenary"); - activate(character); - directory.put(owner, List.of(character)); + void onlyAnAliveMercenaryCanFoundJoinOrStay(CharacterStatus status) { + infos.put(characterId, character(owner, characterId, status, "MERCENARY")); MercenaryEligibility.Status expected = status == CharacterStatus.ALIVE ? MercenaryEligibility.Status.ELIGIBLE : MercenaryEligibility.Status.INELIGIBLE; - assertEquals(expected, MercenaryEligibility.check("Sigrun")); - assertEquals(expected, MercenaryEligibility.livingMercenary("Sigrun")); + assertEquals(expected, MercenaryEligibility.check(key)); + assertEquals(expected, MercenaryEligibility.livingMercenary(key)); + assertEquals(status == CharacterStatus.ALIVE, MercenaryEligibility.canJoin(key)); } @Test - void inactiveAliveMercenaryKeepsTheAccountEligibleWhileTheActiveMerchantCannotJoin() { - CharacterInfo merchant = character(owner, characterId, CharacterStatus.ALIVE, "merchant"); - CharacterInfo mercenary = character(owner, UUID.randomUUID(), CharacterStatus.ALIVE, "mercenary"); - activate(merchant); - directory.put(owner, List.of(merchant, mercenary)); - assertFalse(MercenaryEligibility.canJoin("Sigrun")); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - session = null; - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - } + void twoCharactersOnOneAccountAreJudgedSeparately() { + UUID merchantId = UUID.randomUUID(); + infos.put(characterId, character(owner, characterId, CharacterStatus.ALIVE, "mercenary")); + infos.put(merchantId, character(owner, merchantId, CharacterStatus.ALIVE, "merchant")); + String merchant = Members.key(merchantId); - @Test - void unknownAndKnownEmptyAccountsStayDistinctAndResultsAreNeverCached() { - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - directory.put(owner, List.of()); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - directory.put(owner, List.of(character(owner, characterId, CharacterStatus.ALIVE, "mercenary"))); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - directory.put(owner, List.of(character(owner, characterId, CharacterStatus.ALIVE, "merchant"))); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); - directory.remove(owner); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - } + assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary(key)); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary(merchant)); + assertFalse(MercenaryEligibility.canJoin(merchant), "A mercenary sibling does not qualify the merchant"); - @ParameterizedTest - @ValueSource(strings = {"session-owner", "info-owner", "info-character", "unknown-info", "stale-session", "offline", "replacement-connection"}) - void invalidActiveIdentityCannotAuthorizeMercenaryWork(String invalid) { - CharacterInfo mercenary = character(owner, characterId, CharacterStatus.ALIVE, "mercenary"); - activate(mercenary); - switch (invalid) { - case "session-owner" -> session = new CharacterSession(new CharacterRef(UUID.randomUUID(), characterId), 1); - case "info-owner" -> infos.put(characterId, character(UUID.randomUUID(), characterId, CharacterStatus.ALIVE, "mercenary")); - case "info-character" -> infos.put(characterId, character(owner, UUID.randomUUID(), CharacterStatus.ALIVE, "mercenary")); - case "unknown-info" -> infos.clear(); - case "stale-session" -> doReturn(false).when(provider).isCurrent(any()); - case "offline" -> when(player.isOnline()).thenReturn(false); - case "replacement-connection" -> connections.put(owner, mock(Player.class)); - default -> fail("Unknown invalid identity"); - } - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); - assertFalse(MercenaryEligibility.canCreate("Sigrun")); - assertFalse(MercenaryEligibility.canJoin("Sigrun")); + CompanyFixture fixture = new CompanyFixture(0); + fixture.setLeader(key); + MercenaryCompany company = new MercenaryCompany(fixture.guild, "Hired Blades", CompanyFixture.companyRegiment(), 0); + fixture.guild.setCompany(company); + company.getRegiment().setCurrentSlots(3); + assertTrue(company.enlist(key)); + assertTrue(company.enlist(merchant)); + + assertFalse(company.enforceCharacterGate()); + assertTrue(company.isEnlisted(key)); + assertFalse(company.isEnlisted(merchant), "Only the character without the trait is dismissed"); + assertSame(company, fixture.company()); } - @ParameterizedTest - @ValueSource(strings = {"aba", "pending", "provider-loss", "reconnect"}) - void infoCallbackCannotAuthorizeAnActivationThatEndedDuringTheRead(String change) { - CharacterInfo mercenary = character(owner, characterId, CharacterStatus.ALIVE, "mercenary"); - activate(mercenary); - CharacterSession original = session; - when(provider.info(characterId)).thenAnswer(call -> { - switch (change) { - case "aba" -> { - session = new CharacterSession(new CharacterRef(owner, UUID.randomUUID()), 2); - session = new CharacterSession(mercenary.ref(), 3); - } - case "pending" -> session = null; - case "provider-loss" -> registered = null; - case "reconnect" -> connections.put(owner, mock(Player.class)); - default -> fail("Unknown session change"); - } - return Optional.of(mercenary); - }); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); - if (change.equals("aba")) { - assertEquals(original.ref(), session.ref()); - assertFalse(Characters.isCurrent(original)); - } + @Test + void unknownCharactersAndResultsAreNeverCached() { + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary(key)); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun"), + "A string that is not a member key cannot be read"); + infos.put(characterId, character(owner, characterId, CharacterStatus.ALIVE, "mercenary")); + assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary(key)); + infos.put(characterId, character(owner, characterId, CharacterStatus.ALIVE, "merchant")); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary(key)); + infos.remove(characterId); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary(key)); } @ParameterizedTest - @ValueSource(strings = {"null-entry", "foreign-owner", "missing-ref", "missing-character", "missing-status", "missing-traits", "null-trait"}) - void corruptOrForeignRecordsAreUnknownUnlessAValidLivingMercenaryExists(String corruption) { + @ValueSource(strings = {"foreign-character", "missing-status", "missing-traits", "null-trait"}) + void corruptRecordsAreUnknown(String corruption) { CharacterInfo broken = mock(CharacterInfo.class); when(broken.ref()).thenReturn(new CharacterRef(owner, characterId)); when(broken.status()).thenReturn(CharacterStatus.ALIVE); when(broken.traits()).thenReturn(Set.of("mercenary")); switch (corruption) { - case "null-entry" -> broken = null; - case "foreign-owner" -> when(broken.ref()).thenReturn(new CharacterRef(UUID.randomUUID(), characterId)); - case "missing-ref" -> when(broken.ref()).thenReturn(null); - case "missing-character" -> { - CharacterRef ref = mock(CharacterRef.class); - when(ref.ownerId()).thenReturn(owner); - when(broken.ref()).thenReturn(ref); - } + case "foreign-character" -> when(broken.ref()).thenReturn(new CharacterRef(owner, UUID.randomUUID())); case "missing-status" -> when(broken.status()).thenReturn(null); case "missing-traits" -> when(broken.traits()).thenReturn(null); case "null-trait" -> when(broken.traits()).thenReturn(new HashSet<>(Arrays.asList("mercenary", null))); default -> fail("Unknown damaged record"); } - directory.put(owner, Arrays.asList(broken)); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - directory.put(owner, Arrays.asList(broken, character(owner, UUID.randomUUID(), CharacterStatus.ALIVE, "mercenary"))); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Sigrun")); + infos.put(characterId, broken); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary(key)); } @Test - void cachedOfflineAccountsUseTheDirectoryWithoutAnyMojangLookup() { - UUID offlineOwner = UUID.randomUUID(); - OfflinePlayer account = mock(OfflinePlayer.class); - when(account.getUniqueId()).thenReturn(offlineOwner); - cached.put("Offline", account); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Offline")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Uncached")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Offline")); - directory.put(offlineOwner, List.of()); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.livingMercenary("Offline")); - directory.put(offlineOwner, List.of(character(offlineOwner, UUID.randomUUID(), CharacterStatus.ALIVE, "mercenary"))); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.livingMercenary("Offline")); - cached.put("BrokenAccount", mock(OfflinePlayer.class)); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("BrokenAccount")); - bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); - bukkit.verify(() -> Bukkit.getOfflinePlayer(any(UUID.class)), never()); - } - - @Test - void providerLossDuringTheDirectoryReadCannotTurnUnknownIntoKnownEmpty() { - when(provider.characters(owner)).thenAnswer(call -> { - registered = null; - return Optional.of(List.of()); - }); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.livingMercenary("Sigrun")); - } - - @Test - void unknownAccountAndProviderLossCannotDismissTheMemberOrDisbandTheCompany() { + void unknownCharactersAndProviderLossCannotDismissTheMemberOrDisbandTheCompany() { + UUID leaderId = UUID.randomUUID(); + String leader = Members.key(leaderId); + infos.put(leaderId, character(UUID.randomUUID(), leaderId, CharacterStatus.ALIVE, "merchant")); CompanyFixture fixture = new CompanyFixture(0); + fixture.setLeader(leader); MercenaryCompany company = new MercenaryCompany(fixture.guild, "Hired Blades", CompanyFixture.companyRegiment(), 0); fixture.guild.setCompany(company); company.getRegiment().setCurrentSlots(2); - assertTrue(company.enlist("Sigrun")); - OfflinePlayer leader = mock(OfflinePlayer.class); - UUID leaderId = UUID.randomUUID(); - when(leader.getUniqueId()).thenReturn(leaderId); - cached.put("Ivar", leader); - directory.put(leaderId, List.of()); + assertTrue(company.enlist(key)); assertFalse(company.enforceCharacterGate()); assertSame(company, fixture.company()); - assertTrue(company.isEnlisted("Sigrun")); - directory.put(owner, List.of()); + assertTrue(company.isEnlisted(key)); + infos.put(characterId, character(owner, characterId, CharacterStatus.DEAD, "mercenary")); registered = null; assertFalse(company.enforceCharacterGate()); assertSame(company, fixture.company()); - assertTrue(company.isEnlisted("Sigrun")); + assertTrue(company.isEnlisted(key)); registered = provider; - assertTrue(company.enforceCharacterGate(), "Only known empty accounts permit disbanding"); + assertTrue(company.enforceCharacterGate(), "Only characters known to fail permit disbanding"); assertNull(fixture.company()); } - private void activate(CharacterInfo character) { - infos.put(character.ref().characterId(), character); - session = new CharacterSession(character.ref(), 1); - } - private static CharacterInfo character(UUID ownerId, UUID id, CharacterStatus status, String... traits) { return new CharacterInfo(new CharacterRef(ownerId, id), "Canonical Mercenary", status, null, Set.of(traits), 0, null); } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/WageSettingsTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/WageSettingsTest.java index 3c59a3d9..217879cd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/WageSettingsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/WageSettingsTest.java @@ -31,13 +31,14 @@ void anOverrideBeatsTheBase() { } @Test - void anOverrideIsCaseInsensitiveAndClearable() { + void anOverrideBelongsToExactlyOneMemberKeyAndIsClearable() { WageSettings wages = new WageSettings(); wages.setActivePercent(20); wages.setActiveOverride("Sigrun", 50.0); - assertEquals(50.0, wages.activePercentFor("sigrun"), 1e-9); - wages.clearActiveOverride("SIGRUN"); + assertEquals(50.0, wages.activePercentFor("Sigrun"), 1e-9); + assertEquals(20.0, wages.activePercentFor("sigrun"), 1e-9, "Keys are opaque and never folded"); + wages.clearActiveOverride("Sigrun"); assertNull(wages.getActiveOverride("Sigrun")); assertEquals(20.0, wages.activePercentFor("Sigrun"), 1e-9); } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java index c4ed380a..15d4320b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java @@ -37,7 +37,7 @@ class MercenaryContractBoundaryCoverageTest { @Test - void wageOverridesFollowThePlayerAcrossNameCasingAndLocale() { + void wageOverridesAreKeyedExactlyWhateverTheLocale() { Locale previous = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); @@ -47,11 +47,12 @@ void wageOverridesFollowThePlayerAcrossNameCasingAndLocale() { wages.setActiveOverride("IRIS", 55.0); wages.setPeacetimeOverride("IRIS", 7.0); - assertEquals(55.0, wages.activeShareOf(100, "iris")); - assertEquals(7.0, wages.peacetimeFor("iris")); - assertEquals(7.0, wages.getPeacetimeOverride("iris")); - wages.clearActiveOverride("iris"); - wages.clearPeacetimeOverride("iris"); + assertEquals(55.0, wages.activeShareOf(100, "IRIS")); + assertEquals(7.0, wages.peacetimeFor("IRIS")); + assertEquals(7.0, wages.getPeacetimeOverride("IRIS")); + assertEquals(2.0, wages.peacetimeFor("iris")); + wages.clearActiveOverride("IRIS"); + wages.clearPeacetimeOverride("IRIS"); assertNull(wages.getPeacetimeOverride("IRIS")); assertEquals(20.0, wages.activeShareOf(100, "IRIS")); assertEquals(2.0, wages.peacetimeFor("IRIS")); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java index 71fb46c5..84e6a3b8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java @@ -36,6 +36,7 @@ import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.character.CharacterIdentity; import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; @@ -377,7 +378,7 @@ void healthCleanupSurvivesAnUnloadedMmoProfileAndAnAbsentAttribute() { } @Test - void activeCharacterTraitsControlEligibilityWhileUnavailablePlayersRemainUnknown() throws Exception { + void theEnrolledCharactersTraitsControlEligibilityWhileUnreadableCharactersRemainUnknown() throws Exception { Field probeField = MercenaryEligibility.class.getDeclaredField("probe"); probeField.setAccessible(true); MercenaryEligibility.Probe previousProbe = (MercenaryEligibility.Probe) probeField.get(null); @@ -385,33 +386,23 @@ void activeCharacterTraitsControlEligibilityWhileUnavailablePlayersRemainUnknown CharacterIdentity provider = mock(CharacterIdentity.class); when(Bukkit.isPrimaryThread()).thenReturn(true); when(Bukkit.getServicesManager()).thenReturn(services); + Members.useSharedIdentity(); MercenaryEligibility.useSharedIdentity(); + CharacterRef ref = new CharacterRef(player.getUniqueId(), UUID.randomUUID()); + String sigrun = Members.key(ref); try { - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Offline")); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check(sigrun)); when(services.load(CharacterIdentity.class)).thenReturn(provider); - when(provider.currentSession(player)).thenReturn(Optional.empty()); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); - - CharacterRef ref = new CharacterRef(player.getUniqueId(), UUID.randomUUID()); - CharacterSession session = new CharacterSession(ref, 1); - when(provider.currentSession(player)).thenReturn(Optional.of(session)); - when(provider.isCurrent(session)).thenReturn(true); when(provider.info(ref.characterId())).thenReturn(Optional.empty()); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check(sigrun)); when(provider.info(ref.characterId())).thenReturn(Optional.of(new CharacterInfo( ref, "Canonical Sigrun", CharacterStatus.ALIVE, null, Set.of("merchant"), 0, null))); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check(sigrun)); when(provider.info(ref.characterId())).thenReturn(Optional.of(new CharacterInfo( ref, "Canonical Sigrun", CharacterStatus.ALIVE, null, Set.of("merchant", "MERCENARY"), 0, null))); - assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.check("Sigrun")); - - when(provider.isCurrent(session)).thenReturn(false); - assertEquals(MercenaryEligibility.Status.INELIGIBLE, MercenaryEligibility.check("Sigrun")); + assertEquals(MercenaryEligibility.Status.ELIGIBLE, MercenaryEligibility.check(sigrun)); when(services.load(CharacterIdentity.class)).thenReturn(null); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); - when(Bukkit.getServer()).thenReturn(null); - assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check("Sigrun")); + assertEquals(MercenaryEligibility.Status.UNKNOWN, MercenaryEligibility.check(sigrun)); } finally { MercenaryEligibility.setProbe(previousProbe); } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatServiceTest.java index 794d7811..ce64c655 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatServiceTest.java @@ -173,7 +173,7 @@ void planForReadsStraightFromTheCompanyUpgrades() { company.getUpgrade("company_mana_regen").setLevel(10); - assertEquals(new MercenaryStatPlan(0, 0, 1.0), MercenaryStatService.planFor("sigrun")); + assertEquals(new MercenaryStatPlan(0, 0, 1.0), MercenaryStatService.planFor("Sigrun")); } private static void openGate() { diff --git a/src/test/java/net/tfminecraft/simplefactions/prestige/MemberPlaytimeTest.java b/src/test/java/net/tfminecraft/simplefactions/prestige/MemberPlaytimeTest.java index 3ba6cecf..c1a374c6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/prestige/MemberPlaytimeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/prestige/MemberPlaytimeTest.java @@ -39,6 +39,7 @@ import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.identity.Members; class MemberPlaytimeTest { @@ -166,146 +167,52 @@ void setProbe_nullFallsBackToTheDefault() { } @Test - void readyOnlineTimeNeverFallsBackToAnEarlierCharacterWhilePending() { + void memberTimeIsTheirOwnCharactersNotTheAccountsLastPlayed() { try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - UUID owner = alice.getUniqueId(); - fixture.lastPlayed.put(owner, snapshot(owner, UUID.randomUUID(), CharacterStatus.ALIVE, 500 * HOUR)); - assertNull(MemberPlaytime.secondsFor("Alice")); - assertEquals(0.0, MemberPlaytime.totalFor(List.of("Alice")), 1e-9); - fixture.activate(alice, UUID.randomUUID(), 2 * HOUR); - assertEquals(7200, MemberPlaytime.secondsFor("Alice")); - fixture.sessions.remove(owner); - assertNull(MemberPlaytime.secondsFor("Alice")); - verify(fixture.provider, never()).lastPlayed(any()); - } - } - - @Test - void sharedLookupTracksProviderArrivalLossAndAbsentServerWithoutReinstallation() { - try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - fixture.activate(alice, UUID.randomUUID(), HOUR); - fixture.unregister(); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.register(); - assertEquals(3600, MemberPlaytime.secondsFor("Alice")); - fixture.unregister(); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.register(); - fixture.bukkit.when(Bukkit::getServer).thenReturn(null); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.bukkit.when(Bukkit::getServer).thenReturn(fixture.server); - assertEquals(3600, MemberPlaytime.secondsFor("Alice")); - } - } - - @Test - void offlineTimeUsesCachedOwnersLastPlayedIncludingDeadAndHoldsUnknown() { - try (var fixture = new SharedFixture()) { - assertNull(MemberPlaytime.secondsFor("Uncached")); + UUID owner = UUID.randomUUID(); + String older = fixture.character(owner, CharacterStatus.DEAD, 2 * HOUR); + UUID lastId = UUID.randomUUID(); + fixture.lastPlayed.put(owner, snapshot(owner, lastId, CharacterStatus.ALIVE, 500 * HOUR)); + assertEquals(7200, MemberPlaytime.secondsFor(older), "Dead or offline characters keep their time"); + assertNull(MemberPlaytime.secondsFor(Members.key(lastId)), "An unknown character contributes nothing"); + assertNull(MemberPlaytime.secondsFor("Alice"), "A name is not a member key"); verify(fixture.provider, never()).lastPlayed(any()); - UUID owner = fixture.cached("Alice"); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.lastPlayed.put(owner, snapshot(UUID.randomUUID(), UUID.randomUUID(), CharacterStatus.DEAD, HOUR)); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.lastPlayed.put(owner, snapshot(owner, UUID.randomUUID(), CharacterStatus.DEAD, 2 * HOUR)); - assertEquals(7200, MemberPlaytime.secondsFor("Alice")); fixture.bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); verify(fixture.plugin, never()).getDataFolder(); } } @Test - void onlineTimeRejectsForeignSessionOwnerAndWrongOrMissingInfo() { + void sharedLookupTracksProviderArrivalAndLossWithoutReinstallation() { try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - UUID owner = alice.getUniqueId(); - UUID characterId = UUID.randomUUID(); - CharacterSession session = fixture.activate(alice, characterId, HOUR); - fixture.sessions.put(owner, new CharacterSession(new CharacterRef(UUID.randomUUID(), characterId), 88)); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.sessions.put(owner, session); - fixture.infos.put(characterId, snapshot(UUID.randomUUID(), characterId, CharacterStatus.ALIVE, HOUR)); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.infos.put(characterId, snapshot(owner, UUID.randomUUID(), CharacterStatus.ALIVE, HOUR)); - assertNull(MemberPlaytime.secondsFor("Alice")); - fixture.infos.remove(characterId); - assertNull(MemberPlaytime.secondsFor("Alice")); - } - } - - @Test - void snapshotFromOriginalACannotContributeAfterAToBToA() { - try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - UUID characterId = UUID.randomUUID(); - CharacterSession original = fixture.activate(alice, characterId, HOUR); - CharacterInfo oldInfo = fixture.infos.get(characterId); - when(fixture.provider.info(characterId)).thenAnswer(call -> { - fixture.activate(alice, UUID.randomUUID(), 2 * HOUR); - fixture.activate(alice, characterId, 3 * HOUR); - return Optional.of(oldInfo); - }); - assertNull(MemberPlaytime.secondsFor("Alice")); - assertFalse(Characters.isCurrent(original)); - CharacterInfo fresh = fixture.infos.get(characterId); - doReturn(Optional.of(fresh)).when(fixture.provider).info(characterId); - assertEquals(10800, MemberPlaytime.secondsFor("Alice")); - } - } - - @Test - void providerCallbackCannotContributeForDisconnectedOrReplacedPlayer() { - for (boolean disconnect : new boolean[] {true, false}) { - try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - UUID owner = alice.getUniqueId(); - UUID characterId = UUID.randomUUID(); - CharacterSession original = fixture.activate(alice, characterId, HOUR); - CharacterInfo info = fixture.infos.get(characterId); - when(fixture.provider.info(characterId)).thenAnswer(call -> { - if (disconnect) when(alice.isOnline()).thenReturn(false); - else fixture.connections.put(owner, mock(Player.class)); - return Optional.of(info); - }); - assertNull(MemberPlaytime.secondsFor("Alice")); - assertTrue(Characters.isCurrent(original)); - when(alice.isOnline()).thenReturn(true); - fixture.connections.put(owner, alice); - doReturn(Optional.of(info)).when(fixture.provider).info(characterId); - assertEquals(3600, MemberPlaytime.secondsFor("Alice")); - } + String alice = fixture.character(UUID.randomUUID(), CharacterStatus.ALIVE, HOUR); + fixture.unregister(); + assertNull(MemberPlaytime.secondsFor(alice)); + fixture.register(); + assertEquals(3600, MemberPlaytime.secondsFor(alice)); } } @Test void sharedSecondsSaturateBeforeConvertingToInteger() { try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - fixture.activate(alice, UUID.randomUUID(), Long.MAX_VALUE); - assertEquals(Integer.MAX_VALUE, MemberPlaytime.secondsFor("Alice")); - fixture.activate(alice, UUID.randomUUID(), 0); - assertEquals(0, MemberPlaytime.secondsFor("Alice")); - assertEquals(1.0, MemberPlaytime.totalFor(List.of("Alice")), 1e-9); + String endless = fixture.character(UUID.randomUUID(), CharacterStatus.ALIVE, Long.MAX_VALUE); + assertEquals(Integer.MAX_VALUE, MemberPlaytime.secondsFor(endless)); + String fresh = fixture.character(UUID.randomUUID(), CharacterStatus.ALIVE, 0); + assertEquals(0, MemberPlaytime.secondsFor(fresh)); + assertEquals(1.0, MemberPlaytime.totalFor(List.of(fresh)), 1e-9); } } @Test - void sharedRosterKeepsPrestigeFormulaAndSkipsPendingOrUnknownAccounts() { + void sharedRosterKeepsPrestigeFormulaAndSkipsUnknownMembers() { try (var fixture = new SharedFixture()) { - Player alice = fixture.player("Alice"); - fixture.activate(alice, UUID.randomUUID(), 500 * HOUR); - UUID bob = fixture.cached("Bob"); - fixture.lastPlayed.put(bob, snapshot(bob, UUID.randomUUID(), CharacterStatus.DEAD, 100 * HOUR)); - Player cara = fixture.player("Cara"); - fixture.activate(cara, UUID.randomUUID(), 0); - Player pending = fixture.player("Pending"); - UUID pendingOwner = pending.getUniqueId(); - fixture.lastPlayed.put(pendingOwner, - snapshot(pendingOwner, UUID.randomUUID(), CharacterStatus.ALIVE, Long.MAX_VALUE)); - assertEquals(35.0, MemberPlaytime.totalFor(List.of("Alice", "Bob", "Cara", "Pending", "Ghost")), 1e-9); - verify(fixture.provider, never()).lastPlayed(pendingOwner); + UUID account = UUID.randomUUID(); + String alice = fixture.character(account, CharacterStatus.ALIVE, 500 * HOUR); + String bob = fixture.character(UUID.randomUUID(), CharacterStatus.DEAD, 100 * HOUR); + String cara = fixture.character(account, CharacterStatus.ALIVE, 0); + String ghost = Members.key(UUID.randomUUID()); + assertEquals(35.0, MemberPlaytime.totalFor(List.of(alice, bob, cara, ghost, "Pending")), 1e-9); } } @@ -320,13 +227,8 @@ private static final class SharedFixture implements AutoCloseable { final Server server = mock(Server.class); final Plugin plugin = mock(Plugin.class); final CharacterIdentity provider = mock(CharacterIdentity.class); - final Map<UUID, CharacterSession> sessions = new HashMap<>(); final Map<UUID, CharacterInfo> infos = new HashMap<>(); final Map<UUID, CharacterInfo> lastPlayed = new HashMap<>(); - final Map<String, Player> online = new HashMap<>(); - final Map<UUID, Player> connections = new HashMap<>(); - final Map<String, OfflinePlayer> cached = new HashMap<>(); - private long generation; SharedFixture() { PluginManager plugins = mock(PluginManager.class); @@ -336,48 +238,18 @@ private static final class SharedFixture implements AutoCloseable { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); bukkit.when(Bukkit::getServicesManager).thenReturn(services); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); - bukkit.when(() -> Bukkit.getPlayerExact(anyString())).thenAnswer(call -> online.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenAnswer(call -> connections.get(call.getArgument(0))); - bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())).thenAnswer(call -> cached.get(call.getArgument(0))); - when(provider.currentSession(any())).thenAnswer(call -> { - Player player = call.getArgument(0); - return Optional.ofNullable(sessions.get(player.getUniqueId())); - }); - when(provider.isCurrent(any())).thenAnswer(call -> { - CharacterSession session = call.getArgument(0); - return session != null && session.equals(sessions.get(session.ref().ownerId())); - }); when(provider.info(any())).thenAnswer(call -> Optional.ofNullable(infos.get(call.getArgument(0)))); when(provider.lastPlayed(any())).thenAnswer(call -> Optional.ofNullable(lastPlayed.get(call.getArgument(0)))); register(); + Members.useSharedIdentity(); MemberPlaytime.useSharedIdentity(); } - Player player(String name) { - Player player = mock(Player.class); - UUID owner = UUID.randomUUID(); - when(player.getUniqueId()).thenReturn(owner); - when(player.getName()).thenReturn(name); - when(player.isOnline()).thenReturn(true); - online.put(name, player); - connections.put(owner, player); - return player; - } - - UUID cached(String name) { - UUID owner = UUID.randomUUID(); - OfflinePlayer player = mock(OfflinePlayer.class); - when(player.getUniqueId()).thenReturn(owner); - cached.put(name, player); - return owner; - } - - CharacterSession activate(Player player, UUID characterId, long seconds) { - UUID owner = player.getUniqueId(); - CharacterSession session = new CharacterSession(new CharacterRef(owner, characterId), ++generation); - sessions.put(owner, session); - infos.put(characterId, snapshot(owner, characterId, CharacterStatus.ALIVE, seconds)); - return session; + /** A stored character and its member key. */ + String character(UUID owner, CharacterStatus status, long seconds) { + UUID characterId = UUID.randomUUID(); + infos.put(characterId, snapshot(owner, characterId, status, seconds)); + return Members.key(characterId); } void register() { @@ -390,7 +262,6 @@ void unregister() { @Override public void close() { try { - bukkit.when(Bukkit::getServer).thenReturn(server); unregister(); MemberPlaytime.reset(); } finally { diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java index 4ad67a8f..56a71ab4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java @@ -43,21 +43,22 @@ class FactionCleanupCoverageTest { FactionCleanup.ping(null);FactionCleanup.ping(" "); assertEquals("null",Files.readString(disk.root.resolve("Cache/logins.json"))); FactionCleanup.ping("ALICE"); - assertEquals(0,FactionCleanup.daysOffline("alice")); - assertTrue(Files.readString(disk.root.resolve("Cache/logins.json")).contains("\"alice\":0")); + assertEquals(0,FactionCleanup.daysOffline("ALICE")); + assertTrue(Files.readString(disk.root.resolve("Cache/logins.json")).contains("\"ALICE\":0"),"Member keys are stored as given"); } @Test void dailyAccountingMarksInactiveMembersWithoutRemovingThemAndResetsOnlinePlayers() throws Exception { - disk.write("Cache/logins.json","{\"alice\":20,\"bob\":null,\"cara\":30,\"former\":5}"); + disk.write("Cache/logins.json","{\"Alice\":20,\"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.getOnlinePlayers()).thenAnswer(call -> Arrays.asList(null,bob)); + 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(21,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 is inactive after 21 days")); - FactionCleanup.ping("Alice");assertEquals(0,FactionCleanup.daysOffline("alice")); + verify(ui.plugin.getLogger()).log(eq(Level.INFO),contains("Alice (Alice) is inactive after 21 days")); + FactionCleanup.ping("Alice");assertEquals(0,FactionCleanup.daysOffline("Alice")); } @Test void unreadableOrMalformedHistoryIsPreservedWhenAPlayerLogsIn() throws Exception { @@ -74,7 +75,6 @@ class FactionCleanupCoverageTest { when(Bukkit.getServer()).thenReturn(null); FactionCleanup.advanceOfflineDays(null);assertEquals(3,FactionCleanup.daysOffline("alice")); when(Bukkit.getServer()).thenReturn(ui.server); - when(Bukkit.getOnlinePlayers()).thenThrow(new IllegalStateException("server stopping")); FactionCleanup.advanceOfflineDays(List.of());assertEquals(4,FactionCleanup.daysOffline("alice")); } @@ -92,7 +92,7 @@ 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\":20}");assertEquals(20,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;} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java index 44b4feec..89364ad6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java @@ -661,11 +661,13 @@ private Faction fundedFaction( } private void guard(Faction faction, Player leader) { + // Offices are held by characters: an established appointee whose character is a member. + java.util.UUID character = java.util.UUID.nameUUIDFromBytes((faction.getId() + "-character").getBytes()); SpecialPositionAssignment assignment = new SpecialPositionAssignment(); assignment.playerId = leader.getUniqueId(); assignment.playerName = leader.getName(); - assignment.characterId = faction.getId() + "-character"; - assignment.automatic = true; - faction.getEspionage().assignFounder(SpecialPosition.SPYMASTER, assignment, 75); + assignment.characterId = character.toString(); + faction.addMember(net.tfminecraft.simplefactions.identity.Members.key(character)); + faction.getEspionage().appoint(assignment, 75, 0); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java index 184a7550..7480fd40 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java @@ -38,6 +38,31 @@ class SharedDomainUtilitiesCoverageTest { } } + @Test void characterWealthReadsTheAccountPlayingTheCharacterAndUnknownCharactersHaveNone() { + java.util.UUID account=java.util.UUID.randomUUID(), character=java.util.UUID.randomUUID(); + String member=net.tfminecraft.simplefactions.identity.Members.key(character); + String stranger=net.tfminecraft.simplefactions.identity.Members.key(java.util.UUID.randomUUID()); + var names=net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.INSTANCE; + net.tfminecraft.simplefactions.identity.Members.setResolver(new net.tfminecraft.simplefactions.identity.Members.Resolver() { + @Override public java.util.Optional<net.tfminecraft.tlibs.character.CharacterSession> session(org.bukkit.entity.Player p) { return names.session(p); } + @Override public boolean isCurrent(net.tfminecraft.tlibs.character.CharacterSession s) { return true; } + @Override public String key(org.bukkit.entity.Player p) { return names.key(p); } + @Override public java.util.Optional<org.bukkit.entity.Player> online(String key) { return java.util.Optional.empty(); } + @Override public java.util.Optional<net.tfminecraft.tlibs.character.CharacterInfo> info(String key) { + return key.equals(member) ? java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(account,character),"Lady Alice", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE,null,java.util.Set.of(),0,null)) : java.util.Optional.empty(); + } + }); + try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { + accounts.when(() -> OfflineModifier.balance(eq(account),eq(Accounts.POUCH))).thenReturn(3.0); + accounts.when(() -> OfflineModifier.balance(eq(account),eq(Accounts.BANK))).thenReturn(7.0); + assertEquals(10,Wealth.wealth(member)); + assertEquals(0,Wealth.wealth(stranger)); + accounts.verify(() -> OfflineModifier.balance(anyString(),any(Accounts.class)),never()); + } + } + @Test void wealthRankingIncludesSubjectsOnlyWhenRequestedAndSortsByTotalAssets() { Faction realm=fixture.saved("realm","Alice");realm.addMember("Citizen"); Guild guild=fixture.guild(realm,"merchants","Bob");guild.addMember("Dana"); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandRouteTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandRouteTest.java index 20f33b6b..a42be4d3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandRouteTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandRouteTest.java @@ -87,6 +87,8 @@ void takeAndGiveRoutes() { assertTrue(VehicleCommandRoute.isTake(new String[] {"vehicle", "take"})); assertFalse(VehicleCommandRoute.isTake(new String[] {"vehicle", "give", "Bob"})); assertEquals("Bob", VehicleCommandRoute.giveTarget(new String[] {"vehicle", "give", "Bob"})); + assertEquals("Lady Rowan", VehicleCommandRoute.giveTarget(new String[] {"vehicle", "give", "Lady", "Rowan"})); + assertEquals("Lady Rowan", VehicleCommandRoute.handoverTarget(new String[] {"vehicle", "handover", "Lady", "Rowan"})); assertEquals("", VehicleCommandRoute.giveTarget(new String[] {"vehicle", "give"})); assertNull(VehicleCommandRoute.giveTarget(new String[] {"vehicle", "take"})); assertNull(VehicleCommandRoute.transferInstallationId(new String[] {"vehicle", "take"})); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandsCoverageTest.java index cdda4dda..941603b3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/VehicleCommandsCoverageTest.java @@ -176,7 +176,7 @@ void invalidRecipientNeverReplacesAnExistingAction(String state) { case "null" -> null; case "empty" -> ""; case "blank" -> " "; - case "self" -> "LEADER"; + case "self" -> "Leader"; case "missing" -> "Missing"; default -> "Other"; }; diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java index b2920822..b7e09fdc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java @@ -1477,12 +1477,15 @@ void constructionFeeCallbacksIgnoreIncompleteEventsAndRememberResolvedOwners(Str verifyNoInteractions(save); return; } + UUID paidBy = UUID.randomUUID(); if (state.equals("offline_constructor") || state.equals("unknown_constructor")) { fixture.online.remove("Owner"); - OfflinePlayer offline = mock(OfflinePlayer.class); - when(offline.getName()) - .thenReturn(state.equals("offline_constructor") ? "OfflineOwner" : null); - when(Bukkit.getOfflinePlayer(owner.getUniqueId())).thenReturn(offline); + // An offline builder is known only by the character that paid the fee. + if (state.equals("offline_constructor")) { + feeStore.putPaidBuild( + "world:6:64:7", + new VehicleFeeStore.PaidBuild(paidBy, "home", 5.0)); + } } fees.onVehicleConstruct( new VehicleConstructEvent( @@ -1492,7 +1495,7 @@ void constructionFeeCallbacksIgnoreIncompleteEventsAndRememberResolvedOwners(Str station.getLocation(), station)); assertEquals( - state.equals("offline_constructor") ? "OfflineOwner" : null, + state.equals("offline_constructor") ? paidBy.toString() : null, feeStore.getLastOwner("vehicle-1")); if (state.equals("offline_constructor")) verify(save).run(); else verifyNoInteractions(save); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java index 82d54531..5c1cfb41 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java @@ -87,7 +87,7 @@ void tearDown() throws IOException { } @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.ValueSource(strings = {"Leader", "lEaDeR"}) + @org.junit.jupiter.params.provider.ValueSource(strings = {"Leader"}) void acceptRequest_registersVehicleWhenValidationPasses(String leaderName) { UUID ownerUuid = UUID.randomUUID(); UUID leaderUuid = UUID.randomUUID(); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java new file mode 100644 index 00000000..a125c558 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -0,0 +1,138 @@ +package net.tfminecraft.simplefactions.vehicles.registry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicesManager; +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.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleOwnerSync; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.vehicleframework.data.OwnerData; + +/** VehicleFramework still names accounts; these bridge its owner tags and member keys. */ +class VehicleOwnerKeysTest { + private final UUID account = UUID.randomUUID(); + private final UUID characterId = UUID.randomUUID(); + private final Map<UUID, CharacterInfo> lastPlayed = new HashMap<>(); + private final CharacterIdentity provider = mock(CharacterIdentity.class); + private CharacterIdentity registered = provider; + private MockedStatic<Bukkit> bukkit; + private MockedStatic<OfflineModifier> accounts; + + @BeforeEach + void setUp() { + bukkit = mockStatic(Bukkit.class); + accounts = mockStatic(OfflineModifier.class); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ServicesManager services = mock(ServicesManager.class); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + when(services.load(CharacterIdentity.class)).thenAnswer(call -> registered); + when(provider.lastPlayed(any())).thenAnswer(call -> Optional.ofNullable(lastPlayed.get(call.getArgument(0)))); + when(provider.info(characterId)).thenReturn(Optional.of(info(account, characterId))); + accounts.when(() -> OfflineModifier.playerId("Owner")).thenReturn(account); + Members.useSharedIdentity(); + } + + @AfterEach + void close() { + accounts.close(); + bukkit.close(); + } + + @Test + void ownerKeyIsThePlayedOrLastPlayedCharacterOfTheTaggedAccount() { + assertNull(VehicleOwnershipQueries.ownerKey(null)); + assertNull(VehicleOwnershipQueries.ownerKey(" ")); + assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "No character has been played yet"); + lastPlayed.put(account, info(UUID.randomUUID(), characterId)); + assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "A foreign record never names the owner"); + lastPlayed.put(account, info(account, characterId)); + assertEquals(Members.key(characterId), VehicleOwnershipQueries.ownerKey("Owner")); + assertNull(VehicleOwnershipQueries.ownerKey("Unknown"), "An account DenarEconomy never saw"); + + Player online = mock(Player.class); + when(online.getUniqueId()).thenReturn(account); + when(provider.currentSession(online)).thenReturn(Optional.of(new CharacterSession(new CharacterRef(account, characterId), 1))); + when(provider.isCurrent(any())).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayerExact("Owner")).thenReturn(online); + assertEquals(Members.key(characterId), VehicleOwnershipQueries.ownerKey("Owner")); + assertEquals(characterId, VehicleOwnershipQueries.characterId(online)); + Player stranger = mock(Player.class); + UUID strangerAccount = UUID.randomUUID(); + when(stranger.getUniqueId()).thenReturn(strangerAccount); + assertEquals(strangerAccount, VehicleOwnershipQueries.characterId(stranger), "No character: the account"); + } + + @Test + void withoutAServerOrProviderTheTagNameIsTheKey() { + registered = null; + assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + registered = provider; + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + bukkit.when(Bukkit::getServer).thenReturn(null); + assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + } + + @Test + void leaderOwnedFactionVehiclesNameTheLeadersAccountAndWaitWhileItIsUnknown() { + OfflinePlayer owner = mock(OfflinePlayer.class); + when(owner.getName()).thenReturn("Owner"); + bukkit.when(() -> Bukkit.getOfflinePlayer(account)).thenReturn(owner); + assertNull(VehicleOwnershipQueries.accountNameOf(null)); + assertNull(VehicleOwnershipQueries.accountNameOf("")); + assertEquals("Legacy", VehicleOwnershipQueries.accountNameOf("Legacy")); + assertEquals("Owner", VehicleOwnershipQueries.accountNameOf(Members.key(characterId))); + + Faction faction = mock(Faction.class); + when(faction.getLeader()).thenReturn(Members.key(characterId)); + assertEquals("player_Owner", InstallationVehicleOwnerSync.expectedOwner(faction)); + + registered = null; + assertNull(InstallationVehicleOwnerSync.expectedOwner(faction)); + OwnerData data = new OwnerData(); + data.setOwner("player_Previous"); + InstallationVehicleOwnerSync sync = new InstallationVehicleOwnerSync(new PlayerVehicleRegistry()); + sync.applyLeaderOwner(data, faction); + assertEquals("player_Previous", data.getOwner(), "An unreadable leader leaves the owner alone"); + PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + registry.register(new PlayerVehicleRecord(characterId, "pooled", "cart", OwnershipMode.POOL, null, "home")); + var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("pooled"); + when(vehicle.getOwnerData()).thenReturn(data); + try (var factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class)) { + factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("home")).thenReturn(faction); + new InstallationVehicleOwnerSync(registry).syncIfBerthed(vehicle); + } + assertEquals("player_Previous", data.getOwner()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + + private static CharacterInfo info(UUID owner, UUID id) { + return new CharacterInfo(new CharacterRef(owner, id), "Owner's Character", CharacterStatus.ALIVE, + null, Set.of(), 0, null); + } +} From 099e45a21b516d294a4ba9f8f4a0542bf1a4920f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:57:44 +0000 Subject: [PATCH 14/35] fix: tie vehicle owner tags, payouts and inactivity to member keys --- .../simplefactions/SimpleFactions.java | 2 +- .../simplefactions/identity/Members.java | 18 --------------- .../managers/FactionManager.java | 3 +-- .../managers/PlayerManager.java | 6 +++++ .../managers/inventory/InstallationView.java | 2 +- .../request/VehicleHandoverRequest.java | 5 +++- .../registry/VehicleOwnershipQueries.java | 20 ++++++++-------- .../freeze/VfBuildersConstructionFreeze.java | 4 ++-- .../simplefactions/identity/MembersTest.java | 21 ----------------- .../registry/VehicleOwnerKeysTest.java | 23 ++++++++++--------- .../VfBuildersConstructionFreezeTest.java | 8 +++---- 11 files changed, 39 insertions(+), 73 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index ce2ac6b4..bec4ff9c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -806,7 +806,7 @@ public void recordVehicleOwners() { for (net.tfminecraft.vehicleframework.data.OwnedVehicleSummary vehicle : net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.allPersonalVehicles(vehicleRegistry)) { // VehicleFramework still owns vehicles by account; the fee follows that account's character. - String owner = net.tfminecraft.simplefactions.identity.Members.ofAccountOrHeir( + String owner = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey( net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries .playerNameFromOwner(vehicle.getOwner())); if (owner != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java index c709b7e9..13486ea1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -79,24 +79,6 @@ public static String ofAccountName(String accountName) { return accountName == null ? null : key(onlineByName(accountName)); } - /** - * The member key standing for an account known only by name: its current - * character if online, else its reviewed legacy heir. Used where another - * plugin still records ownership by account (stage 6 moves those). - */ - public static String ofAccountOrHeir(String accountName) { - String online = ofAccountName(accountName); - if (online != null || accountName == null) return online; - try { - if (Bukkit.getServer() == null) return null; - org.bukkit.OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(accountName); - if (cached == null || cached.getUniqueId() == null || !Characters.available()) return null; - return Characters.legacyHeir(cached.getUniqueId()).map(Members::key).orElse(null); - } catch (RuntimeException unavailable) { - return null; - } - } - /** The character name for a key; offline characters are read from the provider. */ public static String display(String key) { if (key == null || key.isBlank()) return "Unknown"; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 5fbc6733..56047fd1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -59,7 +59,6 @@ import net.tfminecraft.simplefactions.government.movement.cause.Cause; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.denareconomy.enums.Accounts; public class FactionManager implements Listener{ @@ -630,7 +629,7 @@ public void settleIncome() { buffer, DenarEconomyPlayerBank.INSTANCE, PlayerEconomyManager.get(), - OfflineModifier::playerId); + net.tfminecraft.simplefactions.utils.MemberAccounts::accountId); for (Guild g : getAllGuilds()) { g.refreshDividendEligibility(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 753f9c7d..7a4dac93 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -48,6 +48,12 @@ public void joinEvent(PlayerJoinEvent e) { net.tfminecraft.simplefactions.inactivity.InactivityService.onLogin(e.getPlayer()); } + /** A character is active once it is played, which is usually after the join. */ + @EventHandler + public void characterStart(net.tfminecraft.tlibs.character.CharacterSessionStartEvent e) { + net.tfminecraft.simplefactions.inactivity.InactivityService.onLogin(e.getPlayer()); + } + //Loans and stuff @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) public void signBook(PlayerEditBookEvent e) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index bd44f892..0965f1f6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -311,7 +311,7 @@ private void handleBerthedVehicleClick( InstallationVehicleUnberthService service = SimpleFactions.getInstance().getInstallationVehicleUnberthService(); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, player.getName(), installation, vehicleUuid); + service.unberth(faction, Members.key(player), installation, vehicleUuid); player.sendMessage(InstallationVehicleUnberthService.messageFor(outcome)); if (outcome.result() == InstallationVehicleUnberthService.UnberthResult.OK) { installationDetailView(player, faction, installationId); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index 638eb326..a1c4b941 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -32,7 +32,10 @@ public VehicleHandoverRequest( this.ownerUuid = ownerUuid; this.ownerName = ownerName; this.recipientUuid = recipientUuid; - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerName, vehicleTypeId); + // Fees are charged to the owner's character, the same one acceptRequest quotes for. + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, + net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey(ownerName), + vehicleTypeId); this.feeFactionId = quote == null ? null : quote.faction().getId(); this.feeAmount = quote == null ? 0.0 : quote.amount(); this.time = System.currentTimeMillis() diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java index 37d714f0..1028373c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java @@ -99,31 +99,29 @@ public static UUID resolvePlayerUuid(String playerName) { } /** - * The member behind a VehicleFramework owner name. Owner tags still name accounts - * (stage 6), so this is the character being played, else the account's last-played - * character. Without a server or character provider the name itself is the key. + * The member key standing for a VehicleFramework owner tag, which still + * names an account (stage 6 moves vehicles to characters): the character + * the account is playing now, else its reviewed legacy heir, the same rule + * the migration used for account-owned rows. Null when neither is known. */ public static String ownerKey(String accountName) { - if (accountName == null || accountName.isBlank()) { + if (accountName == null || accountName.isBlank() || Bukkit.getServer() == null) { return null; } - if (Bukkit.getServer() == null) { - return accountName; - } Player online = Bukkit.getPlayerExact(accountName); if (online != null) { return Members.key(online); } if (!Bukkit.isPrimaryThread() || !Characters.available()) { - return accountName; + return null; } UUID account = OfflineModifier.playerId(accountName); if (account == null) { return null; } - return Characters.lastPlayed(account) - .filter(info -> account.equals(info.ref().ownerId())) - .map(info -> Members.key(info.ref())) + return Characters.legacyHeir(account) + .filter(heir -> account.equals(heir.ownerId())) + .map(Members::key) .orElse(null); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java index 34d67800..5dd0d850 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -35,12 +35,12 @@ public String freezeReason(UUID constructorUuid) { return "battle postponed (" + PreparationFreeze.formatRemaining(until, now) + ")"; } - /** Projects belong to accounts: the character playing now, else the one played last. */ + /** Projects belong to accounts: the character playing now, else the account's legacy heir. */ static String constructorKey(UUID constructorUuid) { Player online = Bukkit.getPlayer(constructorUuid); if (online != null) { return Members.key(online); } - return Characters.lastPlayed(constructorUuid).map(info -> Members.key(info.ref())).orElse(null); + return Characters.legacyHeir(constructorUuid).map(Members::key).orElse(null); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java index 9b6b6ff2..a76e9f4b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java @@ -161,34 +161,13 @@ void resolveOnlineFindsAUniqueCharacterName() { } } - @Test - void anOfflineAccountStandsForItsLegacyHeir() { - try (var identity = new SharedIdentity()) { - UUID account = identity.account("Steve"); - CharacterRef heir = identity.character(account, "Ada"); - assertNull(Members.ofAccountOrHeir("Steve")); - identity.heirs.put(account, heir); - assertEquals(Members.key(heir), Members.ofAccountOrHeir("Steve")); - assertNull(Members.ofAccountOrHeir("Unknown")); - assertNull(Members.ofAccountOrHeir(null)); - Player steve = identity.connect("Alex"); - CharacterRef bea = identity.character(steve.getUniqueId(), "Bea"); - identity.play(steve, bea); - assertEquals(Members.key(bea), Members.ofAccountOrHeir("Alex")); - identity.unregister(); - assertNull(Members.ofAccountOrHeir("Steve")); - } - } - @Test void noServerMeansNobodyIsOnline() { try (var bukkit = mockStatic(Bukkit.class)) { assertNull(Members.findOnline("Steve")); - assertNull(Members.ofAccountOrHeir("Steve")); assertNull(Members.accountName(Members.key(UUID.randomUUID()))); bukkit.when(Bukkit::getServer).thenThrow(new IllegalStateException("no server")); assertNull(Members.findOnline("Steve")); - assertNull(Members.ofAccountOrHeir("Steve")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java index a125c558..62e6fb53 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -35,7 +35,7 @@ class VehicleOwnerKeysTest { private final UUID account = UUID.randomUUID(); private final UUID characterId = UUID.randomUUID(); - private final Map<UUID, CharacterInfo> lastPlayed = new HashMap<>(); + private final Map<UUID, CharacterRef> heirs = new HashMap<>(); private final CharacterIdentity provider = mock(CharacterIdentity.class); private CharacterIdentity registered = provider; private MockedStatic<Bukkit> bukkit; @@ -50,7 +50,7 @@ void setUp() { ServicesManager services = mock(ServicesManager.class); bukkit.when(Bukkit::getServicesManager).thenReturn(services); when(services.load(CharacterIdentity.class)).thenAnswer(call -> registered); - when(provider.lastPlayed(any())).thenAnswer(call -> Optional.ofNullable(lastPlayed.get(call.getArgument(0)))); + when(provider.legacyHeir(any())).thenAnswer(call -> Optional.ofNullable(heirs.get(call.getArgument(0)))); when(provider.info(characterId)).thenReturn(Optional.of(info(account, characterId))); accounts.when(() -> OfflineModifier.playerId("Owner")).thenReturn(account); Members.useSharedIdentity(); @@ -63,13 +63,13 @@ void close() { } @Test - void ownerKeyIsThePlayedOrLastPlayedCharacterOfTheTaggedAccount() { + void ownerKeyIsThePlayedCharacterElseTheLegacyHeirOfTheTaggedAccount() { assertNull(VehicleOwnershipQueries.ownerKey(null)); assertNull(VehicleOwnershipQueries.ownerKey(" ")); - assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "No character has been played yet"); - lastPlayed.put(account, info(UUID.randomUUID(), characterId)); - assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "A foreign record never names the owner"); - lastPlayed.put(account, info(account, characterId)); + assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "No reviewed heir yet"); + heirs.put(account, new CharacterRef(UUID.randomUUID(), characterId)); + assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "A foreign heir never names the owner"); + heirs.put(account, new CharacterRef(account, characterId)); assertEquals(Members.key(characterId), VehicleOwnershipQueries.ownerKey("Owner")); assertNull(VehicleOwnershipQueries.ownerKey("Unknown"), "An account DenarEconomy never saw"); @@ -87,14 +87,15 @@ void ownerKeyIsThePlayedOrLastPlayedCharacterOfTheTaggedAccount() { } @Test - void withoutAServerOrProviderTheTagNameIsTheKey() { + void withoutAServerOrProviderNoCharacterIsKnown() { + heirs.put(account, new CharacterRef(account, characterId)); registered = null; - assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + assertNull(VehicleOwnershipQueries.ownerKey("Owner")); registered = provider; bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); - assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + assertNull(VehicleOwnershipQueries.ownerKey("Owner")); bukkit.when(Bukkit::getServer).thenReturn(null); - assertEquals("Owner", VehicleOwnershipQueries.ownerKey("Owner")); + assertNull(VehicleOwnershipQueries.ownerKey("Owner")); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java index 39b62305..82525dbb 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java @@ -57,14 +57,12 @@ void anOnlineBuilderIsJudgedByTheCharacterPlayingNow() { } @Test - void anOfflineBuilderIsJudgedByTheCharacterPlayedLast() { + void anOfflineBuilderIsJudgedByTheAccountsLegacyHeir() { UUID characterId = UUID.randomUUID(); CharacterIdentity identity = mock(CharacterIdentity.class); when(services.load(CharacterIdentity.class)).thenReturn(identity); - when(identity.lastPlayed(any())).thenReturn(Optional.empty()); - when(identity.lastPlayed(account)).thenReturn(Optional.of(new CharacterInfo( - new CharacterRef(account, characterId), "Hilde", CharacterStatus.ALIVE, null, Set.of(), 0, - Instant.EPOCH))); + when(identity.legacyHeir(any())).thenReturn(Optional.empty()); + when(identity.legacyHeir(account)).thenReturn(Optional.of(new CharacterRef(account, characterId))); assertEquals(Members.key(characterId), VfBuildersConstructionFreeze.constructorKey(account)); assertNull(VfBuildersConstructionFreeze.constructorKey(UUID.randomUUID())); From 0884db6270e36a9353f5a73c3673fc9016d9da9d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:07:16 +0000 Subject: [PATCH 15/35] fix: capture the handover owner's character and key test characters consistently --- .../simplefactions/identity/Members.java | 7 +++++- .../request/VehicleHandoverRequest.java | 12 ++++++---- .../handover/VehicleHandoverService.java | 2 +- .../IntegrationBoundaryCoverageTest.java | 23 ++++++++----------- .../inventory/MercenaryMenusCoverageTest.java | 2 +- .../inventory/PolicyMenusCoverageTest.java | 2 +- .../inventory/ReportedMenusCoverageTest.java | 2 +- .../testsupport/NameKeyedMembers.java | 18 ++++++++++++++- .../maintenance/VehicleUpkeepTaxTest.java | 4 ++++ 9 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java index 13486ea1..d9825ee6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/Members.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -37,6 +37,11 @@ public interface Resolver { Optional<CharacterInfo> info(String key); Optional<Player> online(String key); + + /** The key of a character ID: its lower-case text. */ + default String key(UUID characterId) { + return characterId.toString().toLowerCase(Locale.ROOT); + } } private static final Resolver SHARED = new SharedResolver(); @@ -59,7 +64,7 @@ public static String key(Player player) { /** Key form of a character ID. */ public static String key(UUID characterId) { - return characterId == null ? null : characterId.toString().toLowerCase(Locale.ROOT); + return characterId == null ? null : resolver.key(characterId); } public static String key(CharacterRef ref) { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index a1c4b941..c7beaad3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -14,6 +14,8 @@ public final class VehicleHandoverRequest extends Request { private final String vehicleTypeId; private final UUID ownerUuid; private final String ownerName; + /** The owner's character when the offer was made; it pays the fee even if they log off. */ + private final String ownerKey; private final UUID recipientUuid; private final String feeFactionId; private final double feeAmount; @@ -32,10 +34,8 @@ public VehicleHandoverRequest( this.ownerUuid = ownerUuid; this.ownerName = ownerName; this.recipientUuid = recipientUuid; - // Fees are charged to the owner's character, the same one acceptRequest quotes for. - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, - net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey(ownerName), - vehicleTypeId); + this.ownerKey = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey(ownerName); + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerKey, vehicleTypeId); this.feeFactionId = quote == null ? null : quote.faction().getId(); this.feeAmount = quote == null ? 0.0 : quote.amount(); this.time = System.currentTimeMillis() @@ -58,6 +58,10 @@ public String getOwnerName() { return ownerName; } + public String getOwnerKey() { + return ownerKey; + } + public UUID getRecipientUuid() { return recipientUuid; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java index 7954dd8c..53f15c77 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -130,7 +130,7 @@ public void acceptRequest(Player recipient) { tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); return; } - String ownerKey = VehicleOwnershipQueries.ownerKey(req.getOwnerName()); + String ownerKey = req.getOwnerKey(); Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerKey, outcome.vehicleTypeId()); if (!Objects.equals(req.getVehicleTypeId(), outcome.vehicleTypeId()) || !req.matchesFee(quote)) { tellBoth(req, recipient, VehicleHandoverMessages.feeChanged()); diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java index dddb2fea..23dafdb4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java @@ -135,33 +135,30 @@ void chatHooksRegisterTheCorrectResolversOnlyWhenTheProviderIsEnabled() { } @Test - void leaderNamesUseSharedReadyCharactersWithoutRpcEnablementOrDiskLookup() { + void leaderNamesComeFromTheLeadersOwnCharacterWithoutRpcEnablementOrDiskLookup() { SimpleServicesManager services = sharedServices(); CharacterIdentity provider = mock(CharacterIdentity.class); + Members.useSharedIdentity(); LeaderCharacters.useSharedIdentity(); - assertNull(LeaderCharacters.resolve("Online", null, null).name()); - Player player = rig.player("Online"); - UUID owner = player.getUniqueId(); + UUID owner = UUID.randomUUID(); UUID characterId = UUID.randomUUID(); + String leader = Members.key(characterId); + assertNull(LeaderCharacters.resolve(leader, null, null).name()); CharacterRef ref = new CharacterRef(owner, characterId); - CharacterSession session = new CharacterSession(ref, 1); CharacterInfo info = new CharacterInfo(ref, "Aelin of the Reach", CharacterStatus.ALIVE, null, Set.of(), 7200, Instant.EPOCH); - when(Bukkit.getPlayer(owner)).thenReturn(player); - when(provider.currentSession(player)).thenReturn(Optional.empty()); when(provider.info(characterId)).thenReturn(Optional.of(info)); - when(provider.isCurrent(session)).thenReturn(true); when(Bukkit.getOfflinePlayer(anyString())) .thenThrow(new AssertionError("Remote lookup is forbidden")); services.register(CharacterIdentity.class, provider, rig.ui.plugin, ServicePriority.Normal); try { - assertNull(LeaderCharacters.resolve("Online", null, null).name()); - when(provider.currentSession(player)).thenReturn(Optional.of(session)); - assertEquals("Aelin of the Reach", LeaderCharacters.resolve("Online", null, null).name()); - assertNull(LeaderCharacters.resolve("Uncached", null, null).name()); + // The leader may be offline: the name is the character's own, not whatever its player plays now. + assertEquals("Aelin of the Reach", LeaderCharacters.resolve(leader, null, null).name()); + assertNull(LeaderCharacters.resolve(Members.key(UUID.randomUUID()), null, null).name()); verify(Bukkit.getPluginManager(), never()).isPluginEnabled("RPCharacters"); + verify(provider, never()).currentSession(any()); services.unregister(CharacterIdentity.class, provider); - assertNull(LeaderCharacters.resolve("Online", "Aelin of the Reach", "Online").name()); + assertNull(LeaderCharacters.resolve(leader, "Aelin of the Reach", leader).name()); } finally { services.unregister(CharacterIdentity.class, provider); LeaderCharacters.reset(); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java index e6cd4123..426d7825 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java @@ -458,7 +458,7 @@ void spymasterProtectedCompanyOverviewContainsOnlyReportedInformation() { SpecialPositionAssignment assignment = new SpecialPositionAssignment(); assignment.playerName = "Host"; assignment.playerId = fixture.player("Host").getUniqueId(); - assignment.characterId = "host_character"; + assignment.characterId = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Host").toString(); host.getEspionage().appoint(assignment, 50); companies.companyView(customer, guild); Inventory menu = top(customer); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java index 66121c73..4f267af4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java @@ -311,7 +311,7 @@ void foreignLawAndTaxViewsRemainReportedDuringRefresh() { var spymaster = new net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment(); spymaster.playerName = foreign.getName(); spymaster.playerId = foreign.getUniqueId(); - spymaster.characterId = "character-Foreign"; + spymaster.characterId = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Foreign").toString(); target.getEspionage().appoint(spymaster, 80); fixture.saved("home", "Alice"); inventory.lawView.lawView(alice, target, null); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java index 19e7aeee..f3432227 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java @@ -109,7 +109,7 @@ private void appoint(Faction faction, Player player) { SpecialPositionAssignment spy = new SpecialPositionAssignment(); spy.playerName = player.getName(); spy.playerId = player.getUniqueId(); - spy.characterId = "character-" + player.getName(); + spy.characterId = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(player.getName()).toString(); faction.getEspionage().appoint(spy, 80); } diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java index ef58da80..50e0007d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java @@ -22,6 +22,8 @@ */ public final class NameKeyedMembers implements Members.Resolver, BeforeEachCallback, AfterEachCallback { public static final NameKeyedMembers INSTANCE = new NameKeyedMembers(); + /** Character IDs handed out in sessions, back to the name that keys them. */ + private static final java.util.Map<UUID, String> NAMES = new java.util.concurrent.ConcurrentHashMap<>(); public static void install() { Members.setResolver(INSTANCE); @@ -42,7 +44,21 @@ public Optional<CharacterSession> session(Player player) { String name = player.getName(); if (name == null) return Optional.empty(); UUID owner = player.getUniqueId() != null ? player.getUniqueId() : id("owner:" + name); - return Optional.of(new CharacterSession(new CharacterRef(owner, id(name)), 1L)); + return Optional.of(new CharacterSession(new CharacterRef(owner, characterOf(name)), 1L)); + } + + /** The character ID standing for a test name; its key is the name again. */ + public static UUID characterOf(String name) { + UUID character = id(name); + NAMES.put(character, name); + return character; + } + + /** A session's character keys back to the player's name, as {@link #key(Player)} does. */ + @Override + public String key(UUID characterId) { + String name = NAMES.get(characterId); + return name != null ? name : Members.Resolver.super.key(characterId); } @Override diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java index b0d33fcd..6d7f5660 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java @@ -125,6 +125,10 @@ void missingTheTaxMissesUpkeepToo() { private void runDay() { try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + // The owner tag names Alice's account; the tax follows the character she plays. + org.bukkit.entity.Player owner = mock(org.bukkit.entity.Player.class); + when(owner.getName()).thenReturn("Alice"); + bukkit.when(() -> Bukkit.getPlayerExact("Alice")).thenReturn(owner); service.processDailyUpkeep(); } } From 39ec2d5dee84b9b305e1cdad9b48177a30f8a703 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:14:03 +0000 Subject: [PATCH 16/35] test: cover character start logins, quorum gaps and espionage keys --- .../espionage/EspionageMenusCoverageTest.java | 3 +-- .../espionage/EspionageTestFixtures.java | 7 +++-- .../PlayerManagerCharacterStartTest.java | 26 +++++++++++++++++++ .../vote/BattleQuorumServiceTest.java | 9 +++++++ 4 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCharacterStartTest.java diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java index 46e0d0c5..e084da3f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java @@ -132,8 +132,7 @@ void close() throws Exception { private CharacterSession session(Player player) { if (player == null) return null; return sessions.computeIfAbsent(player.getUniqueId(), owner -> new CharacterSession( - new CharacterRef(owner, UUID.nameUUIDFromBytes( - ("character-" + player.getName()).getBytes(StandardCharsets.UTF_8))), 1)); + new CharacterRef(owner, EspionageTestFixtures.characterOf(player.getName())), 1)); } private SpecialPositionAssignment assign( diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java index d4ed18df..7a761339 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java @@ -11,9 +11,12 @@ final class EspionageTestFixtures { private EspionageTestFixtures() {} - /** A stable character ID for a test name; offices match members by this key. */ + /** + * A stable character ID for a test name; offices match members by its key, + * which is the name itself under the default name-keyed test resolver. + */ static UUID characterOf(String name) { - return UUID.nameUUIDFromBytes(("character-" + name).getBytes(StandardCharsets.UTF_8)); + return net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(name); } /** The member key of {@link #characterOf(String)}. */ diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCharacterStartTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCharacterStartTest.java new file mode 100644 index 00000000..3e5cacb5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCharacterStartTest.java @@ -0,0 +1,26 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; + +class PlayerManagerCharacterStartTest { + @Test + void playingACharacterCountsAsItsLogin() { + Player player = mock(Player.class); + CharacterSession session = new CharacterSession(new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), 1); + try (var inactivity = mockStatic(InactivityService.class)) { + new PlayerManager().characterStart(new CharacterSessionStartEvent(player, session)); + inactivity.verify(() -> InactivityService.onLogin(player)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java index 10e3834d..c0fecda3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java @@ -85,6 +85,15 @@ void meetsQuorum_passesViaSmallestSideFull() { assertEquals(5, result.defenderEligible()); } + @Test + void aMemberWithoutAKnownAccountLeavesTheSmallestSideUnrepresented() { + War war = baseWar(); + UUID alice = UUID.randomUUID(); + war.getBattleVotes().put(alice, Set.of(21)); + assertFalse(BattleQuorumService.isSmallestSideFullyRepresented( + war, name -> "Alice".equals(name) ? alice : null)); + } + @Test void meetsQuorum_failsWhenNeitherThresholdMet() { War war = baseWar(); From b0186f523362792bd358afc2a1caa17102405b5f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:16:59 +0000 Subject: [PATCH 17/35] wip: paused for handoff Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> From d8baabf79b926802dbe289bb70207dbc3ecef147 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:24:16 +0000 Subject: [PATCH 18/35] test: cover absent votes and optional campaign paths --- .../MercenaryContractBoundaryCoverageTest.java | 7 +++++++ .../war/campaign/WarCampaignServiceTest.java | 11 +++++++++++ .../war/campaign/vote/BattleQuorumServiceTest.java | 13 +++++++++++++ 3 files changed, 31 insertions(+) diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java index 15d4320b..19acaf04 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java @@ -36,6 +36,13 @@ import org.junit.jupiter.api.Test; class MercenaryContractBoundaryCoverageTest { + @Test + void absentReputationIntegrationDoesNotChangeTheContract() { + MercenaryContract contract = org.mockito.Mockito.mock(MercenaryContract.class); + ContractReputationSeam.NONE.onTermination(contract, null); + org.mockito.Mockito.verifyNoInteractions(contract); + } + @Test void wageOverridesAreKeyedExactlyWhateverTheLocale() { Locale previous = Locale.getDefault(); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/WarCampaignServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/WarCampaignServiceTest.java index 93106d80..a7793524 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/WarCampaignServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/WarCampaignServiceTest.java @@ -45,6 +45,17 @@ import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; class WarCampaignServiceTest { + @Test + void scheduleCanStartOnTheDeclarationDay() { + Cache.warFirstBattleDayAfterDeclare = false; + War war = mock(War.class); + Instant started = Instant.parse("2026-10-12T12:00:00Z"); + when(war.getStartedAt()).thenReturn(started); + WarCampaignService.initScheduleState(war); + org.mockito.Mockito.verify(war).setBattleDay(LocalDate.of(2026, 10, 12)); + org.mockito.Mockito.verify(war).setBattleSchedulePhase(BattleSchedulePhase.VOTING); + } + private ProvinceManager pm; private WarCampaignService service; private Faction attacker; diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java index c0fecda3..874f5236 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java @@ -94,6 +94,19 @@ void aMemberWithoutAKnownAccountLeavesTheSmallestSideUnrepresented() { war, name -> "Alice".equals(name) ? alice : null)); } + @Test + void aKnownMemberWithoutAVoteLeavesTheSmallestSideUnrepresented() { + War war = baseWar(); + UUID alice = UUID.randomUUID(); + UUID bob = UUID.randomUUID(); + war.getBattleVotes().put(alice, Set.of(21)); + assertFalse(BattleQuorumService.isSmallestSideFullyRepresented( + war, name -> "Alice".equals(name) ? alice : bob)); + war.getBattleVotes().put(bob, Set.of()); + assertFalse(BattleQuorumService.isSmallestSideFullyRepresented( + war, name -> "Alice".equals(name) ? alice : bob)); + } + @Test void meetsQuorum_failsWhenNeitherThresholdMet() { War war = baseWar(); From 768e40cf1a93d653267357a01ddf141f04b189ba Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:32:11 +0000 Subject: [PATCH 19/35] feat: expose character faction APIs and captured battle identities --- pom.xml | 2 +- .../simplefactions/api/SimpleFactionsApi.java | 46 +++++++++++ .../simplefactions/database/BattleData.java | 1 + .../simplefactions/guild/Guild.java | 3 + .../managers/FactionManager.java | 40 +++++++++ .../simplefactions/map/Compiler.java | 2 +- .../map/export/PublicFactionProjection.java | 81 +++++++++++++++++++ .../simplefactions/objects/Bank.java | 5 ++ .../war/battle/engine/core/Battle.java | 20 ++++- .../battle/engine/core/BattleEndSupport.java | 2 +- .../war/battle/events/BattleEndedEvent.java | 12 +++ .../war/battle/events/BattleStartedEvent.java | 10 +++ .../war/battle/persistence/BattleMapper.java | 2 + .../war/battle/warband/Warband.java | 2 + .../api/SimpleFactionsApiTest.java | 35 ++++++++ .../managers/FactionManagerCoverageTest.java | 8 +- .../export/PublicFactionProjectionTest.java | 45 +++++++++++ .../core/CapturedBattleCharactersTest.java | 65 +++++++++++++++ 18 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java diff --git a/pom.xml b/pom.xml index 8d25d52a..1b2f2add 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ <groupId>net.tfminecraft</groupId> <artifactId>simplefactions</artifactId> - <version>main-SNAPSHOT</version> + <version>4.0.0</version> <properties> <argLine></argLine> diff --git a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java index a4badfb8..ef60f6e4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java +++ b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java @@ -1,6 +1,12 @@ package net.tfminecraft.simplefactions.api; import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; +import org.bukkit.entity.Player; +import java.util.Optional; +import java.util.UUID; /** * Entry point for other plugins. Until {@link #ready()} is true, SimpleFactions @@ -16,4 +22,44 @@ public static boolean ready() { SimpleFactions plugin = SimpleFactions.getInstance(); return plugin != null && plugin.isStarted(); } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Guild> getGuildByMember(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByMember(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Guild> getGuildByMember(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByMember(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Guild> getGuildByLeader(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Guild> getGuildByLeader(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Faction> getByMember(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getByMember(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Faction> getByMember(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getByMember(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Faction> getByLeader(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getByLeader(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional<Faction> getByLeader(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getByLeader(characterId)) : Optional.empty(); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/BattleData.java b/src/main/java/net/tfminecraft/simplefactions/database/BattleData.java index ad226a69..dd411386 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/BattleData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/BattleData.java @@ -10,6 +10,7 @@ public class BattleData { public String id; + public java.util.Set<net.tfminecraft.tlibs.character.CharacterRef> participants = new java.util.LinkedHashSet<>(); public String displayName; public String battleType; public Integer warId; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 02973662..5e34e23e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -434,6 +434,9 @@ public boolean consumeInvite(String p) { public String getIncarnation() { return incarnation; } + /** Immutable treasury identity; guild names may be reused after disbanding. */ + public java.util.UUID getIncarnationId() { return java.util.UUID.fromString(incarnation); } + /** A removed branch still saved above level 0. The level is not refunded. */ static void warnRetiredBranch(Faction host, String guildName, String branchId, int level) { if (level <= 0 || branchId == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 56047fd1..ce673d8a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -693,6 +693,16 @@ public static boolean canJoinGuild(Player p) { return guild.getType().isBase(); } + /** The faction identity of the character in this player's current session. */ + public static Guild getGuildByMember(Player player) { + return getGuildByMember(Members.key(player)); + } + + /** Looks up a character ID, never a Minecraft account UUID. */ + public static Guild getGuildByMember(java.util.UUID characterId) { + return getGuildByMember(Members.key(characterId)); + } + public static Guild getGuildByMember(String player) { if(player == null) return null; for(Faction f : factions) { @@ -703,6 +713,16 @@ public static Guild getGuildByMember(String player) { return null; } + /** The faction identity of the character in this player's current session. */ + public static Guild getGuildByLeader(Player player) { + return getGuildByLeader(Members.key(player)); + } + + /** Looks up a character ID, never a Minecraft account UUID. */ + public static Guild getGuildByLeader(java.util.UUID characterId) { + return getGuildByLeader(Members.key(characterId)); + } + public static Guild getGuildByLeader(String leader) { if(leader == null) return null; for(Faction f : factions) { @@ -917,6 +937,16 @@ public static List<Faction> getCopy(){ } return c; } + /** The faction identity of the character in this player's current session. */ + public static Faction getByLeader(Player player) { + return getByLeader(Members.key(player)); + } + + /** Looks up a character ID, never a Minecraft account UUID. */ + public static Faction getByLeader(java.util.UUID characterId) { + return getByLeader(Members.key(characterId)); + } + public static Faction getByLeader(String name) { if(name == null) return null; for(Faction f : factions) { @@ -924,6 +954,16 @@ public static Faction getByLeader(String name) { } return null; } + /** The faction identity of the character in this player's current session. */ + public static Faction getByMember(Player player) { + return getByMember(Members.key(player)); + } + + /** Looks up a character ID, never a Minecraft account UUID. */ + public static Faction getByMember(java.util.UUID characterId) { + return getByMember(Members.key(characterId)); + } + public static Faction getByMember(String name) { if(name == null) return null; for(Faction f : factions) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/Compiler.java b/src/main/java/net/tfminecraft/simplefactions/map/Compiler.java index 9414a776..fe453918 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/Compiler.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/Compiler.java @@ -49,7 +49,7 @@ public void exportAllFactionsToNationJson() { try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { JsonObject factionData = JsonParser.parseReader(reader).getAsJsonObject(); String id = factionData.get("id").getAsString(); - root.add(id, factionData); + root.add(id, net.tfminecraft.simplefactions.map.export.PublicFactionProjection.faction(factionData)); } catch (Exception e) { e.printStackTrace(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java b/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java new file mode 100644 index 00000000..6378940b --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java @@ -0,0 +1,81 @@ +package net.tfminecraft.simplefactions.map.export; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.tfminecraft.simplefactions.identity.Members; + +/** Explicit map schema: private membership keys, offices, votes and accounting never leave storage. */ +public final class PublicFactionProjection { + private PublicFactionProjection() {} + + public static JsonObject faction(JsonObject source) { + JsonObject result = fields(source, "id", "name", "rgb", "ruler title", "government", "culture", + "religion", "capital", "rank", "founded at", "overlord", "tier index"); + for (String field : new String[] {"banner", "provinces", "titles", "relations", "trade relation", "treaty relation", "laws"}) { + result.add(field, values(source, field)); + } + leader(source, result); + JsonArray guilds = new JsonArray(); + JsonArray members = new JsonArray(); + if (source.has("guilds") && source.get("guilds").isJsonArray()) { + for (JsonElement entry : source.getAsJsonArray("guilds")) { + if (!entry.isJsonObject()) continue; + JsonObject guild = guild(entry.getAsJsonObject()); + guilds.add(guild); + guild.getAsJsonArray("members").forEach(members::add); + } + } + result.add("guilds", guilds); + result.add("members", members); + return result; + } + + private static JsonObject guild(JsonObject source) { + JsonObject result = fields(source, "id", "name", "rgb", "type", "capital", "stance"); + result.add("banner", values(source, "banner")); + leader(source, result); + JsonArray members = new JsonArray(); + for (JsonElement member : values(source, "members")) members.add(Members.display(member.getAsString())); + result.add("members", members); + JsonArray branches = new JsonArray(); + if (source.has("branches") && source.get("branches").isJsonArray()) { + for (JsonElement branch : source.getAsJsonArray("branches")) { + if (branch.isJsonObject()) branches.add(fields(branch.getAsJsonObject(), "id", "level")); + } + } + result.add("branches", branches); + return result; + } + + private static void leader(JsonObject source, JsonObject target) { + JsonElement stored = source.get("leader"); + String key = stored != null && stored.isJsonPrimitive() ? stored.getAsString() : null; + String name = Members.display(key); + String account = Members.accountName(key); + // The website verifies these two legacy fields match before showing the character name. + String publicLeader = account == null ? name : account; + target.addProperty("leader", publicLeader); + target.addProperty("leader character", name); + target.addProperty("leader character of", publicLeader); + } + + private static JsonObject fields(JsonObject source, String... fields) { + JsonObject result = new JsonObject(); + for (String field : fields) { + JsonElement value = source.get(field); + if (value != null && value.isJsonPrimitive()) result.add(field, value.deepCopy()); + } + return result; + } + + private static JsonArray values(JsonObject source, String field) { + JsonArray result = new JsonArray(); + if (source.has(field) && source.get(field).isJsonArray()) { + for (JsonElement value : source.getAsJsonArray(field)) { + if (value.isJsonPrimitive()) result.add(value.deepCopy()); + } + } + return result; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java index e1705a1f..db753e65 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java @@ -33,6 +33,11 @@ public Bank(Guild g, double amount, Chunk c) { chunk = c; } + /** Stable DenarEconomy treasury account for this guild incarnation. */ + public String accountId() { + return "guild:" + guild.getIncarnationId(); + } + public Double getWealth() { return bank.getBal(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/Battle.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/Battle.java index e6feb444..f36e158d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/Battle.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/Battle.java @@ -72,6 +72,22 @@ public class Battle { private boolean capturePointsEnabled; private int contestHoldRemainingSeconds; private boolean campaignRaid; + private final Set<net.tfminecraft.tlibs.character.CharacterRef> participants = new HashSet<>(); + + public Set<net.tfminecraft.tlibs.character.CharacterRef> getParticipants() { + return Set.copyOf(participants); + } + + public void restoreParticipants(java.util.Collection<net.tfminecraft.tlibs.character.CharacterRef> saved) { + participants.clear(); + if (saved != null) participants.addAll(saved); + } + + public void captureParticipant(Player player) { + net.tfminecraft.simplefactions.identity.Members.session(player) + .map(net.tfminecraft.tlibs.character.CharacterSession::ref).ifPresent(participants::add); + } + private final Map<String, Integer> recordedSideCasualties = new LinkedHashMap<>(); public List<CapturePoint> getPoints() { return points; @@ -280,6 +296,8 @@ public String start() { } this.started = true; this.startedAt = Instant.now(); + participants.clear(); + getAllParticipants().forEach(this::captureParticipant); fireStartedEvent(); if (!this.started) { return "Battle ended during its start event."; @@ -340,7 +358,7 @@ private void fireStartedEvent() { getId(), getBattleType(), getWarId(), - BattleParticipantCollector.collect(this))); + BattleParticipantCollector.collect(this), getParticipants())); } public void end() { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEndSupport.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEndSupport.java index 6d78f135..74f8d2dd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEndSupport.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEndSupport.java @@ -53,7 +53,7 @@ public static void endBattle(Battle battle, String winningSideId, BattleEndReaso participantIds, endReason, campaignRaid, - lootEnabled)); + lootEnabled, battle.getParticipants())); } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleEndedEvent.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleEndedEvent.java index 48af2a1d..fefe62db 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleEndedEvent.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleEndedEvent.java @@ -21,6 +21,7 @@ public class BattleEndedEvent extends Event { private final String winningSideId; private final Map<String, Integer> sideCasualties; private final Set<UUID> participantIds; + private final Set<net.tfminecraft.tlibs.character.CharacterRef> participants; private final BattleEndReason endReason; private final boolean campaignRaid; private final boolean lootEnabled; @@ -95,6 +96,14 @@ public BattleEndedEvent( BattleEndReason endReason, boolean campaignRaid, boolean lootEnabled) { + this(battleId, battleType, warId, winningSideId, sideCasualties, participantIds, + endReason, campaignRaid, lootEnabled, Set.of()); + } + + public BattleEndedEvent(String battleId, BattleType battleType, Integer warId, String winningSideId, + Map<String, Integer> sideCasualties, Set<UUID> participantIds, BattleEndReason endReason, + boolean campaignRaid, boolean lootEnabled, Set<net.tfminecraft.tlibs.character.CharacterRef> participants) { + this.participants = Set.copyOf(participants); this.battleId = battleId; this.battleType = battleType; this.warId = warId; @@ -141,6 +150,9 @@ public Map<String, Integer> getSideCasualties() { return sideCasualties; } + /** Captured at participation, including characters who have since disconnected. */ + public Set<net.tfminecraft.tlibs.character.CharacterRef> getParticipants() { return participants; } + public Set<UUID> getParticipantIds() { return participantIds; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleStartedEvent.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleStartedEvent.java index f8c99a17..89a36ed4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleStartedEvent.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/events/BattleStartedEvent.java @@ -15,12 +15,19 @@ public class BattleStartedEvent extends Event { private final BattleType battleType; private final Integer warId; private final Set<UUID> participantIds; + private final Set<net.tfminecraft.tlibs.character.CharacterRef> participants; public BattleStartedEvent( String battleId, BattleType battleType, Integer warId, Set<UUID> participantIds) { + this(battleId, battleType, warId, participantIds, Set.of()); + } + + public BattleStartedEvent(String battleId, BattleType battleType, Integer warId, + Set<UUID> participantIds, Set<net.tfminecraft.tlibs.character.CharacterRef> participants) { + this.participants = Set.copyOf(participants); this.battleId = battleId; this.battleType = battleType; this.warId = warId; @@ -40,6 +47,9 @@ public Integer getWarId() { return warId; } + /** Characters captured when participation began. */ + public Set<net.tfminecraft.tlibs.character.CharacterRef> getParticipants() { return participants; } + public Set<UUID> getParticipantIds() { return participantIds; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java index 68185b37..5515f161 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java @@ -32,6 +32,7 @@ public static BattleData toData(Battle battle) { } BattleData data = new BattleData(); data.id = battle.getId(); + data.participants = battle.getParticipants(); data.displayName = battle.getDisplayName(); data.battleType = battle.getBattleType() != null ? battle.getBattleType().toJson() : null; data.warId = battle.getWarId(); @@ -82,6 +83,7 @@ public static Battle fromData(BattleData data) { return null; } Battle battle = new Battle(data.id); + battle.restoreParticipants(data.participants); battle.setDisplayName(data.displayName); battle.setBattleType(parseBattleType(data.battleType)); battle.setWarId(data.warId); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java index 79865587..7318ea05 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java @@ -125,6 +125,8 @@ public UUID getOldestRealMemberId(UUID excludeId) { public void addPlayer(Player p) { memberIds.add(p.getUniqueId()); invitedIds.remove(p.getUniqueId()); + var battle = net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager.getBattleByMemberId(p.getUniqueId()); + if (battle != null && battle.hasStarted()) battle.captureParticipant(p); } public void addMember(UUID memberId) { diff --git a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java index 2d427c72..a0680080 100644 --- a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java @@ -10,6 +10,41 @@ import net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent; class SimpleFactionsApiTest { + @Test + void playerAndCharacterLookupsAreUnavailableUntilReadyAndUseDistinctCharacters() { + SimpleFactions previous = SimpleFactions.plugin; + var factions = new java.util.ArrayList<>(net.tfminecraft.simplefactions.managers.FactionManager.factions); + try (var domain = new net.tfminecraft.simplefactions.testsupport.FactionDomainFixture()) { + var faction = domain.saved("realm", "Alice"); + var guild = faction.getOrCreateMainGuild(); + var player = mock(org.bukkit.entity.Player.class); + when(player.getName()).thenReturn("Alice"); + var character = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); + var plugin = mock(SimpleFactions.class); + SimpleFactions.plugin = plugin; + assertTrue(SimpleFactionsApi.getByMember(player).isEmpty()); + assertTrue(SimpleFactionsApi.getByLeader(character).isEmpty()); + assertTrue(SimpleFactionsApi.getGuildByMember(player).isEmpty()); + assertTrue(SimpleFactionsApi.getGuildByLeader(character).isEmpty()); + when(plugin.isStarted()).thenReturn(true); + assertSame(faction, SimpleFactionsApi.getByMember(player).orElseThrow()); + assertSame(faction, SimpleFactionsApi.getByMember(character).orElseThrow()); + assertSame(faction, SimpleFactionsApi.getByLeader(player).orElseThrow()); + assertSame(faction, SimpleFactionsApi.getByLeader(character).orElseThrow()); + assertSame(guild, SimpleFactionsApi.getGuildByMember(player).orElseThrow()); + assertSame(guild, SimpleFactionsApi.getGuildByMember(character).orElseThrow()); + assertSame(guild, SimpleFactionsApi.getGuildByLeader(player).orElseThrow()); + assertSame(guild, SimpleFactionsApi.getGuildByLeader(character).orElseThrow()); + assertTrue(SimpleFactionsApi.getGuildByMember(java.util.UUID.randomUUID()).isEmpty()); + assertEquals("guild:" + guild.getIncarnation(), guild.getBank().accountId()); + assertEquals(java.util.UUID.fromString(guild.getIncarnation()), guild.getIncarnationId()); + } finally { + SimpleFactions.plugin = previous; + net.tfminecraft.simplefactions.managers.FactionManager.factions.clear(); + net.tfminecraft.simplefactions.managers.FactionManager.factions.addAll(factions); + } + } + @Test void readyOnlyOnceThePluginHasStarted() { SimpleFactions previous = SimpleFactions.plugin; diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index d6d3fb77..fceffeb2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -265,23 +265,23 @@ void managerLookupsMatchMemberKeysExactlyAndRepairLegacyLeaderMembership() { assertSame(traders, FactionManager.getGuildByString("TRADERS")); assertSame(traders, FactionManager.getGuildByLeader("Carol")); assertNull(FactionManager.getGuildByLeader("cARoL")); - assertNull(FactionManager.getGuildByLeader(null)); + assertNull(FactionManager.getGuildByLeader((String) null)); assertSame(traders, FactionManager.getGuildByMember("Dave")); assertNull(FactionManager.getGuildByMember("dAVE")); assertTrue(FactionManager.guildExists("traders")); assertFalse(FactionManager.guildExists("missing")); assertNull(FactionManager.getByString("missing")); assertNull(FactionManager.getByLeader("missing")); - assertNull(FactionManager.getByLeader(null)); + assertNull(FactionManager.getByLeader((String) null)); assertNull(FactionManager.getByMember("missing")); - assertNull(FactionManager.getByMember(null)); + assertNull(FactionManager.getByMember((String) null)); assertNull(FactionManager.getByRGB("missing")); assertNull(FactionManager.getByProvince(9)); assertNull(FactionManager.getTitleOwner(fixture.title("Elsewhere", "county", 9))); assertNull(FactionManager.getGuildByString("missing")); assertNull(FactionManager.getGuildByLeader("missing")); assertNull(FactionManager.getGuildByMember("missing")); - assertNull(FactionManager.getGuildByMember(null)); + assertNull(FactionManager.getGuildByMember((String) null)); assertNull(FactionManager.getMovementById("missing")); assertNull(FactionManager.getMovementById(null)); List<Faction> copy = FactionManager.getCopy(); diff --git a/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java b/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java new file mode 100644 index 00000000..19c929da --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java @@ -0,0 +1,45 @@ +package net.tfminecraft.simplefactions.map.export; + +import com.google.gson.JsonParser; +import net.tfminecraft.simplefactions.identity.Members; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class PublicFactionProjectionTest { + @Test + void projectsPublicFieldsWithoutPrivateKeysOrNestedAccounting() { + String member = java.util.UUID.randomUUID().toString(); + var source = JsonParser.parseString(""" + {"id":"realm","name":"Realm","leader":"%s","banner":["blue",{"private":"value"}], + "governmentData":{"votes":{"secret":"secret"}},"espionage":{"secret":"secret"}, + "guilds":[null,{"id":"guild","type":"trade","leader":"%s","incarnation":"secret", + "members":["%s"],"balance":100,"loans":["secret"],"ledger last day":{"secret":5}, + "branches":[null,{"id":"market","level":2,"private":"secret"}]}]} + """.formatted(member, member, member)).getAsJsonObject(); + var result = PublicFactionProjection.faction(source); + assertEquals("Realm", result.get("name").getAsString()); + assertEquals(1, result.getAsJsonArray("banner").size()); + assertEquals(1, result.getAsJsonArray("members").size()); + assertEquals("Unknown", result.get("leader character").getAsString()); + assertEquals(result.get("leader"), result.get("leader character of")); + assertEquals(1, result.getAsJsonArray("guilds").size()); + assertEquals("market", result.getAsJsonArray("guilds").get(0).getAsJsonObject() + .getAsJsonArray("branches").get(0).getAsJsonObject().get("id").getAsString()); + assertFalse(result.toString().contains("secret")); + assertFalse(result.toString().contains(member)); + assertFalse(result.toString().contains("balance")); + assertTrue(source.toString().contains("secret")); + } + + @Test + void missingOrMalformedPublicFieldsCannotCopyPrivateObjects() { + var result = PublicFactionProjection.faction(JsonParser.parseString(""" + {"name":{"private":"secret"},"leader":null,"guilds":[{"members":{},"branches":{}}]} + """).getAsJsonObject()); + assertFalse(result.has("name")); + assertEquals("Unknown", result.get("leader").getAsString()); + assertEquals(0, result.getAsJsonArray("members").size()); + assertEquals(0, PublicFactionProjection.faction(new com.google.gson.JsonObject()) + .getAsJsonArray("guilds").size()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java new file mode 100644 index 00000000..f5ae7ab9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java @@ -0,0 +1,65 @@ +package net.tfminecraft.simplefactions.war.battle.engine.core; + +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.war.battle.enums.BattleEndReason; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import net.tfminecraft.simplefactions.war.battle.events.BattleStartedEvent; +import net.tfminecraft.simplefactions.war.battle.persistence.BattleMapper; +import net.tfminecraft.simplefactions.war.battle.warband.Warband; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CapturedBattleCharactersTest { + @Test + void savedParticipantsAndEventsRetainTheEnrolledCharacterAfterASwitch() { + Player player = mock(Player.class); + UUID account = UUID.randomUUID(); + CharacterRef first = new CharacterRef(account, UUID.randomUUID()); + CharacterRef second = new CharacterRef(account, UUID.randomUUID()); + when(player.getUniqueId()).thenReturn(account); + try (var members = mockStatic(Members.class)) { + members.when(() -> Members.session(player)).thenReturn(Optional.of(new CharacterSession(first, 1))); + Battle battle = new Battle("captured"); + battle.captureParticipant(player); + members.when(() -> Members.session(player)).thenReturn(Optional.of(new CharacterSession(second, 2))); + assertEquals(Set.of(first), battle.getParticipants()); + Battle restored = BattleMapper.fromData(BattleMapper.toData(battle)); + assertEquals(Set.of(first), restored.getParticipants()); + var started = new BattleStartedEvent("captured", BattleType.FIELD, null, Set.of(account), battle.getParticipants()); + var ended = new BattleEndedEvent("captured", BattleType.FIELD, null, "attackers", Map.of(), Set.of(account), + BattleEndReason.SIDE_WIN, false, true, restored.getParticipants()); + assertEquals(Set.of(first), started.getParticipants()); + assertEquals(Set.of(first), ended.getParticipants()); + assertEquals(Set.of(account), ended.getParticipantIds()); + assertThrows(UnsupportedOperationException.class, () -> ended.getParticipants().add(second)); + battle.restoreParticipants(null); + assertTrue(battle.getParticipants().isEmpty()); + } + } + + @Test + void joiningAnActiveBattleCapturesTheNewParticipation() { + Player player = mock(Player.class); + UUID account = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(account); + when(player.getName()).thenReturn("Fighter"); + Battle battle = new Battle("active"); + battle.setStarted(true); + try (var manager = mockStatic(BattleManager.class)) { + manager.when(() -> BattleManager.getBattleByMemberId(account)).thenReturn(battle); + Warband warband = Warband.createWithMemberIds("band", UUID.randomUUID(), false); + warband.addPlayer(player); + assertEquals(1, battle.getParticipants().size()); + assertEquals(account, battle.getParticipants().iterator().next().ownerId()); + } + } +} From bdd84208c05aa55a0cc2b0a7dcf8c43624729052 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:47:49 +0000 Subject: [PATCH 20/35] feat: add durable treasury foundations and captured warband identities --- pom.xml | 10 ++- .../simplefactions/database/Database.java | 13 +++- .../simplefactions/database/DurableJson.java | 45 +++++++++++++ .../simplefactions/database/GuildData.java | 1 + .../simplefactions/database/WarbandData.java | 1 + .../economy/GuildTaxPolicy.java | 28 ++++++++ .../simplefactions/economy/GuildTreasury.java | 32 +++++++++ .../simplefactions/economy/Money.java | 17 +++++ .../simplefactions/guild/Guild.java | 1 + .../simplefactions/guild/income/Ledger.java | 24 +++++++ .../war/battle/persistence/WarbandMapper.java | 5 +- .../war/battle/warband/Warband.java | 19 ++++++ .../warband/WarbandMembershipService.java | 16 ++--- .../battle/warband/WarbandRejoinState.java | 2 +- .../database/DatabaseCoverageTest.java | 2 +- .../database/DurableJsonTest.java | 43 ++++++++++++ .../economy/GuildTaxPolicyTest.java | 43 ++++++++++++ .../economy/GuildTreasuryTest.java | 67 +++++++++++++++++++ .../income/CasinoProfitOperationTest.java | 41 ++++++++++++ .../managers/PlayerManagerCoverageTest.java | 6 +- .../core/CapturedBattleCharactersTest.java | 25 +++++++ .../BattleRosterLifecycleCoverageTest.java | 15 ++++- 22 files changed, 436 insertions(+), 20 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/GuildTreasury.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/Money.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/database/DurableJsonTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/GuildTreasuryTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java diff --git a/pom.xml b/pom.xml index 1b2f2add..abdc8720 100644 --- a/pom.xml +++ b/pom.xml @@ -13,9 +13,9 @@ <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.proc>full</maven.compiler.proc> - <denareconomy.version>0.2.4</denareconomy.version> + <denareconomy.version>0.3.0</denareconomy.version> <rpcharacters.version>2.16.0</rpcharacters.version> - <tlibs.version>2.4.0</tlibs.version> + <tlibs.version>2.4.1</tlibs.version> <vehicleframework.version>2.10.0</vehicleframework.version> <vfbuilders.version>2.1.0</vfbuilders.version> </properties> @@ -158,6 +158,12 @@ <version>5.2.0</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.xerial</groupId> + <artifactId>sqlite-jdbc</artifactId> + <version>3.49.1.0</version> + <scope>test</scope> + </dependency> </dependencies> <repositories> diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index a045a9f1..98460b56 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -326,6 +326,15 @@ public void saveFaction(Faction f) { /** Office mutations must not acknowledge success unless the faction save completed. */ public boolean saveFactionChecked(Faction f) { + return saveFaction(f, false); + } + + /** Force the saved state to storage before marking an intent's effects complete. */ + public boolean saveFactionDurably(Faction f) { + return saveFaction(f, true); + } + + private boolean saveFaction(Faction f, boolean durable) { try { File file = new File("plugins/SimpleFactions/Data", f.getId() + ".json"); @@ -433,6 +442,7 @@ public boolean saveFactionChecked(Faction f) { gd.donation = g.getDonationAmount(); gd.dividendEligible = g.getDividendEligibleSnapshot(); gd.casinoProfit = g.getLedger().getCasinoProfit(); + gd.casinoProfitOperations = g.getLedger().getCasinoProfitOperations(); gd.vehicleFeeIncome = g.getLedger().getVehicleFeeIncome(); Map<String, Double> citizenTaxes = g.getLedger().getCitizenTaxesCopy(); gd.citizenTaxes = citizenTaxes.isEmpty() ? null : citizenTaxes; @@ -532,7 +542,8 @@ public boolean saveFactionChecked(Faction f) { data.warReparationsObligations.add(obligationData); } - JsonUtil.writeJsonAtomic(file, data); + if (durable) DurableJson.write(file.toPath(), data); + else JsonUtil.writeJsonAtomic(file, data); return true; } catch (Exception e) { diff --git a/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java b/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java new file mode 100644 index 00000000..630f98f1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java @@ -0,0 +1,45 @@ +package net.tfminecraft.simplefactions.database; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** Durable intent files: sync the new content and the directory entry before any dependent effect. */ +public final class DurableJson { + private DurableJson() {} + + public static void write(Path file, Object data) throws IOException { + Path destination = file.toAbsolutePath(); + Path directory = destination.getParent(); + Files.createDirectories(directory); + Path pending = Files.createTempFile(directory, destination.getFileName().toString() + ".", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(pending, StandardOpenOption.WRITE)) { + ByteBuffer bytes = StandardCharsets.UTF_8.encode(JsonUtil.GSON.toJson(data)); + while (bytes.hasRemaining()) channel.write(bytes); + channel.force(true); + } + Files.move(pending, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } finally { + Files.deleteIfExists(pending); + } + } + + public static <T> T read(Path file, Class<T> type) throws IOException { + try { + T value = JsonUtil.GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), type); + if (value == null) throw new IOException("Empty intent file: " + file); + return value; + } catch (com.google.gson.JsonParseException error) { + throw new IOException("Invalid intent file: " + file, error); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index ce0f22e7..55a0305b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -65,6 +65,7 @@ public class GuildData { @SerializedName("casino profit") public Double casinoProfit; + public Map<String, Double> casinoProfitOperations; public Double vehicleFeeIncome; @SerializedName("citizen taxes") diff --git a/src/main/java/net/tfminecraft/simplefactions/database/WarbandData.java b/src/main/java/net/tfminecraft/simplefactions/database/WarbandData.java index e23594dd..11a453b5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/WarbandData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/WarbandData.java @@ -5,6 +5,7 @@ public class WarbandData { public String id; + public java.util.Map<java.util.UUID, net.tfminecraft.tlibs.character.CharacterRef> memberCharacters = new java.util.LinkedHashMap<>(); public String name; public String leaderId; public List<String> memberIds = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java new file mode 100644 index 00000000..5bb69a8a --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java @@ -0,0 +1,28 @@ +package net.tfminecraft.simplefactions.economy; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Optional; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.TaxLeg; +import net.tfminecraft.denareconomy.api.TaxPolicy; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** Taxes are withheld inside the earner's posting and held until the realm-wide settlement. */ +public final class GuildTaxPolicy implements TaxPolicy { + @Override + public Optional<TaxLeg> tax(CharacterRef earner, long grossCents) { + if (!SimpleFactionsApi.ready()) throw new IllegalStateException("Factions are starting up"); + Faction realm = FactionManager.getByMember(earner.characterId()); + if (realm == null || realm.getBank() == null || grossCents <= 0) return Optional.empty(); + double rate = realm.getTaxRate(TaxTarget.CITIZENS, null, true); + if (!Double.isFinite(rate) || rate < 0 || rate > 100) throw new IllegalStateException("Invalid citizen tax: " + rate); + long cents = BigDecimal.valueOf(grossCents).multiply(BigDecimal.valueOf(rate)) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP).longValueExact(); + return Optional.of(new TaxLeg(Denars.guildTax(realm.getOrCreateMainGuild().getIncarnationId()), cents)); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTreasury.java b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTreasury.java new file mode 100644 index 00000000..c7fb86ee --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTreasury.java @@ -0,0 +1,32 @@ +package net.tfminecraft.simplefactions.economy; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; + +/** Imports old treasury and withheld-tax balances once, including debt and quarantined tax keys. */ +public final class GuildTreasury { + private GuildTreasury() {} + + public static void importLegacy(UUID incarnation, double treasury, Map<String, Double> taxes) { + importBalance("import-guild:" + incarnation, Denars.guild(incarnation), Money.cents(treasury)); + long held = 0; + if (taxes != null) { + for (Double tax : taxes.values()) { + if (tax != null) held = Math.addExact(held, Money.cents(tax)); + } + } + importBalance("import-tax:" + incarnation, Denars.guildTax(incarnation), held); + } + + private static void importBalance(String operation, String account, long cents) { + Result previous = Denars.status(operation).orElse(null); + if (previous == Result.APPLIED) return; + Result result = previous == null ? Denars.post(operation, "sf:legacy-import", + List.of(new Leg(Denars.MINT, Math.negateExact(cents)), new Leg(account, cents))) : previous; + if (result != Result.APPLIED) throw new IllegalStateException("Cannot import " + operation + ": " + result); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/Money.java b/src/main/java/net/tfminecraft/simplefactions/economy/Money.java new file mode 100644 index 00000000..c2f80664 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/Money.java @@ -0,0 +1,17 @@ +package net.tfminecraft.simplefactions.economy; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** Boundary between existing display-unit calculations and exact ledger cents. */ +public final class Money { + private Money() {} + + public static long cents(double denars) { + return BigDecimal.valueOf(denars).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact(); + } + + public static double denars(long cents) { + return BigDecimal.valueOf(cents, 2).doubleValue(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 5e34e23e..2f334b93 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -230,6 +230,7 @@ public Guild(GuildData data, Faction host) { if (data.vehicleFeeIncome != null) { this.ledger.setVehicleFeeIncome(data.vehicleFeeIncome); } + this.ledger.restoreCasinoProfitOperations(data.casinoProfitOperations); if (data.casinoProfit != null) { this.ledger.setCasinoProfit(data.casinoProfit); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 1c043c38..e7fd87aa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -52,6 +52,7 @@ public class Ledger { // Pushed by the games plugin as tables win, and saved with the bank balance it arrived in, // so a restart cannot quietly wipe a day of gambling income before it is taxed. private double casinoProfit; + private final Map<String, Double> casinoProfitOperations = new HashMap<>(); /** Today's funded loan payments, set only while the settlement is being built. */ private Map<Loan, LoanFunding.Funded> loanPlan; @@ -150,6 +151,29 @@ public void addCasinoProfitEntry(Double amount) { casinoProfit += amount; } + /** Durably records a Games posting's taxable profit once; exact retries are successful. */ + public boolean addCasinoProfitEntry(String operationId, double amount) { + if (operationId == null || operationId.isBlank() || !Double.isFinite(amount) || amount <= 0) return false; + Double previous = casinoProfitOperations.get(operationId); + if (previous != null) return Double.compare(previous, amount) == 0; + double before = casinoProfit; + casinoProfit += amount; + casinoProfitOperations.put(operationId, amount); + if (new net.tfminecraft.simplefactions.database.Database().saveFactionDurably(guild.getFaction())) return true; + casinoProfit = before; + casinoProfitOperations.remove(operationId); + return false; + } + + public Map<String, Double> getCasinoProfitOperations() { + return new HashMap<>(casinoProfitOperations); + } + + public void restoreCasinoProfitOperations(Map<String, Double> operations) { + casinoProfitOperations.clear(); + if (operations != null) casinoProfitOperations.putAll(operations); + } + public double getCasinoProfit() { return casinoProfit; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/WarbandMapper.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/WarbandMapper.java index e9dd7151..2808bf1e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/WarbandMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/WarbandMapper.java @@ -17,6 +17,7 @@ public static WarbandData toData(Warband warband) { } WarbandData data = new WarbandData(); data.id = warband.getId(); + data.memberCharacters = warband.getMemberCharacters(); data.name = warband.getName(); data.leaderId = warband.getLeaderId() != null ? warband.getLeaderId().toString() : null; data.locked = warband.isLocked(); @@ -38,7 +39,7 @@ public static Warband fromData(WarbandData data) { if (data == null || data.id == null || data.id.isBlank()) { return null; } - return Warband.fromPersistence( + Warband warband = Warband.fromPersistence( data.id, data.name != null ? data.name : data.id, parseUuid(data.leaderId), @@ -47,6 +48,8 @@ public static Warband fromData(WarbandData data) { data.locked, data.faction, data.campaignSideId); + warband.restoreMemberCharacters(data.memberCharacters); + return warband; } private static List<UUID> parseUuidList(List<String> ids) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java index 7318ea05..94d5b486 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/Warband.java @@ -25,6 +25,7 @@ public class Warband { private String name; private UUID leaderId; private Set<UUID> memberIds = new LinkedHashSet<>(); + private final Map<UUID, net.tfminecraft.tlibs.character.CharacterRef> memberCharacters = new HashMap<>(); private Set<UUID> dummyMemberIds = new LinkedHashSet<>(); private Map<UUID, String> dummyDisplayNames = new HashMap<>(); private Set<UUID> invitedIds = new HashSet<>(); @@ -56,6 +57,7 @@ public Warband(String id, Player l) { this.name = id; this.leaderId = l.getUniqueId(); this.memberIds.add(l.getUniqueId()); + Members.session(l).ifPresent(session -> memberCharacters.put(l.getUniqueId(), session.ref())); this.locked = true; this.faction = false; } @@ -122,8 +124,24 @@ public UUID getOldestRealMemberId(UUID excludeId) { return null; } + public java.util.Optional<net.tfminecraft.tlibs.character.CharacterRef> getMemberCharacter(UUID ownerId) { + return java.util.Optional.ofNullable(memberCharacters.get(ownerId)); + } + + public Map<UUID, net.tfminecraft.tlibs.character.CharacterRef> getMemberCharacters() { + return Map.copyOf(memberCharacters); + } + + public void restoreMemberCharacters(Map<UUID, net.tfminecraft.tlibs.character.CharacterRef> saved) { + memberCharacters.clear(); + if (saved != null) saved.forEach((owner, character) -> { + if (character != null && owner.equals(character.ownerId()) && memberIds.contains(owner)) memberCharacters.put(owner, character); + }); + } + public void addPlayer(Player p) { memberIds.add(p.getUniqueId()); + Members.session(p).ifPresent(session -> memberCharacters.put(p.getUniqueId(), session.ref())); invitedIds.remove(p.getUniqueId()); var battle = net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager.getBattleByMemberId(p.getUniqueId()); if (battle != null && battle.hasStarted()) battle.captureParticipant(p); @@ -135,6 +153,7 @@ public void addMember(UUID memberId) { public void removeMember(UUID memberId) { memberIds.remove(memberId); + memberCharacters.remove(memberId); dummyMemberIds.remove(memberId); dummyDisplayNames.remove(memberId); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java index 998b57c3..e08360b8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandMembershipService.java @@ -50,9 +50,11 @@ private void handleQuit(UUID playerId, Player player) { if (warband == null) { return; } - Faction faction = resolveFactionForWarband(warband, player); // Only the character that left may take the place back. - CharacterRef character = Members.session(player).map(CharacterSession::ref).orElse(null); + CharacterRef character = warband.getMemberCharacter(playerId) + .orElseGet(() -> Members.session(player).map(CharacterSession::ref).orElse(null)); + Faction faction = warband.isFaction() && character != null + ? FactionManager.getByMember(character.characterId()) : null; detachFromBattle(player, warband); if (character != null) { pendingRejoin.put(playerId, new WarbandRejoinState(warband.getId(), faction, character)); @@ -89,7 +91,8 @@ boolean attemptRejoin(UUID playerId, String memberKey, Player onlinePlayer) { if (!evaluateRejoin(warband, playerId, memberKey, playerFaction, state)) { return false; } - warband.addMember(playerId); + if (onlinePlayer != null) warband.addPlayer(onlinePlayer); + else warband.addMember(playerId); if (onlinePlayer != null) { restoreBattleState(onlinePlayer); } @@ -142,13 +145,6 @@ private Player getOnlinePlayer(UUID playerId) { return Bukkit.getPlayer(playerId); } - private Faction resolveFactionForWarband(Warband warband, Player player) { - if (!warband.isFaction() || player == null) { - return null; - } - return FactionManager.getByMember(Members.key(player)); - } - private void detachFromBattle(Player player, Warband warband) { if (player == null) { return; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java index 3499e664..a7f8882a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java @@ -17,7 +17,7 @@ public WarbandRejoinState(String warbandId, Faction faction, CharacterRef charac } /** Whether the character that quit is the one now playing. */ - boolean isFor(CharacterRef ref) { + public boolean isFor(CharacterRef ref) { return character != null && character.equals(ref); } diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java index ebde031e..9fcec0cc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java @@ -140,7 +140,7 @@ private FactionData richFaction() { assertEquals(1,merchants.getUpgradeQueue().size());assertEquals(9,merchants.getUpgradeQueue().getFirst().getTimeLeft()); assertEquals(5.0,merchants.getLedger().getCasinoProfit());assertEquals(Map.of("Bob",1.5),merchants.getLedger().getCitizenTaxesCopy()); assertEquals(1,faction.getWarReparationsObligations().size()); - assertTrue(database.saveFactionChecked(faction)); + assertTrue(database.saveFactionDurably(faction)); FactionData saved=JsonUtil.readJson(path.toFile(),FactionData.class); assertEquals(List.of("county"),saved.titles);assertEquals(List.of("guards.5.2"),saved.military); assertEquals(data.laws,saved.laws);assertEquals(12345L,saved.lawChangedAt.get("customs")); diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DurableJsonTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DurableJsonTest.java new file mode 100644 index 00000000..94ac7da7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/database/DurableJsonTest.java @@ -0,0 +1,43 @@ +package net.tfminecraft.simplefactions.database; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DurableJsonTest { + @TempDir Path directory; + + @Test + void durableReplacementReadsBackAndLeavesNoTemporaryFiles() throws Exception { + Path file = directory.resolve("intents/day.json"); + DurableJson.write(file, Map.of("amount", 123, "name", "Éowyn")); + assertEquals("Éowyn", DurableJson.read(file, Map.class).get("name")); + DurableJson.write(file, Map.of("amount", 456)); + assertEquals(456.0, DurableJson.read(file, Map.class).get("amount")); + try (var files = Files.list(file.getParent())) { assertEquals(1, files.count()); } + } + + @Test + void corruptAndEmptyIntentsFailClosed() throws Exception { + Path file = directory.resolve("day.json"); + Files.writeString(file, "null"); + assertThrows(IOException.class, () -> DurableJson.read(file, Map.class)); + Files.writeString(file, "{"); + assertThrows(IOException.class, () -> DurableJson.read(file, Map.class)); + assertThrows(IOException.class, () -> DurableJson.read(directory.resolve("missing"), Map.class)); + } + + @Test + void failedReplacementPreservesExistingDirectoryAndCleansTemporaryFile() throws Exception { + Path destination = directory.resolve("existing"); + Files.createDirectory(destination); + Files.writeString(destination.resolve("original"), "keep"); + assertThrows(IOException.class, () -> DurableJson.write(destination, Map.of("amount", 1))); + assertEquals("keep", Files.readString(destination.resolve("original"))); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java new file mode 100644 index 00000000..b49ed4b2 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java @@ -0,0 +1,43 @@ +package net.tfminecraft.simplefactions.economy; + +import java.util.UUID; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GuildTaxPolicyTest { + @Test + void taxUsesTheEarningCharacterAndItsRealmHoldingAccount() { + CharacterRef earner = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + GuildTaxPolicy policy = new GuildTaxPolicy(); + try (var api = mockStatic(SimpleFactionsApi.class); var factions = mockStatic(FactionManager.class)) { + assertThrows(IllegalStateException.class, () -> policy.tax(earner, 100)); + api.when(SimpleFactionsApi::ready).thenReturn(true); + assertTrue(policy.tax(earner, 100).isEmpty()); + Faction realm = mock(Faction.class); + factions.when(() -> FactionManager.getByMember(earner.characterId())).thenReturn(realm); + assertTrue(policy.tax(earner, 100).isEmpty()); + when(realm.getBank()).thenReturn(mock(Bank.class)); + assertTrue(policy.tax(earner, 0).isEmpty()); + Guild capital = mock(Guild.class); + UUID incarnation = UUID.randomUUID(); + when(realm.getOrCreateMainGuild()).thenReturn(capital); + when(capital.getIncarnationId()).thenReturn(incarnation); + when(realm.getTaxRate(TaxTarget.CITIZENS, null, true)).thenReturn(12.5); + var tax = policy.tax(earner, 101).orElseThrow(); + assertEquals("guildtax:" + incarnation, tax.account()); + assertEquals(13, tax.cents()); + for (double rate : new double[]{-1, 101, Double.NaN}) { + when(realm.getTaxRate(TaxTarget.CITIZENS, null, true)).thenReturn(rate); + assertThrows(IllegalStateException.class, () -> policy.tax(earner, 100)); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/GuildTreasuryTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTreasuryTest.java new file mode 100644 index 00000000..951edde3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTreasuryTest.java @@ -0,0 +1,67 @@ +package net.tfminecraft.simplefactions.economy; + +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.ledger.Ledger; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GuildTreasuryTest { + @TempDir Path directory; + + @Test + void importPreservesDebtAndHeldTaxAcrossReopenWithoutMintingAgain() { + // BungeeCord bundles a JUL binding whose logger is assigned by its own bootstrap. + org.slf4j.jul.JDK14LoggerFactory.LOGGER = Logger.getAnonymousLogger(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getAnonymousLogger()); + Ledger previous = DenarEconomy.getLedger(); + var database = directory.resolve("ledger.db").toFile(); + var ledger = Ledger.open(database, Logger.getAnonymousLogger(), account -> false); + DenarEconomy.setLedger(ledger); + UUID guild = UUID.randomUUID(); + try { + GuildTreasury.importLegacy(guild, -25.12, Map.of("quarantine:unknown", 1.25, "member", 2.50)); + assertEquals(-2512, Denars.balance(Denars.guild(guild))); + assertEquals(375, Denars.balance(Denars.guildTax(guild))); + assertEquals(2137, Denars.balance(Denars.MINT)); + ledger.close(); + ledger = Ledger.open(database, Logger.getAnonymousLogger(), account -> false); + DenarEconomy.setLedger(ledger); + GuildTreasury.importLegacy(guild, 999, Map.of("member", 99.0)); + assertEquals(-2512, Denars.balance(Denars.guild(guild))); + assertEquals(375, Denars.balance(Denars.guildTax(guild))); + GuildTreasury.importLegacy(UUID.randomUUID(), 0, null); + assertEquals(-1, Money.cents(-0.005)); + assertEquals(1, Money.cents(0.005)); + assertEquals(12.34, Money.denars(1234)); + assertThrows(ArithmeticException.class, () -> Money.cents(Double.MAX_VALUE)); + } finally { + ledger.close(); + DenarEconomy.setLedger(previous); + } + } + } + + @Test + void refusedOrUnavailableImportPreventsStartupAndNeverRepostsRefusal() { + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + UUID guild = UUID.randomUUID(); + denars.when(() -> Denars.status("import-guild:" + guild)) + .thenReturn(java.util.Optional.of(net.tfminecraft.denareconomy.api.Result.REFUSED)); + assertThrows(IllegalStateException.class, () -> GuildTreasury.importLegacy(guild, 100, null)); + denars.when(() -> Denars.status("import-guild:" + guild)).thenReturn(java.util.Optional.empty()); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())) + .thenReturn(net.tfminecraft.denareconomy.api.Result.UNAVAILABLE); + assertThrows(IllegalStateException.class, () -> GuildTreasury.importLegacy(guild, 100, null)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java new file mode 100644 index 00000000..c431819e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java @@ -0,0 +1,41 @@ +package net.tfminecraft.simplefactions.guild.income; + +import java.util.Map; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CasinoProfitOperationTest { + @Test + void replayAfterSettlementDoesNotTaxProfitAgainAndSaveFailureRollsBack() { + Guild guild = mock(Guild.class); + Faction faction = mock(Faction.class); + when(guild.getFaction()).thenReturn(faction); + Ledger ledger = new Ledger(guild); + try (var database = mockConstruction(Database.class, (db, context) -> + when(db.saveFactionDurably(faction)).thenReturn(true))) { + assertTrue(ledger.addCasinoProfitEntry("table:one", 25)); + assertEquals(25, ledger.getCasinoProfit()); + ledger.setCasinoProfit(0); + Ledger restored = new Ledger(guild); + restored.restoreCasinoProfitOperations(ledger.getCasinoProfitOperations()); + assertTrue(restored.addCasinoProfitEntry("table:one", 25)); + assertEquals(0, restored.getCasinoProfit()); + assertFalse(restored.addCasinoProfitEntry("table:one", 26)); + assertEquals(1, database.constructed().size()); + } + try (var database = mockConstruction(Database.class)) { + assertFalse(ledger.addCasinoProfitEntry("table:two", 50)); + assertEquals(0, ledger.getCasinoProfit()); + assertEquals(Map.of("table:one", 25.0), ledger.getCasinoProfitOperations()); + } + for (String id : new String[]{null, "", " "}) assertFalse(ledger.addCasinoProfitEntry(id, 2)); + assertFalse(ledger.addCasinoProfitEntry("bad", Double.NaN)); + assertFalse(ledger.addCasinoProfitEntry("bad", 0)); + ledger.restoreCasinoProfitOperations(null); + assertTrue(ledger.getCasinoProfitOperations().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java index 759d5dbd..3c46309d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java @@ -342,7 +342,7 @@ void bankPulsesRequireAnOwnedBankAtThePlayersCurrentChunk(String state) { actor = player("Merchant"); guild.setBank(new Bank(guild, here)); } - PlayerBankPulseEvent event = new PlayerBankPulseEvent(actor); + PlayerBankPulseEvent event = new PlayerBankPulseEvent(actor, net.tfminecraft.simplefactions.identity.Members.session(actor).orElseThrow().ref()); listener.bankPulse(event); boolean allowed = state.endsWith("bank") && !state.equals("no_bank"); assertEquals(!allowed, event.isCancelled()); @@ -359,7 +359,7 @@ void earningsRecordGrossAndTaxesWithoutLosingMoneyOrSettlingTheGuildEarly(String String name = state.equals("outsider") ? "Stranger" : "Leader"; double gross = state.equals("zero_income") ? 0 : 200; double expectedTax = state.equals("taxed") ? 20 : 0; - PlayerEarnMoneyEvent event = new PlayerEarnMoneyEvent(name, gross); + PlayerEarnMoneyEvent event = new PlayerEarnMoneyEvent(name, gross, null); listener.earnMoney(event); PlayerLedger ledger = economy.getLedger(name); assertEquals(expectedTax, event.getAmount()); @@ -391,7 +391,7 @@ void materialDepositsConsumeOnlySupportedMaterialsAtAnOwnedBank(String state) { }) .when(money) .addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); - listener.depositMaterials(new PlayerDepositMaterialsEvent(leader, material)); + listener.depositMaterials(new PlayerDepositMaterialsEvent(leader, material, net.tfminecraft.simplefactions.identity.Members.session(leader).orElseThrow().ref())); assertEquals(state.equals("deposit") ? 0 : 7, material.getAmount()); assertEquals(87.5, credited[0] + material.getAmount() * coin.getValue()); if (state.equals("deposit")) diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java index f5ae7ab9..e2a09fce 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java @@ -46,6 +46,31 @@ void savedParticipantsAndEventsRetainTheEnrolledCharacterAfterASwitch() { } } + @Test + void warbandCaptureSurvivesSessionEndAndPersistence() { + Player player = mock(Player.class); + UUID account = UUID.randomUUID(); + CharacterRef captured = new CharacterRef(account, UUID.randomUUID()); + when(player.getUniqueId()).thenReturn(account); + try (var members = mockStatic(Members.class)) { + members.when(() -> Members.session(player)).thenReturn(Optional.of(new CharacterSession(captured, 1))); + Warband warband = new Warband("band", player); + assertEquals(Optional.of(captured), warband.getMemberCharacter(account)); + Warband restored = net.tfminecraft.simplefactions.war.battle.persistence.WarbandMapper.fromData( + net.tfminecraft.simplefactions.war.battle.persistence.WarbandMapper.toData(warband)); + members.when(() -> Members.session(player)).thenReturn(Optional.empty()); + assertEquals(Optional.of(captured), restored.getMemberCharacter(account)); + restored.removePlayer(player); + assertTrue(restored.getMemberCharacter(account).isEmpty()); + restored.restoreMemberCharacters(Map.of(account, captured)); + assertTrue(restored.getMemberCharacter(account).isEmpty()); + warband.restoreMemberCharacters(Map.of(account, new CharacterRef(UUID.randomUUID(), UUID.randomUUID()))); + assertTrue(warband.getMemberCharacter(account).isEmpty()); + warband.restoreMemberCharacters(null); + assertTrue(warband.getMemberCharacters().isEmpty()); + } + } + @Test void joiningAnActiveBattleCapturesTheNewParticipation() { Player player = mock(Player.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java index 4ac05f65..0cba40a3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterLifecycleCoverageTest.java @@ -294,8 +294,21 @@ void factionMembershipChangesAndAMissingMemberKeyRefuseRejoin() { } @Test - void quittingWithoutAnOnlineCharacterLeavesNoRejoinState() { + void quittingAfterSessionEndsRetainsTheCapturedCharacter() { Warband band = factionBand("offline_quit"); + var captured = session(rig.alice); + rig.domain.online.remove("Alice"); + membership.handleQuit(rig.alice.getUniqueId()); + assertTrue(membership.getPendingRejoin(rig.alice.getUniqueId()).isFor(captured.ref())); + rig.domain.online.put("Alice", rig.alice); + assertTrue(membership.handleSessionStart(rig.alice, session(rig.alice))); + assertTrue(band.hasMember(rig.alice)); + } + + @Test + void quittingWithoutACapturedOrOnlineCharacterLeavesNoRejoinState() { + Warband band = factionBand("uncaptured_quit"); + band.restoreMemberCharacters(null); rig.domain.online.remove("Alice"); membership.handleQuit(rig.alice.getUniqueId()); assertNull(membership.getPendingRejoin(rig.alice.getUniqueId())); From fa4b129409eb9c6250f20bc856621df0fca62779 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:59:29 +0000 Subject: [PATCH 21/35] feat: journal character death cleanup and owed payments --- .../espionage/EspionageState.java | 15 + .../OfficeCharacterDeathListener.java | 15 +- .../simplefactions/government/Government.java | 32 ++ .../government/session/Session.java | 18 + .../simplefactions/guild/Guild.java | 13 +- .../identity/death/CharacterDeaths.java | 83 ++++ .../identity/death/DeathJournal.java | 42 ++ .../identity/death/DeathJournalStore.java | 172 ++++++++ .../identity/death/FactionDeaths.java | 318 ++++++++++++++ .../identity/death/OwedPayment.java | 8 + .../mercenary/company/MercenaryCompany.java | 8 + .../simplefactions/objects/Faction.java | 7 + .../battle/warband/WarbandRejoinState.java | 2 +- .../espionage/OfficeVacancyTest.java | 57 +-- .../identity/death/CharacterDeathsTest.java | 112 +++++ .../identity/death/DeathJournalStoreTest.java | 200 +++++++++ .../identity/death/FactionDeathsTest.java | 401 ++++++++++++++++++ .../SessionLifecycleCoverageTest.java | 18 + 18 files changed, 1489 insertions(+), 32 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/death/OwedPayment.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/death/CharacterDeathsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStoreTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 0b293e28..7abd7d03 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -99,6 +99,21 @@ void rememberDeath(java.util.UUID deathId) { } } + /** The death journal owns completion; an appointment made after this death is preserved. */ + public void removeDeadCharacter(net.tfminecraft.tlibs.character.CharacterRef ref, java.util.UUID deathId, long at) { + var holder = getSpymaster(); + boolean held = holder != null && ref.ownerId().equals(holder.playerId) + && ref.characterId().equals(OfficeCharacters.characterUuid(holder.characterId)) + && Math.max(holder.appointedAt, holder.characterBoundAt) <= at; + boolean pending = isPendingFounder(SpecialPosition.SPYMASTER) + && ref.characterId().equals(OfficeCharacters.characterUuid(pendingFounderCharacter(SpecialPosition.SPYMASTER))) + && pendingFounderBoundAt(SpecialPosition.SPYMASTER) <= at; + if (!held && !pending) return; + removeSpymaster(); + waiveAppointmentWait(SpecialPosition.SPYMASTER); + rememberDeath(deathId); + } + public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } public int appointmentCount(SpecialPosition office) { diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java index adddfca6..559fec3a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java @@ -3,15 +3,20 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.death.CharacterDeaths; import net.tfminecraft.tlibs.character.CharacterDeathEvent; -import net.tfminecraft.tlibs.character.Characters; public final class OfficeCharacterDeathListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onCharacterDeath(CharacterDeathEvent event) { - var ref = event.getCharacter(); - String name = Characters.info(ref.characterId()).filter(info -> info.ref().equals(ref)) - .map(net.tfminecraft.tlibs.character.CharacterInfo::name).orElse("your character"); - EspionageService.characterDied(ref, event.getDeathId(), event.getAt(), name); + try { + CharacterDeaths.onDeath(event.getCharacter(), event.getDeathId(), event.getAt()); + } catch (Exception error) { + var plugin = SimpleFactions.getInstance(); + plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Character death cleanup failed; disabling factions until the saved journal can recover", error); + plugin.getServer().getPluginManager().disablePlugin(plugin); + } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 249e6e5f..d092f270 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -339,6 +339,7 @@ public void replace() { private void replaceLeader() { + if (f.getLeader() == null || f.getLeader().isBlank() || Members.isQuarantined(f.getLeader())) return; if (!f.canRemainLeader(f.getLeader())) { boolean promoted = election.getWinners(Candidate.LEADER).stream() @@ -444,6 +445,37 @@ public boolean canProposePolitical(Player p, Action action) { return true; } + /** Removes only this character's live political rights; other votes retain their order and value. */ + public void removeDeadCharacter(String key) { + council.getMembers().removeIf(key::equals); + if (council.refuses(key)) council.toggleRefuse(key); + council.getProposalHandler().getProposals().removeIf(proposal -> key.equals(proposal.getProposer())); + var candidates = election.serializeCandidates(); + candidates.values().forEach(list -> list.removeIf(key::equals)); + var votes = election.serializeVotes(); + votes.values().forEach(map -> map.entrySet().removeIf(entry -> key.equals(entry.getKey()) || key.equals(entry.getValue()))); + var previous = election.serializePreviousVotes(); + previous.values().forEach(map -> map.remove(key)); + var eligible = new ArrayList<>(election.getStoredEligibleVoters()); + eligible.removeIf(key::equals); + election.restoreFromData(candidates, votes, previous, eligible); + for (Movement movement : movements) { + if (key.equals(movement.getLeader())) movement.setLeader(null); + movement.getSupporters().getCitizens().removeIf(key::equals); + for (var cause : movement.getCauses()) { + if (key.equals(cause.getLeader())) cause.setLeader(null); + cause.getPool().getCitizens().removeIf(key::equals); + } + } + } + + /** Replaying a death must not restart an election and erase ballots cast afterward. */ + public void beginDeathElection(long at) { + if (lastElectionDate.getTime() >= at) return; + election.start(); + lastElectionDate = new Date(at); + } + public Council getCouncil() { return council; } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java index 26a6bb20..2cfc37b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java @@ -22,6 +22,7 @@ public class Session { private Player leader; + private final String leaderCharacter; private Block lantern; private Faction f; private Council c; @@ -39,6 +40,7 @@ public class Session { @SuppressWarnings("deprecation") public Session(Player leader, Faction f) { this.leader = leader; + this.leaderCharacter = Members.key(leader); this.f = f; c = f.getGovernment().getCouncil(); leader.sendTitle("§aSelect Location", "§7Click on a §eLantern §7to select Location", 20, 80, 20); @@ -95,6 +97,22 @@ private void initializeVoting() { } } + public String getLeaderCharacter() { return leaderCharacter; } + + public Map<String, Vote> votesSnapshot() { + return new HashMap<>(currentVotes); + } + + public Set<String> eligibleSnapshot() { + return new HashSet<>(eligibleVoters); + } + + public void removeDeadCharacter(String key) { + currentVotes.remove(key); + eligibleVoters.remove(key); + if (key.equals(leaderCharacter)) endSession(); + } + public boolean recordVote(String voter, Vote vote) { if (!started || terminated || getCurrentProposal() == null || !eligibleVoters.contains(voter)) { return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 2f334b93..f5ff9ed0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -121,7 +121,7 @@ public Guild(Faction f) { bannerPatterns = f.getBannerPatterns(); name = f.getName(); leader = f.getLeader(); - members.add(leader); + if (leader != null && !leader.isBlank()) members.add(leader); type = GuildLoader.getBaseType(); this.wealth = 0.0; for (Branch branch : BranchLoader.getList()) { @@ -188,7 +188,7 @@ public Guild(GuildData data, Faction host) { this.capital = data.capital; this.stance = data.stance != null ? Stance.valueOf(data.stance) : Stance.NEUTRAL; this.members = data.members != null ? data.members : new ArrayList<>(); - if(!this.members.contains(leader)) this.members.add(leader); + if(leader != null && !leader.isBlank() && !this.members.contains(leader)) this.members.add(leader); for (GuildBranchData bd : data.branches) { if (bd != null && bd.level != null && bd.level.intValue() > 0) { warnRetiredBranch(host, name, bd.id, bd.level.intValue()); @@ -472,6 +472,15 @@ public void kick(String member) { String stored = find(members, member); if(stored != null) members.remove(stored); } + /** The death journal has already captured any money owed before this removal. */ + public void removeDeadCharacter(String key) { + members.removeIf(key::equals); + invites.removeIf(key::equals); + dividendEligible.removeIf(key::equals); + if (key.equals(leader)) leader = ""; + if (company != null) company.removeDeadCharacter(key); + } + public String getLeader() { return isBase() ? host.getLeader() : leader; } /** Last known roleplay name of the leader; a realm's own guild shares the realm's. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java new file mode 100644 index 00000000..2521829a --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java @@ -0,0 +1,83 @@ +package net.tfminecraft.simplefactions.identity.death; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +/** Main-thread death reconciliation and the settlement's remaining estate-payment claims. */ +public final class CharacterDeaths { + private static DeathJournalStore store; + private static FactionDeaths factions; + + private CharacterDeaths() {} + + /** After faction loading and settlement recovery, before enabling gameplay or timers. */ + public static void startup(Path dataFolder) throws IOException { + load(dataFolder); + reconcile(); + } + + /** Load obligations before settlement recovery; defer cleanup until that recovery is complete. */ + public static void load(Path dataFolder) throws IOException { + factions = new FactionDeaths(dataFolder); + store = new DeathJournalStore(dataFolder.resolve("deaths"), factions::apply); + store.pendingPayments(); + } + + /** Called at startup and hourly; incomplete journals replay even if membership is already gone. */ + public static void reconcile() throws IOException { + requireStarted(); + store.replayAll(); + for (String key : factions.referencedCharacters()) { + var info = Members.info(key); + if (info.isEmpty() || info.get().status() != CharacterStatus.DEAD) continue; + UUID deathId = Characters.deathId(info.get().ref().characterId()) + .orElseThrow(() -> new IOException("Dead character has no authoritative death ID: " + key)); + if (!store.contains(deathId)) { + store.record(factions.capture(info.get().ref(), deathId, Instant.now().toEpochMilli())); + } + } + } + + public static void onDeath(CharacterRef ref, UUID deathId, Instant at) throws IOException { + requireStarted(); + var info = Characters.info(ref.characterId()); + if (info.isPresent() && (!info.get().ref().equals(ref) || info.get().status() != CharacterStatus.DEAD)) return; + var currentDeath = Characters.deathId(ref.characterId()); + if (currentDeath.isPresent() && !currentDeath.get().equals(deathId)) return; + if (store.contains(deathId)) store.replayAll(); + else store.record(factions.capture(ref, deathId, at.toEpochMilli())); + } + + public static List<OwedPayment> pendingPayments() { + try { + requireStarted(); + return store.pendingPayments(); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + /** Replay-safe partial payments: the same settlement must acknowledge the same amounts. */ + public static void applyPayments(String settlementId, Map<String, Long> paidCents) throws IOException { + requireStarted(); + store.applyPayments(settlementId, paidCents); + } + + public static void stop() { + store = null; + factions = null; + } + + private static void requireStarted() throws IOException { + if (store == null) throw new IOException("Character death journals are not loaded"); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java new file mode 100644 index 00000000..2db04d95 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java @@ -0,0 +1,42 @@ +package net.tfminecraft.simplefactions.identity.death; + +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** A death's original rights, fixed successors, obligations and acknowledged cleanup steps. */ +final class DeathJournal { + UUID deathId; + UUID ownerId; + UUID characterId; + long at; + List<Step> steps = new ArrayList<>(); + List<Claim> claims = new ArrayList<>(); + Map<String, Map<String, Long>> payments = new LinkedHashMap<>(); + boolean complete; + + static final class Step { + String id; + String kind; + JsonObject plan; + boolean done; + Step(String id, String kind, JsonObject plan) { + this.id = id; + this.kind = kind; + this.plan = plan; + } + } + + static final class Claim { + String cleanupStep; + OwedPayment payment; + long paid; + Claim(String cleanupStep, OwedPayment payment) { + this.cleanupStep = cleanupStep; + this.payment = payment; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java new file mode 100644 index 00000000..1b14474c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java @@ -0,0 +1,172 @@ +package net.tfminecraft.simplefactions.identity.death; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.DurableJson; + +/** Reads on every attempt so a failed write never advances the in-memory recovery cursor. */ +final class DeathJournalStore { + @FunctionalInterface + interface ApplyStep { + boolean apply(DeathJournal journal, DeathJournal.Step step) throws IOException; + } + + private final Path folder; + private final ApplyStep effects; + + DeathJournalStore(Path folder, ApplyStep effects) { + this.folder = folder; + this.effects = effects; + } + + boolean contains(UUID deathId) { + return Files.exists(path(deathId)); + } + + void record(DeathJournal journal) throws IOException { + if (!contains(journal.deathId)) DurableJson.write(path(journal.deathId), journal); + else { + DeathJournal existing = read(path(journal.deathId)); + if (!existing.ownerId.equals(journal.ownerId) || !existing.characterId.equals(journal.characterId)) { + throw new IOException("Death ID reused for a different character: " + journal.deathId); + } + } + replay(journal.deathId); + } + + void replayAll() throws IOException { + for (DeathJournal journal : all()) replay(journal.deathId); + } + + private void replay(UUID deathId) throws IOException { + DeathJournal journal = read(path(deathId)); + if (journal.complete) return; + for (DeathJournal.Step step : journal.steps) { + if (step.done) continue; + if (!effects.apply(journal, step)) continue; + step.done = true; + DurableJson.write(path(deathId), journal); + } + journal.complete = journal.steps.stream().allMatch(step -> step.done); + DurableJson.write(path(deathId), journal); + } + + List<OwedPayment> pendingPayments() throws IOException { + List<OwedPayment> pending = new ArrayList<>(); + for (DeathJournal journal : all()) { + for (DeathJournal.Claim claim : journal.claims) { + long left = claim.payment.cents() - claim.paid; + if (left <= 0 || !cleaned(journal, claim.cleanupStep)) continue; + OwedPayment original = claim.payment; + pending.add(new OwedPayment(original.id(), original.guildIncarnation(), + original.characterId(), left, original.kind())); + } + } + return List.copyOf(pending); + } + + void applyPayments(String settlementId, Map<String, Long> paidCents) throws IOException { + if (settlementId == null || settlementId.isBlank()) throw new IOException("Missing settlement ID"); + Path receiptFile = folder.resolve("payments").resolve(UUID.nameUUIDFromBytes( + settlementId.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + ".json"); + if (Files.exists(receiptFile)) { + Receipt receipt = DurableJson.read(receiptFile, Receipt.class); + if (!settlementId.equals(receipt.settlementId) || !paidCents.equals(receipt.paidCents)) { + throw new IOException("Conflicting death payment acknowledgment: " + settlementId); + } + } + List<DeathJournal> journals = all(); + Map<String, DeathJournal.Claim> known = new HashMap<>(); + Map<String, DeathJournal> owners = new HashMap<>(); + for (DeathJournal journal : journals) { + for (DeathJournal.Claim claim : journal.claims) { + known.put(claim.payment.id(), claim); + owners.put(claim.payment.id(), journal); + } + } + // Validate the entire acknowledgment before the first journal is changed. + for (var entry : paidCents.entrySet()) { + DeathJournal.Claim claim = known.get(entry.getKey()); + if (claim == null || entry.getValue() == null || entry.getValue() < 0) { + throw new IOException("Invalid death payment: " + entry.getKey()); + } + DeathJournal owner = owners.get(entry.getKey()); + if (!owner.payments.containsKey(settlementId) + && (!cleaned(owner, claim.cleanupStep) || entry.getValue() > claim.payment.cents() - claim.paid)) { + throw new IOException("Death payment exceeds available obligation: " + entry.getKey()); + } + } + for (DeathJournal journal : journals) { + Map<String, Long> portion = new LinkedHashMap<>(); + for (DeathJournal.Claim claim : journal.claims) { + if (paidCents.containsKey(claim.payment.id())) portion.put(claim.payment.id(), paidCents.get(claim.payment.id())); + } + Map<String, Long> previous = journal.payments.get(settlementId); + if (previous != null && !previous.equals(portion)) throw new IOException("Conflicting death payment acknowledgment: " + settlementId); + } + if (!Files.exists(receiptFile)) DurableJson.write(receiptFile, new Receipt(settlementId, Map.copyOf(paidCents))); + for (DeathJournal journal : journals) { + Map<String, Long> portion = new LinkedHashMap<>(); + for (DeathJournal.Claim claim : journal.claims) { + Long cents = paidCents.get(claim.payment.id()); + if (cents != null) portion.put(claim.payment.id(), cents); + } + if (portion.isEmpty() || journal.payments.containsKey(settlementId)) continue; + for (DeathJournal.Claim claim : journal.claims) claim.paid += portion.getOrDefault(claim.payment.id(), 0L); + journal.payments.put(settlementId, portion); + DurableJson.write(path(journal.deathId), journal); + } + } + + private record Receipt(String settlementId, Map<String, Long> paidCents) {} + + private static boolean cleaned(DeathJournal journal, String id) { + return journal.steps.stream().anyMatch(step -> step.id.equals(id) && step.done); + } + + private List<DeathJournal> all() throws IOException { + if (!Files.exists(folder)) return List.of(); + List<DeathJournal> journals = new ArrayList<>(); + try (var files = Files.list(folder)) { + for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".json")).sorted().toList()) { + journals.add(read(file)); + } + } + return journals; + } + + private DeathJournal read(Path file) throws IOException { + DeathJournal journal = DurableJson.read(file, DeathJournal.class); + if (journal.deathId == null || journal.characterId == null || journal.ownerId == null + || journal.steps == null || journal.claims == null || journal.payments == null + || !file.getFileName().toString().equals(journal.deathId + ".json")) { + throw new IOException("Invalid death journal: " + file); + } + var stepIds = new java.util.HashSet<String>(); + for (DeathJournal.Step step : journal.steps) { + if (step == null || step.id == null || step.kind == null || step.plan == null || !stepIds.add(step.id)) { + throw new IOException("Invalid death step in " + file); + } + } + var claimIds = new java.util.HashSet<String>(); + for (DeathJournal.Claim claim : journal.claims) { + if (claim == null || claim.payment == null || claim.payment.id() == null + || claim.payment.guildIncarnation() == null || claim.payment.kind() == null + || !journal.characterId.equals(claim.payment.characterId()) || claim.payment.cents() <= 0 + || claim.paid < 0 || claim.paid > claim.payment.cents() || !stepIds.contains(claim.cleanupStep) + || !claimIds.add(claim.payment.id())) throw new IOException("Invalid death obligation in " + file); + } + return journal; + } + + private Path path(UUID deathId) { + return folder.resolve(deathId + ".json"); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java new file mode 100644 index 00000000..95a5b17c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -0,0 +1,318 @@ +package net.tfminecraft.simplefactions.identity.death; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.battle.persistence.WarbandMapper; +import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; +import net.tfminecraft.simplefactions.war.battle.warband.WarbandMembershipService; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** Adapts the death journal to faction stores without recomputing successors or debts on recovery. */ +final class FactionDeaths { + private final Path dataFolder; + + FactionDeaths(Path dataFolder) { + this.dataFolder = dataFolder; + } + + Set<String> referencedCharacters() { + Set<String> keys = new LinkedHashSet<>(); + for (Faction faction : FactionManager.getCopy()) collectKeys(snapshot(faction), keys); + for (var warband : WarbandManager.get()) { + warband.getMemberCharacters().values().forEach(ref -> keys.add(Members.key(ref))); + } + return keys; + } + + DeathJournal capture(CharacterRef ref, UUID deathId, long at) throws IOException { + DeathJournal journal = new DeathJournal(); + journal.deathId = deathId; + journal.ownerId = ref.ownerId(); + journal.characterId = ref.characterId(); + journal.at = at; + String key = Members.key(ref); + List<DeathJournal.Step> successions = new ArrayList<>(); + for (Faction faction : FactionManager.getCopy()) { + JsonObject before = snapshot(faction); + Set<String> keys = new LinkedHashSet<>(); + collectKeys(before, keys); + if (!keys.contains(key)) continue; + String cleanup = "rights:" + before.get("realm").getAsString(); + journal.steps.add(new DeathJournal.Step(cleanup, "rights", before)); + for (Guild guild : faction.getGuildHandler().getGuilds()) { + UUID incarnation = UUID.fromString(guild.getIncarnation()); + double wages = guild.getCompany() == null ? 0 + : guild.getCompany().getPendingWages().getOrDefault(key, 0.0); + addClaim(journal, cleanup, incarnation, cents(wages), OwedPayment.Kind.WAGES); + if (guild.getDividendEligibleMembers().contains(key)) { + addClaim(journal, cleanup, incarnation, + cents(guild.getLedger().getDividendBreakdown().perMember()), OwedPayment.Kind.DIVIDENDS); + } + boolean emptyRealm = faction.getMembers().stream().allMatch(key::equals); + if (!key.equals(guild.getLeader()) && !(guild.isBase() && emptyRealm)) continue; + JsonObject succession = new JsonObject(); + succession.addProperty("realm", before.get("realm").getAsString()); + succession.addProperty("faction", faction.getId()); + succession.addProperty("guild", incarnation.toString()); + succession.addProperty("base", guild.isBase()); + succession.addProperty("elective", guild.isBase() && faction.getGovernment().hasLeaderElections()); + succession.addProperty("emptyRealm", emptyRealm); + String successor = guild.getMembers().stream().filter(member -> !key.equals(member)) + .filter(member -> Members.info(member).map(CharacterInfo::alive).orElse(false)) + .findFirst().orElse(""); + succession.addProperty("successor", successor); + successions.add(new DeathJournal.Step("succession:" + incarnation, "succession", succession)); + } + } + // Every original financial claim is durably transferred before disbanding or succession. + journal.steps.addAll(successions); + JsonObject online = new JsonObject(); + var warband = WarbandManager.getByMemberId(ref.ownerId()); + boolean participant = warband != null && warband.getMemberCharacter(ref.ownerId()).filter(ref::equals).isPresent(); + if (!participant) { + var pending = WarbandMembershipService.getInstance().getPendingRejoin(ref.ownerId()); + if (pending != null && pending.isFor(ref)) { + var original = WarbandManager.getByString(pending.getWarbandId()); + if (original != null && pending.belongsTo(original)) { + warband = original; + participant = true; + } + } + } + if (participant) { + online.addProperty("warband", warband.getId()); + online.addProperty("leader", ref.ownerId().equals(warband.getLeaderId())); + for (UUID member : warband.getMemberIds()) { + if (member.equals(ref.ownerId())) continue; + var candidate = warband.getMemberCharacter(member); + if (candidate.isEmpty() || !Members.info(Members.key(candidate.get())).map(CharacterInfo::alive).orElse(false)) continue; + online.addProperty("successorOwner", member.toString()); + online.addProperty("successorCharacter", candidate.get().characterId().toString()); + break; + } + } + journal.steps.add(new DeathJournal.Step("online", "online", online)); + return journal; + } + + boolean apply(DeathJournal journal, DeathJournal.Step step) throws IOException { + return switch (step.kind) { + case "rights" -> removeRights(journal, step.plan); + case "succession" -> succeed(journal, step.plan); + case "online" -> removeOnline(journal, step.plan); + default -> throw new IOException("Unknown death step: " + step.kind); + }; + } + + private boolean removeRights(DeathJournal journal, JsonObject plan) throws IOException { + Faction faction = realm(plan.get("realm").getAsString()); + if (faction == null) return true; + String key = journal.characterId.toString(); + for (Guild guild : faction.getGuildHandler().getGuilds()) guild.removeDeadCharacter(key); + faction.succeedDeadLeader(key, ""); + faction.getGovernment().removeDeadCharacter(key); + faction.getEspionage().removeDeadCharacter(new CharacterRef(journal.ownerId, journal.characterId), + journal.deathId, journal.at); + var plugin = SimpleFactions.getInstance(); + if (plugin != null && plugin.getSessionManager() != null) { + var session = plugin.getSessionManager().getSession(faction.getGovernment().getCouncil()); + if (session != null) session.removeDeadCharacter(key); + } + save(faction); + return true; + } + + private boolean succeed(DeathJournal journal, JsonObject plan) throws IOException { + String key = journal.characterId.toString(); + Faction faction = realm(plan.get("realm").getAsString()); + if (plan.get("emptyRealm").getAsBoolean() && plan.get("base").getAsBoolean()) { + if (faction != null && (!faction.getMembers().isEmpty() + || faction.getGuildHandler().getGuilds().stream().anyMatch(Guild::hasUnresolvedLeader))) return false; + disbandRealm(faction, plan.get("faction").getAsString()); + return true; + } + if (faction == null) return true; + Guild guild = faction.getGuildHandler().getGuilds().stream() + .filter(candidate -> candidate.getIncarnation().equals(plan.get("guild").getAsString())) + .findFirst().orElse(null); + if (guild == null) { + save(faction); + return true; + } + if (guild.hasUnresolvedLeader()) return false; + String leader = guild.getLeader(); + // Once staff or a later event appoints someone else, this death cannot replace them. + if (!key.equals(leader) && leader != null && !leader.isBlank()) { + save(faction); + return true; + } + if (!guild.isBase() && guild.getMembers().isEmpty()) { + faction.getGuildHandler().removeGuild(guild.getId()); + save(faction); + return true; + } + String successor = plan.get("successor").getAsString(); + if (guild.isBase() && plan.get("elective").getAsBoolean()) { + faction.succeedDeadLeader(key, ""); + faction.getGovernment().beginDeathElection(journal.at); + save(faction); + return true; + } + if (successor.isBlank() || !guild.isMember(successor) + || !Members.info(successor).map(CharacterInfo::alive).orElse(false)) { + if (guild.isBase()) faction.succeedDeadLeader(key, ""); + else guild.setLeader(""); + save(faction); + return false; + } + if (guild.isBase()) faction.succeedDeadLeader(key, successor); + else guild.setLeader(successor); + save(faction); + return true; + } + + private boolean removeOnline(DeathJournal journal, JsonObject plan) throws IOException { + var membership = WarbandMembershipService.getInstance(); + var pending = membership.getPendingRejoin(journal.ownerId); + CharacterRef ref = new CharacterRef(journal.ownerId, journal.characterId); + if (pending != null && pending.isFor(ref)) membership.clearPendingRejoin(journal.ownerId); + if (!plan.has("warband")) return true; + var warband = WarbandManager.getByString(plan.get("warband").getAsString()); + if (warband == null) return true; + var current = warband.getMemberCharacter(journal.ownerId); + if ((current.isPresent() && !current.get().equals(ref)) + || (current.isEmpty() && warband.hasMember(journal.ownerId))) { + saveWarband(warband); + return true; + } + if (plan.get("leader").getAsBoolean() && journal.ownerId.equals(warband.getLeaderId())) { + UUID successor = plan.has("successorOwner") ? UUID.fromString(plan.get("successorOwner").getAsString()) : null; + var successorRef = successor == null ? java.util.Optional.<CharacterRef>empty() : warband.getMemberCharacter(successor); + if (successorRef.isPresent() && successorRef.get().characterId().toString().equals(plan.get("successorCharacter").getAsString()) + && Members.info(Members.key(successorRef.get())).map(CharacterInfo::alive).orElse(false)) { + warband.setLeaderId(successor); + } else warband.resetToPendingLeader(); + } + var online = org.bukkit.Bukkit.getPlayer(journal.ownerId); + if (online != null && WarbandManager.getByMemberId(journal.ownerId) == warband) { + membership.handleQuit(online); + membership.clearPendingRejoin(journal.ownerId); + } + warband.removeMember(journal.ownerId); + saveWarband(warband); + return true; + } + + private void saveWarband(net.tfminecraft.simplefactions.war.battle.warband.Warband warband) throws IOException { + DurableJson.write(dataFolder.resolve("Warbands/warband_" + warband.getId() + ".json"), WarbandMapper.toData(warband)); + } + + private void disbandRealm(Faction faction, String id) throws IOException { + if (faction == null && FactionManager.getByString(id) != null) { + throw new IOException("A new realm uses the old death journal's name: " + id); + } + if (faction != null) { + FactionManager.deleteFaction(faction); + } + Files.deleteIfExists(dataFolder.resolve("Data/" + id + ".json")); + try (FileChannel directory = FileChannel.open(dataFolder.resolve("Data"), StandardOpenOption.READ)) { + directory.force(true); + } + for (Faction survivor : FactionManager.getCopy()) { + survivor.getDiplomacyHandler().removeRelation(id); + save(survivor); + } + } + + private static Faction realm(String incarnation) { + return FactionManager.getCopy().stream() + .filter(faction -> faction.getOrCreateMainGuild().getIncarnation().equals(incarnation)) + .findFirst().orElse(null); + } + + private static void save(Faction faction) throws IOException { + if (!new Database().saveFactionDurably(faction)) throw new IOException("Cannot save death cleanup for " + faction.getId()); + } + + private static void addClaim(DeathJournal journal, String cleanup, UUID guild, long cents, OwedPayment.Kind kind) { + if (cents <= 0) return; + String id = "death:" + journal.deathId + ":" + guild + ":" + kind.name().toLowerCase(java.util.Locale.ROOT); + journal.claims.add(new DeathJournal.Claim(cleanup, + new OwedPayment(id, guild, journal.characterId, cents, kind))); + } + + private static long cents(double amount) throws IOException { + try { + return BigDecimal.valueOf(amount).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact(); + } catch (NumberFormatException | ArithmeticException error) { + throw new IOException("Invalid owed death amount: " + amount, error); + } + } + + private static JsonObject snapshot(Faction faction) { + JsonObject snapshot = new JsonObject(); + snapshot.addProperty("realm", faction.getOrCreateMainGuild().getIncarnation()); + snapshot.addProperty("faction", faction.getId()); + snapshot.addProperty("leader", faction.getLeader()); + snapshot.add("government", JsonUtil.GSON.toJsonTree(faction.getGovernment().serialize())); + snapshot.add("offices", JsonUtil.GSON.toJsonTree(faction.getEspionage())); + JsonArray guilds = new JsonArray(); + for (Guild guild : faction.getGuildHandler().getGuilds()) { + JsonObject entry = new JsonObject(); + entry.addProperty("incarnation", guild.getIncarnation()); + entry.addProperty("leader", guild.getLeader()); + entry.add("members", JsonUtil.GSON.toJsonTree(guild.getMembers())); + entry.add("invites", JsonUtil.GSON.toJsonTree(guild.getInvites())); + entry.add("dividends", JsonUtil.GSON.toJsonTree(guild.getDividendEligibleSnapshot())); + if (guild.getCompany() != null) entry.add("company", JsonUtil.GSON.toJsonTree(guild.getCompany().serialize())); + guilds.add(entry); + } + snapshot.add("guilds", guilds); + var plugin = SimpleFactions.getInstance(); + if (plugin != null && plugin.getSessionManager() != null) { + var session = plugin.getSessionManager().getSession(faction.getGovernment().getCouncil()); + if (session != null) { + snapshot.addProperty("sessionLeader", session.getLeaderCharacter()); + snapshot.add("sessionVotes", JsonUtil.GSON.toJsonTree(session.votesSnapshot())); + snapshot.add("sessionEligible", JsonUtil.GSON.toJsonTree(session.eligibleSnapshot())); + } + } + return snapshot; + } + + private static void collectKeys(JsonElement value, Set<String> keys) { + if (value.isJsonObject()) { + for (var entry : value.getAsJsonObject().entrySet()) { + if (Members.characterId(entry.getKey()) != null) keys.add(entry.getKey()); + collectKeys(entry.getValue(), keys); + } + } else if (value.isJsonArray()) { + value.getAsJsonArray().forEach(item -> collectKeys(item, keys)); + } else if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + String key = value.getAsString(); + if (Members.characterId(key) != null) keys.add(key); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/OwedPayment.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/OwedPayment.java new file mode 100644 index 00000000..75d005a2 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/OwedPayment.java @@ -0,0 +1,8 @@ +package net.tfminecraft.simplefactions.identity.death; + +import java.util.UUID; + +/** An unpaid claim preserved when death removes a character from its guild. */ +public record OwedPayment(String id, UUID guildIncarnation, UUID characterId, long cents, Kind kind) { + public enum Kind { WAGES, DIVIDENDS } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index 2f33412f..c5da1406 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -488,6 +488,14 @@ public Map<String, Double> getPendingWages() { return Collections.unmodifiableMap(pendingWages); } + /** Called only after the death journal durably takes ownership of the owed wages. */ + public void removeDeadCharacter(String key) { + enlisted.removeIf(key::equals); + pendingWages.remove(key); + wages.clearActiveOverride(key); + wages.clearPeacetimeOverride(key); + } + public void clearPendingWages() { pendingWages.clear(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 7276a5f1..3d784116 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -774,6 +774,13 @@ public void setLeader(String leader) { this.leader = leader; net.tfminecraft.simplefactions.mercenary.contract.MercenaryLoyaltyWatcher.onGovernmentChanged(this); } + /** Applies only the journal's captured succession; a later deliberate appointment wins. */ + public void succeedDeadLeader(String dead, String successor) { + if (leader != null && !leader.isBlank() && !dead.equals(leader)) return; + leader = successor; + getOrCreateMainGuild().setLeader(successor); + } + public void promoteToLeader(String name) { if (!canBecomeLeader(name)) return; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java index a7f8882a..28e3a42f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/warband/WarbandRejoinState.java @@ -21,7 +21,7 @@ public boolean isFor(CharacterRef ref) { return character != null && character.equals(ref); } - boolean belongsTo(Warband warband) { + public boolean belongsTo(Warband warband) { return originalWarband == warband; } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index 5b3cb9e6..9e773522 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.database.JsonUtil; import net.tfminecraft.tlibs.character.CharacterDeathEvent; import net.tfminecraft.tlibs.character.CharacterIdentity; @@ -328,37 +329,45 @@ class OfficeVacancyTest { } } - @Test void committedSharedDeathListenerSettlesImmediatelyWithCanonicalName() { + @Test void journalOfficeCleanupPreservesLaterAppointmentsAndHandlesPendingFounder() { try (var fixture = new DeathFixture()) { - fixture.appoint(fixture.ref.ownerId(), fixture.ref.characterId().toString(), 1000); + var holder = fixture.appoint(fixture.ref.ownerId(), fixture.ref.characterId().toString(), 3000); UUID deathId = UUID.randomUUID(); - CharacterInfo info = new CharacterInfo(fixture.ref, "Canonical Spy", CharacterStatus.DEAD, - null, Set.of(), 0, Instant.EPOCH); - when(fixture.provider.info(fixture.ref.characterId())).thenReturn(Optional.of(info)); - new OfficeCharacterDeathListener().onCharacterDeath(new CharacterDeathEvent( - fixture.ref, deathId, Instant.ofEpochMilli(2000), null, null, "OTHER")); + fixture.state.removeDeadCharacter(fixture.ref, deathId, 2000); + assertSame(holder, fixture.state.getSpymaster()); + fixture.state.removeDeadCharacter(fixture.ref, deathId, 4000); assertNull(fixture.state.getSpymaster()); assertTrue(fixture.state.handledDeath(deathId)); - verify(fixture.owner).sendMessage(contains("passing of Canonical Spy")); - fixture.bukkit.verify(Bukkit::getScheduler, never()); - verify(fixture.provider, never()).currentSession(any()); + assertEquals(0, fixture.state.lastAppointedAt(SpecialPosition.SPYMASTER)); + fixture.state.pendingFounder(SpecialPosition.SPYMASTER, fixture.ref.characterId().toString()); + fixture.state.removeDeadCharacter(fixture.ref, UUID.randomUUID(), Long.MAX_VALUE); + assertFalse(fixture.state.hasPendingFounder()); } } - @Test void committedDeathStillSettlesWhenDisplaySnapshotIsMissingOrBelongsElsewhere() { - for (boolean foreignInfo : new boolean[] {false, true}) { - try (var fixture = new DeathFixture()) { - fixture.appoint(fixture.ref.ownerId(), fixture.ref.characterId().toString(), 1000); - if (foreignInfo) { - CharacterInfo wrong = new CharacterInfo(new CharacterRef(UUID.randomUUID(), fixture.ref.characterId()), - "Someone else", CharacterStatus.DEAD, null, Set.of(), 0, Instant.EPOCH); - when(fixture.provider.info(fixture.ref.characterId())).thenReturn(Optional.of(wrong)); - } - new OfficeCharacterDeathListener().onCharacterDeath(new CharacterDeathEvent(fixture.ref, - UUID.randomUUID(), Instant.ofEpochMilli(2000), null, null, "OTHER")); - assertNull(fixture.state.getSpymaster()); - verify(fixture.owner).sendMessage(contains("passing of your character")); - } + @Test void committedSharedDeathListenerForwardsTheAuthoritativeEvent() throws Exception { + var ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var deathId = UUID.randomUUID(); + var at = Instant.ofEpochMilli(2000); + try (var deaths = mockStatic(net.tfminecraft.simplefactions.identity.death.CharacterDeaths.class)) { + new OfficeCharacterDeathListener().onCharacterDeath(new CharacterDeathEvent(ref, deathId, at, null, null, "OTHER")); + deaths.verify(() -> net.tfminecraft.simplefactions.identity.death.CharacterDeaths.onDeath(ref, deathId, at)); + } + } + + @Test void deathCleanupFailureDisablesGameplayUntilRecovery() throws Exception { + var ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var deathId = UUID.randomUUID(); + var at = Instant.ofEpochMilli(2000); + var plugin = mock(SimpleFactions.class, RETURNS_DEEP_STUBS); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("death-failure-test")); + try (var deaths = mockStatic(net.tfminecraft.simplefactions.identity.death.CharacterDeaths.class); + var plugins = mockStatic(SimpleFactions.class)) { + plugins.when(SimpleFactions::getInstance).thenReturn(plugin); + deaths.when(() -> net.tfminecraft.simplefactions.identity.death.CharacterDeaths.onDeath(ref, deathId, at)) + .thenThrow(new java.io.IOException("disk failure")); + new OfficeCharacterDeathListener().onCharacterDeath(new CharacterDeathEvent(ref, deathId, at, null, null, "OTHER")); + verify(plugin.getServer().getPluginManager()).disablePlugin(plugin); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/CharacterDeathsTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/CharacterDeathsTest.java new file mode 100644 index 00000000..293472a9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/CharacterDeathsTest.java @@ -0,0 +1,112 @@ +package net.tfminecraft.simplefactions.identity.death; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class CharacterDeathsTest { + @TempDir Path folder; + final CharacterRef ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + final Instant at = Instant.ofEpochMilli(1000); + + @AfterEach void close() { CharacterDeaths.stop(); } + + CharacterInfo info(CharacterRef who, CharacterStatus status) { + return new CharacterInfo(who, "Name", status, null, Set.of(), 0, Instant.EPOCH); + } + + DeathJournal journal() { + DeathJournal journal = new DeathJournal(); + journal.deathId = death; + journal.ownerId = ref.ownerId(); + journal.characterId = ref.characterId(); + journal.at = at.toEpochMilli(); + journal.steps.add(new DeathJournal.Step("rights", "rights", new JsonObject())); + journal.claims.add(new DeathJournal.Claim("rights", new OwedPayment("wages", UUID.randomUUID(), + ref.characterId(), 1500, OwedPayment.Kind.WAGES))); + return journal; + } + + @Test void unloadedConsumerFailsClosed() { + CharacterDeaths.stop(); + assertThrows(IOException.class, CharacterDeaths::reconcile); + assertThrows(UncheckedIOException.class, CharacterDeaths::pendingPayments); + assertThrows(IOException.class, () -> CharacterDeaths.applyPayments("day", Map.of())); + assertThrows(IOException.class, () -> CharacterDeaths.onDeath(ref, death, at)); + } + + @Test void startupFindsOfflineDeadRightsAndUsesProviderDeathIdentityAcrossReopen() throws Exception { + try (var characters = mockStatic(Characters.class); + var members = mockStatic(Members.class); + var effects = mockConstruction(FactionDeaths.class, (adapter, context) -> { + when(adapter.referencedCharacters()).thenReturn(Set.of(ref.characterId().toString(), "living", "unknown")); + when(adapter.capture(eq(ref), eq(death), anyLong())).thenReturn(journal()); + when(adapter.apply(any(), any())).thenReturn(true); + })) { + members.when(() -> Members.info(ref.characterId().toString())).thenReturn(Optional.of(info(ref, CharacterStatus.DEAD))); + members.when(() -> Members.info("living")).thenReturn(Optional.of(info(ref, CharacterStatus.ALIVE))); + characters.when(() -> Characters.deathId(ref.characterId())).thenReturn(Optional.of(death)); + CharacterDeaths.startup(folder); + assertEquals(1500, CharacterDeaths.pendingPayments().getFirst().cents()); + CharacterDeaths.applyPayments("day:1", Map.of("wages", 500L)); + CharacterDeaths.stop(); + CharacterDeaths.startup(folder); + CharacterDeaths.applyPayments("day:1", Map.of("wages", 500L)); + assertEquals(1000, CharacterDeaths.pendingPayments().getFirst().cents()); + verify(effects.constructed().getFirst()).capture(eq(ref), eq(death), anyLong()); + verify(effects.constructed().getLast(), never()).capture(any(), any(), anyLong()); + Files.writeString(folder.resolve("deaths/" + death + ".json"), "broken"); + assertThrows(UncheckedIOException.class, CharacterDeaths::pendingPayments); + assertThrows(IOException.class, () -> CharacterDeaths.load(folder)); + } + } + + @Test void missingAuthoritativeDeathIdentityStopsReconciliationBeforeCleanup() throws Exception { + try (var characters = mockStatic(Characters.class); + var members = mockStatic(Members.class); + var effects = mockConstruction(FactionDeaths.class, (adapter, context) -> + when(adapter.referencedCharacters()).thenReturn(Set.of(ref.characterId().toString())))) { + members.when(() -> Members.info(ref.characterId().toString())).thenReturn(Optional.of(info(ref, CharacterStatus.DEAD))); + assertThrows(IOException.class, () -> CharacterDeaths.startup(folder)); + verify(effects.constructed().getFirst(), never()).capture(any(), any(), anyLong()); + } + } + + @Test void committedEventsReplayOnceAndIgnoreForeignRevivedOrSupersededDeaths() throws Exception { + try (var characters = mockStatic(Characters.class); + var effects = mockConstruction(FactionDeaths.class, (adapter, context) -> { + when(adapter.capture(ref, death, at.toEpochMilli())).thenReturn(journal()); + when(adapter.apply(any(), any())).thenReturn(true); + })) { + CharacterDeaths.load(folder); + characters.when(() -> Characters.info(ref.characterId())).thenReturn(Optional.of(info( + new CharacterRef(UUID.randomUUID(), ref.characterId()), CharacterStatus.DEAD))); + CharacterDeaths.onDeath(ref, death, at); + characters.when(() -> Characters.info(ref.characterId())).thenReturn(Optional.of(info(ref, CharacterStatus.ALIVE))); + CharacterDeaths.onDeath(ref, death, at); + characters.when(() -> Characters.info(ref.characterId())).thenReturn(Optional.of(info(ref, CharacterStatus.DEAD))); + characters.when(() -> Characters.deathId(ref.characterId())).thenReturn(Optional.of(UUID.randomUUID())); + CharacterDeaths.onDeath(ref, death, at); + verify(effects.constructed().getFirst(), never()).capture(any(), any(), anyLong()); + characters.when(() -> Characters.deathId(ref.characterId())).thenReturn(Optional.of(death)); + CharacterDeaths.onDeath(ref, death, at); + characters.when(() -> Characters.info(ref.characterId())).thenReturn(Optional.empty()); + CharacterDeaths.onDeath(ref, death, at); + verify(effects.constructed().getFirst()).capture(ref, death, at.toEpochMilli()); + assertEquals(1, CharacterDeaths.pendingPayments().size()); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStoreTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStoreTest.java new file mode 100644 index 00000000..4ccf22e5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStoreTest.java @@ -0,0 +1,200 @@ +package net.tfminecraft.simplefactions.identity.death; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import net.tfminecraft.simplefactions.database.DurableJson; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DeathJournalStoreTest { + @TempDir Path folder; + + DeathJournal journal(long id) { + DeathJournal journal = new DeathJournal(); + journal.deathId = new UUID(0, id); + journal.ownerId = UUID.randomUUID(); + journal.characterId = UUID.randomUUID(); + journal.at = 100; + JsonObject rights = new JsonObject(); + rights.addProperty("originalMember", journal.characterId.toString()); + rights.addProperty("fixedSuccessor", "chosen-before-effects"); + journal.steps.add(new DeathJournal.Step("rights", "rights", rights)); + journal.steps.add(new DeathJournal.Step("succession", "succession", new JsonObject())); + journal.claims.add(new DeathJournal.Claim("rights", new OwedPayment("wage-" + id, + UUID.randomUUID(), journal.characterId, 1000, OwedPayment.Kind.WAGES))); + return journal; + } + + @Test + void settlementReceiptRejectsAddingPreviouslyUntouchedClaimsOnRetry() throws Exception { + var store = new DeathJournalStore(folder, (journal, step) -> true); + store.record(journal(40)); + store.record(journal(41)); + store.applyPayments("day", Map.of("wage-40", 100L)); + assertThrows(IOException.class, () -> store.applyPayments("day", Map.of("wage-40", 100L, "wage-41", 100L))); + assertEquals(1900, store.pendingPayments().stream().mapToLong(OwedPayment::cents).sum()); + store.applyPayments("day", Map.of("wage-40", 100L)); + assertEquals(1900, store.pendingPayments().stream().mapToLong(OwedPayment::cents).sum()); + } + + @Test + void recordsOriginalRightsBeforeEffectsAndReplaysOnlyIncompleteStepsAfterReopen() throws Exception { + DeathJournal original = journal(1); + List<String> applied = new ArrayList<>(); + var first = new DeathJournalStore(folder, (journal, step) -> { + assertTrue(Files.readString(folder.resolve(original.deathId + ".json")).contains("chosen-before-effects")); + if (step.kind.equals("succession")) throw new IOException("save failed after membership was removed"); + applied.add(step.id); + return true; + }); + assertThrows(IOException.class, () -> first.record(original)); + assertEquals(List.of("rights"), applied); + assertEquals(1000, first.pendingPayments().getFirst().cents()); + var reopened = new DeathJournalStore(folder, (journal, step) -> { + applied.add(step.id); + return true; + }); + reopened.replayAll(); + reopened.replayAll(); + reopened.record(original); + assertEquals(List.of("rights", "succession"), applied); + assertTrue(DurableJson.read(folder.resolve(original.deathId + ".json"), DeathJournal.class).complete); + assertEquals(1000, reopened.pendingPayments().getFirst().cents()); + } + + @Test + void interruptedAcknowledgmentRepeatsAbsoluteEffectsWithoutRecomputingPlan() throws Exception { + var original = journal(2); + AtomicBoolean memberPresent = new AtomicBoolean(true); + AtomicInteger removals = new AtomicInteger(); + var store = new DeathJournalStore(folder, (journal, step) -> { + if (step.id.equals("rights") && memberPresent.getAndSet(false)) removals.incrementAndGet(); + return true; + }); + try (var files = mockStatic(DurableJson.class, CALLS_REAL_METHODS)) { + files.when(() -> DurableJson.write(any(Path.class), any())).thenAnswer(call -> { + DeathJournal writing = call.getArgument(1); + if (writing.steps.getFirst().done) throw new IOException("ack write failed"); + return call.callRealMethod(); + }); + assertThrows(IOException.class, () -> store.record(original)); + } + assertFalse(memberPresent.get()); + assertEquals(List.of(), store.pendingPayments()); + store.replayAll(); + assertEquals(1, removals.get()); + assertEquals(1000, store.pendingPayments().getFirst().cents()); + } + + @Test + void blockedSuccessionDoesNotHideTransferredDebtOrPreventLaterResolution() throws Exception { + var original = journal(3); + AtomicBoolean resolved = new AtomicBoolean(); + var store = new DeathJournalStore(folder, (journal, step) -> !step.kind.equals("succession") || resolved.get()); + store.record(original); + assertFalse(DurableJson.read(folder.resolve(original.deathId + ".json"), DeathJournal.class).complete); + assertEquals(1, store.pendingPayments().size()); + resolved.set(true); + store.replayAll(); + assertTrue(DurableJson.read(folder.resolve(original.deathId + ".json"), DeathJournal.class).complete); + } + + @Test + void partialSettlementsSurviveReopenAndReplayWithoutLosingTheRemainder() throws Exception { + var store = new DeathJournalStore(folder, (journal, step) -> true); + store.record(journal(4)); + store.applyPayments("day:1", Map.of("wage-4", 400L)); + var reopened = new DeathJournalStore(folder, (journal, step) -> true); + reopened.applyPayments("day:1", Map.of("wage-4", 400L)); + assertEquals(600, reopened.pendingPayments().getFirst().cents()); + assertThrows(IOException.class, () -> reopened.applyPayments("day:1", Map.of("wage-4", 500L))); + assertThrows(IOException.class, () -> reopened.applyPayments("day:2", Map.of("wage-4", 601L))); + reopened.applyPayments("day:2", Map.of("wage-4", 600L)); + assertEquals(List.of(), reopened.pendingPayments()); + reopened.applyPayments("day:2", Map.of("wage-4", 600L)); + } + + @Test + void multiDeathAcknowledgmentRecoversAfterOnlyTheFirstFileSaved() throws Exception { + var store = new DeathJournalStore(folder, (journal, step) -> true); + store.record(journal(5)); + store.record(journal(6)); + Map<String, Long> amounts = Map.of("wage-5", 300L, "wage-6", 200L); + try (var files = mockStatic(DurableJson.class, CALLS_REAL_METHODS)) { + files.when(() -> DurableJson.write(eq(folder.resolve(new UUID(0, 6) + ".json")), any())) + .thenThrow(new IOException("second disk write failed")); + assertThrows(IOException.class, () -> store.applyPayments("day:1", amounts)); + } + store.applyPayments("day:1", amounts); + assertEquals(List.of(700L, 800L), store.pendingPayments().stream().map(OwedPayment::cents).toList()); + } + + @Test + void invalidPaymentsAndUntransferredObligationsCannotBeAcknowledged() throws Exception { + var store = new DeathJournalStore(folder, (journal, step) -> false); + store.record(journal(7)); + assertThrows(IOException.class, () -> store.applyPayments("day", Map.of("wage-7", 1L))); + assertThrows(IOException.class, () -> store.applyPayments("day", Map.of("unknown", 1L))); + assertThrows(IOException.class, () -> store.applyPayments("day", Map.of("wage-7", -1L))); + assertThrows(IOException.class, () -> store.applyPayments("", Map.of())); + store.applyPayments("empty", Map.of()); + assertEquals(List.of(), store.pendingPayments()); + } + + @Test + void conflictingPersistedLocalReceiptFailsWithoutReducingDebt() throws Exception { + var store = new DeathJournalStore(folder, (journal, step) -> true); + var record = journal(42); + store.record(record); + store.applyPayments("day", Map.of("wage-42", 100L)); + Path file = folder.resolve(record.deathId + ".json"); + record = DurableJson.read(file, DeathJournal.class); + record.payments.put("day", Map.of("wage-42", 101L)); + DurableJson.write(file, record); + assertThrows(IOException.class, () -> store.applyPayments("day", Map.of("wage-42", 100L))); + assertEquals(900, store.pendingPayments().getFirst().cents()); + } + + @Test + void corruptFilesAndReusedDeathIdsFailWithoutApplyingEffects() throws Exception { + AtomicInteger effects = new AtomicInteger(); + var store = new DeathJournalStore(folder, (journal, step) -> { effects.incrementAndGet(); return true; }); + assertEquals(List.of(), store.pendingPayments()); + Files.writeString(folder.resolve("ignored.tmp"), "not a journal"); + var original = journal(8); + store.record(original); + assertThrows(IOException.class, () -> store.record(journal(8))); + var file = folder.resolve(original.deathId + ".json"); + Files.writeString(file, "{}"); + assertThrows(IOException.class, store::replayAll); + original.steps.getFirst().id = null; + DurableJson.write(file, original); + assertThrows(IOException.class, store::replayAll); + original.steps.getFirst().id = "rights"; + original.claims.getFirst().paid = 1001; + DurableJson.write(file, original); + assertThrows(IOException.class, store::replayAll); + assertEquals(2, effects.get()); + } + + @Test + void failedIntentWriteCannotRemoveAnyRights() throws Exception { + Path blocked = folder.resolve("blocked"); + Files.writeString(blocked, "not a directory"); + AtomicInteger effects = new AtomicInteger(); + var store = new DeathJournalStore(blocked, (journal, step) -> { effects.incrementAndGet(); return true; }); + assertThrows(IOException.class, () -> store.record(journal(9))); + assertEquals(0, effects.get()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java new file mode 100644 index 00000000..55ca3e09 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java @@ -0,0 +1,401 @@ +package net.tfminecraft.simplefactions.identity.death; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.simplefactions.database.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.DividendBreakdown; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.battle.persistence.WarbandMapper; +import net.tfminecraft.simplefactions.war.battle.warband.*; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; + +class FactionDeathsTest { + @TempDir Path folder; + FactionDomainFixture fixture; + MockedConstruction<Database> databases; + MockedConstruction<Ledger> ledgers; + final Map<String, CharacterInfo> characters = new HashMap<>(); + final AtomicBoolean saveWorks = new AtomicBoolean(true); + final CharacterRef dead = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final String key = dead.characterId().toString(); + FactionDeaths effects; + DeathJournalStore store; + + @BeforeEach void setup() throws Exception { + fixture = new FactionDomainFixture(); + databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionDurably(any())).thenAnswer(call -> saveWorks.get())); + ledgers = mockConstruction(Ledger.class, (ledger, context) -> { + when(ledger.getHistory()).thenReturn(mock(net.tfminecraft.simplefactions.guild.income.LedgerHistory.class)); + when(ledger.getDividendBreakdown()).thenReturn(new DividendBreakdown(0, 0, 0, 0, 1, 2.55)); + }); + Members.Resolver resolver = mock(Members.Resolver.class, CALLS_REAL_METHODS); + when(resolver.info(anyString())).thenAnswer(call -> Optional.ofNullable(characters.get(call.getArgument(0)))); + when(resolver.online(anyString())).thenReturn(Optional.empty()); + Members.setResolver(resolver); + characters.put(key, info(dead, CharacterStatus.DEAD)); + when(fixture.ui.plugin.getSessionManager()).thenReturn(new SessionManager()); + WarbandManager.resetForTests(); + WarbandMembershipService.resetForTests(); + Files.createDirectories(folder.resolve("Data")); + effects = new FactionDeaths(folder); + store = new DeathJournalStore(folder.resolve("deaths"), effects::apply); + } + + @AfterEach void close() { + Members.useSharedIdentity(); + WarbandManager.resetForTests(); + WarbandMembershipService.resetForTests(); + ledgers.close(); + databases.close(); + fixture.close(); + } + + static CharacterInfo info(CharacterRef ref, CharacterStatus status) { + return new CharacterInfo(ref, "Character", status, null, Set.of(), 0, Instant.EPOCH); + } + + String living() { + CharacterRef ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + String member = ref.characterId().toString(); + characters.put(member, info(ref, CharacterStatus.ALIVE)); + return member; + } + + Faction realm(String leader) { + Faction faction = fixture.saved("realm", leader); + faction.getOrCreateMainGuild(); + return faction; + } + + DeathJournal capture() throws Exception { return effects.capture(dead, UUID.randomUUID(), 1000); } + DeathJournal.Step step(DeathJournal journal, String kind) { + return journal.steps.stream().filter(value -> value.kind.equals(kind)).findFirst().orElseThrow(); + } + + @Test void capturedSuccessionAndFinancialClaimsSurviveRightsSaveBeforeSuccession() throws Exception { + Faction faction = realm(key); + Guild guild = faction.getOrCreateMainGuild(); + String oldest = living(), younger = living(); + guild.addMember(oldest); + guild.addMember(younger); + guild.getInvites().add(key); + MercenaryCompanyData company = new MercenaryCompanyData(); + company.name = "Company"; + company.enlisted.addAll(List.of(key, oldest)); + company.pendingWages.put(key, 12.34); + company.pendingWages.put(oldest, 7.0); + company.activeWageOverrides.put(key, 10.0); + company.peacetimeWageOverrides.put(key, 2.0); + guild.setCompany(new MercenaryCompany(guild, company, null)); + var sessions = fixture.ui.plugin.getSessionManager(); + sessions.newSession(fixture.player("Account"), faction); + DeathJournal journal = capture(); + assertTrue(effects.referencedCharacters().contains(key)); + assertEquals(oldest, step(journal, "succession").plan.get("successor").getAsString()); + var interrupted = new DeathJournalStore(folder.resolve("deaths"), (record, stage) -> { + if (stage.kind.equals("succession")) throw new IOException("interrupted after rights saved"); + return effects.apply(record, stage); + }); + assertThrows(IOException.class, () -> interrupted.record(journal)); + assertFalse(guild.getMembers().contains(key)); + assertFalse(guild.getInvites().contains(key)); + assertEquals("", faction.getLeader()); + assertFalse(guild.getCompany().getPendingWages().containsKey(key)); + assertEquals(7.0, guild.getCompany().getPendingWages().get(oldest)); + assertFalse(guild.getCompany().serialize().activeWageOverrides.containsKey(key)); + assertFalse(guild.getCompany().serialize().peacetimeWageOverrides.containsKey(key)); + assertEquals(Set.of(1234L, 255L), new HashSet<>(store.pendingPayments().stream().map(OwedPayment::cents).toList())); + // Reordering the live collection must not replace the successor recorded before effects. + guild.getMembers().remove(oldest); + guild.getMembers().add(oldest); + store.replayAll(); + assertEquals(oldest, faction.getLeader()); + store.replayAll(); + assertEquals(oldest, faction.getLeader()); + assertTrue(DurableJson.read(folder.resolve("deaths/" + journal.deathId + ".json"), DeathJournal.class).complete); + } + + @Test void failedFactionSaveKeepsClaimsUnavailableUntilAbsoluteCleanupCanRepeat() throws Exception { + Faction faction = realm(key); + faction.getOrCreateMainGuild().addMember(living()); + DeathJournal journal = capture(); + saveWorks.set(false); + assertThrows(IOException.class, () -> store.record(journal)); + assertTrue(store.pendingPayments().isEmpty()); + assertEquals("", faction.getLeader()); + saveWorks.set(true); + store.replayAll(); + assertFalse(faction.getLeader().isBlank()); + assertEquals(1, store.pendingPayments().size()); + } + + @Test void failedSuccessionSaveIsRetriedEvenWhenMemoryAlreadyHasItsFinalLeaderOrGuildRemoval() throws Exception { + for (boolean empty : List.of(false, true)) { + FactionManager.factions.clear(); + Faction faction = realm(living()); + Guild guild = fixture.guild(faction, "guild", key); + String successor = living(); + if (!empty) guild.addMember(successor); + DeathJournal journal = capture(); + var interrupted = new DeathJournalStore(folder.resolve("deaths"), (record, stage) -> { + if (stage.kind.equals("succession")) saveWorks.set(false); + return effects.apply(record, stage); + }); + saveWorks.set(true); + assertThrows(IOException.class, () -> interrupted.record(journal)); + if (empty) assertFalse(faction.getGuildHandler().getGuilds().contains(guild)); + else assertEquals(successor, guild.getLeader()); + int savesBeforeReplay = databases.constructed().size(); + saveWorks.set(true); + store.replayAll(); + assertTrue(databases.constructed().size() > savesBeforeReplay); + assertTrue(DurableJson.read(folder.resolve("deaths/" + journal.deathId + ".json"), DeathJournal.class).complete); + } + } + + @Test void ordinaryEmptyGuildIsRemovedButItsPaymentSurvives() throws Exception { + Faction faction = realm(living()); + Guild guild = fixture.guild(faction, "company", key); + guild.refreshDividendEligibility(); + UUID incarnation = UUID.fromString(guild.getIncarnation()); + store.record(capture()); + assertFalse(faction.getGuildHandler().getGuilds().contains(guild)); + assertEquals(incarnation, store.pendingPayments().getFirst().guildIncarnation()); + assertSame(faction, FactionManager.getByString("realm")); + } + + @Test void emptyRulingGuildPreservesLivingSubguildAndBlocksUntilStaffAppoint() throws Exception { + Faction faction = realm(key); + Guild livingGuild = fixture.guild(faction, "survivors", living()); + DeathJournal journal = capture(); + store.record(journal); + assertEquals("", faction.getLeader()); + assertTrue(faction.getGuildHandler().getGuilds().contains(livingGuild)); + assertFalse(DurableJson.read(folder.resolve("deaths/" + journal.deathId + ".json"), DeathJournal.class).complete); + faction.succeedDeadLeader(key, livingGuild.getLeader()); + store.replayAll(); + assertTrue(DurableJson.read(folder.resolve("deaths/" + journal.deathId + ".json"), DeathJournal.class).complete); + } + + @Test void electiveVacancyStartsElectionAndReplayDoesNotEraseLaterBallots() throws Exception { + fixture.lawGroup("constitution", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = realm(key); + fixture.guild(faction, "survivors", living()); + assertTrue(faction.getGovernment().hasLeaderElections()); + DeathJournal journal = capture(); + store.record(journal); + assertEquals("", faction.getLeader()); + assertTrue(faction.getGovernment().getElection().isActive()); + long started = faction.getGovernment().serialize().lastElectionDate; + faction.getGovernment().beginDeathElection(started); + assertEquals(started, faction.getGovernment().serialize().lastElectionDate); + } + + @Test void completelyEmptyRealmDeletesItsFileAndRelationsButPreservesClaims() throws Exception { + Faction faction = realm(key); + Faction survivor = fixture.saved("survivor", living()); + survivor.getOrCreateMainGuild(); + fixture.subject(survivor, faction); + Files.writeString(folder.resolve("Data/realm.json"), "old faction"); + DeathJournal journal = capture(); + store.record(journal); + assertNull(FactionManager.getByString("realm")); + assertFalse(Files.exists(folder.resolve("Data/realm.json"))); + assertFalse(store.pendingPayments().isEmpty()); + effects.apply(journal, step(journal, "succession")); + fixture.saved("realm", living()).getOrCreateMainGuild(); + assertThrows(IOException.class, () -> effects.apply(journal, step(journal, "succession"))); + } + + @Test void changedRealmMembershipAndQuarantinePreventDestructiveSuccession() throws Exception { + Faction faction = realm(key); + DeathJournal journal = capture(); + effects.apply(journal, step(journal, "rights")); + faction.getOrCreateMainGuild().addMember(living()); + assertFalse(effects.apply(journal, step(journal, "succession"))); + faction.getOrCreateMainGuild().getMembers().clear(); + faction.setLeader(Members.QUARANTINED + "unresolved"); + assertFalse(effects.apply(journal, step(journal, "succession"))); + step(journal, "succession").plan.addProperty("emptyRealm", false); + assertFalse(effects.apply(journal, step(journal, "succession"))); + } + + @Test void missingDeadOrReplacedSuccessorNeverSelectsANewPersonDuringReplay() throws Exception { + Faction faction = realm(living()); + Guild guild = fixture.guild(faction, "guild", key); + String successor = living(); + guild.addMember(successor); + DeathJournal journal = capture(); + effects.apply(journal, step(journal, "rights")); + CharacterInfo chosen = characters.get(successor); + characters.put(successor, info(chosen.ref(), CharacterStatus.DEAD)); + assertFalse(effects.apply(journal, step(journal, "succession"))); + assertEquals("", guild.getLeader()); + String staff = living(); + guild.setLeader(staff); + assertTrue(effects.apply(journal, step(journal, "succession"))); + assertEquals(staff, guild.getLeader()); + faction.getGuildHandler().removeGuild(guild.getId()); + assertTrue(effects.apply(journal, step(journal, "succession"))); + FactionManager.factions.clear(); + assertTrue(effects.apply(journal, step(journal, "rights"))); + assertTrue(effects.apply(journal, step(journal, "succession"))); + assertThrows(IOException.class, () -> effects.apply(journal, new DeathJournal.Step("bad", "unknown", new JsonObject()))); + } + + @Test void politicalCleanupRemovesOnlyTheDeadCharactersRights() { + Faction faction = realm(living()); + var government = faction.getGovernment(); + var council = government.getCouncil(); + String other = living(); + council.getMembers().addAll(List.of(key, other)); + council.toggleRefuse(key); + var proposal = new net.tfminecraft.simplefactions.government.proposal.Proposal(key, government); + council.getProposalHandler().getProposals().add(proposal); + var election = government.getElection(); + election.restoreFromData(Map.of("LEADER", List.of(key, other)), + Map.of("LEADER", Map.of(key, other, other, key, "survivor", other)), + Map.of("LEADER", Map.of(key, 3, other, 1)), List.of(key, other)); + var movement = mock(net.tfminecraft.simplefactions.government.movement.Movement.class); + var cause = mock(net.tfminecraft.simplefactions.government.movement.cause.Cause.class); + var supporters = new net.tfminecraft.simplefactions.government.movement.Pool(); + supporters.getCitizens().addAll(List.of(key, other)); + var causePool = new net.tfminecraft.simplefactions.government.movement.Pool(); + causePool.getCitizens().addAll(List.of(key, other)); + when(movement.getLeader()).thenReturn(key); + when(movement.getSupporters()).thenReturn(supporters); + when(movement.getCauses()).thenReturn(List.of(cause)); + when(cause.getLeader()).thenReturn(key); + when(cause.getPool()).thenReturn(causePool); + government.getMovements().add(movement); + government.removeDeadCharacter(key); + assertEquals(List.of(other), council.getMembers()); + assertFalse(council.refuses(key)); + assertTrue(council.getProposalHandler().getProposals().isEmpty()); + assertEquals(List.of(other), election.serializeCandidates().get("LEADER")); + assertEquals(Map.of("survivor", other), election.serializeVotes().get("LEADER")); + assertEquals(Map.of(other, 1), election.serializePreviousVotes().get("LEADER")); + assertEquals(List.of(other), election.getStoredEligibleVoters()); + assertEquals(List.of(other), supporters.getCitizens()); + assertEquals(List.of(other), causePool.getCitizens()); + verify(movement).setLeader(null); + verify(cause).setLeader(null); + } + + @Test void ordinaryGuildChoosesItsFirstLivingRemainingMember() throws Exception { + Faction faction = realm(living()); + Guild guild = fixture.guild(faction, "guild", key); + String fallen = UUID.randomUUID().toString(); + characters.put(fallen, info(new CharacterRef(UUID.randomUUID(), UUID.fromString(fallen)), CharacterStatus.DEAD)); + String oldest = living(), younger = living(); + guild.getMembers().addAll(List.of(fallen, oldest, younger)); + store.record(capture()); + assertEquals(oldest, guild.getLeader()); + } + + @Test void invalidFinancialAmountRefusesToCaptureBeforeAnyRightsAreRemoved() throws Exception { + Faction faction = realm(key); + when(faction.getOrCreateMainGuild().getLedger().getDividendBreakdown()) + .thenReturn(new DividendBreakdown(0, 0, 0, 0, 1, Double.NaN)); + assertThrows(IOException.class, this::capture); + assertTrue(faction.getOrCreateMainGuild().getMembers().contains(key)); + } + + @Test void warbandLeadershipAndInterruptedSaveReplayUseTheOriginalCharacters() throws Exception { + String successorKey = living(); + CharacterRef successor = characters.get(successorKey).ref(); + UUID unmapped = UUID.randomUUID(); + var band = Warband.createWithMemberIds("surviving-band", dead.ownerId(), true, unmapped, successor.ownerId()); + band.restoreMemberCharacters(Map.of(dead.ownerId(), dead, successor.ownerId(), successor)); + WarbandManager.addWarband(band); + assertTrue(effects.referencedCharacters().contains(key)); + DeathJournal journal = capture(); + Path warbandFile = folder.resolve("Warbands/warband_surviving-band.json"); + try (var disk = mockStatic(DurableJson.class, CALLS_REAL_METHODS)) { + disk.when(() -> DurableJson.write(eq(warbandFile), any())).thenThrow(new IOException("interrupted save")); + assertThrows(IOException.class, () -> store.record(journal)); + } + assertFalse(band.hasMember(dead.ownerId())); + assertEquals(successor.ownerId(), band.getLeaderId()); + store.replayAll(); + var saved = WarbandMapper.fromData(DurableJson.read(warbandFile, WarbandData.class)); + assertFalse(saved.hasMember(dead.ownerId())); + assertEquals(successor.ownerId(), saved.getLeaderId()); + var empty = Warband.createWithMemberIds("vacant-band", dead.ownerId(), true); + empty.restoreMemberCharacters(Map.of(dead.ownerId(), dead)); + WarbandManager.addWarband(empty); + store.record(capture()); + assertTrue(empty.isPendingLeader()); + } + + @Test void deathClearsOnlyItsOwnPendingRejoin() throws Exception { + var band = Warband.createWithMemberIds("band", dead.ownerId(), true); + band.restoreMemberCharacters(Map.of(dead.ownerId(), dead)); + WarbandManager.addWarband(band); + var membership = WarbandMembershipService.getInstance(); + membership.handleQuit(dead.ownerId()); + assertTrue(membership.getPendingRejoin(dead.ownerId()).isFor(dead)); + store.record(capture()); + assertNull(membership.getPendingRejoin(dead.ownerId())); + assertTrue(band.isPendingLeader()); + CharacterRef other = new CharacterRef(dead.ownerId(), UUID.randomUUID()); + band.addMember(dead.ownerId()); + band.restoreMemberCharacters(Map.of(dead.ownerId(), other)); + membership.handleQuit(dead.ownerId()); + store.record(capture()); + assertTrue(membership.getPendingRejoin(dead.ownerId()).isFor(other)); + } + + @Test void lastMemberDeathAlsoDisbandsAnAlreadyVacantRealm() throws Exception { + Faction faction = realm(""); + assertTrue(faction.getOrCreateMainGuild().getMembers().isEmpty()); + fixture.guild(faction, "last", key); + store.record(capture()); + assertNull(FactionManager.getByString("realm")); + } + + @Test void warbandRemovalUsesCapturedCharacterAfterSessionHasEnded() throws Exception { + var band = mock(Warband.class); + when(band.getId()).thenReturn("band"); + when(band.hasMember(dead.ownerId())).thenReturn(true); + when(band.getMemberCharacter(dead.ownerId())).thenReturn(Optional.of(dead)); + WarbandManager.addWarband(band); + var account = fixture.player("Account"); + when(account.getUniqueId()).thenReturn(dead.ownerId()); + DeathJournal journal = capture(); + try (var mapper = mockStatic(WarbandMapper.class)) { + mapper.when(() -> WarbandMapper.toData(band)).thenReturn(new net.tfminecraft.simplefactions.database.WarbandData()); + store.record(journal); + verify(band, atLeastOnce()).removeMember(dead.ownerId()); + assertTrue(Files.exists(folder.resolve("Warbands/warband_band.json"))); + } + var step = step(journal, "online"); + when(band.getMemberCharacter(dead.ownerId())).thenReturn(Optional.of(new CharacterRef(dead.ownerId(), UUID.randomUUID()))); + clearInvocations(band); + assertTrue(effects.apply(journal, step)); + verify(band, never()).removeMember(any()); + WarbandManager.resetForTests(); + assertTrue(effects.apply(journal, step)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java index fb56441b..a96be8fc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java @@ -117,6 +117,24 @@ private AsyncPlayerChatEvent chat(Player player, String message) { return new AsyncPlayerChatEvent(true, player, message, Set.of()); } + @Test + void characterDeathRemovesCurrentBallotAndHostDeathEndsCapturedSession() { + Faction faction = faction("home", "Alice", "Bob"); + Session session = started(faction, block(Material.LANTERN, 0)); + session.recordVote("Bob", Vote.YAY); + assertEquals("Alice", session.getLeaderCharacter()); + assertEquals(Vote.YAY, session.votesSnapshot().get("Bob")); + assertTrue(session.eligibleSnapshot().contains("Bob")); + session.removeDeadCharacter("Bob"); + assertFalse(session.votesSnapshot().containsKey("Bob")); + assertFalse(session.eligibleSnapshot().contains("Bob")); + assertFalse(session.recordVote("Bob", Vote.NAY)); + assertTrue(session.isStarted()); + session.removeDeadCharacter("Alice"); + assertFalse(session.isStarted()); + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + } + @Test void asyncChatDefersWorldHologramAndVoteChangesToTheCapturedMainThreadCallback() { Faction faction = faction("home", "Alice", "Bob"); From 761c9161767907c1bdc0ca9d2540684cf6dbe183 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:02:51 +0000 Subject: [PATCH 22/35] fix: retain private office death notice after durable cleanup --- .../espionage/EspionageState.java | 5 +++-- .../identity/death/FactionDeaths.java | 8 +++++++- .../identity/death/FactionDeathsTest.java | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 7abd7d03..49e78308 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -100,7 +100,7 @@ void rememberDeath(java.util.UUID deathId) { } /** The death journal owns completion; an appointment made after this death is preserved. */ - public void removeDeadCharacter(net.tfminecraft.tlibs.character.CharacterRef ref, java.util.UUID deathId, long at) { + public boolean removeDeadCharacter(net.tfminecraft.tlibs.character.CharacterRef ref, java.util.UUID deathId, long at) { var holder = getSpymaster(); boolean held = holder != null && ref.ownerId().equals(holder.playerId) && ref.characterId().equals(OfficeCharacters.characterUuid(holder.characterId)) @@ -108,10 +108,11 @@ public void removeDeadCharacter(net.tfminecraft.tlibs.character.CharacterRef ref boolean pending = isPendingFounder(SpecialPosition.SPYMASTER) && ref.characterId().equals(OfficeCharacters.characterUuid(pendingFounderCharacter(SpecialPosition.SPYMASTER))) && pendingFounderBoundAt(SpecialPosition.SPYMASTER) <= at; - if (!held && !pending) return; + if (!held && !pending) return false; removeSpymaster(); waiveAppointmentWait(SpecialPosition.SPYMASTER); rememberDeath(deathId); + return true; } public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java index 95a5b17c..8f7edef1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -133,7 +133,7 @@ private boolean removeRights(DeathJournal journal, JsonObject plan) throws IOExc for (Guild guild : faction.getGuildHandler().getGuilds()) guild.removeDeadCharacter(key); faction.succeedDeadLeader(key, ""); faction.getGovernment().removeDeadCharacter(key); - faction.getEspionage().removeDeadCharacter(new CharacterRef(journal.ownerId, journal.characterId), + boolean vacatedOffice = faction.getEspionage().removeDeadCharacter(new CharacterRef(journal.ownerId, journal.characterId), journal.deathId, journal.at); var plugin = SimpleFactions.getInstance(); if (plugin != null && plugin.getSessionManager() != null) { @@ -141,6 +141,12 @@ private boolean removeRights(DeathJournal journal, JsonObject plan) throws IOExc if (session != null) session.removeDeadCharacter(key); } save(faction); + if (vacatedOffice) { + var owner = org.bukkit.Bukkit.getPlayer(journal.ownerId); + String name = Members.info(key).map(CharacterInfo::name).orElse("your character"); + if (owner != null) owner.sendMessage("§8§oWith the passing of " + name + + ", the keys to the Spymaster's office return to the faction leader until a successor is appointed."); + } return true; } diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java index 55ca3e09..d49a6eef 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java @@ -264,6 +264,23 @@ DeathJournal.Step step(DeathJournal journal, String kind) { assertThrows(IOException.class, () -> effects.apply(journal, new DeathJournal.Step("bad", "unknown", new JsonObject()))); } + @Test void successfulOfficeCleanupPreservesTheExistingPrivateVacancyNotice() throws Exception { + Faction faction = realm(key); + faction.getOrCreateMainGuild().addMember(living()); + var holder = new net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment(); + holder.playerId = dead.ownerId(); + holder.characterId = key; + faction.getEspionage().assignFounder(net.tfminecraft.simplefactions.espionage.SpecialPosition.SPYMASTER, holder, 50); + holder.characterBoundAt = 0; + var owner = fixture.player("Owner"); + when(owner.getUniqueId()).thenReturn(dead.ownerId()); + store.record(capture()); + assertNull(faction.getEspionage().getSpymaster()); + verify(owner).sendMessage(contains("passing of Character")); + store.replayAll(); + verify(owner).sendMessage(contains("passing of Character")); + } + @Test void politicalCleanupRemovesOnlyTheDeadCharactersRights() { Faction faction = realm(living()); var government = faction.getGovernment(); From 8312b60c355b56752a56c8189def99cc705f5e89 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:13:34 +0000 Subject: [PATCH 23/35] feat: plan vehicle upkeep in the realm settlement --- .../maintenance/VehicleSettlement.java | 150 ++++++++++++ .../maintenance/VehicleSettlementTest.java | 219 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java new file mode 100644 index 00000000..db4f08de --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java @@ -0,0 +1,150 @@ +package net.tfminecraft.simplefactions.vehicles.maintenance; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.economy.settlement.RealmSettlementChanges; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.tlibs.character.CharacterInfo; + +/** Adds vehicle costs last to the realm posting; no live money or maintenance state changes here. */ +public final class VehicleSettlement { + private VehicleSettlement() {} + + public static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes) { + calculate(balances, legs, changes, SimpleFactions.getVehicleRegistry(), + SimpleFactions.getInstance().getVehicleMaintenanceStore(), System.currentTimeMillis()); + } + + static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes, + PlayerVehicleRegistry registry, VehicleMaintenanceStore maintenance, long now) { + Map<String, Long> unpaid = new LinkedHashMap<>(maintenance.snapshot()); + JsonArray notices = new JsonArray(); + for (var vehicle : VehicleOwnershipQueries.allPersonalVehicles(registry)) { + long upkeep = Money.cents(VehiclesConfigLoader.getUpkeep(vehicle.getTypeId())); + if (upkeep <= 0) { + unpaid.remove(vehicle.getUuid()); + continue; + } + var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); + String payer = record == null || record.getPlayerUuid() == null ? null : record.getPlayerUuid().toString(); + var character = Members.info(payer).filter(CharacterInfo::alive); + if (character.isEmpty()) { + markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + continue; + } + var ref = character.get().ref(); + var tax = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, payer, vehicle.getTypeId()); + long fee = tax == null ? 0 : Money.cents(tax.amount()); + long total = Math.addExact(upkeep, fee); + String account = Denars.bank(ref); + if ((tax != null && tax.faction().getBank() == null) || balance(balances, legs, account) < total) { + markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + continue; + } + transfer(balances, legs, account, Denars.MINT, upkeep); + income(changes, ref.characterId(), PlayerCashflow.VEHICLE_UPKEEP, -upkeep); + if (tax != null) { + var guild = tax.faction().getOrCreateMainGuild(); + var saved = changes.guilds.stream().filter(value -> guild.getIncarnationId().equals(value.incarnation)) + .findFirst().orElseThrow(() -> new IllegalStateException("Vehicle tax guild absent from settlement")); + transfer(balances, legs, account, Denars.guild(guild.getIncarnationId()), fee); + saved.vehicleFeeIncome = Money.denars(Math.addExact(Money.cents(saved.vehicleFeeIncome), fee)); + income(changes, ref.characterId(), PlayerCashflow.VEHICLE_TAX, -fee); + } + unpaid.remove(vehicle.getUuid()); + } + for (var record : registry.getAll()) { + if (record.getMode() == OwnershipMode.PERSONAL) continue; + long upkeep = Money.cents(VehiclesConfigLoader.getUpkeep(record.getVehicleTypeId())); + var faction = FactionVehiclePoolService.payingFaction(record); + if (upkeep <= 0 || (faction == null && record.getMode() == OwnershipMode.INSTALLATION)) { + unpaid.remove(record.getVehicleUuid()); + continue; + } + String account = faction == null ? null : Denars.guild(faction.getOrCreateMainGuild().getIncarnationId()); + if (faction == null || faction.getBank() == null || balance(balances, legs, account) < upkeep) { + markUnpaid(unpaid, notices, record.getVehicleUuid(), record.getVehicleTypeId(), null, + faction == null ? null : faction.getId(), now); + continue; + } + transfer(balances, legs, account, Denars.MINT, upkeep); + unpaid.remove(record.getVehicleUuid()); + } + changes.vehicleChanges.add("maintenance", JsonUtil.GSON.toJsonTree(unpaid)); + changes.vehicleChanges.add("notices", notices); + } + + public static void apply(JsonObject changes) throws IOException { + var plugin = SimpleFactions.getInstance(); + apply(changes, plugin.getDataFolder().toPath(), plugin.getVehicleMaintenanceStore()); + } + + static void apply(JsonObject changes, Path folder, VehicleMaintenanceStore maintenance) throws IOException { + Map<String, Long> unpaid = new LinkedHashMap<>(); + if (!changes.has("maintenance") || !changes.get("maintenance").isJsonObject()) + throw new IOException("Missing vehicle maintenance after-values"); + try { + changes.getAsJsonObject("maintenance").entrySet().forEach(entry -> unpaid.put(entry.getKey(), entry.getValue().getAsLong())); + } catch (RuntimeException error) { + throw new IOException("Invalid vehicle maintenance after-values", error); + } + DurableJson.write(folder.resolve("Cache/vehicle_maintenance.json"), unpaid); + maintenance.replaceAll(unpaid); + if (changes.has("notices")) { + for (var value : changes.getAsJsonArray("notices")) { + JsonObject notice = value.getAsJsonObject(); + String character = notice.has("character") ? notice.get("character").getAsString() : null; + if (notice.has("faction")) { + var faction = net.tfminecraft.simplefactions.managers.FactionManager.getByString(notice.get("faction").getAsString()); + if (faction != null) character = faction.getLeader(); + } + Members.online(character).ifPresent(player -> player.sendMessage( + "§cCould not pay vehicle upkeep (" + notice.get("type").getAsString() + "): insufficient bank balance.")); + } + } + } + + private static long balance(Map<String, Long> balances, Map<String, Long> legs, String account) { + return balances.computeIfAbsent(account, key -> Math.addExact(Denars.balance(key), legs.getOrDefault(key, 0L))); + } + + private static void transfer(Map<String, Long> balances, Map<String, Long> legs, String from, String to, long amount) { + balances.computeIfPresent(from, (key, value) -> Math.subtractExact(value, amount)); + balances.computeIfPresent(to, (key, value) -> Math.addExact(value, amount)); + legs.merge(from, -amount, Math::addExact); + legs.merge(to, amount, Math::addExact); + } + + private static void income(RealmSettlementChanges changes, UUID character, PlayerCashflow kind, long cents) { + changes.playerIncome.computeIfAbsent(character, ignored -> new LinkedHashMap<>()) + .merge(kind.name(), Money.denars(cents), Double::sum); + } + + private static void markUnpaid(Map<String, Long> unpaid, JsonArray notices, String vehicle, String type, + String character, String faction, long now) { + unpaid.putIfAbsent(vehicle, now); + JsonObject notice = new JsonObject(); + notice.addProperty("type", type); + if (character != null) notice.addProperty("character", character); + if (faction != null) notice.addProperty("faction", faction); + notices.add(notice); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java new file mode 100644 index 00000000..4ee0b380 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java @@ -0,0 +1,219 @@ +package net.tfminecraft.simplefactions.vehicles.maintenance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.economy.settlement.RealmSettlementChanges; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; +import net.tfminecraft.simplefactions.vehicles.registry.*; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class VehicleSettlementTest { + @TempDir Path folder; + final CharacterRef payer = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + final VehicleMaintenanceStore maintenance = new VehicleMaintenanceStore(); + final Map<String, Long> balances = new LinkedHashMap<>(); + final Map<String, Long> legs = new LinkedHashMap<>(); + final RealmSettlementChanges changes = new RealmSettlementChanges(); + MockedStatic<Denars> denars; + MockedStatic<Members> members; + MockedStatic<VehicleFeeService> fees; + MockedStatic<FactionVehiclePoolService> pools; + FakeOwnedInventory inventory; + + @BeforeEach void setup() throws Exception { + Path config = folder.resolve("vehicles.yml"); + Files.writeString(config, "categories:\n ships:\n ship:\n upkeep: 20\n size: 1\n free:\n upkeep: 0\n size: 1\n"); + VehiclesConfigLoader.load(config.toFile()); + inventory = new FakeOwnedInventory(); + VehicleOwnershipQueries.setSourceForTests(inventory); + denars = mockStatic(Denars.class, CALLS_REAL_METHODS); + denars.when(() -> Denars.balance(anyString())).thenReturn(10000L); + members = mockStatic(Members.class, CALLS_REAL_METHODS); + members.when(() -> Members.info(any())).thenReturn(Optional.empty()); + members.when(() -> Members.info(payer.characterId().toString())).thenReturn(Optional.of(new CharacterInfo( + payer, "Payer", CharacterStatus.ALIVE, null, Set.of(), 0, Instant.EPOCH))); + members.when(() -> Members.online(any())).thenReturn(Optional.empty()); + fees = mockStatic(VehicleFeeService.class); + pools = mockStatic(FactionVehiclePoolService.class); + } + + @AfterEach void close() { + pools.close(); fees.close(); members.close(); denars.close(); + VehicleOwnershipQueries.setSourceForTests(null); + } + + PlayerVehicleRecord vehicle(String id, String type, UUID character, OwnershipMode mode) { + var record = new PlayerVehicleRecord(character, id, type, mode, "fort", "realm"); + registry.register(record); + inventory.add(id, type, "player_UnrelatedAccountTag"); + return record; + } + + Faction faction() { + Faction faction = mock(Faction.class); + Guild guild = mock(Guild.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getLeader()).thenReturn(payer.characterId().toString()); + when(faction.getBank()).thenReturn(mock(Bank.class)); + when(faction.getOrCreateMainGuild()).thenReturn(guild); + UUID incarnation = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(incarnation); + RealmSettlementChanges.GuildChange saved = new RealmSettlementChanges.GuildChange(); + saved.id = "realm"; saved.incarnation = incarnation; + changes.guilds.add(saved); + return faction; + } + + void calculate() { VehicleSettlement.calculate(balances, legs, changes, registry, maintenance, 1000); } + JsonObject after() { return changes.vehicleChanges.getAsJsonObject("maintenance"); } + + @Test void registryCharacterPaysUpkeepAndTaxAtomicallyUsingEarlierSettlementIncome() { + vehicle("v", "ship", payer.characterId(), OwnershipMode.PERSONAL); + maintenance.markUnpaid("v", 1); + Faction faction = faction(); + var quote = new VehicleFeeService.Quote(FeeKind.VEHICLE_TAX, "ship", faction, 25, 5); + fees.when(() -> VehicleFeeService.quote(FeeKind.VEHICLE_TAX, payer.characterId().toString(), "ship")).thenReturn(quote); + String account = Denars.bank(payer); + denars.when(() -> Denars.balance(account)).thenReturn(0L); + legs.put(account, 5000L); + legs.put(Denars.MINT, -5000L); + String treasury = Denars.guild(changes.guilds.getFirst().incarnation); + balances.put(treasury, 1000L); + calculate(); + assertEquals(2500L, balances.get(account)); + assertEquals(2500L, legs.get(account)); + assertEquals(1500L, balances.get(treasury)); + assertEquals(500L, legs.get(treasury)); + assertEquals(-3000L, legs.get(Denars.MINT)); + assertEquals(0, legs.values().stream().mapToLong(Long::longValue).sum()); + assertEquals(5, changes.guilds.getFirst().vehicleFeeIncome); + assertEquals(Map.of("VEHICLE_UPKEEP", -20.0, "VEHICLE_TAX", -5.0), changes.playerIncome.get(payer.characterId())); + assertTrue(maintenance.isUnpaid("v")); + assertFalse(after().has("v")); + denars.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); + } + + @Test void personalShortfallMovesNeitherUpkeepNorTaxAndPreservesOriginalUnpaidDate() { + vehicle("v", "ship", payer.characterId(), OwnershipMode.PERSONAL); + maintenance.markUnpaid("v", 1); + Faction faction = faction(); + fees.when(() -> VehicleFeeService.quote(any(), any(), any())).thenReturn( + new VehicleFeeService.Quote(FeeKind.VEHICLE_TAX, "ship", faction, 25, 5)); + balances.put(Denars.bank(payer), 2499L); + calculate(); + assertTrue(legs.isEmpty()); + assertEquals(1, after().get("v").getAsLong()); + assertTrue(changes.playerIncome.isEmpty()); + assertEquals(0, changes.guilds.getFirst().vehicleFeeIncome); + } + + @Test void missingRegistryCharacterNeverFallsBackToVehicleFrameworkOwner() { + inventory.add("unknown", "ship", "player_CurrentAccount"); + vehicle("missing", "ship", UUID.randomUUID(), OwnershipMode.PERSONAL); + vehicle("free", "free", payer.characterId(), OwnershipMode.PERSONAL); + maintenance.markUnpaid("free", 1); + calculate(); + assertEquals(1000, after().get("unknown").getAsLong()); + assertEquals(1000, after().get("missing").getAsLong()); + assertFalse(after().has("free")); + assertTrue(legs.isEmpty()); + denars.verify(() -> Denars.balance(anyString()), never()); + } + + @Test void pooledVehiclesCannotBorrowAndOrphanInstallationsStopDecaying() { + var orphan = vehicle("orphan", "ship", null, OwnershipMode.INSTALLATION); + var pool = vehicle("pool", "ship", null, OwnershipMode.POOL); + var negative = vehicle("negative", "ship", null, OwnershipMode.POOL); + var paid = vehicle("paid", "ship", null, OwnershipMode.INSTALLATION); + vehicle("free", "free", null, OwnershipMode.POOL); + maintenance.markUnpaid(orphan.getVehicleUuid(), 1); + maintenance.markUnpaid(paid.getVehicleUuid(), 1); + maintenance.markUnpaid("free", 1); + Faction debtor = faction(), rich = faction(); + pools.when(() -> FactionVehiclePoolService.payingFaction(negative)).thenReturn(debtor); + pools.when(() -> FactionVehiclePoolService.payingFaction(paid)).thenReturn(rich); + String debtAccount = Denars.guild(debtor.getOrCreateMainGuild().getIncarnationId()); + String richAccount = Denars.guild(rich.getOrCreateMainGuild().getIncarnationId()); + balances.put(debtAccount, -100L); + balances.put(richAccount, 2000L); + calculate(); + assertEquals(-100L, balances.get(debtAccount)); + assertEquals(0L, balances.get(richAccount)); + assertEquals(-2000L, legs.get(richAccount)); + assertFalse(after().has("orphan")); + assertFalse(after().has("paid")); + assertFalse(after().has("free")); + assertTrue(after().has("pool")); + assertTrue(after().has("negative")); + assertTrue(maintenance.isUnpaid("orphan")); + } + + @Test void durableAfterValuesReplayWithoutChangingUnpaidAgesOrChargingAgain() throws Exception { + vehicle("v", "ship", null, OwnershipMode.PERSONAL); + calculate(); + var online = mock(Player.class); + members.when(() -> Members.online(any())).thenReturn(Optional.of(online)); + VehicleSettlement.apply(changes.vehicleChanges, folder, maintenance); + VehicleSettlement.apply(changes.vehicleChanges, folder, maintenance); + assertEquals(Map.of("v", 1000L), maintenance.snapshot()); + assertEquals(1000, DurableJson.read(folder.resolve("Cache/vehicle_maintenance.json"), JsonObject.class).get("v").getAsLong()); + verify(online, times(2)).sendMessage(contains("insufficient bank")); + denars.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); + } + + @Test void invalidOrUnwritableAfterValuesDoNotChangeTheLiveStore() throws Exception { + maintenance.markUnpaid("old", 5); + assertThrows(IOException.class, () -> VehicleSettlement.apply(new JsonObject(), folder, maintenance)); + JsonObject corrupt = new JsonObject(); + JsonObject map = new JsonObject(); map.addProperty("v", "bad"); corrupt.add("maintenance", map); + assertThrows(IOException.class, () -> VehicleSettlement.apply(corrupt, folder, maintenance)); + calculate(); + Files.writeString(folder.resolve("blocked"), "file"); + assertThrows(IOException.class, () -> VehicleSettlement.apply(changes.vehicleChanges, folder.resolve("blocked"), maintenance)); + assertEquals(Map.of("old", 5L), maintenance.snapshot()); + } + + @Test void pluginAdaptersUseTheLoadedRegistryAndNotifyTheFactionLeader() throws Exception { + var record = vehicle("pool", "ship", null, OwnershipMode.POOL); + Faction faction = faction(); + pools.when(() -> FactionVehiclePoolService.payingFaction(record)).thenReturn(faction); + balances.put(Denars.guild(changes.guilds.getFirst().incarnation), 0L); + var plugin = mock(SimpleFactions.class); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + when(plugin.getVehicleMaintenanceStore()).thenReturn(maintenance); + var player = mock(Player.class); + members.when(() -> Members.online(payer.characterId().toString())).thenReturn(Optional.of(player)); + try (var plugins = mockStatic(SimpleFactions.class); var factions = mockStatic(FactionManager.class)) { + plugins.when(SimpleFactions::getInstance).thenReturn(plugin); + plugins.when(SimpleFactions::getVehicleRegistry).thenReturn(registry); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(faction); + VehicleSettlement.calculate(balances, legs, changes); + VehicleSettlement.apply(changes.vehicleChanges); + verify(player).sendMessage(contains("insufficient bank")); + } + } +} From e515d2688488cdc1ab26c7a2ac538648e57c484b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:22:33 +0000 Subject: [PATCH 24/35] feat: plan durable realm settlement and recovery --- .../simplefactions/SimpleFactions.java | 59 +++- .../simplefactions/database/DurableJson.java | 7 +- .../economy/EconomyRuntime.java | 22 ++ .../economy/OperationJournal.java | 111 ++++++ .../settlement/DailyPreparationJournal.java | 157 ++++++++ .../economy/settlement/FactionUpkeepPlan.java | 81 +++++ .../RealmSettlementCalculation.java | 334 ++++++++++++++++++ .../settlement/RealmSettlementChanges.java | 36 ++ .../settlement/RealmSettlementEffects.java | 106 ++++++ .../settlement/RealmSettlementService.java | 48 +++ .../economy/settlement/SettlementGuilds.java | 14 + .../economy/settlement/SettlementJournal.java | 109 ++++++ .../government/movement/Movement.java | 4 + .../simplefactions/guild/Guild.java | 4 + .../simplefactions/guild/branch/Branch.java | 1 + .../simplefactions/guild/income/Ledger.java | 145 +++++--- .../guild/income/SettlementLedgerView.java | 31 ++ .../simplefactions/guild/loans/Loan.java | 44 ++- .../guild/loans/LoanHandler.java | 4 + .../handler/InstallationHandler.java | 9 + .../managers/FactionManager.java | 186 ++-------- .../mercenary/company/MercenaryCompany.java | 12 + .../mercenary/contract/MercenaryContract.java | 12 + .../simplefactions/objects/Bank.java | 77 ++-- .../player/PlayerEconomyManager.java | 6 +- .../simplefactions/utils/FactionCleanup.java | 18 + .../simplefactions/utils/Wealth.java | 10 +- .../economy/OperationJournalTest.java | 93 +++++ .../RealmSettlementCalculationTest.java | 149 ++++++++ .../settlement/SettlementJournalTest.java | 112 ++++++ .../testsupport/GuiTestFixture.java | 3 + .../testsupport/TestTreasuries.java | 51 +++ 32 files changed, 1784 insertions(+), 271 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/EconomyRuntime.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/OperationJournal.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementChanges.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementGuilds.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournal.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/income/SettlementLedgerView.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/OperationJournalTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournalTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/testsupport/TestTreasuries.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index bec4ff9c..cd06faf2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -140,6 +140,8 @@ public class SimpleFactions extends JavaPlugin{ private final MercenaryCommandManager mercenaryCommandManager = new MercenaryCommandManager(); private final BankManager bankManager = new BankManager(); private final Database db = new Database(); + private net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService realmSettlementService; + public net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService getRealmSettlementService() { return realmSettlementService; } private final FactionManager factionManager = new FactionManager(); private final TitleManager titleManager = new TitleManager(); private final PlayerManager playerManager = new PlayerManager(); @@ -274,7 +276,7 @@ public void onEnable() { net.tfminecraft.tlibs.character.Characters.legacyHeirsReady(); } catch (LinkageError error) { incompatibleCharacterApi = true; - getLogger().severe("SimpleFactions requires TLibs 2.4.0 or newer; disabling."); + getLogger().severe("SimpleFactions requires TLibs 2.4.1 or newer; disabling."); getServer().getPluginManager().disablePlugin(this); return; } @@ -321,7 +323,7 @@ private void awaitIdentity() { getServer().getPluginManager().registerEvents(new Listener() { @EventHandler public void onPluginEnable(PluginEnableEvent event) { - if ("RPCharacters".equalsIgnoreCase(event.getPlugin().getName()) && identityReady()) start(); + if (("RPCharacters".equalsIgnoreCase(event.getPlugin().getName()) || "DenarEconomy".equalsIgnoreCase(event.getPlugin().getName())) && identityReady()) start(); } }, this); identityPoll = getServer().getScheduler().runTaskTimer(this, () -> { @@ -330,10 +332,22 @@ public void onPluginEnable(PluginEnableEvent event) { } static boolean identityReady() { - return net.tfminecraft.tlibs.character.Characters.available() + return net.tfminecraft.denareconomy.api.Denars.ready() + && net.tfminecraft.tlibs.character.Characters.available() && net.tfminecraft.tlibs.character.Characters.legacyHeirsReady(); } + /** A persisted operation must finish before another character or money mutation is allowed. */ + public void failFinancialState(Throwable error) { + started = false; + startupComplete = false; + startFailed = true; + getServer().getServicesManager().unregisterAll(this); + getLogger().log(java.util.logging.Level.SEVERE, + "Faction money recovery is incomplete; disabling until the saved operation can be recovered", error); + getServer().getPluginManager().disablePlugin(this); + } + /** Whether the data is migrated and everything has started; see {@code SimpleFactionsApi.ready()}. */ public boolean isStarted() { return started; @@ -376,8 +390,7 @@ private void startServices() { net.tfminecraft.simplefactions.identity.LeaderCharacters.useSharedIdentity(); net.tfminecraft.simplefactions.prestige.MemberPlaytime.useSharedIdentity(); net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility.useSharedIdentity(); - registerListeners(); - registerRpCharactersChatHooks(); + vehicleRegistryPersistence = new VehicleRegistryPersistence( new File(getDataFolder(), "Cache"), vehicleRegistry); @@ -445,7 +458,27 @@ private void startServices() { return; } } - factionManager.run(); + factionManager.prepare(); + WarManager.start(); + net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.loadAll(); + try { + for (var guild : FactionManager.getAllGuilds()) { + net.tfminecraft.simplefactions.economy.GuildTreasury.importLegacy(guild.getIncarnationId(), + guild.getBank() == null ? 0 : guild.getBank().legacyBalance(), guild.getLedger().getCitizenTaxesCopy()); + guild.updateWealth(); + } + net.tfminecraft.simplefactions.identity.death.CharacterDeaths.load(getDataFolder().toPath()); + realmSettlementService = new net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService(getDataFolder().toPath(), db); + realmSettlementService.recover(); + net.tfminecraft.simplefactions.identity.death.CharacterDeaths.reconcile(); + net.tfminecraft.simplefactions.economy.EconomyRuntime.recovered(); + } catch (Exception error) { + failFinancialState(error); + return; + } + factionManager.startTicks(); + registerListeners(); + registerRpCharactersChatHooks(); if (Cache.provincesEnabled) { ProvincePresenceTickService.start(); } @@ -466,8 +499,6 @@ private void startServices() { getCommand(MovementCommandManager.CMD).setExecutor(movementCommandManager); getCommand(MovementCommandManager.CMD).setTabCompleter(new MovementTabCompletion()); RequestManager.start(); - WarManager.start(); - net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.loadAll(); net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.startAutosave(); BattleScheduleTickService.start(); CampaignViewRefreshService.start(); @@ -482,8 +513,14 @@ private void startServices() { inventoryManager.start(); vehicleMaintenanceDecayTask.start(); startupComplete = true; - started = true; - net.tfminecraft.simplefactions.identity.LeaderCharacterListener.refresh(null); + started = true; + getServer().getServicesManager().register(net.tfminecraft.denareconomy.api.TaxPolicy.class, + new net.tfminecraft.simplefactions.economy.GuildTaxPolicy(), this, org.bukkit.plugin.ServicePriority.Normal); + getServer().getScheduler().runTaskTimer(this, () -> { + try { net.tfminecraft.simplefactions.identity.death.CharacterDeaths.reconcile(); } + catch (Exception error) { net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed(error); } + }, 72000L, 72000L); + net.tfminecraft.simplefactions.identity.LeaderCharacterListener.refresh(null); } @Override public void onDisable() { @@ -491,6 +528,8 @@ public void onDisable() { if (identityPoll != null) identityPoll.cancel(); identityPoll = null; started = false; + getServer().getServicesManager().unregisterAll(this); + net.tfminecraft.simplefactions.identity.death.CharacterDeaths.stop(); if (!servicesBegun) { // Still waiting for identity: nothing was loaded, so nothing may be saved. return; diff --git a/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java b/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java index 630f98f1..82ec7009 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java @@ -25,8 +25,11 @@ public static void write(Path file, Object data) throws IOException { channel.force(true); } Files.move(pending, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + // A previous failed attempt may already have created an unsynced parent entry. + for (Path path = directory; path != null; path = path.getParent()) { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } } } finally { Files.deleteIfExists(pending); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/EconomyRuntime.java b/src/main/java/net/tfminecraft/simplefactions/economy/EconomyRuntime.java new file mode 100644 index 00000000..c43467d3 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/EconomyRuntime.java @@ -0,0 +1,22 @@ +package net.tfminecraft.simplefactions.economy; + +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; + +/** Stops new actions after an uncertain posting or incomplete durable effect. */ +public final class EconomyRuntime { + private static boolean failed; + private EconomyRuntime() {} + + public static boolean ready() { return !failed && SimpleFactionsApi.ready() && Denars.ready(); } + + /** Called only after every imported balance and stored intent has recovered successfully. */ + public static void recovered() { failed = false; } + + public static void failClosed(Throwable error) { + failed = true; + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin != null) plugin.failFinancialState(error); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/OperationJournal.java b/src/main/java/net/tfminecraft/simplefactions/economy/OperationJournal.java new file mode 100644 index 00000000..8581dead --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/OperationJournal.java @@ -0,0 +1,111 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; + +/** Caller-owned operation IDs and replayable domain after-values around one atomic money posting. */ +public final class OperationJournal { + public record Intent(String id, String reason, List<Leg> legs, JsonObject changes) { + public Intent { + if (id == null || id.isBlank() || reason == null || reason.isBlank() || legs.isEmpty()) + throw new IllegalArgumentException("An intent needs an ID, reason and balanced legs"); + legs = List.copyOf(legs); + changes = changes.deepCopy(); + } + @Override public JsonObject changes() { return changes.deepCopy(); } + } + @FunctionalInterface public interface Effects { void apply(Intent intent) throws IOException; } + private static final class Saved { + Intent intent; + String hash; + Result completed; + } + private final Path folder; + public OperationJournal(Path folder) { this.folder = folder; } + + /** Replays the original values for an intrinsic retry, without rebuilding them from changed state. */ + public java.util.Optional<Result> resume(String id, Effects effects) throws IOException { + Path path = folder.resolve(hash(id) + ".json"); + return Files.exists(path) ? java.util.Optional.of(finish(path, read(path), effects)) : java.util.Optional.empty(); + } + + public Result execute(Intent intent, Effects effects) throws IOException { + Path path = folder.resolve(hash(intent.id()) + ".json"); + Saved saved; + if (Files.exists(path)) { + saved = read(path); + if (!JsonUtil.GSON.toJsonTree(saved.intent).equals(JsonUtil.GSON.toJsonTree(intent))) return Result.CONFLICT; + } else { + saved = new Saved(); + saved.intent = intent; + saved.hash = hash(JsonUtil.GSON.toJson(intent)); + DurableJson.write(path, saved); + } + return finish(path, saved, effects); + } + + public void recover(Effects effects) throws IOException { + if (!Files.exists(folder)) return; + try (var files = Files.list(folder)) { + for (Path path : files.filter(p -> p.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(p -> p.getFileName().toString())).toList()) { + Result result = finish(path, read(path), effects); + if (result != Result.APPLIED && result != Result.REFUSED) + throw new IOException("Money intent remains " + result + ": " + path); + } + } + } + + /** Restores split-file topology from already posted intents before domain objects are loaded. */ + public void recoverAppliedEffects(Effects effects) throws IOException { + if (!Files.exists(folder)) return; + try (var files = Files.list(folder)) { + for (Path path : files.filter(p -> p.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(p -> p.getFileName().toString())).toList()) { + Saved saved = read(path); + if (saved.completed == null && Denars.status(saved.intent.id()).orElse(null) == Result.APPLIED) + effects.apply(saved.intent); + } + } + } + + private Result finish(Path path, Saved saved, Effects effects) throws IOException { + Intent intent = saved.intent; + if (saved.completed != null) { + if (Denars.status(intent.id()).orElse(null) != saved.completed) + throw new IOException("Completed money intent disagrees with ledger: " + intent.id()); + return saved.completed; + } + Result result = Denars.post(intent.id(), intent.reason(), intent.legs()); + if (result == Result.APPLIED) effects.apply(intent); + if (result == Result.APPLIED || result == Result.REFUSED) { + saved.completed = result; + DurableJson.write(path, saved); + } + return result; + } + + private static Saved read(Path path) throws IOException { + Saved saved = DurableJson.read(path, Saved.class); + if (saved.intent == null || !hash(JsonUtil.GSON.toJson(saved.intent)).equals(saved.hash) + || !path.getFileName().toString().equals(hash(saved.intent.id()) + ".json") + || saved.completed == Result.CONFLICT || saved.completed == Result.UNAVAILABLE) + throw new IOException("Invalid money intent: " + path); + return saved; + } + + @SuppressWarnings("deprecation") + private static String hash(String value) { + return com.google.common.hash.Hashing.sha256().hashString(value, StandardCharsets.UTF_8).toString(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java new file mode 100644 index 00000000..768bf497 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java @@ -0,0 +1,157 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import net.tfminecraft.simplefactions.war.civilwar.wartime.CivilWarBorderLock; + +/** Replays the nonfinancial preparation of a day before its monetary plan is published. */ +public final class DailyPreparationJournal { + private record Removal(String faction, UUID incarnation, Integer province) {} + public record Prepared(long day, long timestamp, List<FactionUpkeepPlan> upkeep) {} + private static final class Saved { + long day; + long timestamp; + List<Removal> removals; + List<FactionUpkeepPlan> upkeep; + Map<String, Map<String, Double>> organization; + Map<String, Integer> offlineDays; + boolean prepared; + boolean settled; + } + private final Path folder; + private final Database database; + public DailyPreparationJournal(Path folder, Database database) { + this.folder = folder; + this.database = database; + } + + public List<Long> unfinishedDays() throws IOException { + if (!Files.exists(folder)) return List.of(); + List<Long> days = new ArrayList<>(); + try (var files = Files.list(folder)) { + for (Path file : files.filter(p -> p.getFileName().toString().matches("[0-9]+\\.json")).toList()) { + Saved saved = read(file); + if (!saved.settled) days.add(saved.day); + } + } + return days.stream().sorted().toList(); + } + + public Prepared prepare(long day, long now) throws IOException { + Path path = file(day); + Saved saved; + if (Files.exists(path)) saved = read(path); + else { + saved = new Saved(); + saved.day = day; + saved.timestamp = now; + saved.removals = new ArrayList<>(); + saved.upkeep = new ArrayList<>(); + for (Faction faction : FactionManager.factions.stream().sorted(Comparator.comparing(Faction::getId)).toList()) { + saved.removals.add(new Removal(faction.getId(), SettlementGuilds.capital(faction).getIncarnationId(), removal(faction))); + saved.upkeep.add(FactionUpkeepPlan.calculate(faction)); + } + DurableJson.write(path, saved); + } + if (!saved.prepared) { + if (saved.organization == null) { + for (Removal removal : saved.removals) { + Faction faction = require(removal); + // A faction save includes ownership, dependent titles, settlements and installations. + if (removal.province() != null && faction.hasProvince(removal.province())) + faction.removeProvince(removal.province(), true); + save(faction); + } + saved.organization = new LinkedHashMap<>(); + for (Removal removal : saved.removals) { + Faction faction = require(removal); + Map<String, Double> values = new LinkedHashMap<>(); + for (Movement movement : faction.getGovernment().getMovements()) { + if (!movement.isFrozen()) values.put(movement.getId(), Math.clamp( + movement.getOrganization() + movement.getOrganizationGain(), 0, movement.getMaxOrganization())); + } + saved.organization.put(faction.getId(), values); + } + saved.offlineDays = FactionCleanup.planOfflineDays(FactionManager.factions); + List<Removal> removals = saved.removals; + saved.upkeep = saved.upkeep.stream().map(plan -> afterProvinceLoss(plan, removals)).toList(); + DurableJson.write(path, saved); + } + for (Removal removal : saved.removals) { + Faction faction = require(removal); + for (var value : saved.organization.get(faction.getId()).entrySet()) { + Movement movement = faction.getGovernment().getMovementById(value.getKey()); + if (movement == null) throw new IOException("Prepared movement disappeared: " + value.getKey()); + movement.restoreOrganization(value.getValue()); + } + save(faction); + } + FactionCleanup.restoreOfflineDays(saved.offlineDays); + saved.prepared = true; + DurableJson.write(path, saved); + } + return new Prepared(saved.day, saved.timestamp, saved.upkeep); + } + + public void complete(long day) throws IOException { + Path path = file(day); + Saved saved = read(path); + saved.settled = true; + DurableJson.write(path, saved); + } + + private static FactionUpkeepPlan afterProvinceLoss(FactionUpkeepPlan plan, List<Removal> removals) { + Integer lost = removals.stream().filter(r -> r.faction().equals(plan.faction())).findFirst().orElseThrow().province(); + if (lost == null) return plan; + var installations = plan.installations().stream().filter(i -> !lost.equals(i.province)).toList(); + var construction = plan.construction(); + if (construction != null && lost.equals(construction.province)) construction = null; + double cost = installations.stream().mapToDouble(i -> InstallationConfigLoader.getDailyUpkeep(InstallationKind.fromCommand(i.kind), i.level)).sum(); + return new FactionUpkeepPlan(plan.faction(), plan.regimentSlots(), installations, construction, + plan.charge(), plan.armyLedgerCost(), cost); + } + + private static Integer removal(Faction faction) { + List<Integer> provinces = faction.getProvinces(); + if (CivilWarBorderLock.isLocked(faction) || !TitleManager.overProvinceCap(faction) || provinces.isEmpty()) return null; + int remove = provinces.getLast(); + if (remove == faction.getCapital() && provinces.size() > 1) remove = provinces.get(provinces.size() - 2); + return remove == faction.getCapital() ? null : remove; + } + + private static Faction require(Removal removal) throws IOException { + Faction faction = FactionManager.getByString(removal.faction()); + if (faction == null || !SettlementGuilds.capital(faction).getIncarnationId().equals(removal.incarnation())) + throw new IOException("Prepared faction missing or replaced: " + removal.faction()); + return faction; + } + + private void save(Faction faction) throws IOException { + if (!database.saveFactionDurably(faction)) throw new IOException("Cannot persist day preparation for " + faction.getId()); + } + + private Path file(long day) { return folder.resolve(day + ".json"); } + private static Saved read(Path path) throws IOException { + Saved saved = DurableJson.read(path, Saved.class); + if (!path.getFileName().toString().equals(saved.day + ".json") || saved.timestamp <= 0 + || saved.removals == null || saved.upkeep == null || (saved.prepared && saved.organization == null)) + throw new IOException("Invalid daily preparation: " + path); + return saved; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java new file mode 100644 index 00000000..bd161daf --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java @@ -0,0 +1,81 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.InstallationConstructionData; +import net.tfminecraft.simplefactions.database.InstallationData; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** The pre-income army and installation decisions, calculated without changing the faction. */ +public record FactionUpkeepPlan(String faction, Map<String, Integer> regimentSlots, + List<InstallationData> installations, InstallationConstructionData construction, + long charge, double armyLedgerCost, double installationLedgerCost) { + + public static FactionUpkeepPlan calculate(Faction faction) { + List<Regiment> regiments = new ArrayList<>(); + if (faction.getMilitary() != null) { + for (Regiment regiment : faction.getMilitary().getRegiments()) { + if (regiment != null) regiments.add(new Regiment(regiment)); + } + } + boolean bank = SettlementGuilds.capital(faction).getBank() != null; + double wealth = bank ? SettlementGuilds.capital(faction).getBank().getWealth() : 0; + double factor = 1 + faction.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount() / 100; + double army = armyCost(regiments, factor); + if (!bank && army > 0) { + for (Regiment regiment : regiments) { + if (!regiment.isLevy()) regiment.setCurrentSlots(Math.min(regiment.getCurrentSlots(), regiment.getFreeSlots())); + } + army = 0; + } + int guard = 0; + while (army > 0 && wealth < army && guard++ < 100000) { + Regiment shrink = regiments.stream().filter(r -> !r.isLevy() && r.getCurrentSlots() > r.getFreeSlots()).findFirst().orElse(null); + if (shrink == null) break; + shrink.sizeDecrease(); + army = armyCost(regiments, factor); + } + long charge = bank && army > 0 ? Money.cents(army) : 0; + wealth -= Money.denars(charge); + Map<String, Integer> slots = new LinkedHashMap<>(); + for (Regiment regiment : regiments) slots.put(regiment.getId(), regiment.getCurrentSlots()); + List<Installation> sorted = new ArrayList<>(faction.getInstallationHandler().getAll()); + sorted.sort(Comparator.comparingDouble((Installation i) -> InstallationConfigLoader.getDailyUpkeep(i.getKind(), i.getLevel())) + .thenComparingLong(Installation::getCompletedAt).thenComparing(Installation::getId)); + List<InstallationData> installations = new ArrayList<>(); + InstallationConstructionData construction = faction.getInstallationHandler().serializeConstruction(); + double ledgerCost = 0; + for (Installation current : sorted) { + InstallationData data = current.toData(); + double upkeep = InstallationConfigLoader.getDailyUpkeep(current.getKind(), current.getLevel()); + if (upkeep > 0 && (!bank || wealth < upkeep)) { + if (construction != null && Boolean.TRUE.equals(construction.upgrade) && current.getId().equals(construction.id)) construction = null; + data.level--; + } else if (upkeep > 0) { + long paid = Money.cents(upkeep); + charge = Math.addExact(charge, paid); + wealth -= Money.denars(paid); + } + if (data.level > 0) { + installations.add(data); + ledgerCost += InstallationConfigLoader.getDailyUpkeep(current.getKind(), data.level); + } + } + return new FactionUpkeepPlan(faction.getId(), slots, installations, construction, charge, army, ledgerCost); + } + + private static double armyCost(List<Regiment> regiments, double factor) { + double raw = 0; + for (Regiment regiment : regiments) raw += regiment.getTotalUpkeep(); + return Formatter.formatDouble(Formatter.formatDouble(raw) * factor); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java new file mode 100644 index 00000000..2217a40e --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java @@ -0,0 +1,334 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Cashflow; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; +import net.tfminecraft.simplefactions.guild.income.SettlementLedgerView; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryReputationSeam; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.mercenary.contract.TerminationReason; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; +import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; + +/** Owns detached financial objects. Calculation changes no live faction, bank, loan or obligation. */ +public final class RealmSettlementCalculation { + public record Claim(String id, UUID guildIncarnation, UUID characterId, long cents, net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind kind) {} + + /** Vehicle costs run last against the balances projected by the whole realm posting. */ + @FunctionalInterface + public interface Vehicles { + void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes); + } + + private final long day; + private final long now; + private final List<Guild> guilds; + private final List<Claim> claims; + private List<FactionUpkeepPlan> preparedUpkeep; + private final Map<Guild, Ledger> ledgers = new LinkedHashMap<>(); + private final Map<Guild, SettlementLedgerView> views = new LinkedHashMap<>(); + private final Map<Guild, MercenaryCompany> companies = new LinkedHashMap<>(); + private final Map<Loan, Loan> loans = new IdentityHashMap<>(); + private final Map<Guild, Integer> credits = new LinkedHashMap<>(); + private final Map<Guild, Double> initial = new LinkedHashMap<>(); + private final Map<String, Long> balances = new LinkedHashMap<>(); + private final Map<String, Long> legs = new LinkedHashMap<>(); + private final RealmSettlementChanges changes = new RealmSettlementChanges(); + + public RealmSettlementCalculation(long day, long now, Collection<Guild> guilds, List<Claim> claims) { + this.day = day; + this.now = now; + this.guilds = guilds.stream().sorted(Comparator.comparing(Guild::getId)).toList(); + this.claims = List.copyOf(claims); + } + + public RealmSettlementCalculation withPreparedUpkeep(List<FactionUpkeepPlan> upkeep) { + this.preparedUpkeep = List.copyOf(upkeep); + return this; + } + + public SettlementJournal.Plan calculate(Vehicles vehicles) { + capture(); + accrue(); + var history = Ledger.collectHistoryDay(guilds, ledgers::get); + DailyGuildTransfers buffer = new DailyGuildTransfers(); + for (Guild guild : guilds) ledgers.get(guild).populateDailyTransfers(buffer); + for (var from : buffer.getTransfers().entrySet()) { + for (var to : from.getValue().entrySet()) transfer(account(from.getKey()), account(to.getKey()), Money.cents(to.getValue())); + } + for (var entry : buffer.getExternalDeltas().entrySet()) transfer(Denars.MINT, account(entry.getKey()), Money.cents(entry.getValue())); + for (Guild guild : guilds) { + if (canMove(guild) && guild.isBase()) { + // CITIZENS in the transfer buffer is backed by held tax, never newly minted. + long held = Money.cents(ledgers.get(guild).getIncome(Cashflow.CITIZENS)); + transfer(Denars.guildTax(guild.getIncarnationId()), Denars.MINT, held); + } + } + for (Guild guild : guilds) liquidate(guild); + for (Guild guild : guilds) payouts(guild, buffer); + payOrphanClaims(); + for (Guild guild : guilds) { + for (Loan loan : guild.getLoanHandler().getLoansGiven()) loans.get(loan).tickDay(); + ageContracts(guild); + } + for (Guild guild : guilds) { + afterValues(guild, history.get(guild)); + } + for (Faction faction : guilds.stream().map(Guild::getFaction).distinct().toList()) { + List<WarReparationsObligation> next = new ArrayList<>(); + for (WarReparationsObligation obligation : faction.getWarReparationsObligations()) { + if (obligation != null && obligation.getDaysRemaining() > 1) next.add(new WarReparationsObligation( + obligation.getPayeeFactionId(), obligation.getIncomePercent(), obligation.getDaysRemaining() - 1)); + } + changes.reparations.put(faction.getId(), next); + } + vehicles.calculate(balances, legs, changes); + List<Leg> posting = legs.entrySet().stream().filter(e -> e.getValue() != 0) + .sorted(Map.Entry.comparingByKey()).map(e -> new Leg(e.getKey(), e.getValue())).toList(); + return new SettlementJournal.Plan(day, posting, JsonUtil.GSON.toJsonTree(changes).getAsJsonObject()); + } + + private void capture() { + Map<Faction, FactionUpkeepPlan> upkeep = new LinkedHashMap<>(); + for (Guild guild : guilds) { + balances.put(account(guild), guild.getBank() == null ? 0 : Money.cents(guild.getBank().getWealth())); + initial.put(guild, guild.getBank() == null ? 0 : guild.getBank().getWealth()); + credits.put(guild, guild.getLoanHandler().getCreditScore()); + MercenaryCompany company = guild.getCompany(); + if (company != null) companies.put(guild, new MercenaryCompany(guild, company.serialize(), + company.getRegiment() == null ? null : new Regiment(company.getRegiment()))); + } + for (Faction faction : guilds.stream().map(Guild::getFaction).distinct().toList()) { + FactionUpkeepPlan plan = preparedUpkeep == null ? FactionUpkeepPlan.calculate(faction) + : preparedUpkeep.stream().filter(p -> p.faction().equals(faction.getId())).findFirst().orElseThrow(); + changes.upkeep.add(plan); + upkeep.put(faction, plan); + Guild capital = SettlementGuilds.capital(faction); + transfer(account(capital), Denars.MINT, plan.charge()); + initial.put(capital, Money.denars(balances.get(account(capital)))); + } + for (Guild guild : guilds) { + for (Loan original : guild.getLoanHandler().getLoansGiven()) { + if (!credits.containsKey(original.getBorrower()) || original.getIssuer() != guild) + throw new IllegalStateException("Loan parties missing from settlement: " + original.getId()); + loans.put(original, original.copyForSettlement(now, delta -> credits.compute(original.getBorrower(), + (key, value) -> Math.max(0, Math.min(100, value + delta))))); + } + } + for (Guild guild : guilds) { + Map<Cashflow, Double> income = new EnumMap<>(Cashflow.class); + if (guild.isBase()) { + FactionUpkeepPlan costs = upkeep.get(guild.getFaction()); + MercenaryCompany company = companies.get(guild); + income.put(Cashflow.MILITARY_UPKEEP, -costs.armyLedgerCost() - (company == null || !company.isFormed() ? 0 : company.getSlotUpkeep())); + income.put(Cashflow.INSTALLATIONS, -costs.installationLedgerCost()); + income.put(Cashflow.CITIZENS, Money.denars(Denars.balance(Denars.guildTax(guild.getIncarnationId())))); + } + SettlementLedgerView view = new SettlementLedgerView(ledgers, + guild.getLoanHandler().getLoansGiven().stream().map(loans::get).toList(), + guild.getLoanHandler().getLoansTaken().stream().map(loans::get).toList(), companies.get(guild), + income, initial.get(guild), guild.getDonationAmount()); + views.put(guild, view); + ledgers.put(guild, guild.getLedger().copyForSettlement(view)); + } + } + + private void accrue() { + for (Guild guild : guilds) { + MercenaryCompany company = companies.get(guild); + if (!canMove(guild) || company == null || !company.isFormed()) continue; + for (String member : company.getEnlisted()) company.accrueWage(member, company.getWageSettings().peacetimeFor(member)); + for (MercenaryContract contract : company.getContractHandler().getActive()) { + Guild capital = capital(contract); + if (!canMove(capital)) continue; + contract.accrueDayPrice(); + List<String> enlisted = company.getEnlisted(); + for (int i = 0; i < Math.min(contract.getSlots(), enlisted.size()); i++) { + String member = enlisted.get(i); + company.accrueWage(member, company.getWageSettings().activeShareOf(contract.getPricePerSlotPerDay(), member)); + } + } + for (MercenaryContract contract : company.getContractHandler().getAll()) { + Guild capital = capital(contract); + if (capital == null) continue; + ledgers.get(capital).addMercenaryPaymentEntry(guild.getId(), contract.getAccruedToCompany()); + ledgers.get(capital).addRefundEntry(guild.getId(), contract.getAccruedToHirer()); + } + } + } + + private void liquidate(Guild guild) { + var change = new RealmSettlementChanges.GuildChange(); + change.id = guild.getId(); + change.incarnation = guild.getIncarnationId(); + guild.getBranches().forEach((key, branch) -> change.branches.put(key, branch.getLevel())); + changes.guilds.add(change); + if (guild.getBank() == null || net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers())) return; + int size = change.branches.values().stream().mapToInt(Integer::intValue).sum(); + Random random = new Random(day ^ guild.getIncarnationId().getMostSignificantBits() ^ guild.getIncarnationId().getLeastSignificantBits()); + for (int guard = 0; balances.get(account(guild)) < 0 && size > 0 && guard < 1000; guard++) { + List<Integer> expanded = change.branches.entrySet().stream().filter(e -> e.getValue() > 0).map(Map.Entry::getKey).sorted().toList(); + int selected = expanded.get(random.nextInt(expanded.size())); + double refund = Math.round(Cache.branchUpgradeCost * Math.pow(Cache.branchUpgradeExponent, --size) * 0.8 * 100) / 100.0; + change.branches.compute(selected, (key, value) -> value - 1); + transfer(Denars.MINT, account(guild), Money.cents(refund)); + } + } + + private void payouts(Guild guild, DailyGuildTransfers buffer) { + if (guild.getBank() == null || balances.get(account(guild)) < 0) return; + long pool = Money.cents(buffer.getPendingDividendPools().getOrDefault(guild, 0.0)); + long newDividendPool = pool; + Map<UUID, Double> wages = buffer.getPlayerPayouts().getOrDefault(guild, Map.of()); + long wageTotal = wages.values().stream().mapToLong(Money::cents).sum(); + List<Claim> ownClaims = claims.stream().filter(c -> c.guildIncarnation().equals(guild.getIncarnationId())).toList(); + pool = Math.max(pool, ownClaims.stream().filter(c -> c.kind() == net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.DIVIDENDS).mapToLong(Claim::cents).sum()); + long deathWages = ownClaims.stream().filter(c -> c.kind() == net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.WAGES).mapToLong(Claim::cents).sum(); + long needed = Math.addExact(pool, Math.addExact(wageTotal, deathWages)); + if (needed <= 0) return; + long available = Math.max(0, balances.get(account(guild))); + double scale = Math.min(1, (double) available / needed); + long actualPool = Math.min(available, Math.round(pool * scale)); + if (actualPool > 0 && !guild.isBase()) { + long taxablePool = Math.min(actualPool, Math.round(newDividendPool * scale)); + long tax = Math.min(taxablePool, Math.max(0, Math.round(taxablePool * guild.getFaction().getTaxRate(TaxTarget.DIVIDENDS, guild.getId(), true) / 100))); + Guild capital = SettlementGuilds.capital(guild.getFaction()); + if (capital.getBank() != null && capital != guild) transfer(account(guild), account(capital), tax); + long left = actualPool - tax; + for (Claim claim : ownClaims) { + if (claim.kind() != net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.DIVIDENDS) continue; + long paid = Math.min(left, claim.cents()); + payClaim(guild, claim, paid); + left -= paid; + } + List<String> eligible = guild.getDividendEligibleMembers().stream().filter(key -> Members.characterId(key) != null).sorted().toList(); + if (!eligible.isEmpty()) { + long each = left / eligible.size(); + long remainder = left % eligible.size(); + for (String key : eligible) payCharacter(guild, Members.characterId(key), each + (remainder-- > 0 ? 1 : 0), PlayerCashflow.DIVIDEND_PAYOUT); + } + } + MercenaryCompany company = companies.get(guild); + Map<String, Double> unpaid = company == null ? new LinkedHashMap<>() : new LinkedHashMap<>(company.getPendingWages()); + for (var wage : wages.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) { + long paid = Math.min(Math.max(0, balances.get(account(guild))), Math.round(Money.cents(wage.getValue()) * scale)); + payCharacter(guild, wage.getKey(), paid, PlayerCashflow.WAGES); + unpaid.computeIfPresent(Members.key(wage.getKey()), (key, value) -> Math.max(0, Formatter.formatDouble(value - Money.denars(paid)))); + } + if (company != null) { + company.clearPendingWages(); + unpaid.forEach(company::accrueWage); + } + for (Claim claim : ownClaims) { + if (claim.kind() != net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.WAGES) continue; + long paid = Math.min(Math.max(0, balances.get(account(guild))), Math.round(claim.cents() * scale)); + payClaim(guild, claim, paid); + } + } + + private void payClaim(Guild guild, Claim claim, long cents) { + if (cents <= 0) return; + payCharacter(guild, claim.characterId(), cents, claim.kind() == net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.WAGES ? PlayerCashflow.WAGES : PlayerCashflow.DIVIDEND_PAYOUT); + changes.deathPayments.put(claim.id(), cents); + } + + private void payOrphanClaims() { + Map<UUID, List<Claim>> orphaned = new LinkedHashMap<>(); + for (Claim claim : claims) { + if (guilds.stream().noneMatch(g -> g.getIncarnationId().equals(claim.guildIncarnation()))) + orphaned.computeIfAbsent(claim.guildIncarnation(), key -> new ArrayList<>()).add(claim); + } + for (var entry : orphaned.entrySet()) { + String account = Denars.guild(entry.getKey()); + long available = Math.max(0, Denars.balance(account)); + long owed = entry.getValue().stream().mapToLong(Claim::cents).sum(); + double scale = owed == 0 ? 0 : Math.min(1, (double) available / owed); + for (Claim claim : entry.getValue().stream().sorted(Comparator.comparing(Claim::id)).toList()) { + long paid = Math.min(available, Math.round(claim.cents() * scale)); + if (paid <= 0) continue; + transfer(account, "char:" + claim.characterId() + ":bank", paid); + changes.deathPayments.put(claim.id(), paid); + available -= paid; + } + } + } + + private void payCharacter(Guild guild, UUID character, long cents, PlayerCashflow source) { + if (cents <= 0) return; + transfer(account(guild), "char:" + character + ":bank", cents); + changes.playerIncome.computeIfAbsent(character, key -> new LinkedHashMap<>()).merge(source.name(), Money.denars(cents), Double::sum); + } + + private void ageContracts(Guild guild) { + MercenaryCompany company = companies.get(guild); + if (company == null || !company.isFormed()) return; + for (MercenaryContract contract : company.getContractHandler().getActive()) contract.addDayServed(); + for (MercenaryContract contract : company.getContractHandler().getActive()) { + TerminationReason reason = now >= contract.getDueDate() ? TerminationReason.DURATION_ELAPSED + : balances.get(account(guild)) < 0 ? TerminationReason.HOST_BANKRUPT : null; + if (reason != null && contract.finish(reason.getOutcome())) new MercenaryReputationSeam().onTermination(contract, reason); + } + } + + private void afterValues(Guild guild, Map<LedgerHistory.Source, Map<String, Double>> historyDay) { + var change = changes.guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + change.donation = views.get(guild).donation(); + change.citizenTaxes = canMove(guild) ? Map.of() : guild.getLedger().getCitizenTaxesCopy(); + change.casinoProfit = canMove(guild) ? 0 : guild.getLedger().getCasinoProfit(); + change.vehicleFeeIncome = canMove(guild) ? 0 : guild.getLedger().getVehicleFeeIncome(); + change.creditScore = credits.get(guild); + for (Loan original : guild.getLoanHandler().getLoansGiven()) change.loans.add(new LoanData(loans.get(original))); + MercenaryCompany company = companies.get(guild); + if (company != null) { + change.company = company.serialize(); + for (var contract : change.company.contracts) { + Faction hirer = net.tfminecraft.simplefactions.managers.FactionManager.getByString(contract.hirer); + if (canMove(guild) && hirer != null && canMove(SettlementGuilds.capital(hirer))) contract.accruedToCompany = 0.0; + if (canMove(guild) && hirer != null) contract.accruedToHirer = 0.0; + } + } + change.dividendEligible = new ArrayList<>(guild.getMembers()); + LedgerHistory history = new LedgerHistory(); + history.load(guild.getLedger().getHistory().getLastDayCopy(), guild.getLedger().getHistory().getLifetimeCopy(), guild.getLedger().getHistory().getDepositsTodayCopy()); + history.closeDay(historyDay); + change.ledgerLastDay = history.getLastDayCopy(); + change.ledgerLifetime = history.getLifetimeCopy(); + } + + private boolean canMove(Guild guild) { return guild != null && guild.getBank() != null && initial.containsKey(guild) && initial.get(guild) >= 0; } + private static Guild capital(MercenaryContract contract) { return contract.getHirer() == null ? null : SettlementGuilds.capital(contract.getHirer()); } + private static String account(Guild guild) { return Denars.guild(guild.getIncarnationId()); } + + private void transfer(String from, String to, long cents) { + if (cents == 0) return; + legs.merge(from, -cents, Math::addExact); + legs.merge(to, cents, Math::addExact); + balances.computeIfPresent(from, (key, value) -> Math.subtractExact(value, cents)); + balances.computeIfPresent(to, (key, value) -> Math.addExact(value, cents)); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementChanges.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementChanges.java new file mode 100644 index 00000000..593b971e --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementChanges.java @@ -0,0 +1,36 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.database.MercenaryCompanyData; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; + +/** Exact financial after-values stored with the immutable posting. */ +public final class RealmSettlementChanges { + public final List<FactionUpkeepPlan> upkeep = new ArrayList<>(); + public final List<GuildChange> guilds = new ArrayList<>(); + public final Map<String, List<WarReparationsObligation>> reparations = new LinkedHashMap<>(); + public final Map<String, Long> deathPayments = new LinkedHashMap<>(); + public final Map<UUID, Map<String, Double>> playerIncome = new LinkedHashMap<>(); + public com.google.gson.JsonObject vehicleChanges = new com.google.gson.JsonObject(); + + public static final class GuildChange { + public String id; + public UUID incarnation; + public double donation; + public Map<String, Double> citizenTaxes; + public double casinoProfit; + public double vehicleFeeIncome; + public int creditScore; + public List<LoanData> loans = new ArrayList<>(); + public MercenaryCompanyData company; + public Map<Integer, Integer> branches = new LinkedHashMap<>(); + public List<String> dividendEligible; + public Map<String, Map<String, Double>> ledgerLastDay; + public Map<String, Map<String, Double>> ledgerLifetime; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java new file mode 100644 index 00000000..ad2c4395 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java @@ -0,0 +1,106 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.TimerData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; + +/** Applies saved after-values only after the complete realm posting is APPLIED. */ +public final class RealmSettlementEffects implements SettlementJournal.Effects { + @FunctionalInterface public interface Payments { void apply(String id, Map<String, Long> paid) throws IOException; } + @FunctionalInterface public interface Vehicles { void apply(JsonObject changes) throws IOException; } + private final Path folder; + private final Database database; + private final PlayerEconomyManager economy; + private final Payments deaths; + private final Vehicles vehicles; + + public RealmSettlementEffects(Path folder, Database database, PlayerEconomyManager economy, Payments deaths, Vehicles vehicles) { + this.folder = folder; + this.database = database; + this.economy = economy; + this.deaths = deaths; + this.vehicles = vehicles; + } + + @Override public void apply(SettlementJournal.Plan plan) throws IOException { + RealmSettlementChanges changes = JsonUtil.GSON.fromJson(plan.changes(), RealmSettlementChanges.class); + // Validate every incarnation before changing anything, including on a mixed-backup restore. + for (var saved : changes.guilds) { + Guild guild = FactionManager.getGuildByString(saved.id); + if (guild == null || !guild.getIncarnationId().equals(saved.incarnation)) + throw new IOException("Settlement guild missing or replaced: " + saved.id); + } + Set<Faction> save = new LinkedHashSet<>(); + for (FactionUpkeepPlan saved : changes.upkeep) { + Faction faction = FactionManager.getByString(saved.faction()); + if (faction == null) throw new IOException("Settlement faction missing: " + saved.faction()); + if (faction.getMilitary() != null) { + for (var regiment : saved.regimentSlots().entrySet()) { + var current = faction.getMilitary().getRegiment(regiment.getKey()); + if (current == null) throw new IOException("Settlement regiment missing: " + regiment.getKey()); + current.setCurrentSlots(regiment.getValue()); + } + } + faction.getInstallationHandler().restoreSettlement(saved.installations(), saved.construction()); + save.add(faction); + } + for (var saved : changes.guilds) { + Guild guild = FactionManager.getGuildByString(saved.id); + guild.setDonationAmount(saved.donation); + guild.getLedger().setCitizenTaxes(saved.citizenTaxes); + guild.getLedger().setCasinoProfit(saved.casinoProfit); + guild.getLedger().setVehicleFeeIncome(saved.vehicleFeeIncome); + guild.getLoanHandler().restoreCreditScore(saved.creditScore); + for (var loan : saved.loans) { + var current = guild.getLoanHandler().getLoanById(loan.id); + if (current == null) throw new IOException("Settlement loan missing: " + loan.id); + current.restoreSettlement(loan); + } + if (saved.company != null) { + if (guild.getCompany() == null) throw new IOException("Settlement company missing: " + saved.id); + guild.getCompany().restoreSettlement(saved.company); + } + for (var branch : saved.branches.entrySet()) { + var current = guild.getBranch(branch.getKey()); + if (current == null) throw new IOException("Settlement branch missing: " + branch.getKey()); + current.restoreLevel(branch.getValue()); + } + guild.restoreDividendEligibility(saved.dividendEligible); + guild.getLedger().getHistory().load(saved.ledgerLastDay, saved.ledgerLifetime, Map.of()); + guild.updateWealth(); + save.add(guild.getFaction()); + } + for (var entry : changes.reparations.entrySet()) { + Faction faction = FactionManager.getByString(entry.getKey()); + if (faction == null) throw new IOException("Settlement reparations payer missing: " + entry.getKey()); + faction.getWarReparationsObligations().clear(); + faction.getWarReparationsObligations().addAll(entry.getValue()); + save.add(faction); + } + for (Faction faction : save) { + if (!database.saveFactionDurably(faction)) throw new IOException("Cannot persist settlement for " + faction.getId()); + } + vehicles.apply(changes.vehicleChanges); + deaths.apply("sf:settle:" + plan.day(), changes.deathPayments); + economy.clearAllDaily(); + changes.playerIncome.forEach((character, flows) -> flows.forEach((source, amount) -> + economy.getLedger(character).add(PlayerCashflow.valueOf(source), amount))); + TimerData timer = new TimerData(); + timer.day = Math.toIntExact(plan.day() + 1); + DurableJson.write(folder.resolve("Cache/data.json"), timer); + FactionManager.day = Math.max(FactionManager.day, timer.day); + FactionManager.timer = 0; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java new file mode 100644 index 00000000..a1c1753c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java @@ -0,0 +1,48 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.io.IOException; +import java.nio.file.Path; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.death.CharacterDeaths; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleSettlement; + +/** Coordinates one prepared day and its immutable, realm-wide monetary settlement. */ +public final class RealmSettlementService { + private final DailyPreparationJournal preparation; + private final SettlementJournal settlements; + private final RealmSettlementEffects effects; + + public RealmSettlementService(Path folder, Database database) { + preparation = new DailyPreparationJournal(folder.resolve("day-preparation"), database); + settlements = new SettlementJournal(folder.resolve("settlements")); + effects = new RealmSettlementEffects(folder, database, PlayerEconomyManager.get(), + CharacterDeaths::applyPayments, VehicleSettlement::apply); + } + + public void recover() throws IOException { + settlements.recover(effects); + for (long day : preparation.unfinishedDays()) settle(day); + } + + public void settle(long day) throws IOException { + var prepared = preparation.prepare(day, System.currentTimeMillis()); + Result result = settlements.settle(day, () -> { + net.tfminecraft.simplefactions.inactivity.InactivityService.armAll(FactionManager.factions, prepared.timestamp()); + net.tfminecraft.simplefactions.guild.network.TradeGraph.forgetRoutes(); + SimpleFactions plugin = SimpleFactions.getInstance(); + if (!plugin.refreshTrackProvinces()) plugin.getProvinceManager().recalculate(); + var claims = CharacterDeaths.pendingPayments().stream().map(payment -> new RealmSettlementCalculation.Claim( + payment.id(), payment.guildIncarnation(), payment.characterId(), payment.cents(), payment.kind())).toList(); + return new RealmSettlementCalculation(day, prepared.timestamp(), FactionManager.getAllGuilds(), claims) + .withPreparedUpkeep(prepared.upkeep()).calculate(VehicleSettlement::calculate); + }, effects); + if (result != Result.APPLIED) throw new IOException("Realm settlement " + day + " remains " + result); + preparation.complete(day); + FactionManager.day = Math.max(FactionManager.day, Math.toIntExact(day + 1)); + FactionManager.timer = 0; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementGuilds.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementGuilds.java new file mode 100644 index 00000000..419a8bc8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementGuilds.java @@ -0,0 +1,14 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +/** Settlement reads only existing incarnations; it cannot repair or create a treasury while planning. */ +public final class SettlementGuilds { + private SettlementGuilds() {} + public static Guild capital(Faction faction) { + Guild guild = faction.getGuildHandler().getGuild(faction.getId()); + if (guild == null) throw new IllegalStateException("Missing ruling guild for settlement: " + faction.getId()); + return guild; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournal.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournal.java new file mode 100644 index 00000000..89f6937f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournal.java @@ -0,0 +1,109 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.function.Supplier; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; + +/** One durable, immutable realm-wide plan and one atomic posting per day. */ +public final class SettlementJournal { + /** Changes contain exact after-values, so applying them again after an interrupted save is safe. */ + public record Plan(long day, List<Leg> legs, JsonObject changes) { + public Plan { + if (day < 0) throw new IllegalArgumentException("Invalid settlement day"); + legs = legs.isEmpty() ? List.of(new Leg(Denars.MINT, 0)) : List.copyOf(legs); + changes = changes.deepCopy(); + } + + @Override + public JsonObject changes() { return changes.deepCopy(); } + } + + @FunctionalInterface + public interface Effects { + /** Applies the recorded after-values and durably saves every affected store. */ + void apply(Plan plan) throws IOException; + } + + private static final class Saved { + Plan plan; + String hash; + boolean done; + } + + private final Path directory; + + public SettlementJournal(Path directory) { + this.directory = directory; + } + + public Result settle(long day, Supplier<Plan> calculate, Effects effects) throws IOException { + Path path = directory.resolve(day + ".json"); + Saved saved; + if (Files.exists(path)) { + saved = read(path); + } else { + Plan plan = calculate.get(); + if (plan.day() != day) throw new IOException("Settlement day changed during planning"); + saved = new Saved(); + saved.plan = plan; + saved.hash = hash(plan); + DurableJson.write(path, saved); + } + return finish(path, saved, effects); + } + + /** Replays stored plans before any membership or economic state can change. Never recalculates. */ + public void recover(Effects effects) throws IOException { + if (!Files.exists(directory)) return; + try (var files = Files.list(directory)) { + for (Path file : files.filter(path -> path.getFileName().toString().matches("[0-9]+\\.json")) + .sorted(Comparator.comparingLong(SettlementJournal::dayOf)).toList()) { + Result result = finish(file, read(file), effects); + if (result != Result.APPLIED) throw new IOException("Settlement remains " + result + ": " + file); + } + } + } + + private Result finish(Path path, Saved saved, Effects effects) throws IOException { + if (saved.done) { + if (Denars.status("sf:settle:" + saved.plan.day()).orElse(null) != Result.APPLIED) { + throw new IOException("Completed settlement is absent from the money ledger: " + path); + } + return Result.APPLIED; + } + Result result = Denars.post("sf:settle:" + saved.plan.day(), "sf:settlement", saved.plan.legs()); + if (result != Result.APPLIED) return result; + effects.apply(saved.plan); + saved.done = true; + DurableJson.write(path, saved); + return result; + } + + private static Saved read(Path path) throws IOException { + Saved saved = DurableJson.read(path, Saved.class); + if (saved.plan == null || !hash(saved.plan).equals(saved.hash) || saved.plan.day() != dayOf(path)) { + throw new IOException("Settlement plan hash or day mismatch: " + path); + } + return saved; + } + + private static long dayOf(Path path) { + String name = path.getFileName().toString(); + return Long.parseLong(name.substring(0, name.length() - ".json".length())); + } + + @SuppressWarnings("deprecation") + private static String hash(Plan plan) { + return com.google.common.hash.Hashing.sha256().hashString(JsonUtil.GSON.toJson(plan), StandardCharsets.UTF_8).toString(); + } +} 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 ca5fcf5c..6e96e02f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java @@ -381,6 +381,10 @@ public void leaveAsForeignBacker(Faction backer) { foreignBackers.remove(backer); } + public void restoreOrganization(double amount) { + organization = amount; + } + public void changeOrganization(double amount) { organization += amount; if (organization < 0) organization = 0; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index f5ff9ed0..5d928ef6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -1100,6 +1100,10 @@ public List<String> getDividendEligibleMembers() { return eligible; } + public void restoreDividendEligibility(List<String> members) { + dividendEligible = new ArrayList<>(members); + } + public void refreshDividendEligibility() { dividendEligible = new ArrayList<>(members); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java b/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java index 4fdd4731..8c3c5366 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java @@ -85,6 +85,7 @@ public boolean isAllowed(GuildType type) { return allowedTypes.contains(type); } public int getLevel() { return level; } + public void restoreLevel(int level) { this.level = Math.max(0, level); } public void levelUp() { level++; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index e7fd87aa..6a64ec73 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -38,6 +38,7 @@ public class Ledger { private Guild guild; + private SettlementLedgerView settlement; // Keyed by the earning character's member key. private final Map<String, Double> citizenTaxes = new HashMap<>(); @@ -80,6 +81,45 @@ public Ledger(Guild guild) { this.guild = guild; } + /** A calculation owns these copies; the live ledger and company remain untouched. */ + public Ledger copyForSettlement(SettlementLedgerView view) { + Ledger copy = new Ledger(guild); + copy.settlement = view; + copy.citizenTaxes.putAll(citizenTaxes); + copy.loanPayments.putAll(loanPayments); + copy.interestPayments.putAll(interestPayments); + copy.casinoProfit = casinoProfit; + copy.vehicleFeeIncome = vehicleFeeIncome; + return copy; + } + + private Ledger ledgerOf(Guild other) { + return settlement == null ? other.getLedger() : settlement.realm.get(other); + } + + private Guild capitalOf(Faction faction) { + return settlement == null ? faction.getOrCreateMainGuild() + : net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds.capital(faction); + } + + private List<Loan> loansTaken() { + return settlement == null ? guild.getLoanHandler().getLoansTaken() : settlement.taken; + } + + private List<Loan> loansGiven() { + return settlement == null ? guild.getLoanHandler().getLoansGiven() : settlement.given; + } + + private double donationOf(Guild other) { + if (settlement == null) return other.getDonationAmount(); + Ledger otherLedger = ledgerOf(other); + return otherLedger.settlement == null ? other.getDonationAmount() : otherLedger.settlement.donation; + } + + private boolean bankrupt() { + return settlement == null ? guild.isBankrupt() : settlement.wealth < 0; + } + public void addCitizenTaxEntry(String p, Double tax) { if(p == null || p.isBlank() || tax == null || tax <= 0) return; if(citizenTaxes.containsKey(p)) { @@ -209,13 +249,14 @@ public double getIncome(Cashflow cashflow) { if(skipsMoneyMovement()) return 0.0; //bankrupt guilds dont pay or receive money, they need to get our of bankrupcy first Faction f = guild.getFaction(); if (f == null) return 0.0; + if (settlement != null && settlement.income.containsKey(cashflow)) return settlement.income.get(cashflow); switch (cashflow) { case GUILDS: if(!guild.isBase()) return 0; if (f.getGuildHandler() == null || f.getGuildHandler().getGuilds() == null) break; for(Guild g : f.getGuildHandler().getGuilds()) { if(g == null || g.isBase() || g.getLedger() == null) continue; - amount += Math.abs(g.getLedger().getIncome(Cashflow.GUILD_PAYMENTS)); + amount += Math.abs(ledgerOf(g).getIncome(Cashflow.GUILD_PAYMENTS)); } break; case GUILD_PAYMENTS: @@ -238,7 +279,7 @@ public double getIncome(Cashflow cashflow) { case VASSALS: if(!guild.isBase()) return 0; for(Faction vassal : RelationManager.getSubjects(guild.getFaction())) { - amount += Math.abs(vassal.getOrCreateMainGuild().getLedger().getIncome(Cashflow.OVERLORD_TAX)); + amount += Math.abs(ledgerOf(capitalOf(vassal)).getIncome(Cashflow.OVERLORD_TAX)); } break; case CITIZENS: @@ -277,8 +318,8 @@ public double getIncome(Cashflow cashflow) { } case LOANS: amount += getAggregatedLoanPayments(); - if (guild.getLoanHandler() == null || guild.getLoanHandler().getLoansGiven() == null) break; - for(Loan loan : guild.getLoanHandler().getLoansGiven()) { + if (guild.getLoanHandler() == null || loansGiven() == null) break; + for(Loan loan : loansGiven()) { if(!LoanFunding.isCharged(loan)) continue; amount += projectedFunding(loan).principal(); } @@ -292,8 +333,8 @@ public double getIncome(Cashflow cashflow) { } case INTEREST: amount += getAggregatedInterestPayments(); - if (guild.getLoanHandler() == null || guild.getLoanHandler().getLoansGiven() == null) break; - for(Loan loan : guild.getLoanHandler().getLoansGiven()) { + if (guild.getLoanHandler() == null || loansGiven() == null) break; + for(Loan loan : loansGiven()) { if(!LoanFunding.isCharged(loan)) continue; amount += projectedFunding(loan).interest(); } @@ -392,17 +433,17 @@ public double getIncome(Cashflow cashflow) { if (f.getGuildHandler() == null || f.getGuildHandler().getGuilds() == null) break; for (Guild other : f.getGuildHandler().getGuilds()) { if (other == null || other.isBase() || other.getLedger() == null) continue; - if (other.getLedger().skipsMoneyMovement()) continue; - amount += Math.max(0, other.getDonationAmount()); + if (ledgerOf(other).skipsMoneyMovement()) continue; + amount += Math.max(0, donationOf(other)); } break; case DONATION_PAYMENTS: if (guild.isBase()) return 0; - amount = -Math.max(0, guild.getDonationAmount()); + amount = -Math.max(0, donationOf(guild)); break; case DONATION_FEE: if (guild.isBase()) return 0; - amount = -GuildDonation.fee(guild.getDonationAmount()); + amount = -GuildDonation.fee(donationOf(guild)); break; case PENALTIES: if(!guild.isBase()) return 0; @@ -454,7 +495,7 @@ private double getAggregatedInterestPayments() { } private MercenaryCompany getFormedCompany() { - MercenaryCompany company = guild.getCompany(); + MercenaryCompany company = settlement == null ? guild.getCompany() : settlement.company; if(company == null || !company.isFormed()) return null; return company; } @@ -674,7 +715,7 @@ private double getDividendTaxReceived() { if (g == null || g.isBase()) { continue; } - total += Math.abs(g.getLedger().getIncome(Cashflow.DIVIDEND_PAYMENT)); + total += Math.abs(ledgerOf(g).getIncome(Cashflow.DIVIDEND_PAYMENT)); } return total; } @@ -716,7 +757,7 @@ public double getTributeRecieved() { if(!mod.getType().equals(FactionModifiers.TRIBUTE)) continue; if(!mod.getFrom().getId().equals(guild.getFaction().getId())) continue; - double base = f.getOrCreateMainGuild().getLedger().getInternalTaxableIncome(); + double base = ledgerOf(capitalOf(f)).getInternalTaxableIncome(); total += base * (mod.getAmount() / 100.0); } } @@ -751,7 +792,7 @@ public double getWarReparationsReceived() { continue; } for (Guild payerGuild : f.getGuildHandler().getGuilds()) { - Ledger payerLedger = payerGuild.getLedger(); + Ledger payerLedger = ledgerOf(payerGuild); if (payerLedger == null || payerLedger.skipsMoneyMovement()) { continue; } @@ -824,7 +865,7 @@ public void clearDailyIncome() { * cannot pay or receive, so settlement must skip it instead of throwing. */ boolean skipsMoneyMovement() { - if (guild.isBankrupt()) { + if (bankrupt()) { return true; } return guild.getBank() == null; @@ -835,10 +876,15 @@ boolean skipsMoneyMovement() { * as {@link #applySettlementFor} moves it. Read before settlement clears the day. */ public static Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> collectHistoryDay(Iterable<Guild> guilds) { + return collectHistoryDay(guilds, Guild::getLedger); + } + + public static Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> collectHistoryDay( + Iterable<Guild> guilds, java.util.function.Function<Guild, Ledger> ledgers) { Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> out = new HashMap<>(); for (Guild payer : guilds) { if (payer == null || payer.getLedger() == null) continue; - Ledger ledger = payer.getLedger(); + Ledger ledger = ledgers.apply(payer); Faction f = payer.getFaction(); if (f == null || ledger.skipsMoneyMovement()) continue; @@ -848,7 +894,7 @@ public static Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> collect Faction overlord = f.getOverlord(); if (overlord != null) { - recordHistory(out, overlord.getOrCreateMainGuild(), LedgerHistory.Source.VASSALS, + recordHistory(out, ledger.capitalOf(overlord), LedgerHistory.Source.VASSALS, f.getName(), Math.abs(ledger.getIncome(Cashflow.OVERLORD_TAX))); } @@ -857,12 +903,12 @@ public static Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> collect for (FactionModifier mod : f.getModifiers()) { if (mod.getFrom() == null) continue; if (!mod.getType().equals(FactionModifiers.TRIBUTE)) continue; - recordHistory(out, mod.getFrom().getOrCreateMainGuild(), LedgerHistory.Source.TRIBUTES, + recordHistory(out, ledger.capitalOf(mod.getFrom()), LedgerHistory.Source.TRIBUTES, f.getName(), base * (mod.getAmount() / 100.0)); } } } else { - recordHistory(out, f.getOrCreateMainGuild(), LedgerHistory.Source.GUILD_TAXES, + recordHistory(out, ledger.capitalOf(f), LedgerHistory.Source.GUILD_TAXES, payer.getName(), Math.abs(ledger.getIncome(Cashflow.GUILD_PAYMENTS))); } @@ -870,7 +916,7 @@ public static Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> collect if (trade != null && trade.getTariffsByFactionMap() != null) { for (Map.Entry<Faction, Double> entry : trade.getTariffsByFactionMap().entrySet()) { if (entry.getKey() == null || entry.getValue() == null) continue; - recordHistory(out, entry.getKey().getOrCreateMainGuild(), LedgerHistory.Source.TARIFFS, + recordHistory(out, ledger.capitalOf(entry.getKey()), LedgerHistory.Source.TARIFFS, f.getName(), entry.getValue()); } } @@ -887,21 +933,23 @@ private static void recordHistory(Map<Guild, Map<LedgerHistory.Source, Map<Strin .merge(name, amount, Double::sum); } - private static double wealthOf(Guild g) { + private double wealthOf(Guild g) { + if (settlement != null && g != null) return Math.max(0, settlement.realm.get(g).settlement.wealth); return g == null || g.getBank() == null ? 0.0 : Math.max(0.0, g.getBank().getWealth()); } /** This guild's automatic loan payments as its current balance would fund them. */ private Map<Loan, LoanFunding.Funded> projectedLoanPlan() { if (guild.getLoanHandler() == null) return Map.of(); - return LoanFunding.plan(guild.getLoanHandler().getLoansTaken(), wealthOf(guild)); + return LoanFunding.plan(loansTaken(), wealthOf(guild)); } /** What a borrower's current balance would fund of this loan it took. */ - private static LoanFunding.Funded projectedFunding(Loan loan) { + private LoanFunding.Funded projectedFunding(Loan loan) { Guild borrower = loan.getBorrower(); if (borrower == null || borrower.getLoanHandler() == null) return LoanFunding.NONE; - return LoanFunding.plan(borrower.getLoanHandler().getLoansTaken(), wealthOf(borrower)) + return LoanFunding.plan(settlement == null ? borrower.getLoanHandler().getLoansTaken() + : ledgerOf(borrower).loansTaken(), wealthOf(borrower)) .getOrDefault(loan, LoanFunding.NONE); } @@ -932,8 +980,8 @@ public void populateDailyTransfers(DailyGuildTransfers buffer) { // Drop the gift before anything else is committed. Other costs may then still fit, // so a guild that was short only because of the donation stays solvent. cancelDonationIfUnaffordable(); - if(guild.isBankrupt() && guild.getLoanHandler() != null && guild.getLoanHandler().getLoansTaken() != null) { - for(Loan loan : guild.getLoanHandler().getLoansTaken()) { + if(bankrupt() && guild.getLoanHandler() != null && loansTaken() != null) { + for(Loan loan : loansTaken()) { if(loan == null || !loan.isAutoPay()) continue; if(loan.isPaidOff()) continue; loan.setAutoPay(false); @@ -953,13 +1001,14 @@ public void populateDailyTransfers(DailyGuildTransfers buffer) { // Loans are paid last, from what is left after today's other payments. The lender is // credited in full, so an unfunded payment would create money; any shortfall stays owed. loanPlan = guild.getLoanHandler() == null ? Map.of() - : LoanFunding.plan(guild.getLoanHandler().getLoansTaken(), loanBudget(buffer)); + : LoanFunding.plan(loansTaken(), loanBudget(buffer)); try { applySettlementFor(Cashflow.LOAN_PAYMENTS, buffer); applySettlementFor(Cashflow.INTEREST_PAYMENTS, buffer); } finally { loanPlan = null; } + if (settlement != null) return; if (!skipsMoneyMovement()) { citizenTaxes.clear(); } @@ -981,7 +1030,7 @@ public void populateDailyTransfers(DailyGuildTransfers buffer) { * its buckets, because its debts survive the bankruptcy that froze them. */ private void clearSettledContractBuckets() { - if (skipsMoneyMovement()) return; + if (settlement != null || skipsMoneyMovement()) return; MercenaryCompany company = getFormedCompany(); if (company == null) return; for (MercenaryContract c : company.getContractHandler().getAll()) { @@ -1010,15 +1059,18 @@ private boolean donationOverdraws() { } private void cancelDonationIfUnaffordable() { - if (guild.isBase() || guild.getDonationAmount() <= 0) { + if (guild.isBase() || donationOf(guild) <= 0) { return; } if (!skipsMoneyMovement() && !donationOverdraws()) { return; } - double sent = guild.getDonationAmount(); - guild.setDonationAmount(0); - GuildDonation.notifyCancelled(guild, sent); + double sent = donationOf(guild); + if (settlement != null) settlement.donation = 0; + else { + guild.setDonationAmount(0); + GuildDonation.notifyCancelled(guild, sent); + } } private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { @@ -1064,7 +1116,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { if (guild.isBase()) return; // base doesn't pay guild tax Faction faction = guild.getFaction(); if (faction == null) return; - Guild capital = faction.getOrCreateMainGuild(); + Guild capital = capitalOf(faction); double amount = Math.abs(getIncome(Cashflow.GUILD_PAYMENTS)); buffer.add(guild, capital, amount); return; @@ -1074,7 +1126,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { if (!guild.isBase()) return; //only base pays Faction overlord = guild.getFaction().getOverlord(); if (overlord == null) return; - Guild overlordCapital = overlord.getOrCreateMainGuild(); + Guild overlordCapital = capitalOf(overlord); double amount = Math.abs(getIncome(Cashflow.OVERLORD_TAX)); buffer.add(guild, overlordCapital, amount); return; @@ -1092,7 +1144,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { Faction receiverFaction = mod.getFrom(); if (receiverFaction == null) continue; - Guild receiverGuild = receiverFaction.getOrCreateMainGuild(); + Guild receiverGuild = capitalOf(receiverFaction); double amount = base * (mod.getAmount() / 100.0); if (amount <= 0) continue; @@ -1112,7 +1164,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { if (receiverFaction == null) { continue; } - Guild receiverGuild = receiverFaction.getOrCreateMainGuild(); + Guild receiverGuild = capitalOf(receiverFaction); double amount = base * (obligation.getIncomePercent() / 100.0); if (amount <= 0) { continue; @@ -1129,7 +1181,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { for(Map.Entry<Faction, Double> entry : tariffs.getTariffsByFactionMap().entrySet()) { if (entry.getKey() == null || entry.getValue() == null) continue; Faction receiverFaction = entry.getKey(); - Guild receiverGuild = receiverFaction.getOrCreateMainGuild(); + Guild receiverGuild = capitalOf(receiverFaction); double amount = entry.getValue(); if(amount <= 0) continue; buffer.add(guild, receiverGuild, amount); @@ -1139,8 +1191,8 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { //Loans case LOAN_PAYMENTS: { - if (guild.getLoanHandler() == null || guild.getLoanHandler().getLoansTaken() == null) return; - for(Loan loan : guild.getLoanHandler().getLoansTaken()) { + if (guild.getLoanHandler() == null || loansTaken() == null) return; + for(Loan loan : loansTaken()) { if(!LoanFunding.isCharged(loan)) continue; double amount = funded(loan).principal(); if(amount <= 0) continue; @@ -1152,8 +1204,8 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { //Interest case INTEREST_PAYMENTS: { - if (guild.getLoanHandler() == null || guild.getLoanHandler().getLoansTaken() == null) return; - for(Loan loan : guild.getLoanHandler().getLoansTaken()) { + if (guild.getLoanHandler() == null || loansTaken() == null) return; + for(Loan loan : loansTaken()) { if(!LoanFunding.isCharged(loan)) continue; if(loan.getDailyInterest() <= 0) continue; double amount = funded(loan).interest(); @@ -1194,7 +1246,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { if (amount <= 0) continue; Faction hirer = c.getHirer(); if (hirer == null) continue; - Guild capital = hirer.getOrCreateMainGuild(); + Guild capital = capitalOf(hirer); if (capital == null || capital == guild) continue; buffer.add(guild, capital, amount); } @@ -1203,11 +1255,11 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case DONATION_PAYMENTS: { if (guild.isBase()) return; - double amount = guild.getDonationAmount(); + double amount = donationOf(guild); if (amount <= 0) return; Faction faction = guild.getFaction(); if (faction == null) return; - Guild capital = faction.getOrCreateMainGuild(); + Guild capital = capitalOf(faction); if (capital == null || capital == guild) return; buffer.add(guild, capital, amount); return; @@ -1215,7 +1267,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case DONATION_FEE: { if (guild.isBase()) return; - double fee = GuildDonation.fee(guild.getDonationAmount()); + double fee = GuildDonation.fee(donationOf(guild)); if (fee <= 0) return; buffer.addExternalDelta(guild, -fee); return; @@ -1224,7 +1276,8 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case WAGE_PAYMENTS: { MercenaryCompany company = getFormedCompany(); if (company == null) return; - PlayerUuidLookup uuids = MercenaryEngagements.uuidLookup(); + PlayerUuidLookup uuids = settlement == null ? MercenaryEngagements.uuidLookup() + : net.tfminecraft.simplefactions.identity.Members::characterId; if (uuids == null) return; for (Map.Entry<String, Double> entry : company.getPendingWages().entrySet()) { double amount = entry.getValue() == null ? 0 : entry.getValue(); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/SettlementLedgerView.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/SettlementLedgerView.java new file mode 100644 index 00000000..2608f193 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/SettlementLedgerView.java @@ -0,0 +1,31 @@ +package net.tfminecraft.simplefactions.guild.income; + +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; + +/** Detached financial inputs shared by the ledgers of one realm calculation. */ +public final class SettlementLedgerView { + final Map<Guild, Ledger> realm; + final List<Loan> given; + final List<Loan> taken; + final MercenaryCompany company; + final Map<Cashflow, Double> income; + final double wealth; + double donation; + + public SettlementLedgerView(Map<Guild, Ledger> realm, List<Loan> given, List<Loan> taken, + MercenaryCompany company, Map<Cashflow, Double> income, double wealth, double donation) { + this.realm = realm; + this.given = List.copyOf(given); + this.taken = List.copyOf(taken); + this.company = company; + this.income = Map.copyOf(income); + this.wealth = wealth; + this.donation = donation; + } + + public double donation() { return donation; } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/loans/Loan.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/Loan.java index d4aa18e8..0fdb7859 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/loans/Loan.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/loans/Loan.java @@ -8,6 +8,8 @@ import net.tfminecraft.simplefactions.utils.Formatter; public class Loan { + private Long calculationTime; + private java.util.function.IntConsumer creditSink; private String id; private double amount; private double paidInterest; @@ -71,6 +73,40 @@ public Loan(LoanData data) { } } + /** Detached copy used by the realm planner; no live loan, ledger or credit score is changed. */ + public Loan copyForSettlement(long now, java.util.function.IntConsumer creditChanges) { + Loan copy = new Loan(id, amount, issuer, borrower, issueDate, getDurationInDays(), interestRate, overdueFee, autoPay); + copy.restoreSettlement(new LoanData(this)); + copy.calculationTime = now; + copy.creditSink = creditChanges; + return copy; + } + + /** Exact after-values from a durable plan. Repeating this operation has no further effect. */ + public void restoreSettlement(LoanData data) { + if (!id.equals(data.id)) throw new IllegalArgumentException("Wrong loan incarnation"); + amount = data.amount; + paidInterest = data.paidInterest; + unpaidInterest = data.unpaidInterest; + issueDate = data.issueDate; + dueDate = data.dueDate; + interestRate = data.interestRate; + overdueFee = data.overdueFee; + paid = data.paid; + autoPay = data.autoPay; + defaulted = data.defaulted; + pausedInterest = data.pausedInterest; + status = LoanStatus.valueOf(data.status); + tempPayment = 0; + tempInterestPayment = 0; + interestCharged = false; + } + + private void changeCredit(int amount) { + if (creditSink != null) creditSink.accept(amount); + else borrower.getLoanHandler().changeCreditScore(amount); + } + public String getId() { return id; } @@ -132,7 +168,7 @@ public void setDefaulted(boolean defaulted) { if(status == LoanStatus.ACTIVE && defaulted) { status = LoanStatus.DEFAULTED; int penalty = CreditCalculator.calculateDefaultPenalty(this); - borrower.getLoanHandler().changeCreditScore(penalty); + changeCredit(penalty); } } @@ -149,7 +185,7 @@ public void tickDay() { if (isOverdue() && status == LoanStatus.ACTIVE && borrower != null && borrower.getLoanHandler() != null) { int penalty = CreditCalculator.calculateDailyOverduePenalty(this); - borrower.getLoanHandler().changeCreditScore(penalty); + changeCredit(penalty); } if (!autoPay) { // Interest compounds onto the loan @@ -219,7 +255,7 @@ public double getTotalOwed() { } public int getDaysUntilDue() { - long now = System.currentTimeMillis(); + long now = calculationTime == null ? System.currentTimeMillis() : calculationTime; long diff = dueDate - now; return (int) (diff / (1000 * 60 * 60 * 24)); } @@ -276,7 +312,7 @@ public double makePayment(double paymentAmount, boolean ledger) { if(status == LoanStatus.ACTIVE && isPaidOff()) { status = LoanStatus.PAID_OFF; int bonus = CreditCalculator.calculatePayoffBonus(this); - borrower.getLoanHandler().changeCreditScore(bonus); + changeCredit(bonus); } return actualPayment; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanHandler.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanHandler.java index 5fb299df..e96b50e0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanHandler.java @@ -70,6 +70,10 @@ public Loan getLoanById(String id) { return issuedLoans.get(id); } + public void restoreCreditScore(int score) { + creditScore = Math.max(0, Math.min(100, score)); + } + public void changeCreditScore(int amount) { creditScore += amount; creditScore = Math.max(0, Math.min(100, creditScore)); diff --git a/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java b/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java index 9e578b82..22b959d0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java @@ -77,6 +77,15 @@ public InstallationConstructionData serializeConstruction() { return pendingConstruction.toData(); } + /** Exact upkeep outcome; a queued construction is not advanced during replay. */ + public void restoreSettlement(List<InstallationData> installations, InstallationConstructionData construction) { + load(installations); + pendingConstruction = null; + pendingByProvinceKind.clear(); + if (construction != null) setPendingConstruction(new InstallationConstruction(construction)); + enqueueMapUpdate(); + } + public InstallationConstruction getPendingConstruction() { return pendingConstruction; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index ce673d8a..5aca7036 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -537,143 +537,46 @@ public void time() { SimpleFactions.getInstance().getVehicleUpkeepService() .warnBankShortfalls(Bukkit.getOnlinePlayers(), getSecondsUntilNewDay()); } - if (timer >= DAY_LENGTH_SECONDS) { - runDailyStep("player ledgers", () -> PlayerEconomyManager.get().clearAllDaily()); - for(Faction f : new ArrayList<>(factions)){ - if(f == null) continue; - runDailyStep("faction " + f.getId(), f::newDay); - } - runDailyStep("inactive members", () -> { - FactionCleanup.advanceOfflineDays(factions); - net.tfminecraft.simplefactions.inactivity.InactivityService.armAll(factions, System.currentTimeMillis()); - }); - // Track may have been laid or cut since yesterday, so measure routes again - // and settle the day on trade that reflects them. - runDailyStep("trade routes", () -> { - net.tfminecraft.simplefactions.guild.network.TradeGraph.forgetRoutes(); - boolean tracksChanged = SimpleFactions.getInstance().refreshTrackProvinces(); - if (!tracksChanged) SimpleFactions.getInstance().getProvinceManager().recalculate(); - }); - runDailyStep("income", this::settleIncome); - timer = 0; - day++; - } - } - - public void settleIncome() { - DailyGuildTransfers buffer = new DailyGuildTransfers(); - - // Phase 0: contracts accrue the day and hand the hiring capital its bill, which - // it cannot compute itself because it owns no contract object. - runDailyStep("mercenary accrual", () -> - net.tfminecraft.simplefactions.mercenary.contract.ContractAccrualService.accrueDailyAndPush()); - - // Read the ledger view history before Phase 1 clears the day's citizen taxes. - Map<Guild, Map<LedgerHistory.Source, Map<String, Double>>> history = new HashMap<>(); - runDailyStep("ledger history", () -> history.putAll(Ledger.collectHistoryDay(getAllGuilds()))); - - // Phase 1: collect transfers & external deltas - for (Guild g : getAllGuilds()) { - if (g == null || g.getLedger() == null) continue; - runDailyStep("ledger " + g.getId(), () -> g.getLedger().populateDailyTransfers(buffer)); - } - - // Phase 2: compute net deltas - Map<Guild, Double> deltas = new HashMap<>(); - - // Guild -> Guild transfers - for (var fromEntry : buffer.getTransfers().entrySet()) { - Guild from = fromEntry.getKey(); - for (var toEntry : fromEntry.getValue().entrySet()) { - Guild to = toEntry.getKey(); - double amount = toEntry.getValue(); - - deltas.merge(from, -amount, Double::sum); - deltas.merge(to, amount, Double::sum); - } - } - - // External deposits / withdrawals - for (var entry : buffer.getExternalDeltas().entrySet()) { - Guild guild = entry.getKey(); - double delta = entry.getValue(); - - deltas.merge(guild, delta, Double::sum); - } - - // Phase 3: apply atomically - for (var entry : deltas.entrySet()) { - Guild guild = entry.getKey(); - if (guild == null || guild.getBank() == null) continue; - double amount = entry.getValue() == null ? 0.0 : Formatter.formatDouble(entry.getValue()); - if (amount == 0.0) continue; - runDailyStep("deposit " + guild.getId(), () -> { - guild.getBank().deposit(amount); - int liquidations = 0; - while(guild.isBankrupt() && guild.canLiquidate() - && !net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers()) - && liquidations++ < 1000) { - int before = guild.getSize(); - guild.liquidateRandom(); - if(guild.getSize() >= before) break; - } - }); - } - - for (Guild g : getAllGuilds()) { - if (g == null || g.getLedger() == null) continue; - runDailyStep("ledger history " + g.getId(), () -> g.getLedger().getHistory().closeDay(history.get(g))); - } - - PostSettlementPayouts.apply( - buffer, - DenarEconomyPlayerBank.INSTANCE, - PlayerEconomyManager.get(), - net.tfminecraft.simplefactions.utils.MemberAccounts::accountId); - for (Guild g : getAllGuilds()) { - g.refreshDividendEligibility(); - } - - for(Guild g : getAllGuilds()) { - if (g == null || g.getLoanHandler() == null || g.getLoanHandler().getLoansGiven() == null) continue; - for(Loan loan : g.getLoanHandler().getLoansGiven()) { - if (loan == null) continue; - runDailyStep("loan " + loan.getId(), loan::tickDay); - } - } + if (timer >= DAY_LENGTH_SECONDS) settleIncome(); + } - //Contracts age a day, then elapsed and bankrupt ones are closed out - net.tfminecraft.simplefactions.mercenary.contract.ContractTerminationService.tickDay(); + public void settleIncome() { + if (!net.tfminecraft.simplefactions.economy.EconomyRuntime.ready()) return; + try { + SimpleFactions.getInstance().getRealmSettlementService().settle(day); + SimpleFactions.getInstance().recordVehicleOwners(); + } catch (Exception error) { + net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed(error); + } + } - for (Faction faction : factions) { - WarReparationsService.tickAfterDailySettlement(faction); - } + /** Loads references before money recovery; no repeating tasks can mutate the loaded state yet. */ + public void prepare() { + Database database = new Database(); + timer = database.getTimer(); + day = database.getDay(); + loadRelations(); + for (Faction f : factions) { + f.getLawHandler().apply(); + f.countyCheck(); + f.getGovernment().loadMovements(); + f.ping(); + } + updateAllPrestigeConverged(); + fixRelations(); + loadDBLoans(); + } - buffer.clear(); - SimpleFactions.getInstance().getVehicleUpkeepService().processDailyUpkeep(); - SimpleFactions.getInstance().recordVehicleOwners(); - } + public void startTicks() { + net.tfminecraft.simplefactions.inactivity.InactivityService.armAll(factions, System.currentTimeMillis()); + tickCycle(); + loaded = true; + } - - public void run() { - Database database = new Database(); - timer = database.getTimer(); - day = database.getDay(); - LogManager.relations("FactionManager.run loadRelations"); - loadRelations(); - tickCycle(); - for(Faction f : factions) { - f.getLawHandler().apply(); - f.countyCheck(); - f.getGovernment().loadMovements(); - f.ping(); - } - updateAllPrestigeConverged(); - net.tfminecraft.simplefactions.inactivity.InactivityService.armAll(factions, System.currentTimeMillis()); - fixRelations(); - loadDBLoans(); - loaded = true; - } + public void run() { + prepare(); + startTicks(); + } public static boolean guildExists(String id) { for(Faction f : factions) { @@ -751,22 +654,7 @@ public static List<Guild> getAllGuilds() { return guilds; } - /** One bad faction or guild must not abort the day, or the next second would pay everyone again. */ - private static void runDailyStep(String step, Runnable action) { - try { - action.run(); - } catch (RuntimeException ex) { - SimpleFactions plugin = SimpleFactions.getInstance(); - String message = "Daily tick failed during " + step; - if (plugin != null && plugin.getLogger() != null) { - plugin.getLogger().log(java.util.logging.Level.SEVERE, message, ex); - } else { - java.util.logging.Logger.getLogger(FactionManager.class.getName()) - .log(java.util.logging.Level.SEVERE, message, ex); - } - } - } - + public void start(List<Faction> l) { factions = l; } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index c5da1406..3b9c936e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -496,6 +496,18 @@ public void removeDeadCharacter(String key) { wages.clearPeacetimeOverride(key); } + /** Replays only the financial state, keeping the company's identity and regiment references. */ + public void restoreSettlement(MercenaryCompanyData data) { + reputation = data.reputation; + pendingWages.clear(); + pendingWages.putAll(data.pendingWages); + for (MercenaryContractData saved : data.contracts) { + MercenaryContract contract = contractHandler.getById(saved.id); + if (contract == null) throw new IllegalStateException("Missing settlement contract " + saved.id); + contract.restoreSettlement(saved); + } + } + public void clearPendingWages() { pendingWages.clear(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContract.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContract.java index b4d144f1..0f6651b1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContract.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContract.java @@ -102,6 +102,18 @@ public MercenaryContract(MercenaryCompany company, MercenaryContractData data) { * Identity * ===================================================== */ + /** Exact financial after-values; preserves references held by active battles and menus. */ + public void restoreSettlement(MercenaryContractData data) { + if (!id.equals(data.id)) throw new IllegalArgumentException("Wrong contract identity"); + status = ContractStatus.valueOf(data.status); + daysServed = data.daysServed; + accruedToCompany = data.accruedToCompany; + accruedToHirer = data.accruedToHirer; + servedDaysValue = data.servedDaysValue; + pendingSlots = data.pendingSlots; + pendingExpiresAt = data.pendingSlotExpiry == null ? 0 : data.pendingSlotExpiry; + } + public String getId() { return id; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java index db753e65..4951bd3f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java @@ -1,63 +1,36 @@ package net.tfminecraft.simplefactions.objects; - import org.bukkit.Bukkit; import org.bukkit.Chunk; - +import net.tfminecraft.denareconomy.api.Denars; import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.economy.Money; import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.denareconomy.data.Account; +/** A location and a read-only view of the immutable guild incarnation's DenarEconomy treasury. */ public class Bank { - private Guild guild; - - private Account bank; - - private Chunk chunk; + private final Guild guild; + private final double legacyBalance; + private Chunk chunk; - public Bank(Guild g) { - guild = g; - bank = new Account(0, false); - chunk = Bukkit.getWorld(Cache.worldName).getChunkAt(0, 0); //TODO world name - } - - public Bank(Guild g, Chunk c) { - guild = g; - bank = new Account(0, false); - chunk = c; - } - - public Bank(Guild g, double amount, Chunk c) { - guild = g; - bank = new Account(amount, false); - chunk = c; - } - - /** Stable DenarEconomy treasury account for this guild incarnation. */ - public String accountId() { - return "guild:" + guild.getIncarnationId(); - } + public Bank(Guild guild) { + this(guild, 0, Bukkit.getWorld(Cache.worldName).getChunkAt(0, 0)); + } + public Bank(Guild guild, Chunk chunk) { this(guild, 0, chunk); } + public Bank(Guild guild, double amount, Chunk chunk) { + this.guild = guild; + this.legacyBalance = amount; + this.chunk = chunk; + } + public String accountId() { return Denars.guild(guild.getIncarnationId()); } + public Double getWealth() { return Money.denars(Denars.balance(accountId())); } + /** Read only by the one-time, ledger-keyed legacy importer during startup. */ + public double legacyBalance() { return legacyBalance; } + public Chunk getChunk() { return chunk; } + public void setChunk(Chunk chunk) { this.chunk = chunk; } - public Double getWealth() { - return bank.getBal(); - } - public void setWealth(Double wealth) { - bank.setBal(wealth); - } - public void deposit(Double a) { - bank.change(a); - guild.updateWealth(); - } - public void withdraw(Double a) { - bank.change(a*-1); - guild.updateWealth(); - } - - public Chunk getChunk() { - return chunk; - } - - public void setChunk(Chunk c) { - chunk = c; - } + /** Removed in 4.0: every mutation needs balanced legs, a durable intent and a stable operation ID. */ + @Deprecated public void setWealth(Double amount) { throw new UnsupportedOperationException("Use a durable Denars operation"); } + @Deprecated public void deposit(Double amount) { throw new UnsupportedOperationException("Use a durable Denars operation"); } + @Deprecated public void withdraw(Double amount) { throw new UnsupportedOperationException("Use a durable Denars operation"); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java b/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java index 5af8246d..619db654 100644 --- a/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java @@ -6,7 +6,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.player.income.PlayerLedger; -import net.tfminecraft.simplefactions.utils.MemberAccounts; +import net.tfminecraft.simplefactions.identity.Members; public final class PlayerEconomyManager { private final Map<UUID, PlayerLedger> ledgers = new HashMap<>(); @@ -22,9 +22,9 @@ public PlayerLedger getLedger(UUID playerUuid) { return ledgers.computeIfAbsent(playerUuid, ignored -> new PlayerLedger()); } - /** Ledger of the account a member's money reaches. */ + /** Ledger of the character a member key names. */ public PlayerLedger getLedger(String member) { - UUID uuid = MemberAccounts.accountId(member); + UUID uuid = Members.characterId(member); if (uuid == null) { return new PlayerLedger(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java index 87cd4ae3..8c6c5e09 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java @@ -72,6 +72,24 @@ public static void advanceOfflineDays(List<Faction> factions) { } } + /** Pure day snapshot; recovery applies this exact map instead of incrementing it again. */ + public static Map<String, Integer> planOfflineDays(List<Faction> factions) throws IOException { + if (!ensureLoaded()) throw new IOException("Cannot read offline days"); + Map<String, Integer> result = new HashMap<>(offlineDays); + for (Faction faction : factions) { + for (String member : faction.getMembers()) { + if (InactivityRules.isTrackedMember(member)) result.putIfAbsent(member, 0); + } + } + result.replaceAll((member, days) -> isOnline(member) ? 0 : Math.addExact(days == null ? 0 : days, 1)); + return result; + } + + public static void restoreOfflineDays(Map<String, Integer> values) throws IOException { + net.tfminecraft.simplefactions.database.DurableJson.write(LOGIN_FILE.toPath(), values); + offlineDays = new HashMap<>(values); + } + public static int daysOffline(String member) { if (member == null) return 0; if (!ensureLoaded()) return 0; diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java index 965a7246..19a7a4d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java @@ -10,12 +10,11 @@ import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.denareconomy.enums.Accounts; import net.tfminecraft.simplefactions.managers.RelationManager; public class Wealth { - /** Pouch, bank and a share of their guild; the member's account holds the money until part C. */ + /** Pouch, bank and a share of the character's guild. */ public static double wealth(String player) { double wealth = balance(player, Accounts.POUCH); wealth += balance(player, Accounts.BANK); @@ -31,9 +30,10 @@ public static double wealth(String player) { } private static double balance(String member, Accounts account) { - if (Members.characterId(member) == null) return OfflineModifier.balance(member, account); - UUID id = Members.accountId(member).orElse(null); - return id == null ? 0 : OfflineModifier.balance(id, account); + UUID id = Members.characterId(member); + if (id == null) return 0; + return net.tfminecraft.simplefactions.economy.Money.denars(net.tfminecraft.denareconomy.api.Denars.balance( + "char:" + id + ":" + (account == Accounts.POUCH ? "pouch" : "bank"))); } public static List<String> topWealth(Faction faction, boolean includeVassals) { diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/OperationJournalTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/OperationJournalTest.java new file mode 100644 index 00000000..4e190793 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/OperationJournalTest.java @@ -0,0 +1,93 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OperationJournalTest { + @TempDir Path directory; + OperationJournal.Intent intent(String id, String account, long cents) { + JsonObject values = new JsonObject(); + values.addProperty("level", 2); + return new OperationJournal.Intent(id, "test:operation", List.of(new Leg(Denars.MINT, -cents), new Leg(account, cents)), values); + } + + @Test void crashAfterPostingRestoresOriginalEffectsAndAcknowledgesOnce() throws Exception { + try (var fixture = new GuiTestFixture()) { + String account = Denars.guild(UUID.randomUUID()); + var journal = new OperationJournal(directory); + var original = intent("buy", account, 20); + original.changes().addProperty("level", 99); + assertTrue(journal.resume("absent", value -> fail()).isEmpty()); + assertThrows(IOException.class, () -> journal.execute(original, value -> { throw new IOException("partial save"); })); + assertEquals(20, Denars.balance(account)); + AtomicInteger raw = new AtomicInteger(); + journal.recoverAppliedEffects(value -> { + assertEquals(2, value.changes().get("level").getAsInt()); + raw.incrementAndGet(); + }); + assertEquals(1, raw.get()); + assertEquals(Result.CONFLICT, journal.execute(intent("buy", account, 30), value -> fail())); + var reopened = new OperationJournal(directory); + assertEquals(Result.APPLIED, reopened.resume("buy", value -> assertEquals(original, value)).orElseThrow()); + assertEquals(Result.APPLIED, reopened.execute(original, value -> fail("already completed"))); + reopened.recover(value -> fail("already completed")); + reopened.recoverAppliedEffects(value -> fail("completed prepass")); + assertEquals(20, Denars.balance(account)); + } + } + + @Test void unavailableConflictsAndRefusedAreNotEffects() throws Exception { + var journal = new OperationJournal(directory); + var operation = intent("pending", Denars.guild(UUID.randomUUID()), 1); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.UNAVAILABLE); + assertEquals(Result.UNAVAILABLE, journal.execute(operation, value -> fail())); + journal.recoverAppliedEffects(value -> fail()); + assertThrows(IOException.class, () -> journal.recover(value -> fail())); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.CONFLICT); + assertThrows(IOException.class, () -> journal.recover(value -> fail())); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.REFUSED); + journal.recover(value -> fail()); + assertThrows(IOException.class, () -> journal.recover(value -> fail())); + denars.when(() -> Denars.status("pending")).thenReturn(java.util.Optional.of(Result.REFUSED)); + journal.recover(value -> fail()); + } + new OperationJournal(directory.resolve("missing")).recover(value -> fail()); + new OperationJournal(directory.resolve("missing")).recoverAppliedEffects(value -> fail()); + } + + @Test void alteredIntentNeverReachesPostingOrRawTopologyRecovery() throws Exception { + var journal = new OperationJournal(directory); + var operation = intent("pending", Denars.guild(UUID.randomUUID()), 1); + assertThrows(IllegalArgumentException.class, () -> new OperationJournal.Intent("", "why", operation.legs(), new JsonObject())); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.UNAVAILABLE); + journal.execute(operation, value -> fail()); + Path path; + try (var files = Files.list(directory)) { path = files.findFirst().orElseThrow(); } + var saved = JsonUtil.GSON.fromJson(Files.readString(path), JsonObject.class); + saved.getAsJsonObject("intent").getAsJsonObject("changes").addProperty("level", 999); + DurableJson.write(path, saved); + assertThrows(IOException.class, () -> journal.resume("pending", value -> fail())); + assertThrows(IOException.class, () -> journal.recoverAppliedEffects(value -> fail())); + saved.add("intent", null); + DurableJson.write(path, saved); + assertThrows(IOException.class, () -> journal.recover(value -> fail())); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java new file mode 100644 index 00000000..5f200540 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -0,0 +1,149 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RealmSettlementCalculationTest { + FactionDomainFixture fixture; + MockedStatic<Denars> denars; + Faction faction; + Guild capital; + Guild guild; + UUID character = UUID.randomUUID(); + boolean eligibility; + + @BeforeEach void setup() { + fixture = new FactionDomainFixture(); + faction = fixture.saved("realm", UUID.randomUUID().toString()); + capital = faction.getOrCreateMainGuild(); + guild = fixture.guild(faction, "guild", character.toString()); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capital.getBank(), 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 100.0); + faction.getTaxHandler().setGuildTax(0); + faction.getTaxHandler().setDividendTax(0); + denars = mockStatic(Denars.class, CALLS_REAL_METHODS); + + eligibility = Cache.dividendRequirePreviousTickMembership; + Cache.dividendRequirePreviousTickMembership = false; + Ledger.setNodeUpkeepLookup(g -> 0); + Ledger.setVehiclePoolUpkeepForTests(id -> 0); + } + + @AfterEach void close() { + Cache.dividendRequirePreviousTickMembership = eligibility; + Ledger.setNodeUpkeepLookup(null); + Ledger.setVehiclePoolUpkeepForTests(null); + denars.close(); + fixture.close(); + } + + SettlementJournal.Plan plan(List<RealmSettlementCalculation.Claim> claims) { + return new RealmSettlementCalculation(10, 1000000000L, FactionManager.getAllGuilds(), claims) + .calculate((balances, legs, changes) -> {}); + } + + long net(SettlementJournal.Plan plan, String account) { + return plan.legs().stream().filter(leg -> leg.account().equals(account)).mapToLong(Leg::cents).sum(); + } + + RealmSettlementChanges changes(SettlementJournal.Plan plan) { + return JsonUtil.GSON.fromJson(plan.changes(), RealmSettlementChanges.class); + } + + @Test void heldCitizenTaxMovesOnceAndPlanningLeavesEverythingUntouched() { + denars.when(() -> Denars.balance(Denars.guildTax(capital.getIncarnationId()))).thenReturn(1250L); + capital.getLedger().addCitizenTaxEntry("quarantine:old", 12.5); + var plan = plan(List.of()); + assertEquals(1250, net(plan, capital.getBank().accountId())); + assertEquals(-1250, net(plan, Denars.guildTax(capital.getIncarnationId()))); + assertEquals(0, net(plan, Denars.MINT)); + assertEquals(100, capital.getBank().getWealth()); + assertEquals(Map.of("quarantine:old", 12.5), capital.getLedger().getCitizenTaxesCopy()); + assertEquals(JsonUtil.GSON.toJson(plan), JsonUtil.GSON.toJson(plan(List.of()))); + assertEquals(0, plan.legs().stream().mapToLong(Leg::cents).sum()); + assertTrue(changes(plan).guilds.stream().filter(c -> c.incarnation.equals(capital.getIncarnationId())).findFirst().orElseThrow().citizenTaxes.isEmpty()); + } + + @Test void debtIsPreservedAndLiquidationIsRecordedWithoutTouchingBranches() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), -100.0); + guild.getBranch(0).levelUp(); + guild.getBranch(0).levelUp(); + var plan = plan(List.of()); + assertEquals(16000, net(plan, guild.getBank().accountId())); + var after = changes(plan).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + assertEquals(1, after.branches.get(0)); + assertEquals(2, guild.getSize()); + assertEquals(-100, guild.getBank().getWealth()); + guild.getBranch(0).levelDown(); + guild.getBranch(0).levelDown(); + assertEquals(0, net(plan(List.of()), guild.getBank().accountId())); + } + + @Test void fixedDeathDividendsReserveNormalPoolAndPartialWagesStayOwed() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 10.0); + var company = new MercenaryCompany(guild, "Company", null, 0); + company.accrueWage(character.toString(), 30); + guild.setCompany(company); + UUID deceased = UUID.randomUUID(); + var claim = new RealmSettlementCalculation.Claim("death:dividend", guild.getIncarnationId(), deceased, 1000, net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.DIVIDENDS); + var wages = new RealmSettlementCalculation.Claim("death:wage", guild.getIncarnationId(), deceased, 1000, net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.WAGES); + var plan = plan(List.of(claim, wages)); + assertEquals(-1000, net(plan, guild.getBank().accountId())); + assertEquals(600, net(plan, "char:" + character + ":bank")); + assertEquals(400, net(plan, "char:" + deceased + ":bank")); + var after = changes(plan).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + assertEquals(24, after.company.pendingWages.get(character.toString())); + assertEquals(30, company.getPendingWages().get(character.toString())); + assertEquals(Map.of("death:dividend", 200L, "death:wage", 200L), changes(plan).deathPayments); + } + + @Test void dissolvedGuildStillPaysOwedCharacterFromItsIncarnationTreasury() { + UUID incarnation = UUID.randomUUID(); + denars.when(() -> Denars.balance(Denars.guild(incarnation))).thenReturn(500L); + var claim = new RealmSettlementCalculation.Claim("dissolved", incarnation, character, 1000, net.tfminecraft.simplefactions.identity.death.OwedPayment.Kind.WAGES); + var plan = plan(List.of(claim)); + assertEquals(-500, net(plan, Denars.guild(incarnation))); + assertEquals(500, net(plan, "char:" + character + ":bank")); + assertEquals(500, changes(plan).deathPayments.get("dissolved")); + } + + @Test void loansRecordExactAfterValuesAndCreditWithoutChangingLiveLoan() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 10.0); + Loan loan = new Loan("test", 100, capital, guild, 1000000000L - 86400000L * 3, 1, 7, 5, true); + capital.getLoanHandler().issueLoan(loan); + String before = JsonUtil.GSON.toJson(new LoanData(loan)); + var plan = plan(List.of()); + assertEquals(-1000, net(plan, guild.getBank().accountId())); + assertEquals(1000, net(plan, capital.getBank().accountId())); + assertEquals(before, JsonUtil.GSON.toJson(new LoanData(loan))); + var changes = changes(plan); + LoanData after = changes.guilds.stream().filter(c -> c.incarnation.equals(capital.getIncarnationId())).findFirst().orElseThrow().loans.getFirst(); + assertTrue(after.paid > 0); + assertTrue(changes.guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow().creditScore < guild.getLoanHandler().getCreditScore()); + loan.restoreSettlement(after); + double owed = loan.getTotalOwed(); + loan.restoreSettlement(after); + assertEquals(owed, loan.getTotalOwed()); + after.id = "wrong"; + assertThrows(IllegalArgumentException.class, () -> loan.restoreSettlement(after)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournalTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournalTest.java new file mode 100644 index 00000000..55ffe233 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/SettlementJournalTest.java @@ -0,0 +1,112 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.denareconomy.ledger.Ledger; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SettlementJournalTest { + @TempDir Path directory; + + @Test + void replayAfterPostingAndPartialEffectsUsesSavedPlanAndNeverPaysTwice() throws Exception { + org.slf4j.jul.JDK14LoggerFactory.LOGGER = Logger.getAnonymousLogger(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getAnonymousLogger()); + Ledger previous = DenarEconomy.getLedger(); + Path db = directory.resolve("ledger.db"); + Ledger ledger = Ledger.open(db.toFile(), Logger.getAnonymousLogger(), account -> false); + DenarEconomy.setLedger(ledger); + String guild = Denars.guild(UUID.randomUUID()); + var changes = new JsonObject(); + changes.addProperty("wagesRemaining", 50); + var plan = new SettlementJournal.Plan(1, List.of(new Leg(guild, -200), new Leg(Denars.MINT, 200)), changes); + changes.addProperty("wagesRemaining", 999); + plan.changes().addProperty("wagesRemaining", 998); + var journal = new SettlementJournal(directory.resolve("settlements")); + AtomicInteger calculated = new AtomicInteger(); + try { + assertThrows(IOException.class, () -> journal.settle(1, () -> { + calculated.incrementAndGet(); return plan; + }, recorded -> { + DurableJson.write(directory.resolve("wages.json"), recorded.changes()); + throw new IOException("crash after first guild save"); + })); + assertEquals(-200, Denars.balance(guild)); + ledger.close(); + ledger = Ledger.open(db.toFile(), Logger.getAnonymousLogger(), account -> false); + DenarEconomy.setLedger(ledger); + var recovered = new SettlementJournal(directory.resolve("settlements")); + AtomicInteger applied = new AtomicInteger(); + recovered.recover(recorded -> { + assertEquals(50, recorded.changes().get("wagesRemaining").getAsInt()); + DurableJson.write(directory.resolve("wages.json"), recorded.changes()); + applied.incrementAndGet(); + }); + assertEquals(Result.APPLIED, recovered.settle(1, () -> { fail("must not recalculate"); return null; }, recorded -> fail("done"))); + recovered.recover(recorded -> fail("already done")); + assertEquals(1, calculated.get()); + assertEquals(1, applied.get()); + assertEquals(-200, Denars.balance(guild)); + assertEquals(200, Denars.balance(Denars.MINT)); + } finally { + ledger.close(); + DenarEconomy.setLedger(previous); + } + } + } + + @Test + void savedIntentSurvivesUnavailableBeforePostingAndRefusalNeverAppliesEffects() throws Exception { + var journal = new SettlementJournal(directory); + var plan = new SettlementJournal.Plan(2, List.of(), new JsonObject()); + assertEquals(List.of(new Leg(Denars.MINT, 0)), plan.legs()); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.UNAVAILABLE); + assertEquals(Result.UNAVAILABLE, journal.settle(2, () -> plan, p -> fail())); + assertTrue(Files.exists(directory.resolve("2.json"))); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.REFUSED); + assertThrows(IOException.class, () -> journal.recover(p -> fail())); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.APPLIED); + assertEquals(Result.APPLIED, journal.settle(2, () -> { fail(); return null; }, p -> {})); + assertThrows(IOException.class, () -> journal.recover(p -> fail())); + } + } + + @Test + void corruptedPlanOrChangedDayNeverReachesLedger() throws Exception { + var journal = new SettlementJournal(directory); + assertThrows(IllegalArgumentException.class, () -> new SettlementJournal.Plan(-1, List.of(), new JsonObject())); + assertThrows(IOException.class, () -> journal.settle(1, () -> new SettlementJournal.Plan(2, List.of(), new JsonObject()), p -> fail())); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.UNAVAILABLE); + journal.settle(1, () -> new SettlementJournal.Plan(1, List.of(), new JsonObject()), p -> fail()); + Path path = directory.resolve("1.json"); + JsonObject saved = JsonUtil.GSON.fromJson(Files.readString(path), JsonObject.class); + saved.getAsJsonObject("plan").getAsJsonObject("changes").addProperty("tampered", true); + DurableJson.write(path, saved); + assertThrows(IOException.class, () -> journal.recover(p -> fail())); + saved.add("plan", null); + DurableJson.write(path, saved); + assertThrows(IOException.class, () -> journal.recover(p -> fail())); + } + new SettlementJournal(directory.resolve("absent")).recover(p -> fail()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java index f9262f1d..35225868 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java @@ -81,6 +81,7 @@ public final class GuiTestFixture implements AutoCloseable { private final Map<Inventory, String> titles = new IdentityHashMap<>(); private final MockedConstruction<ItemStack> itemConstruction; private final MockedStatic<Bukkit> bukkit; + private final TestTreasuries treasuries; public GuiTestFixture() { when(world.getName()).thenReturn("world"); @@ -103,6 +104,7 @@ public GuiTestFixture() { bukkit = mockStatic(Bukkit.class); bukkit.when(Bukkit::getServer).thenReturn(server); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + treasuries = new TestTreasuries(); var services = new org.bukkit.plugin.SimpleServicesManager(); bukkit.when(Bukkit::getServicesManager).thenReturn(services); when(server.getServicesManager()).thenReturn(services); @@ -688,6 +690,7 @@ ItemState copy() { public void close() { try { itemConstruction.close(); + treasuries.close(); } finally { try { bukkit.close(); diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/TestTreasuries.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestTreasuries.java new file mode 100644 index 00000000..49f2329a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestTreasuries.java @@ -0,0 +1,51 @@ +package net.tfminecraft.simplefactions.testsupport; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.denareconomy.ledger.Ledger; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.objects.Bank; + +/** Real isolated SQLite ledger for domain tests; production has no in-memory treasury fallback. */ +public final class TestTreasuries implements AutoCloseable { + private final Ledger previous = DenarEconomy.getLedger(); + private final Path directory; + private final Ledger ledger; + public TestTreasuries() { + try { + directory = Files.createTempDirectory("sf-test-money-"); + } catch (IOException error) { throw new java.io.UncheckedIOException(error); } + org.slf4j.jul.JDK14LoggerFactory.LOGGER = Logger.getAnonymousLogger(); + ledger = Ledger.open(directory.resolve("ledger.db").toFile(), Logger.getAnonymousLogger(), account -> false); + DenarEconomy.setLedger(ledger); + } + public static void set(Bank bank, double amount) { + set(bank.accountId(), Money.cents(amount)); + for (var guild : net.tfminecraft.simplefactions.managers.FactionManager.getAllGuilds()) { + if (bank.accountId().equals("guild:" + guild.getIncarnationId())) guild.updateWealth(); + } + } + public static void add(Bank bank, double amount) { set(bank, bank.getWealth() + amount); } + public static void set(String account, long cents) { + long change = Math.subtractExact(cents, Denars.balance(account)); + Result result = Denars.post("test:" + UUID.randomUUID(), "test:opening-balance", + List.of(new Leg(Denars.MINT, -change), new Leg(account, change))); + if (result != Result.APPLIED) throw new AssertionError("Test funding failed: " + result); + } + @Override public void close() { + ledger.close(); + DenarEconomy.setLedger(previous); + try (var files = Files.walk(directory)) { + for (Path path : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } catch (IOException error) { throw new java.io.UncheckedIOException(error); } + } +} From 919145bd219463b69a02c3c0b9af089643136377 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:35:54 +0000 Subject: [PATCH 25/35] feat: recover daily preparation and retire legacy payouts --- .../simplefactions/SimpleFactions.java | 43 ++-- .../economy/ExternalIncome.java | 86 ++++++++ .../economy/GuildTaxPolicy.java | 2 +- .../RealmSettlementCalculation.java | 19 +- .../settlement/RealmSettlementEffects.java | 15 ++ .../simplefactions/guild/Guild.java | 8 - .../simplefactions/guild/income/Ledger.java | 12 +- .../handler/InstallationHandler.java | 63 +----- .../simplefactions/objects/Faction.java | 41 ---- .../utils/PostSettlementPayouts.java | 161 +------------- .../PluginLifecycleCoverageTest.java | 62 +++--- .../database/DatabaseCoverageTest.java | 8 +- .../DailyPreparationJournalTest.java | 74 +++++++ .../RealmSettlementCalculationTest.java | 15 +- .../RealmSettlementEffectsTest.java | 73 +++++++ .../RealmSettlementServiceTest.java | 52 +++++ .../espionage/EspionageMenusCoverageTest.java | 4 +- .../EspionageOperationsCoverageTest.java | 6 +- .../government/GovernmentCoverageTest.java | 8 +- .../guild/GuildCoverageTest.java | 38 +--- .../income/CasinoProfitOperationTest.java | 70 ++++--- .../IncomePreviewLifecycleCoverageTest.java | 2 +- .../income/LedgerSettlementCoverageTest.java | 8 +- .../inactivity/InactivityCoverageTest.java | 4 +- .../InstallationHandlerConstructTest.java | 20 -- .../InstallationLifecycleCoverageTest.java | 16 +- .../managers/FactionManagerCoverageTest.java | 149 +++---------- .../map/MapClaimsCoverageTest.java | 4 +- .../MapExportLifecycleCoverageTest.java | 8 +- .../objects/FactionCoverageTest.java | 28 +-- .../handler/TaxLifecycleCoverageTest.java | 4 +- .../testsupport/TestSettlements.java | 41 ++++ .../utils/FactionEconomicsCoverageTest.java | 90 +++----- .../utils/PostSettlementPayoutsTest.java | 198 ------------------ ...SettlementPaymentBoundaryCoverageTest.java | 151 +------------ .../SharedDomainUtilitiesCoverageTest.java | 84 ++++---- .../CivilWarLifecycleCoverageTest.java | 10 +- .../CivilWarWartimeBoundaryCoverageTest.java | 12 +- 38 files changed, 616 insertions(+), 1073 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/ExternalIncome.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/testsupport/TestSettlements.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/utils/PostSettlementPayoutsTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index cd06faf2..bebe6822 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -380,7 +380,8 @@ void start() { "Cannot migrate faction data to characters; factions stay offline until this is fixed", error); return; } - startServices(); + try { startServices(); } + catch (Exception error) { failFinancialState(error); } if (!started) return; getServer().getPluginManager().callEvent(new net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent()); } @@ -467,7 +468,9 @@ private void startServices() { guild.getBank() == null ? 0 : guild.getBank().legacyBalance(), guild.getLedger().getCitizenTaxesCopy()); guild.updateWealth(); } + FactionManager.updateAllPrestigeConverged(); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.load(getDataFolder().toPath()); + net.tfminecraft.simplefactions.economy.ExternalIncome.recover(getDataFolder().toPath()); realmSettlementService = new net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService(getDataFolder().toPath(), db); realmSettlementService.recover(); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.reconcile(); @@ -833,33 +836,17 @@ public void recordVehicleOwner(String vehicleUuid, String memberKey) { vehicleFeeStore.save(); } - /** - * Records the current owner of every personal vehicle, so a vehicle released before - * anyone built or claimed it through SimpleFactions still has a last owner. - */ - public void recordVehicleOwners() { - if (!getServer().getPluginManager().isPluginEnabled("VehicleFramework")) { - return; - } - try { - for (net.tfminecraft.vehicleframework.data.OwnedVehicleSummary vehicle - : net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.allPersonalVehicles(vehicleRegistry)) { - // VehicleFramework still owns vehicles by account; the fee follows that account's character. - String owner = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey( - net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries - .playerNameFromOwner(vehicle.getOwner())); - if (owner != null) { - vehicleFeeStore.setLastOwner(vehicle.getUuid(), owner); - } - } - for (net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord record : vehicleRegistry.getAll()) { - vehicleFeeStore.forgetVehicle(record.getVehicleUuid()); - } - vehicleFeeStore.save(); - } catch (RuntimeException | LinkageError e) { - getLogger().warning("Could not record vehicle owners for transfer fees: " + e); - } - } + /** Retains captured character payers; a current account session cannot replace a registry identity. */ + public void recordVehicleOwners() { + for (var record : vehicleRegistry.getAll()) { + if (record.getMode() != net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode.PERSONAL) { + vehicleFeeStore.forgetVehicle(record.getVehicleUuid()); + } else if (record.getPlayerUuid() != null && vehicleFeeStore.getLastOwner(record.getVehicleUuid()) == null) { + vehicleFeeStore.setLastOwner(record.getVehicleUuid(), record.getPlayerUuid().toString()); + } + } + vehicleFeeStore.save(); + } public InstallationVehicleUnberthService getInstallationVehicleUnberthService() { return installationVehicleUnberthService; diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/ExternalIncome.java b/src/main/java/net/tfminecraft/simplefactions/economy/ExternalIncome.java new file mode 100644 index 00000000..3d93a485 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/ExternalIncome.java @@ -0,0 +1,86 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; + +/** Replayable bookkeeping for money already posted by another plugin. */ +public final class ExternalIncome { + private ExternalIncome() {} + + public static void recover(Path folder) throws IOException { + journal(folder).recover(ExternalIncome::apply); + } + + public static boolean casino(Guild guild, String operation, double amount) { + if (!EconomyRuntime.ready()) return false; + try { + Path folder = SimpleFactions.getInstance().getDataFolder().toPath(); + var journal = journal(folder); + String id = "sf:casino:" + guild.getIncarnationId() + ":" + operation; + var resumed = journal.resume(id, saved -> { + if (Double.compare(saved.changes().get("amount").getAsDouble(), amount) != 0) + throw new IOException("Casino operation amount changed: " + operation); + apply(saved); + }); + Result result; + if (resumed.isPresent()) result = resumed.get(); + else { + var values = new JsonObject(); + values.addProperty("guild", guild.getId()); + values.addProperty("incarnation", guild.getIncarnation()); + values.addProperty("operation", operation); + values.addProperty("amount", amount); + values.addProperty("before", guild.getLedger().getCasinoProfit()); + values.addProperty("after", guild.getLedger().getCasinoProfit() + amount); + result = journal.execute(new OperationJournal.Intent(id, "sf:casino-income", List.of(new Leg(Denars.MINT, 0)), values), ExternalIncome::apply); + } + if (result != Result.APPLIED || !Double.valueOf(amount).equals(guild.getLedger().getCasinoProfitOperations().get(operation))) + throw new IOException("Casino bookkeeping remains incomplete: " + operation); + return true; + } catch (IOException | RuntimeException error) { + EconomyRuntime.failClosed(error); + return false; + } + } + + private static OperationJournal journal(Path folder) { return new OperationJournal(folder.resolve("external-income")); } + + private static void apply(OperationJournal.Intent intent) throws IOException { + JsonObject values = intent.changes(); + Guild guild = FactionManager.getGuildByString(values.get("guild").getAsString()); + if (guild == null || !guild.getIncarnation().equals(values.get("incarnation").getAsString())) + throw new IOException("Casino treasury disappeared or was replaced"); + String operation = values.get("operation").getAsString(); + double amount = values.get("amount").getAsDouble(); + var ledger = guild.getLedger(); + double originalProfit = ledger.getCasinoProfit(); + Map<String, Double> originalOperations = ledger.getCasinoProfitOperations(); + Map<String, Double> operations = new java.util.HashMap<>(originalOperations); + Double known = operations.get(operation); + double before = values.get("before").getAsDouble(), after = values.get("after").getAsDouble(); + if (known != null) { + if (Double.compare(known, amount) != 0) throw new IOException("Casino operation disagrees with saved amount"); + } else { + if (Double.compare(ledger.getCasinoProfit(), before) != 0 && Double.compare(ledger.getCasinoProfit(), after) != 0) + throw new IOException("Casino income changed before unfinished bookkeeping recovered"); + operations.put(operation, amount); + ledger.setCasinoProfit(after); + ledger.restoreCasinoProfitOperations(operations); + } + if (!new Database().saveFactionDurably(guild.getFaction())) { + ledger.setCasinoProfit(originalProfit); + ledger.restoreCasinoProfitOperations(originalOperations); + throw new IOException("Cannot save casino income"); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java index 5bb69a8a..04b33a90 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java @@ -23,6 +23,6 @@ public Optional<TaxLeg> tax(CharacterRef earner, long grossCents) { if (!Double.isFinite(rate) || rate < 0 || rate > 100) throw new IllegalStateException("Invalid citizen tax: " + rate); long cents = BigDecimal.valueOf(grossCents).multiply(BigDecimal.valueOf(rate)) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP).longValueExact(); - return Optional.of(new TaxLeg(Denars.guildTax(realm.getOrCreateMainGuild().getIncarnationId()), cents)); + return Optional.of(new TaxLeg(Denars.guildTax(net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds.capital(realm).getIncarnationId()), cents)); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java index 2217a40e..9005e724 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java @@ -76,6 +76,7 @@ public SettlementJournal.Plan calculate(Vehicles vehicles) { capture(); accrue(); var history = Ledger.collectHistoryDay(guilds, ledgers::get); + Map<String, Long> beforeIncome = new LinkedHashMap<>(balances); DailyGuildTransfers buffer = new DailyGuildTransfers(); for (Guild guild : guilds) ledgers.get(guild).populateDailyTransfers(buffer); for (var from : buffer.getTransfers().entrySet()) { @@ -89,7 +90,7 @@ public SettlementJournal.Plan calculate(Vehicles vehicles) { transfer(Denars.guildTax(guild.getIncarnationId()), Denars.MINT, held); } } - for (Guild guild : guilds) liquidate(guild); + for (Guild guild : guilds) liquidate(guild, !java.util.Objects.equals(beforeIncome.get(account(guild)), balances.get(account(guild)))); for (Guild guild : guilds) payouts(guild, buffer); payOrphanClaims(); for (Guild guild : guilds) { @@ -120,8 +121,12 @@ private void capture() { initial.put(guild, guild.getBank() == null ? 0 : guild.getBank().getWealth()); credits.put(guild, guild.getLoanHandler().getCreditScore()); MercenaryCompany company = guild.getCompany(); - if (company != null) companies.put(guild, new MercenaryCompany(guild, company.serialize(), - company.getRegiment() == null ? null : new Regiment(company.getRegiment()))); + if (company != null) { + MercenaryCompany copy = new MercenaryCompany(guild, company.serialize(), + company.getRegiment() == null ? null : new Regiment(company.getRegiment())); + copy.restoreSettlement(company.serialize()); + companies.put(guild, copy); + } } for (Faction faction : guilds.stream().map(Guild::getFaction).distinct().toList()) { FactionUpkeepPlan plan = preparedUpkeep == null ? FactionUpkeepPlan.calculate(faction) @@ -182,13 +187,13 @@ private void accrue() { } } - private void liquidate(Guild guild) { + private void liquidate(Guild guild, boolean moved) { var change = new RealmSettlementChanges.GuildChange(); change.id = guild.getId(); change.incarnation = guild.getIncarnationId(); guild.getBranches().forEach((key, branch) -> change.branches.put(key, branch.getLevel())); changes.guilds.add(change); - if (guild.getBank() == null || net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers())) return; + if (!moved || guild.getBank() == null || net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers())) return; int size = change.branches.values().stream().mapToInt(Integer::intValue).sum(); Random random = new Random(day ^ guild.getIncarnationId().getMostSignificantBits() ^ guild.getIncarnationId().getLeastSignificantBits()); for (int guard = 0; balances.get(account(guild)) < 0 && size > 0 && guard < 1000; guard++) { @@ -299,8 +304,8 @@ private void afterValues(Guild guild, Map<LedgerHistory.Source, Map<String, Doub var change = changes.guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); change.donation = views.get(guild).donation(); change.citizenTaxes = canMove(guild) ? Map.of() : guild.getLedger().getCitizenTaxesCopy(); - change.casinoProfit = canMove(guild) ? 0 : guild.getLedger().getCasinoProfit(); - change.vehicleFeeIncome = canMove(guild) ? 0 : guild.getLedger().getVehicleFeeIncome(); + change.casinoProfit = 0; + change.vehicleFeeIncome = 0; change.creditScore = credits.get(guild); for (Loan original : guild.getLoanHandler().getLoansGiven()) change.loans.add(new LoanData(loans.get(original))); MercenaryCompany company = companies.get(guild); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java index ad2c4395..009132dd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java @@ -43,9 +43,11 @@ public RealmSettlementEffects(Path folder, Database database, PlayerEconomyManag throw new IOException("Settlement guild missing or replaced: " + saved.id); } Set<Faction> save = new LinkedHashSet<>(); + java.util.List<Runnable> notices = new java.util.ArrayList<>(); for (FactionUpkeepPlan saved : changes.upkeep) { Faction faction = FactionManager.getByString(saved.faction()); if (faction == null) throw new IOException("Settlement faction missing: " + saved.faction()); + if (faction.getMilitary() == null && !saved.regimentSlots().isEmpty()) throw new IOException("Settlement military missing: " + saved.faction()); if (faction.getMilitary() != null) { for (var regiment : saved.regimentSlots().entrySet()) { var current = faction.getMilitary().getRegiment(regiment.getKey()); @@ -53,6 +55,18 @@ public RealmSettlementEffects(Path folder, Database database, PlayerEconomyManag current.setCurrentSlots(regiment.getValue()); } } + for (var current : faction.getInstallationHandler().getAll()) { + var after = saved.installations().stream().filter(i -> i.id.equals(current.getId())).findFirst().orElse(null); + if (after == null || after.level < current.getLevel()) { + String text = "§c" + current.getKind().getDisplayName() + " §f" + current.getName() + + (after == null ? " §chas been destroyed" : " §cdropped to level " + after.level) + + " §7(unable to pay upkeep)"; + notices.add(() -> { + var leader = net.tfminecraft.simplefactions.identity.Members.player(faction.getLeader()); + if (leader != null) leader.sendMessage(text); + }); + } + } faction.getInstallationHandler().restoreSettlement(saved.installations(), saved.construction()); save.add(faction); } @@ -102,5 +116,6 @@ public RealmSettlementEffects(Path folder, Database database, PlayerEconomyManag DurableJson.write(folder.resolve("Cache/data.json"), timer); FactionManager.day = Math.max(FactionManager.day, timer.day); FactionManager.timer = 0; + notices.forEach(Runnable::run); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 5d928ef6..e68647b1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -1119,14 +1119,6 @@ public boolean canLiquidate() { return getSize() > 0; } - public void liquidateRandom() { - if (!canLiquidate() || bank == null) return; - List<Branch> expanded = branches.values().stream().filter(br -> br.getLevel() > 0).toList(); - Branch b = expanded.get((int) (Math.random() * expanded.size())); - double refund = getRefund(); - b.levelDown(); - bank.deposit(refund); - } public boolean isFavoured() { Boolean overlay = IncomePreviewContext.overlayFavoured(this); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 6a64ec73..c495b71c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -196,13 +196,7 @@ public boolean addCasinoProfitEntry(String operationId, double amount) { if (operationId == null || operationId.isBlank() || !Double.isFinite(amount) || amount <= 0) return false; Double previous = casinoProfitOperations.get(operationId); if (previous != null) return Double.compare(previous, amount) == 0; - double before = casinoProfit; - casinoProfit += amount; - casinoProfitOperations.put(operationId, amount); - if (new net.tfminecraft.simplefactions.database.Database().saveFactionDurably(guild.getFaction())) return true; - casinoProfit = before; - casinoProfitOperations.remove(operationId); - return false; + return net.tfminecraft.simplefactions.economy.ExternalIncome.casino(guild, operationId, amount); } public Map<String, Double> getCasinoProfitOperations() { @@ -1084,7 +1078,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case TRADE_UPKEEP: buffer.addExternalDelta(guild, getIncome(cf)); return; - // INSTALLATIONS: withdrawn in Faction.newDay(), so getIncome() is ledger GUI display only. + // INSTALLATIONS: charged by the pre-income upkeep plan, so getIncome() is ledger GUI display only. // VEHICLE_UPKEEP: withdrawn in VehicleUpkeepService for pool and installation vehicles. case UPGRADES_UPKEEP: case PENALTIES: @@ -1102,7 +1096,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case VEHICLE_FEES: return; - // The faction share of military upkeep is withdrawn in Faction.newDay(); + // The faction share of military upkeep is charged by the pre-income upkeep plan; // only the company's slot upkeep settles here. case MILITARY_UPKEEP: { double slots = getCompanySlotUpkeep(); diff --git a/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java b/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java index 22b959d0..732bc55e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java @@ -79,7 +79,16 @@ public InstallationConstructionData serializeConstruction() { /** Exact upkeep outcome; a queued construction is not advanced during replay. */ public void restoreSettlement(List<InstallationData> installations, InstallationConstructionData construction) { - load(installations); + java.util.Set<String> surviving = new java.util.HashSet<>(); + for (InstallationData data : installations) { + surviving.add(data.id); + Installation current = byId.get(data.id); + if (current == null) register(new Installation(data)); + else current.setLevel(data.level); + } + for (Installation current : new ArrayList<>(getAll())) { + if (!surviving.contains(current.getId())) removeInstallation(current); + } pendingConstruction = null; pendingByProvinceKind.clear(); if (construction != null) setPendingConstruction(new InstallationConstruction(construction)); @@ -231,44 +240,6 @@ public void tick() { completeConstruction(pendingConstruction); } - public void payDailyUpkeep() { - List<Installation> installations = new ArrayList<>(getAll()); - installations.sort( - Comparator.comparingDouble( - (Installation installation) -> - InstallationConfigLoader.getDailyUpkeep( - installation.getKind(), installation.getLevel())) - .thenComparingLong(Installation::getCompletedAt)); - - Bank bank = faction.getBank(); - for (Installation installation : installations) { - double upkeep = InstallationConfigLoader.getDailyUpkeep( - installation.getKind(), installation.getLevel()); - if (upkeep <= 0) { - continue; - } - if (bank == null || bank.getWealth() < upkeep) { - if (installation.getLevel() > 1) { - cancelUpgrade(installation.getId()); - installation.setLevel(installation.getLevel() - 1); - enqueueMapUpdate(); - Player leader = Members.player(faction.getLeader()); - if (leader != null) { - leader.sendMessage( - "§c" + installation.getKind().getDisplayName() - + " §f" + installation.getName() - + " §cdropped to level " + installation.getLevel() - + " §7(unable to pay upkeep)"); - } - } else { - dissolveForNonPayment(installation); - } - continue; - } - bank.withdraw(upkeep); - } - } - public void cancelPendingConstructionOnProvince(int province) { if (pendingConstruction != null && pendingConstruction.getProvince() == province) { clearPendingConstruction(); @@ -411,20 +382,6 @@ void dissolve(Installation installation) { } } - private void dissolveForNonPayment(Installation installation) { - removeInstallation(installation); - - Player leader = Members.player(faction.getLeader()); - if (leader != null) { - leader.sendMessage( - "§c" - + installation.getKind().getDisplayName() - + " §f" - + installation.getName() - + " §chas been destroyed §7(unable to pay upkeep)"); - } - } - private void removeInstallation(Installation installation) { cancelUpgrade(installation.getId()); byId.remove(installation.getId()); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 3d784116..67f39d21 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -1384,47 +1384,6 @@ public FactionModifier getModifier(FactionModifiers m) { return new FactionModifier(m, totalAmount); } - public void newDay() { - double armyCost = military == null ? 0.0 : military.getTotalUpkeep(); - Bank bank = getBank(); - if(armyCost > 0 && bank == null && military != null && military.getRegiments() != null){ - for(Regiment r : military.getRegiments()){ - if(r == null || r.isLevy()) continue; - while(r.getCurrentSlots() > r.getFreeSlots()){ - int before = r.getCurrentSlots(); - r.sizeDecrease(); - if(r.getCurrentSlots() >= before) break; - } - } - armyCost = 0.0; - } - Double wealth = bank == null ? null : bank.getWealth(); - int guard = 0; - while(armyCost > 0 && wealth != null && wealth < armyCost && guard++ < 100000) { - boolean shrank = false; - if(military != null && military.getRegiments() != null) { - for(Regiment r : military.getRegiments()) { - if(r == null || r.isLevy()) continue; - if(r.getCurrentSlots() > r.getFreeSlots()) { - int before = r.getCurrentSlots(); - r.sizeDecrease(); - if(r.getCurrentSlots() < before) shrank = true; - break; - } - } - } - if(!shrank) break; - armyCost = military.getTotalUpkeep(); - } - if(armyCost > 0 && bank != null && wealth != null) { - bank.withdraw(armyCost); - } - installationHandler.payDailyUpkeep(); - provinceCap(); - if (government != null) { - government.applyDailyOrganizationGain(); - } - } public void provinceCap() { provinceHandler.provinceCap(); diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java b/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java index 64cb8706..4d598620 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java @@ -1,163 +1,10 @@ package net.tfminecraft.simplefactions.utils; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.income.DividendBreakdown; -import net.tfminecraft.simplefactions.objects.Bank; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.player.PlayerEconomyManager; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; - +/** Character lookup seam used by ledger wage previews. Payouts belong to the realm settlement. */ public final class PostSettlementPayouts { - - @FunctionalInterface - public interface PlayerUuidLookup { - UUID uuidOf(String playerName); - } - - private PostSettlementPayouts() {} - - public static void apply( - DailyGuildTransfers buffer, - PlayerBank playerBank, - PlayerEconomyManager economy, - PlayerUuidLookup uuids) { - if (buffer == null) { - return; - } - Set<Guild> guilds = new HashSet<>(); - guilds.addAll(buffer.getPendingDividendPools().keySet()); - guilds.addAll(buffer.getPlayerPayouts().keySet()); - for (Guild guild : guilds) { - settleGuild(guild, buffer, playerBank, economy, uuids); - } - } - - private static void settleGuild( - Guild guild, - DailyGuildTransfers buffer, - PlayerBank playerBank, - PlayerEconomyManager economy, - PlayerUuidLookup uuids) { - if (guild == null || guild.getBank() == null) { - return; - } - if (guild.isBankrupt()) { - return; - } - double pool = buffer.getPendingDividendPools().getOrDefault(guild, 0.0); - Map<UUID, Double> payouts = buffer.getPlayerPayouts().getOrDefault(guild, Map.of()); - double wages = 0.0; - for (Map.Entry<UUID, Double> entry : payouts.entrySet()) { - Double amount = entry.getValue(); - if (entry.getKey() != null && amount != null && Double.isFinite(amount) && amount > 0) { - wages += amount; - } - } - double needed = pool + wages; - if (needed <= 0) { - return; - } - double available = Math.max(0.0, guild.getBank().getWealth()); - double scale = PayoutClamp.scaleFactor(available, needed); - if (scale <= 0.0) { - return; - } - - double actualPool = Formatter.formatDouble(pool * scale); - payDividends(guild, actualPool, playerBank, economy, uuids); - payPlayerPayouts(guild, payouts, scale, playerBank, economy); - } - - private static void payDividends( - Guild guild, - double pool, - PlayerBank playerBank, - PlayerEconomyManager economy, - PlayerUuidLookup uuids) { - if (pool <= 0 || guild.isBase()) { - return; - } - DividendBreakdown breakdown = guild.getLedger().breakdownForPool(pool); - double tax = breakdown.tax(); - double payout = breakdown.payout(); - Faction faction = guild.getFaction(); - Guild capital = faction == null ? null : faction.getOrCreateMainGuild(); - double withdrawn = 0.0; - if (tax > 0 && capital != null && capital.getBank() != null && capital != guild) { - capital.getBank().deposit(tax); - withdrawn += tax; - } - java.util.List<String> eligible = guild.getDividendEligibleMembers(); - double perMember = breakdown.perMember(); - if (perMember > 0 && eligible != null) { - for (String name : eligible) { - if (name == null || name.isBlank()) { - continue; - } - UUID uuid = uuids == null ? null : uuids.uuidOf(name); - if (uuid == null) { - continue; - } - if (playerBank == null || !playerBank.depositToBank(uuid, perMember)) { - continue; - } - if (economy != null) { - economy.getLedger(uuid).add(PlayerCashflow.DIVIDEND_PAYOUT, perMember); - } - withdrawn += perMember; - } - } - withdrawn = Formatter.formatDouble(withdrawn); - if (withdrawn > 0) { - Bank bank = guild.getBank(); - double pay = Math.min(withdrawn, Math.max(0.0, bank.getWealth())); - if (pay > 0) { - bank.withdraw(pay); - } - } - } - - private static void payPlayerPayouts( - Guild guild, - Map<UUID, Double> payouts, - double scale, - PlayerBank playerBank, - PlayerEconomyManager economy) { - if (payouts == null || payouts.isEmpty() || playerBank == null) { - return; - } - double withdrawn = 0.0; - for (Map.Entry<UUID, Double> entry : payouts.entrySet()) { - if (entry.getKey() == null || entry.getValue() == null || !Double.isFinite(entry.getValue()) || entry.getValue() <= 0) { - continue; - } - double amount = Formatter.formatDouble(entry.getValue() * scale); - if (amount <= 0) { - continue; - } - if (!playerBank.depositToBank(entry.getKey(), amount)) { - continue; - } - // Wages are deliberately not taxed as citizen income for now; the seam is - // the PlayerCashflow entry, so taxing them later means reading this one line. - if (economy != null) { - economy.getLedger(entry.getKey()).add(PlayerCashflow.WAGES, amount); - } - withdrawn += amount; - } - withdrawn = Formatter.formatDouble(withdrawn); - if (withdrawn > 0) { - Bank bank = guild.getBank(); - double pay = Math.min(withdrawn, Math.max(0.0, bank.getWealth())); - if (pay > 0) { - bank.withdraw(pay); - } - } - } + @FunctionalInterface + public interface PlayerUuidLookup { UUID uuidOf(String member); } + private PostSettlementPayouts() {} } diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 71500a72..e71a3478 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -229,7 +229,7 @@ void incompatibleCharacterApiDisablesBeforeOpeningOrSavingState() { plugin.onEnable(); plugin.onDisable(); verify(plugins).disablePlugin(plugin); - verify(logger).severe(contains("TLibs 2.4.0")); + verify(logger).severe(contains("TLibs 2.4.1")); verify(plugin, never()).getConfig(); scoped(BattlePersistenceService.class).verifyNoInteractions(); scoped(InactivityService.class).verifyNoInteractions(); @@ -272,7 +272,7 @@ void startupRegistersGameplayAndShutdownSavesLoadedState() throws Exception { } verify(commands.get("battle")) .setExecutor(dependency("battleCommandManager", BattleCommandManager.class)); - verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("factionManager", FactionManager.class)).prepare(); verify(dependency("inventoryManager", InventoryManager.class)).start(); verify(dependency("sessionManager", SessionManager.class)).start(); verify(dependency("battleManager", BattleManager.class)).start(); @@ -368,7 +368,7 @@ void malformedInitialDefinitionsAbortBeforeGameplayAndPreserveSavedState(String assertTrue( listeners.isEmpty(), "Gameplay listeners must wait for valid configuration"), () -> verify(dependency("db", Database.class), never()).loadFactions(), - () -> verify(dependency("factionManager", FactionManager.class), never()).run(), + () -> verify(dependency("factionManager", FactionManager.class), never()).prepare(), () -> verify(dependency("inventoryManager", InventoryManager.class), never()).start(), () -> scoped(BattlePersistenceService.class) @@ -400,7 +400,7 @@ void failedStartupDisablesThePluginBeforeStartingRuntimeLoops(String step) throw grids.when(() -> ProvinceGrid.load(any())).thenThrow(new IllegalStateException("bad grid")); plugin.onEnable(); verify(plugins).disablePlugin(plugin); - verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("factionManager", FactionManager.class), never()).prepare(); verify(dependency("inventoryManager", InventoryManager.class), never()).start(); plugin.onDisable(); assertSavedStateUnchanged(saved); @@ -645,7 +645,7 @@ void guildConversionRetainsCapitalAfterConfigurationReload(boolean reloadSucceed domain.subject(ruler, source); var mainGuild = source.getOrCreateMainGuild(); mainGuild.addMember("Cara"); - source.getBank().deposit(20.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(source.getBank(), 20.0); var originalBank = source.getBank(); var originalGuildTypes = GuildLoader.get(); var originalEntries = new LinkedHashMap<>(originalGuildTypes); @@ -749,7 +749,7 @@ void rejectedReloadRetainsDependentGuildDefinitions(String rejectedFile) throws mainGuild.getBranches().get(0).levelUp(); mainGuild.getUpgrades().getFirst().setLevel(3); mainGuild.addMember("Cara"); - source.getBank().deposit(20.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(source.getBank(), 20.0); var originalBank = source.getBank(); Path broken = disk.write( @@ -910,7 +910,7 @@ void enabledProvinceGridSchedulesRefreshAndCancelsItAtShutdown() throws Exceptio verify(logger).severe(contains("TFMCWeb is not loaded")); verify(logger).info(contains("VehicleFramework is not enabled")); scoped(ProvincePresenceTickService.class).verify(ProvincePresenceTickService::start); - assertEquals(1, domain.ui.repeatingTasks.size()); + assertEquals(2, domain.ui.repeatingTasks.size()); domain.ui.repeatingTasks.getFirst().run(); plugin.onDisable(); var taskField = SimpleFactions.class.getDeclaredField("trackRefreshTask"); @@ -1050,28 +1050,16 @@ void ownerAndMaintenancePersistenceUsesTheExposedLiveStores() throws Exception { UUID.randomUUID(), "berthed", "cart", OwnershipMode.PERSONAL, "dock")); plugin.recordVehicleOwner("berthed", "PreviousOwner"); when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); - try (var queries = mockStatic(VehicleOwnershipQueries.class, CALLS_REAL_METHODS)) { - OwnedVehicleSummary personal = mock(OwnedVehicleSummary.class), - unowned = mock(OwnedVehicleSummary.class); - when(personal.getUuid()).thenReturn("personal"); - when(personal.getOwner()).thenReturn("player_Bob"); - when(unowned.getUuid()).thenReturn("unowned"); - when(unowned.getOwner()).thenReturn("player_none"); - queries - .when(() -> VehicleOwnershipQueries.allPersonalVehicles(registry)) - .thenReturn(List.of(personal, unowned)); - // The fee follows the character the owning account plays. - domain.player("Bob"); - plugin.recordVehicleOwners(); - fees.load(); - assertEquals("Bob", fees.getLastOwner("personal")); - assertNull(fees.getLastOwner("berthed")); - queries - .when(() -> VehicleOwnershipQueries.allPersonalVehicles(registry)) - .thenThrow(new LinkageError("old VehicleFramework")); - assertDoesNotThrow(plugin::recordVehicleOwners); - verify(logger).warning(contains("Could not record vehicle owners")); - } + UUID payer = UUID.randomUUID(); + registry.register(new PlayerVehicleRecord(payer, "personal", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "pool", "cart", OwnershipMode.POOL, null)); + plugin.recordVehicleOwner("pool", "PreviousOwner"); + domain.player("Bob"); + plugin.recordVehicleOwners(); + fees.load(); + assertEquals(payer.toString(), fees.getLastOwner("personal")); + assertEquals("PreviousOwner", fees.getLastOwner("berthed")); + assertNull(fees.getLastOwner("pool")); var persistence = mock(VehicleRegistryPersistence.class); when(persistence.save()).thenReturn(false); field("vehicleRegistryPersistence", persistence); @@ -1195,7 +1183,7 @@ void waitingForIdentityLoadsTicksAndSavesNothingAndCommandsSaySo() throws Except assertEquals(1, listeners.size(), "only the identity watcher listens"); assertEquals(1, domain.ui.repeatingTasks.size(), "only the identity poll ticks"); verify(dependency("db", Database.class), never()).loadFactions(); - verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("factionManager", FactionManager.class), never()).prepare(); for (String name : SimpleFactions.COMMANDS) { var executor = firstExecutor(name); var sender = domain.player("Waiter"); @@ -1208,7 +1196,7 @@ void waitingForIdentityLoadsTicksAndSavesNothingAndCommandsSaySo() throws Except // Not ready yet: neither the poll nor another plugin's enable starts anything. domain.ui.repeatingTasks.getFirst().run(); enableEvent("RPCharacters"); - verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("factionManager", FactionManager.class), never()).prepare(); plugin.onDisable(); verify(dependency("sessionManager", SessionManager.class), never()).end(); @@ -1229,11 +1217,11 @@ void identityArrivingByPollStartsEverythingOnceAndAnnouncesReadiness() throws Ex assertTrue(plugin.isStarted()); assertTrue(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); - verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("factionManager", FactionManager.class)).prepare(); verify(plugins).callEvent(any(net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent.class)); poll.run(); enableEvent("RPCharacters"); - verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("factionManager", FactionManager.class)).prepare(); plugin.onDisable(); assertFalse(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); @@ -1246,9 +1234,9 @@ void identityArrivingWithRpCharactersStartsEverything() throws Exception { plugin.onEnable(); identityReady(true); enableEvent("SomethingElse"); - verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("factionManager", FactionManager.class), never()).prepare(); enableEvent("RPCharacters"); - verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("factionManager", FactionManager.class)).prepare(); assertTrue(plugin.isStarted()); } @@ -1259,7 +1247,7 @@ void aFailedMigrationKeepsFactionsOfflineWithoutRetrying() throws Exception { verify(logger).log(eq(java.util.logging.Level.SEVERE), contains("Cannot migrate faction data"), any(Throwable.class)); assertFalse(plugin.isStarted()); plugin.start(); - verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("factionManager", FactionManager.class), never()).prepare(); plugin.onDisable(); scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll, never()); } @@ -1360,7 +1348,7 @@ void olderVfBuildersWithoutOptionalApisStillAllowsThePluginToStart() throws Exce when(plugins.isPluginEnabled("VFBuilders")).thenReturn(true); when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); legacy.onEnable(); - verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("factionManager", FactionManager.class)).prepare(); verify(plugins, never()).disablePlugin(legacy); verify(logger).warning(contains("VFBuilders is older than 2.1.0")); verify(logger).warning(contains("VFBuilders has no ConstructionFreeze API")); diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java index 9fcec0cc..8534f478 100644 --- a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java @@ -129,8 +129,14 @@ private FactionData richFaction() { FactionData data=richFaction(); Path path=disk.write("Data/realm.json",JsonUtil.GSON.toJson(data)); database.loadFactions();Faction faction=FactionManager.getByString("realm");assertNotNull(faction); + assertEquals("renowned",faction.getRank().getId()); + for (var guild : FactionManager.getAllGuilds()) { + net.tfminecraft.simplefactions.economy.GuildTreasury.importLegacy(guild.getIncarnationId(), guild.getBank() == null ? 0 : guild.getBank().legacyBalance(), guild.getLedger().getCitizenTaxesCopy()); + guild.updateWealth(); + } + FactionManager.updateAllPrestigeConverged(); assertEquals(400.0,faction.getWealth());assertEquals(75.0,faction.getBank().getWealth());assertEquals(1234L,faction.getFoundedAt()); - assertEquals(2,faction.getCapitalMoves());assertEquals("renowned",faction.getRank().getId()); + assertEquals(2,faction.getCapitalMoves()); assertEquals(3.5,faction.getTaxRate(TaxTarget.GUILD_ID,"merchants",false)); assertEquals(12345L,faction.getLawHandler().getGroup("customs").getChangedAt()); assertEquals(5,faction.getMilitary().getRegiment("guards").getCurrentSlots()); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java new file mode 100644 index 00000000..edd6ea42 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java @@ -0,0 +1,74 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.MovementData; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DailyPreparationJournalTest { + @Test void crashDuringPreparationReplaysTheChosenProvinceAndExactDayValues() throws Exception { + try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture(); var titles = mockStatic(TitleManager.class, CALLS_REAL_METHODS)) { + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + faction.addProvince(1); faction.addProvince(2); faction.addProvince(3); + faction.setCapital(1, true); + var movement = new Movement(faction, new MovementData()); + movement.restoreOrganization(20); + faction.getGovernment().getMovements().add(movement); + titles.when(() -> TitleManager.overProvinceCap(faction)).thenReturn(true); + Field offline = FactionCleanup.class.getDeclaredField("offlineDays"); + offline.setAccessible(true); + Object previous = offline.get(null); + offline.set(null, new HashMap<>(Map.of(faction.getLeader(), 3))); + try { + var database = mock(Database.class); + when(database.saveFactionDurably(faction)).thenReturn(false, true); + var journal = new DailyPreparationJournal(disk.root.resolve("day-preparation"), database); + assertThrows(IOException.class, () -> journal.prepare(2, 1000)); + assertEquals(List.of(1, 2), faction.getProvinces()); + // A later retry cannot choose the next province, increment organization twice or change the timestamp. + var restored = new DailyPreparationJournal(disk.root.resolve("day-preparation"), database); + var prepared = restored.prepare(2, 9000); + assertEquals(1000, prepared.timestamp()); + assertEquals(List.of(1, 2), faction.getProvinces()); + assertEquals(10, movement.getOrganization()); + assertEquals(4, FactionCleanup.daysOffline(faction.getLeader())); + restored.prepare(2, 10000); + assertEquals(10, movement.getOrganization()); + assertEquals(4, FactionCleanup.daysOffline(faction.getLeader())); + assertEquals(List.of(2L), restored.unfinishedDays()); + restored.complete(2); + assertTrue(restored.unfinishedDays().isEmpty()); + } finally { offline.set(null, previous); } + } + } + + @Test void malformedOrReplacedPreparationCannotStartAnotherDay() throws Exception { + try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture()) { + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var database = mock(Database.class); + var folder = disk.root.resolve("day-preparation"); + var journal = new DailyPreparationJournal(folder, database); + assertTrue(journal.unfinishedDays().isEmpty()); + assertThrows(IOException.class, () -> journal.prepare(4, 1000)); + FactionManager.factions.clear(); + assertThrows(IOException.class, () -> journal.prepare(4, 9000)); + DurableJson.write(folder.resolve("7.json"), Map.of("day", 2)); + assertThrows(IOException.class, journal::unfinishedDays); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java index 5f200540..f089654f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -83,19 +83,20 @@ RealmSettlementChanges changes(SettlementJournal.Plan plan) { assertTrue(changes(plan).guilds.stream().filter(c -> c.incarnation.equals(capital.getIncarnationId())).findFirst().orElseThrow().citizenTaxes.isEmpty()); } - @Test void debtIsPreservedAndLiquidationIsRecordedWithoutTouchingBranches() { - net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), -100.0); + @Test void debtIsPreservedAndDailyShortfallsLiquidateWithoutTouchingLiveBranches() { guild.getBranch(0).levelUp(); guild.getBranch(0).levelUp(); + Ledger.setNodeUpkeepLookup(g -> g == guild ? 200 : 0); var plan = plan(List.of()); - assertEquals(16000, net(plan, guild.getBank().accountId())); + assertEquals(-4000, net(plan, guild.getBank().accountId())); var after = changes(plan).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); assertEquals(1, after.branches.get(0)); assertEquals(2, guild.getSize()); - assertEquals(-100, guild.getBank().getWealth()); - guild.getBranch(0).levelDown(); - guild.getBranch(0).levelDown(); - assertEquals(0, net(plan(List.of()), guild.getBank().accountId())); + assertEquals(100, guild.getBank().getWealth()); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), -100); + var debt = plan(List.of()); + assertEquals(0, net(debt, guild.getBank().accountId())); + assertEquals(2, changes(debt).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow().branches.get(0)); } @Test void fixedDeathDividendsReserveNormalPoolAndPartialWagesStayOwed() { diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java new file mode 100644 index 00000000..946c4a6c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java @@ -0,0 +1,73 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.TimerData; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RealmSettlementEffectsTest { + @TempDir Path folder; + + @Test void partialFactionSaveKeepsClaimsAndClockPendingAndReplaysExactAfterValues() throws Exception { + try (var fixture = new FactionDomainFixture()) { + UUID character = UUID.randomUUID(); + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var capital = faction.getOrCreateMainGuild(); + var guild = fixture.guild(faction, "guild", character.toString()); + TestTreasuries.set(guild.getBank(), 10); + var company = new MercenaryCompany(guild, "Company", null, 0); + company.accrueWage(character.toString(), 30); + guild.setCompany(company); + var loan = new Loan("loan", 100, capital, guild, 1000, 1, 0, 0, true); + capital.getLoanHandler().issueLoan(loan); + var plan = new RealmSettlementCalculation(2, 100000000L, FactionManager.getAllGuilds(), List.of()) + .calculate((balances, legs, changes) -> changes.playerIncome.computeIfAbsent(character, key -> new java.util.LinkedHashMap<>()).put(PlayerCashflow.WAGES.name(), 10.0)); + var database = mock(Database.class); + when(database.saveFactionDurably(faction)).thenReturn(false, true); + var deaths = mock(RealmSettlementEffects.Payments.class); + var vehicles = mock(RealmSettlementEffects.Vehicles.class); + var economy = new PlayerEconomyManager(); + var effects = new RealmSettlementEffects(folder, database, economy, deaths, vehicles); + assertThrows(IOException.class, () -> effects.apply(plan)); + verifyNoInteractions(deaths, vehicles); + effects.apply(plan); + double owed = loan.getTotalOwed(); + effects.apply(plan); + assertEquals(owed, loan.getTotalOwed()); + assertEquals(10, economy.getLedger(character).getNetDaily()); + assertEquals(3, JsonUtil.GSON.fromJson(java.nio.file.Files.readString(folder.resolve("Cache/data.json")), TimerData.class).day); + assertEquals(3, FactionManager.day); + verify(deaths, times(2)).apply("sf:settle:2", Map.of()); + assertEquals(10, guild.getBank().getWealth(), "Effects never perform money movement"); + } + } + + @Test void replacementIncarnationFailsBeforeAnySave() throws Exception { + try (var fixture = new FactionDomainFixture()) { + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var plan = new RealmSettlementCalculation(3, 1000, FactionManager.getAllGuilds(), List.of()).calculate((b,l,c) -> {}); + var changes = JsonUtil.GSON.fromJson(plan.changes(), RealmSettlementChanges.class); + changes.guilds.getFirst().incarnation = UUID.randomUUID(); + var altered = new SettlementJournal.Plan(3, plan.legs(), JsonUtil.GSON.toJsonTree(changes).getAsJsonObject()); + var database = mock(Database.class); + var effects = new RealmSettlementEffects(folder, database, new PlayerEconomyManager(), (id, paid) -> fail(), c -> fail()); + assertThrows(IOException.class, () -> effects.apply(altered)); + verifyNoInteractions(database); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java new file mode 100644 index 00000000..5a9f922d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java @@ -0,0 +1,52 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.death.CharacterDeaths; +import net.tfminecraft.simplefactions.identity.death.OwedPayment; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleSettlement; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RealmSettlementServiceTest { + @Test void preparedDayRecoversBeforeAnotherDayAndPublishedPlanDoesNotRecalculateRoutes() throws Exception { + try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture(); + var deaths = mockStatic(CharacterDeaths.class); var vehicles = mockStatic(VehicleSettlement.class); + var playerEconomy = mockStatic(net.tfminecraft.simplefactions.player.PlayerEconomyManager.class)) { + playerEconomy.when(net.tfminecraft.simplefactions.player.PlayerEconomyManager::get).thenReturn(new net.tfminecraft.simplefactions.player.PlayerEconomyManager()); + when(fixture.ui.plugin.getDataFolder()).thenReturn(disk.root.toFile()); + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + Field offline = FactionCleanup.class.getDeclaredField("offlineDays"); + offline.setAccessible(true); + Object original = offline.get(null); + offline.set(null, new HashMap<>()); + try { + var database = mock(Database.class); + when(database.saveFactionDurably(any())).thenReturn(true); + new DailyPreparationJournal(disk.root.resolve("day-preparation"), database).prepare(5, 1000); + deaths.when(CharacterDeaths::pendingPayments).thenReturn(List.of(new OwedPayment("owed", UUID.randomUUID(), UUID.randomUUID(), 100, OwedPayment.Kind.WAGES))); + var service = new RealmSettlementService(disk.root, database); + service.recover(); + assertEquals(6, FactionManager.day); + assertEquals(0, FactionManager.timer); + verify(fixture.provinces).recalculate(); + service.settle(5); + verify(fixture.provinces, times(1)).recalculate(); + service.recover(); + when(fixture.ui.plugin.refreshTrackProvinces()).thenReturn(true); + service.settle(6); + verify(fixture.provinces, times(1)).recalculate(); + assertEquals(7, FactionManager.day); + } finally { offline.set(null, original); } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java index e084da3f..f321ac6b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java @@ -176,8 +176,8 @@ private Inventory inventory(String id, SFGUI type, int size) { @Test void foreignFactionAndGuildMenusUseTheDatedReportWithoutRevealingCurrentBalances() { - target.getBank().deposit(1234567.0); - guild.getBank().deposit(7654321.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(target.getBank(), 1234567.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 7654321.0); report.estimates.put("Wealth", new EspionageMath.Estimate(100, 150)); report.estimates.put( IntelligenceLedger.key(guild, "Wealth"), new EspionageMath.Estimate(200, 250)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java index 90a2ed5d..927cc15f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java @@ -307,8 +307,8 @@ void publicReportRefreshCapturesRealGuildArmyAndOfficeStateOncePerDay() throws E target .getInstallationHandler() .acceptTransferred(new Installation("fort", "Fort", InstallationKind.FORT, 37, 0, 0, 1)); - target.getBank().deposit(2000.0); - guild.getBank().deposit(700.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(target.getBank(), 2000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 700.0); double capturedWealth = target.getWealth(); assertTrue(capturedWealth > 0); @@ -340,7 +340,7 @@ void publicReportRefreshCapturesRealGuildArmyAndOfficeStateOncePerDay() throws E assertNotNull(wealthEstimate); assertTrue( wealthEstimate.lower() <= capturedWealth && wealthEstimate.upper() >= capturedWealth); - target.getBank().deposit(9000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(target.getBank(), 9000.0); EspionageService.refreshReports(candidate); assertSame(report, EspionageService.report(candidate, target)); assertEquals(wealthEstimate, report.estimate("Wealth")); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java index 1f212fb8..71f4536f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java @@ -234,9 +234,9 @@ void electedCouncilFillsFromStoredWinnersThenEligibleFactionMembers() { @Test void wealthCouncilSelectsEligibleWealthiestMembersAndRespectsRefusals() { - try (MockedStatic<OfflineModifier> economy = mockStatic(OfflineModifier.class)) { + try (MockedStatic<net.tfminecraft.simplefactions.utils.Wealth> economy = mockStatic(net.tfminecraft.simplefactions.utils.Wealth.class, CALLS_REAL_METHODS)) { economy - .when(() -> OfflineModifier.balance(anyString(), any(Accounts.class))) + .when(() -> net.tfminecraft.simplefactions.utils.Wealth.wealth(anyString())) .thenAnswer( call -> Map.of("Alice", 1000.0, "Bob", 800.0, "Cara", 500.0, "Drew", 100.0) @@ -709,7 +709,7 @@ void changingCouncilKindsClearsOldSeatsWhenRequiredAndPreservesElectedSeatsUntil net.tfminecraft.simplefactions.managers.FactionManager.class.getDeclaredField("loaded"); loaded.setAccessible(true); boolean previous = loaded.getBoolean(null); - try (MockedStatic<OfflineModifier> economy = mockStatic(OfflineModifier.class)) { + try (MockedStatic<net.tfminecraft.simplefactions.utils.Wealth> economy = mockStatic(net.tfminecraft.simplefactions.utils.Wealth.class, CALLS_REAL_METHODS)) { loaded.setBoolean(null, true); council.addMember("Bob"); var wealth = @@ -722,7 +722,7 @@ void changingCouncilKindsClearsOldSeatsWhenRequiredAndPreservesElectedSeatsUntil "effects.faction.council-size", 1)); economy - .when(() -> OfflineModifier.balance(anyString(), any(Accounts.class))) + .when(() -> net.tfminecraft.simplefactions.utils.Wealth.wealth(anyString())) .thenAnswer(call -> "Cara".equals(call.getArgument(0)) ? 1000.0 : 0.0); faction.applyLaw(wealth, faction.getLawHandler().getGroup("constitution")); council.reorganize(); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java index 9773d286..04af78d3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java @@ -101,31 +101,7 @@ void anExpansionExponentOfOneMeansConstantCostRatherThanZeroInvestedWealth() { assertEquals(240.0, guild.getTotalPossibleRefund()); } - @Test - void liquidationRefundsTheLevelBeingRemovedAndRecalculatesWealthAfterTheLevelDrops() { - Guild guild = fixture.guild("guild", 2); - assertEquals(160.0, guild.getRefund()); - guild.liquidateRandom(); - assertEquals(1, guild.getSize()); - assertEquals(160.0, guild.getBank().getWealth()); - assertEquals(260.0, guild.getWealth()); - } - @Test - void liquidationWorksWhenConfiguredBranchGroupsHaveGaps() { - YamlConfiguration config = new YamlConfiguration(); - config.set("trade.group", 4); - config.set("trade.allowed-types", List.of("guild")); - Branch template = new Branch("trade", config.getConfigurationSection("trade")); - BranchLoader.map.clear(); - BranchLoader.map.put("trade", template); - GuildData data = fixture.data("guild", 2); - data.branches.getFirst().id = "trade"; - Guild guild = new Guild(data, fixture.host); - guild.liquidateRandom(); - assertEquals(1, guild.getSize()); - assertEquals(160.0, guild.getBank().getWealth()); - } @Test void savedGuildDataSurvivesJsonRoundTripAndRestoresFinancialSettings() { @@ -256,7 +232,7 @@ void creatingGuildsInitializesDefinitionsAndWaitsForBannerFetch(boolean base) { @Test void wealthModifiersReplaceAccumulateAndRemoveWithoutDuplicatingSources() { Guild guild = fixture.guild("guild", 2); - guild.getBank().setWealth(50.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 50.0); guild.addWealthModifier(new Modifier("Gift", 3.0, false)); guild.addWealthModifier(new Modifier("GIFT", 4.0, false)); guild.addPersistentWealthModifier(new Modifier("Treasure", 10.0, true)); @@ -284,7 +260,6 @@ void expansionAndRelocationCostsFollowLevelAndProvinceOwnership() { assertEquals(0, guild.getTotalExpansionSpent()); assertEquals(0, guild.getTotalPossibleRefund()); assertFalse(guild.canLiquidate()); - guild.liquidateRandom(); assertEquals(0, guild.getSize()); guild.getBranch(0).levelUp(); guild.getBranch(0).levelUp(); @@ -304,7 +279,6 @@ void expansionAndRelocationCostsFollowLevelAndProvinceOwnership() { when(province.getOwner()).thenReturn(other); assertEquals(945, guild.getRelocationCost(7)); guild.setBank(null); - guild.liquidateRandom(); assertEquals(6, guild.getSize()); } @@ -501,16 +475,6 @@ void incomePreviewChangesNeitherLiveTradeNorFavoursAndRepression() { /** * Real saved guilds and definitions; only server and faction integration boundaries are mocked. */ - @Test - void removingTheLastExpansionDropsItsHistoricalWealthContribution() { - Guild guild = fixture.guild("guild", 1); - guild.updateWealth(); - assertEquals(100, guild.getWealth()); - guild.liquidateRandom(); - assertEquals(0, guild.getSize()); - assertEquals(80, guild.getBank().getWealth()); - assertEquals(80, guild.getWealth()); - } @Test void financialLimitsAndDividendEligibilityUseFiniteValuesAndCopies() { diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java index c431819e..4dd25f9e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java @@ -1,41 +1,55 @@ package net.tfminecraft.simplefactions.guild.income; +import java.nio.file.Path; import java.util.Map; +import java.util.UUID; import net.tfminecraft.simplefactions.database.Database; -import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; +import net.tfminecraft.simplefactions.economy.ExternalIncome; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class CasinoProfitOperationTest { - @Test - void replayAfterSettlementDoesNotTaxProfitAgainAndSaveFailureRollsBack() { - Guild guild = mock(Guild.class); - Faction faction = mock(Faction.class); - when(guild.getFaction()).thenReturn(faction); - Ledger ledger = new Ledger(guild); - try (var database = mockConstruction(Database.class, (db, context) -> - when(db.saveFactionDurably(faction)).thenReturn(true))) { - assertTrue(ledger.addCasinoProfitEntry("table:one", 25)); - assertEquals(25, ledger.getCasinoProfit()); + @TempDir Path folder; + @Test void restartRecoversFailedBookkeepingBeforeSettlementAndExactRetryDoesNotRetax() throws Exception { + try (var fixture = new FactionDomainFixture(); var runtime = mockStatic(EconomyRuntime.class)) { + when(fixture.ui.plugin.getDataFolder()).thenReturn(folder.toFile()); + runtime.when(EconomyRuntime::ready).thenReturn(true); + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var ledger = faction.getOrCreateMainGuild().getLedger(); + try (var database = mockConstruction(Database.class, (db, context) -> when(db.saveFactionDurably(faction)).thenReturn(true))) { + assertTrue(ledger.addCasinoProfitEntry("table:one", 25)); + assertEquals(25, ledger.getCasinoProfit()); + ledger.setCasinoProfit(0); + assertTrue(ledger.addCasinoProfitEntry("table:one", 25)); + assertEquals(0, ledger.getCasinoProfit()); + assertFalse(ledger.addCasinoProfitEntry("table:one", 26)); + } + try (var database = mockConstruction(Database.class)) { + assertFalse(ledger.addCasinoProfitEntry("table:two", 50)); + runtime.verify(() -> EconomyRuntime.failClosed(any())); + } + // Reopen the prior persisted guild image; the pending SF journal owns the missing bookkeeping. ledger.setCasinoProfit(0); - Ledger restored = new Ledger(guild); - restored.restoreCasinoProfitOperations(ledger.getCasinoProfitOperations()); - assertTrue(restored.addCasinoProfitEntry("table:one", 25)); - assertEquals(0, restored.getCasinoProfit()); - assertFalse(restored.addCasinoProfitEntry("table:one", 26)); - assertEquals(1, database.constructed().size()); + ledger.restoreCasinoProfitOperations(Map.of("table:one", 25.0)); + runtime.when(EconomyRuntime::ready).thenReturn(false); + try (var database = mockConstruction(Database.class, (db, context) -> when(db.saveFactionDurably(faction)).thenReturn(true))) { + ExternalIncome.recover(folder); + assertEquals(50, ledger.getCasinoProfit()); + assertTrue(ledger.addCasinoProfitEntry("table:two", 50)); + assertFalse(ledger.addCasinoProfitEntry("table:three", 25)); + ledger.setCasinoProfit(0); + ExternalIncome.recover(folder); + assertEquals(0, ledger.getCasinoProfit()); + } + for (String id : new String[]{null, "", " "}) assertFalse(ledger.addCasinoProfitEntry(id, 2)); + assertFalse(ledger.addCasinoProfitEntry("bad", Double.NaN)); + assertFalse(ledger.addCasinoProfitEntry("bad", 0)); + ledger.restoreCasinoProfitOperations(null); + assertTrue(ledger.getCasinoProfitOperations().isEmpty()); } - try (var database = mockConstruction(Database.class)) { - assertFalse(ledger.addCasinoProfitEntry("table:two", 50)); - assertEquals(0, ledger.getCasinoProfit()); - assertEquals(Map.of("table:one", 25.0), ledger.getCasinoProfitOperations()); - } - for (String id : new String[]{null, "", " "}) assertFalse(ledger.addCasinoProfitEntry(id, 2)); - assertFalse(ledger.addCasinoProfitEntry("bad", Double.NaN)); - assertFalse(ledger.addCasinoProfitEntry("bad", 0)); - ledger.restoreCasinoProfitOperations(null); - assertTrue(ledger.getCasinoProfitOperations().isEmpty()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java index b64d80be..441eb773 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java @@ -420,7 +420,7 @@ void aPreparedBranchPreviewUsesFrozenProvinceDataAndNeverMutatesItsGuildOrBank() Branch branch = new Branch(new Branch("fields", config), 2); guild.getBranches().clear(); guild.getBranches().put(1, branch); - guild.getBank().deposit(250.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 250.0); TradeBreakdown original = guild.getTradeBreakdown(); original.setIncome(80); ProvinceManager live = new ProvinceManager(); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java index 49120910..518b49a4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java @@ -49,8 +49,8 @@ void setup() { previousEligibility = Cache.dividendRequirePreviousTickMembership; Cache.dividendRequirePreviousTickMembership = false; previousLookup = MercenaryEngagements.uuidLookup(); - capital.getBank().setWealth(1000.0); - guild.getBank().setWealth(1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capital.getBank(), 1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 1000.0); } @AfterEach @@ -148,7 +148,7 @@ void bankruptPayersAndGuildsWithoutTradeDoNotPayReparations() { Guild receiver = creditor.getOrCreateMainGuild(); home.addWarReparationsObligation(new WarReparationsObligation("creditor", 25, 4)); guild.getTradeBreakdown().setIncome(120); - guild.getBank().setWealth(-1.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), -1.0); assertTrue(guild.isBankrupt()); assertEquals(0, receiver.getLedger().getWarReparationsReceived()); DailyGuildTransfers transfers = new DailyGuildTransfers(); @@ -219,7 +219,7 @@ void bankruptGuildKeepsCitizenTaxAndStopsAutomaticLoanPayments() { Loan manual = new Loan(20, capital, guild, System.currentTimeMillis(), 7, 7, 0, false); capital.getLoanHandler().issueLoan(debt); capital.getLoanHandler().issueLoan(manual); - guild.getBank().setWealth(-1.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), -1.0); guild.getLedger().addCitizenTaxEntry("Bob", 3.0); assertEquals(0, guild.getLedger().getInternalTaxableIncome()); assertEquals(DividendBreakdown.none(), guild.getLedger().getDividendBreakdown()); diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java index b8e02fce..44e3d6af 100644 --- a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java @@ -209,8 +209,8 @@ void armingAndDueTicksApplyOneStepWithoutKickingMembersOrChangingTheirBanks() th Faction faction = fixture.saved("home", "Alice"); Guild base = faction.getOrCreateMainGuild(); Guild guild = fixture.guild(faction, "merchants", "Bob"); - base.getBank().deposit(37.0); - guild.getBank().deposit(19.0); + 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)); InactivityService.armAll(null, 1000); InactivityService.armAll(Arrays.asList(null, faction), 1000); diff --git a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandlerConstructTest.java b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandlerConstructTest.java index 841a4893..b4714768 100644 --- a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandlerConstructTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandlerConstructTest.java @@ -106,26 +106,6 @@ void deconstructingInstallationCancelsItsPendingUpgrade() { } } - @Test - void missedUpkeepDropsLevelAndCancelsUpgradeButLevelOneDissolves() { - Fixture fx = fixture(); - Bank bank = mock(Bank.class); - when(bank.getWealth()).thenReturn(0.0); - when(fx.faction.getBank()).thenReturn(bank); - Installation station = new Installation("central_station", "Central", InstallationKind.TRAIN_STATION, PROVINCE, X, Z, 1L, 2); - fx.handler.acceptTransferred(station); - try (TestMocks mocks = testMocks(fx, false)) { - assertTrue(fx.handler.upgrade("central_station").isSuccess()); - fx.handler.payDailyUpkeep(); - assertEquals(1, station.getLevel()); - assertNull(fx.handler.getPendingConstruction()); - verify(bank, never()).withdraw(any()); - assertTrue(fx.handler.upgrade("central_station").isSuccess()); - fx.handler.payDailyUpkeep(); - assertNull(fx.handler.getById("central_station")); - assertNull(fx.handler.getPendingConstruction()); - } - } @Test void constructInstant_fort_success() { diff --git a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java index 3a309ba7..b1b75f6f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java @@ -354,8 +354,8 @@ void upkeepPaysCheapestFirstAndPreservesUnspentMoneyWithoutChargingDestroyedBuil Installation cheap = install(home, "station", InstallationKind.TRAIN_STATION, 42, 0, 0, 5, 1); Installation port = install(home, "port", InstallationKind.PORT, 42, 0, 0, 3, 1); install(home, "fort", InstallationKind.FORT, 42, 0, 0, 1, 1); - home.getBank().setWealth(45.0); - home.getInstallationHandler().payDailyUpkeep(); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(home.getBank(), 45.0); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(home); assertEquals(25.0, home.getBank().getWealth()); assertSame(cheap, home.getInstallationHandler().getById("station")); assertSame(port, home.getInstallationHandler().getById("port")); @@ -370,8 +370,8 @@ void upkeepPaysCheapestFirstAndPreservesUnspentMoneyWithoutChargingDestroyedBuil void equalUpkeepIsPaidInCompletionOrder() { Installation older = install(home, "older", InstallationKind.PORT, 42, 0, 0, 1, 1); install(home, "newer", InstallationKind.PORT, 44, 8, 0, 2, 1); - home.getBank().setWealth(15.0); - home.getInstallationHandler().payDailyUpkeep(); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(home.getBank(), 15.0); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(home); assertEquals(0.0, home.getBank().getWealth()); assertSame(older, home.getInstallationHandler().getById("older")); assertNull(home.getInstallationHandler().getById("newer")); @@ -385,7 +385,7 @@ void freeInstallationDoesNotRequireBankFunds() { 80, Map.of(1, new InstallationKindConfig.Level(0, 2, Map.of())))); Installation free = install(home, "free", InstallationKind.FORT, 42, 0, 0, 1, 1); home.setBank(null); - home.getInstallationHandler().payDailyUpkeep(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(home); assertSame(free, home.getInstallationHandler().getById("free")); } @@ -395,14 +395,14 @@ void unpaidUpgradeDowngradesThenDissolvesWithoutSpendingMoney(boolean online) { if (!online) fixture.online.remove("Leader"); Installation station = install(home, "station", InstallationKind.TRAIN_STATION, 42, 0, 0, 1, 2); assertTrue(home.getInstallationHandler().upgrade("station").isSuccess()); - home.getBank().setWealth(0.0); - home.getInstallationHandler().payDailyUpkeep(); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(home.getBank(), 0.0); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(home); assertEquals(1, station.getLevel()); assertNull(home.getInstallationHandler().getPendingConstruction()); if (online) verify(fixture.online.get("Leader")) .sendMessage(argThat((String message) -> message.contains("dropped to level 1"))); - home.getInstallationHandler().payDailyUpkeep(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(home); assertTrue(home.getInstallationHandler().getAll().isEmpty()); assertEquals(0.0, home.getBank().getWealth()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index fceffeb2..b52a5972 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -945,7 +945,6 @@ private final class DailyServices implements AutoCloseable { final MockedStatic<InactivityService> inactivity = mockStatic(InactivityService.class); final MockedStatic<ContractAccrualService> accrual = mockStatic(ContractAccrualService.class); final MockedStatic<ContractTerminationService> termination = mockStatic(ContractTerminationService.class); - final MockedStatic<PostSettlementPayouts> payouts = mockStatic(PostSettlementPayouts.class); final MockedStatic<WarReparationsService> reparations = mockStatic(WarReparationsService.class); final MockedStatic<TradeGraph> routes = mockStatic(TradeGraph.class); final MockedStatic<FactionCleanup> cleanup = mockStatic(FactionCleanup.class); @@ -967,7 +966,6 @@ public void close() { cleanup.close(); routes.close(); reparations.close(); - payouts.close(); termination.close(); accrual.close(); inactivity.close(); @@ -975,108 +973,22 @@ public void close() { } @Test - void dailySettlementNetsTransfersRoundsBalancesAndPublishesHistoryBeforeClearingBuffer() { - Faction home = faction("home", "Alice"); - Guild payer = guild(home, "payer", "Alice"); - Guild recipient = guild(home, "recipient", "Bob"); - Guild zero = guild(home, "zero", "Carol"); - Guild noBank = guild(home, "no-bank", "Dave"); - when(noBank.getBank()).thenReturn(null); - Loan loan = mock(Loan.class); - when(loan.getId()).thenReturn("loan"); - when(payer.getLoanHandler().getLoansGiven()).thenReturn(Arrays.asList(null, loan)); - Ledger payerLedger = payer.getLedger(); - AtomicReference<DailyGuildTransfers> collected = new AtomicReference<>(); - doAnswer(call -> { - DailyGuildTransfers buffer = call.getArgument(0); - collected.set(buffer); - buffer.add(payer, recipient, 7.12); - buffer.addExternalDelta(payer, 10.004); - buffer.addExternalDelta(recipient, -1.004); - buffer.addExternalDelta(zero, 3.0); - buffer.addExternalDelta(zero, -3.0); - buffer.addExternalDelta(noBank, 2.0); - return null; - }).when(payerLedger).populateDailyTransfers(any()); - try (DailyServices daily = new DailyServices()) { - Map<LedgerHistory.Source, Map<String, Double>> before = new HashMap<>(); - daily.histories.when(() -> Ledger.collectHistoryDay(any())).thenReturn(Map.of(payer, before)); - daily.payouts.when(() -> PostSettlementPayouts.apply(any(), any(), any(), any())) - .thenAnswer(call -> { - DailyGuildTransfers buffer = call.getArgument(0); - assertEquals(7.12, buffer.getTransfers().get(payer).get(recipient)); - verify(payer.getBank()).deposit(2.88); - verify(recipient.getBank()).deposit(6.12); - return null; - }); - + void dailySettlementUsesOneRealmJournalAndStopsOnUncertainRecovery() throws Exception { + var service = mock(net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService.class); + when(fixture.ui.plugin.getRealmSettlementService()).thenReturn(service); + try (var runtime = mockStatic(net.tfminecraft.simplefactions.economy.EconomyRuntime.class)) { manager.settleIncome(); - - verify(payer.getLedger().getHistory()).closeDay(same(before)); - verify(recipient.getLedger().getHistory()).closeDay(null); - verify(zero.getBank(), never()).deposit(anyDouble()); - verify(loan).tickDay(); - verify(payer).refreshDividendEligibility(); - verify(recipient).refreshDividendEligibility(); - daily.accrual.verify(ContractAccrualService::accrueDailyAndPush); - daily.termination.verify(ContractTerminationService::tickDay); - daily.reparations.verify(() -> WarReparationsService.tickAfterDailySettlement(home)); - verify(daily.upkeep).processDailyUpkeep(); + verifyNoInteractions(service); + runtime.when(net.tfminecraft.simplefactions.economy.EconomyRuntime::ready).thenReturn(true); + FactionManager.day = 7; + manager.settleIncome(); + verify(service).settle(7); verify(fixture.ui.plugin).recordVehicleOwners(); - assertTrue(collected.get().getTransfers().isEmpty()); - assertTrue(collected.get().getExternalDeltas().isEmpty()); - } - } - - @Test - void settlementLiquidatesWhileAssetsShrinkAndStopsAStalledLiquidator() { - Faction home = faction("home", "Alice"); - Guild shrinking = guild(home, "shrinking", "Alice"); - Guild stalled = guild(home, "stalled", "Bob"); - AtomicInteger remaining = new AtomicInteger(5); - when(shrinking.isBankrupt()).thenAnswer(call -> remaining.get() > 3); - when(shrinking.canLiquidate()).thenReturn(true); - when(shrinking.getSize()).thenAnswer(call -> remaining.get()); - doAnswer(call -> { remaining.decrementAndGet(); return null; }).when(shrinking).liquidateRandom(); - when(stalled.isBankrupt()).thenReturn(true); - when(stalled.canLiquidate()).thenReturn(true); - when(stalled.getSize()).thenReturn(3); - Ledger shrinkingLedger = shrinking.getLedger(); - doAnswer(call -> { - DailyGuildTransfers buffer = call.getArgument(0); - buffer.addExternalDelta(shrinking, -20); - buffer.addExternalDelta(stalled, -20); - return null; - }).when(shrinkingLedger).populateDailyTransfers(any()); - try (DailyServices daily = new DailyServices()) { + var failure = new java.io.IOException("incomplete effect"); + doThrow(failure).when(service).settle(7); manager.settleIncome(); - verify(shrinking, times(2)).liquidateRandom(); - assertEquals(3, remaining.get()); - verify(stalled).liquidateRandom(); - verify(daily.upkeep).processDailyUpkeep(); - } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void dailyFailureIsLoggedAndDoesNotPreventOtherGuildsOrFinalMaintenance(boolean availableLogger) { - Faction home = faction("home", "Alice"); - Guild broken = guild(home, "broken", "Alice"); - Guild healthy = guild(home, "healthy", "Bob"); - Logger logger = mock(Logger.class); - when(fixture.ui.plugin.getLogger()).thenReturn(availableLogger ? logger : null); - IllegalStateException failure = new IllegalStateException("broken ledger"); - Ledger brokenLedger = broken.getLedger(); - Ledger healthyLedger = healthy.getLedger(); - doThrow(failure).when(brokenLedger).populateDailyTransfers(any()); - doAnswer(call -> { ((DailyGuildTransfers) call.getArgument(0)).addExternalDelta(healthy, 10); return null; }) - .when(healthyLedger).populateDailyTransfers(any()); - try (DailyServices daily = new DailyServices()) { - assertDoesNotThrow(manager::settleIncome); - verify(healthy.getBank()).deposit(10.0); - verify(healthy.getLedger().getHistory()).closeDay(null); - verify(daily.upkeep).processDailyUpkeep(); - if (availableLogger) verify(logger).log(Level.SEVERE, "Daily tick failed during ledger broken", failure); + runtime.verify(() -> net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed(failure)); + assertEquals(7, FactionManager.day); } } @@ -1110,34 +1022,21 @@ void hourlyAndFiveMinuteTicksAdvancePowerAndPillageAndWarnOnlyOnlineOverCapLeade assertEquals(0, FactionManager.getSecondsUntilNewDay()); } - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void dayRolloverContinuesAfterOneFactionFailureAndDoesNotPayAgainNextSecond(boolean tracksChanged) { - Faction broken = faction("broken", "Alice"); - Faction healthy = faction("healthy", "Bob"); - IllegalStateException failure = new IllegalStateException("one faction failed"); - doThrow(failure).when(broken).newDay(); - Logger logger = mock(Logger.class); - when(fixture.ui.plugin.getLogger()).thenReturn(logger); - when(fixture.ui.plugin.refreshTrackProvinces()).thenReturn(tracksChanged); - try (DailyServices daily = new DailyServices()) { + @Test + void dayRolloverAdvancesOnlyAfterTheJournalCompletesAndDoesNotPayAgainNextSecond() throws Exception { + var service = mock(net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService.class); + when(fixture.ui.plugin.getRealmSettlementService()).thenReturn(service); + doAnswer(call -> { FactionManager.day = 6; FactionManager.timer = 0; return null; }).when(service).settle(5); + try (DailyServices daily = new DailyServices(); var runtime = mockStatic(net.tfminecraft.simplefactions.economy.EconomyRuntime.class)) { + runtime.when(net.tfminecraft.simplefactions.economy.EconomyRuntime::ready).thenReturn(true); FactionManager.timer = 86399; FactionManager.day = 5; manager.time(); - assertEquals(0, FactionManager.getTimer()); - assertEquals(6, FactionManager.getDay()); - verify(healthy).newDay(); - verify(daily.economy).clearAllDaily(); - daily.cleanup.verify(() -> FactionCleanup.advanceOfflineDays(FactionManager.factions)); - daily.routes.verify(TradeGraph::forgetRoutes); - verify(fixture.provinces, tracksChanged ? never() : times(1)).recalculate(); - verify(logger).log(Level.SEVERE, "Daily tick failed during faction broken", failure); - verify(daily.upkeep).processDailyUpkeep(); + assertEquals(0, FactionManager.timer); + assertEquals(6, FactionManager.day); manager.time(); - assertEquals(1, FactionManager.getTimer()); - assertEquals(6, FactionManager.getDay()); - verify(daily.upkeep, times(1)).processDailyUpkeep(); - verify(healthy, times(1)).newDay(); + assertEquals(1, FactionManager.timer); + verify(service, times(1)).settle(5); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java index ff38813d..b1357d5b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java @@ -162,7 +162,7 @@ void lockedOverCapOwnerCannotLoseProvinceToAnotherClaim() throws Exception { Faction owner = realm("other", "Other", 3, 2, 3); civilWar(owner); Cache.provinceCost = 1000; - home.getBank().deposit(1_000_000_000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(home.getBank(), 1_000_000_000.0); home.updatePrestige(); map.claim(leader, home, 2, false); assertSame(owner, FactionManager.getByProvince(2)); @@ -175,7 +175,7 @@ void lockedOverCapOwnerCannotLoseProvinceToAnotherClaim() throws Exception { void stealingAnOverCapHoldingTransfersItsInstallationWithTheLand(boolean online) { Faction owner = realm("other", "Other", 3, 2, 3); Cache.provinceCost = 1000; - home.getBank().deposit(1_000_000_000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(home.getBank(), 1_000_000_000.0); home.updatePrestige(); assertTrue(TitleManager.overProvinceCap(owner)); assertTrue(home.getPrestige() >= TitleManager.getClaimCost(home)); diff --git a/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java index eb54946e..a05f22f3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java @@ -59,7 +59,7 @@ class MapExportLifecycleCoverageTest { @BeforeEach void setup() throws Exception { fixture = new FactionDomainFixture(); - economy = mockStatic(DenarEconomy.class); + economy = mockStatic(DenarEconomy.class, CALLS_REAL_METHODS); money = mock(MoneyManager.class); economy.when(DenarEconomy::getMoneyManager).thenReturn(money); when(money.getServerBal(Accounts.POUCH)).thenReturn(25.5); @@ -193,7 +193,7 @@ void chronicleReflectsRealSubjectsGuildWealthPopulationAndWarMembership() throws void absentOrFailingEconomyDegradesOnlyItsAggregateFigures() { Faction home = fixture.saved("home", "Alice"); assertNull(WarMapExporter.exportCapitalCoords(home)); - home.getBank().deposit(25.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(home.getBank(), 25.0); economy.when(DenarEconomy::getMoneyManager).thenReturn(null); JsonObject root = ChronicleSnapshot.build(FactionManager.factions, 1, 0, Instant.EPOCH); assertEquals(0, root.getAsJsonObject("global").get("pouch_wealth").getAsDouble()); @@ -286,8 +286,8 @@ private WorldState worldState() { Guild traders = fixture.guild(home, "traders", "Bob"); traders.setCapital(2); traders.addMember("Charlie"); - home.getBank().deposit(100.0); - traders.getBank().deposit(50.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(home.getBank(), 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(traders.getBank(), 50.0); home.getSettlementHandler().acceptTransferred(new Settlement("capital", "Capital", 1, 10, 20)); home.getSettlementHandler().acceptTransferred(new Settlement("town", "Town", 2, 20, 40)); Installation fort = new Installation("fort", "Fort", InstallationKind.FORT, 2, 20, 40, 1); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java index 73573eba..c87cf8d9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java @@ -423,8 +423,8 @@ void guildBankMutationsRecalculateFactionWealthAndReportItsActualSources() { Guild guild = fixture.guild(faction, "artisans", "Bob"); assertFalse( faction.getWealthModifiers().stream().anyMatch(m -> m.getType().contains("artisans"))); - guild.getBank().deposit(30.5); - faction.getBank().deposit(20.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 30.5); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(faction.getBank(), 20.0); assertEquals(50.5, faction.getWealth()); assertTrue( faction.getWealthModifiers().stream() @@ -437,7 +437,7 @@ void guildBankMutationsRecalculateFactionWealthAndReportItsActualSources() { faction.setBank(replacement); assertSame(replacement, faction.getBank()); faction.getOrCreateMainGuild().updateWealth(); - assertEquals(35.5, faction.getWealth()); + assertEquals(50.5, faction.getWealth()); } @Test @@ -645,17 +645,17 @@ void dailyArmyUpkeepShrinksPaidSlotsOnlyUntilAffordableAndPreservesFreeSlots() { var guard = faction.getMilitary().getRegiment("guard"); guard.setFreeSlots(1); guard.setCurrentSlots(4); - faction.getBank().deposit(25.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(faction.getBank(), 25.0); assertEquals(30, faction.getMilitary().getTotalUpkeep()); - faction.newDay(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(faction); assertEquals(3, guard.getCurrentSlots()); assertEquals(0, faction.getMilitary().getRegiment("reserve").getCurrentSlots()); assertEquals(1, guard.getFreeSlots()); assertEquals(5, faction.getBank().getWealth()); - faction.newDay(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(faction); assertEquals(1, guard.getCurrentSlots()); assertEquals(5, faction.getBank().getWealth()); - faction.newDay(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(faction); assertEquals(1, guard.getCurrentSlots()); assertEquals(5, faction.getBank().getWealth()); } @@ -671,7 +671,7 @@ void missingBankRemovesOnlyPaidRegimentSlotsAndStillAllowsDailyHousekeeping() { guard.setCurrentSlots(4); levy.setCurrentSlots(5); faction.setBank(null); - faction.newDay(); + net.tfminecraft.simplefactions.testsupport.TestSettlements.upkeep(faction); assertEquals(2, guard.getCurrentSlots()); assertEquals(5, levy.getCurrentSlots()); assertNull(faction.getBank()); @@ -1007,9 +1007,9 @@ void independenceAndNationhoodMoveTheGuildAndRespectSubjectAndProvinceOwnership( fixture.subject(ruler, child); Guild guild = fixture.guild(ruler, "merchants", "Bob"); guild.setCapital(withSeparateCapital ? 8 : 7); - guild.getBank().deposit(37.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 37.0); Faction observer = fixture.saved("observer", "Dana"); - observer.getBank().deposit(2000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(observer.getBank(), 2000.0); double existingShare = observer.getPrestigeModifiers().stream() .filter(m -> m.getType().equals("Wealth")) @@ -1078,8 +1078,8 @@ void independenceAndNationhoodMoveTheGuildAndRespectSubjectAndProvinceOwnership( source.setCapital(8); Guild artisans = fixture.guild(source, "artisans", "Fay"); artisans.setCapital(9); - source.getBank().deposit(20.0); - artisans.getBank().deposit(30.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(source.getBank(), 20.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(artisans.getBank(), 30.0); Guild oldBase = source.getOrCreateMainGuild(); fixture.subject(ruler, source); fixture.subject(source, peaceful); @@ -1119,7 +1119,7 @@ void anIndependentRealmDissolvesItsGuildsAndSubjectsWhilePreservingItsBaseRealm( fixture.subject(ruler, subject); Guild guild = fixture.guild(ruler, "merchants", "Bob"); guild.setCapital(withSeparateCapital ? 8 : 7); - guild.getBank().deposit(12.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 12.0); var proposal = new Proposal("Alice", ruler.getGovernment()); proposal.setPoliticalActionProposal(new PoliticalAction(Action.DISSOLVE)); ruler.applyPoliticalAction(null, proposal); @@ -1147,7 +1147,7 @@ private void connectProvinces(int... ids) { void promotedGuildWealthIsImmediatelyVisibleInTheNewFactionAndItsPrestigeBreakdown() { Faction old = fixture.saved("old", "Alice"); Guild guild = fixture.guild(old, "merchants", "Bob"); - guild.getBank().deposit(37.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 37.0); assertEquals(37, guild.getWealth()); old.getGuildHandler().removeGuild("merchants", false, false); Faction promoted = new Faction(guild); diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java index f606332b..951c3b53 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java @@ -237,8 +237,8 @@ void effectiveTaxUsesTheCurrentGovernmentAndPreviewDoesNotMutateTheRate() { void taxChangePreviewConservesRealGuildBalancesAndTaxIncome() { Guild realm = faction.getOrCreateMainGuild(); Guild merchants = fixture.guild(faction, "merchants", "Merchant"); - realm.getBank().deposit(100.0); - merchants.getBank().deposit(100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(realm.getBank(), 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(merchants.getBank(), 100.0); merchants.getTradeBreakdown().setIncome(200.0); faction.getTaxHandler().setGuildTax(10.0); faction.getGovernment().setPower(faction.getGovernment().getMaxPower()); diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/TestSettlements.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestSettlements.java new file mode 100644 index 00000000..53dc4174 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestSettlements.java @@ -0,0 +1,41 @@ +package net.tfminecraft.simplefactions.testsupport; + +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.economy.settlement.*; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Exercises the real upkeep planner and durable after-value applier against the test SQLite ledger. */ +public final class TestSettlements { + private TestSettlements() {} + public static void upkeep(Faction faction) { + try { + var planned = FactionUpkeepPlan.calculate(faction); + var changes = new RealmSettlementChanges(); + changes.upkeep.add(planned); + var plan = new SettlementJournal.Plan(0, + List.of(new Leg(Denars.guild(SettlementGuilds.capital(faction).getIncarnationId()), -planned.charge()), + new Leg(Denars.MINT, planned.charge())), JsonUtil.GSON.toJsonTree(changes).getAsJsonObject()); + var directory = Files.createTempDirectory("sf-upkeep-effects-"); + var database = mock(Database.class); + when(database.saveFactionDurably(any())).thenReturn(true); + try { + assertEquals(Result.APPLIED, Denars.post("test:upkeep:" + UUID.randomUUID(), "test:upkeep", plan.legs())); + new RealmSettlementEffects(directory, database, new PlayerEconomyManager(), (id, paid) -> {}, values -> {}).apply(plan); + } finally { + try (var files = Files.walk(directory)) { + for (var file : files.sorted(java.util.Comparator.reverseOrder()).toList()) Files.delete(file); + } + } + } catch (java.io.IOException error) { throw new java.io.UncheckedIOException(error); } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java index 89364ad6..4daa18d5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java @@ -368,8 +368,8 @@ void guildRankingsAndVisibleValuesUseRealGuildEconomics(RankType type) { Faction faction = fundedFaction(fixture, "home", "Leader", 0); Guild small = fixture.guild(faction, "small", "Small"); Guild large = fixture.guild(faction, "large", "Large"); - small.getBank().deposit(100.0); - large.getBank().deposit(300.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(small.getBank(), 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(large.getBank(), 300.0); large.addMember("Additional"); small.getTradeBreakdown().setTradePower(5); large.getTradeBreakdown().setTradePower(25); @@ -461,7 +461,7 @@ void dailyIntelligenceRanksTheStoredEstimateRatherThanTheLiveSecretBalance() { assertEquals(1, ranker.getVisibleRank(viewer, foreign, RankType.WEALTH)); assertEquals( 2, ranker.getVisibleGuildRank(viewer, foreign.getOrCreateMainGuild(), RankType.WEALTH)); - foreign.getBank().deposit(5000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(foreign.getBank(), 5000.0); assertEquals(200.0, ranker.visibleValue(viewer, foreign, RankType.WEALTH)); assertEquals( 50.0, ranker.visibleGuildValue(viewer, foreign.getOrCreateMainGuild(), RankType.WEALTH)); @@ -507,14 +507,19 @@ void bankRegistryFollowsExplicitChunkMovesWithoutChangingItsBalance() { assertEquals(80.0, bank.getWealth()); Bank openingBalance = new Bank(faction.getOrCreateMainGuild(), 25.0, original); Bank empty = new Bank(faction.getOrCreateMainGuild(), original); - assertEquals(25.0, openingBalance.getWealth()); - assertEquals(0.0, empty.getWealth()); + assertEquals(25.0, openingBalance.legacyBalance()); + assertEquals(0.0, empty.legacyBalance()); + assertEquals(80.0, openingBalance.getWealth()); + assertEquals(80.0, empty.getWealth()); assertSame(original, openingBalance.getChunk()); - bank.withdraw(12.5); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(bank, -12.5); assertEquals(67.5, bank.getWealth()); assertEquals(67.5, faction.getWealth()); - bank.setWealth(50.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(bank, 50.0); assertEquals(50.0, bank.getWealth()); + assertThrows(UnsupportedOperationException.class, () -> bank.deposit(1.0)); + assertThrows(UnsupportedOperationException.class, () -> bank.withdraw(1.0)); + assertThrows(UnsupportedOperationException.class, () -> bank.setWealth(1.0)); } finally { BankManager.banks = oldBanks; } @@ -593,70 +598,29 @@ void legacyZeroCapitalDoesNotImposeAnUnresolvableBankRestriction() { } @Test - void playerLedgersResolveAccountsWithoutSharingAnonymousEntries() { + void playerLedgersResolveCharactersWithoutSharingAnonymousEntries() { UUID alice = UUID.randomUUID(); PlayerEconomyManager manager = new PlayerEconomyManager(); - try (var accounts = mockStatic(OfflineModifier.class)) { - accounts.when(() -> OfflineModifier.playerId("Alice")).thenReturn(alice); - manager.getLedger("Alice").add(PlayerCashflow.EARNINGS, 50); - manager.getLedger(alice).add(PlayerCashflow.CITIZEN_TAX, -10); - assertSame(manager.getLedger(alice), manager.getLedger("Alice")); - assertEquals(40.0, manager.getLedger(alice).getNetDaily()); - manager.getLedger((UUID) null).add(PlayerCashflow.EARNINGS, 999); - manager.getLedger("Unknown").add(PlayerCashflow.EARNINGS, 888); - assertEquals(0.0, manager.getLedger((UUID) null).getNetDaily()); - assertEquals(0.0, manager.getLedger("Unknown").getNetDaily()); - assertEquals(40.0, manager.getLedger("Alice").getNetDaily()); - var previous = manager.getLedger(alice); - manager.clearAllDaily(); - assertEquals(0.0, previous.getNetDaily()); - assertEquals(0.0, manager.getLedger(alice).getNetDaily()); - assertNotSame(previous, manager.getLedger(alice)); - } - } - - @Test - void denarAdapterConservesSeparateBankAndPouchBalances() { - UUID account = UUID.randomUUID(); - Map<Accounts, Double> balances = - new HashMap<>(Map.of(Accounts.BANK, 100.0, Accounts.POUCH, 40.0)); - try (var external = mockStatic(OfflineModifier.class)) { - external.when(() -> OfflineModifier.playerId("Alice")).thenReturn(account); - external - .when(() -> OfflineModifier.balance(eq(account), any(Accounts.class))) - .thenAnswer(call -> balances.get(call.getArgument(1))); - external - .when(() -> OfflineModifier.apply(eq(account), any(Accounts.class), anyDouble())) - .thenAnswer( - call -> { - Accounts type = call.getArgument(1); - double next = balances.get(type) + (double) call.getArgument(2); - if (next < 0) return false; - balances.put(type, next); - return true; - }); - var bank = DenarEconomyPlayerBank.INSTANCE; - assertEquals(account, bank.resolve("Alice")); - assertEquals(100.0, bank.getBankBalance(account)); - assertEquals(40.0, bank.getPouchBalance(account)); - assertTrue(bank.withdrawFromBank(account, 30)); - assertTrue(bank.depositToBank(account, 5)); - assertTrue(bank.withdrawFromPouch(account, 8)); - assertEquals(75.0, bank.getBankBalance(account)); - assertEquals(32.0, bank.getPouchBalance(account)); - assertFalse(bank.withdrawFromBank(account, 100)); - assertFalse(bank.withdrawFromBank(account, 0)); - assertFalse(bank.withdrawFromPouch(account, -1)); - assertFalse(bank.depositToBank(account, -10)); - assertEquals(Map.of(Accounts.BANK, 75.0, Accounts.POUCH, 32.0), balances); - } + manager.getLedger(alice.toString()).add(PlayerCashflow.EARNINGS, 50); + manager.getLedger(alice).add(PlayerCashflow.CITIZEN_TAX, -10); + assertSame(manager.getLedger(alice), manager.getLedger(alice.toString())); + assertEquals(40.0, manager.getLedger(alice).getNetDaily()); + manager.getLedger((UUID) null).add(PlayerCashflow.EARNINGS, 999); + manager.getLedger("Unknown").add(PlayerCashflow.EARNINGS, 888); + assertEquals(0.0, manager.getLedger((UUID) null).getNetDaily()); + assertEquals(0.0, manager.getLedger("Unknown").getNetDaily()); + var previous = manager.getLedger(alice); + manager.clearAllDaily(); + assertEquals(0.0, previous.getNetDaily()); + assertEquals(0.0, manager.getLedger(alice).getNetDaily()); + assertNotSame(previous, manager.getLedger(alice)); } private Faction fundedFaction( FactionDomainFixture fixture, String id, String leader, double balance) { fixture.player(leader); Faction faction = fixture.saved(id, leader); - faction.getBank().deposit(balance); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(faction.getBank(), balance); return faction; } diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/PostSettlementPayoutsTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/PostSettlementPayoutsTest.java deleted file mode 100644 index aa12ce0d..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/utils/PostSettlementPayoutsTest.java +++ /dev/null @@ -1,198 +0,0 @@ -package net.tfminecraft.simplefactions.utils; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.income.Ledger; -import net.tfminecraft.simplefactions.objects.Bank; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.player.PlayerEconomyManager; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; - -class PostSettlementPayoutsTest { - - @Test - void playerPayoutDebitsGuildAndCreditsPlayer() { - Guild guild = mock(Guild.class); - Bank bank = mock(Bank.class); - when(guild.getBank()).thenReturn(bank); - when(guild.isBankrupt()).thenReturn(false); - when(bank.getWealth()).thenReturn(100.0); - - UUID player = UUID.randomUUID(); - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.addPlayerPayout(guild, player, 25.0); - - RecordingBank playerBank = new RecordingBank(); - PostSettlementPayouts.apply(buffer, playerBank, new PlayerEconomyManager(), name -> null); - - assertEquals(25.0, playerBank.balance(player), 1e-9); - verify(bank).withdraw(25.0); - } - - @Test - void clampScalesWhenGuildCannotCoverPool() { - Guild guild = mock(Guild.class); - Bank bank = mock(Bank.class); - Faction faction = mock(Faction.class); - Ledger ledger = mock(Ledger.class); - when(guild.getBank()).thenReturn(bank); - when(guild.isBankrupt()).thenReturn(false); - when(guild.isBase()).thenReturn(false); - when(guild.getFaction()).thenReturn(faction); - when(guild.getLedger()).thenReturn(ledger); - when(bank.getWealth()).thenReturn(5.0); - - UUID ann = UUID.randomUUID(); - when(guild.getDividendEligibleMembers()).thenReturn(List.of("Ann")); - when(ledger.breakdownForPool(5.0)).thenReturn( - new net.tfminecraft.simplefactions.guild.income.DividendBreakdown(100, 5.0, 0.0, 5.0, 1, 5.0)); - - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.setPendingDividendPool(guild, 20.0); - - RecordingBank playerBank = new RecordingBank(); - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(buffer, playerBank, economy, name -> "Ann".equals(name) ? ann : null); - - assertEquals(5.0, playerBank.balance(ann), 1e-9); - assertEquals(5.0, economy.getLedger(ann).getAmount(PlayerCashflow.DIVIDEND_PAYOUT), 1e-9); - verify(bank).withdraw(5.0); - } - - @Test - void bankruptGuildPaysNothing() { - Guild guild = mock(Guild.class); - Bank bank = mock(Bank.class); - when(guild.getBank()).thenReturn(bank); - when(guild.isBankrupt()).thenReturn(true); - when(bank.getWealth()).thenReturn(-10.0); - - UUID player = UUID.randomUUID(); - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.addPlayerPayout(guild, player, 10.0); - - RecordingBank playerBank = new RecordingBank(); - PostSettlementPayouts.apply(buffer, playerBank, new PlayerEconomyManager(), name -> null); - - assertEquals(0.0, playerBank.balance(player), 1e-9); - verify(bank, never()).withdraw(org.mockito.ArgumentMatchers.any()); - } - - /** - * A wage is the only guild to player leg besides dividends, so the soldier has - * to be able to see where the money came from in {@code /ledger}. - */ - @Test - void aWageShowsAsItsOwnIncomeLineInThePlayersLedger() { - Guild guild = mock(Guild.class); - Bank bank = mock(Bank.class); - when(guild.getBank()).thenReturn(bank); - when(guild.isBankrupt()).thenReturn(false); - when(bank.getWealth()).thenReturn(100.0); - - UUID soldier = UUID.randomUUID(); - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.addPlayerPayout(guild, soldier, 12.0); - - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(buffer, new RecordingBank(), economy, name -> null); - - assertEquals(12.0, economy.getLedger(soldier).getAmount(PlayerCashflow.WAGES), 1e-9); - assertEquals(12.0, economy.getLedger(soldier).getNetDaily(), 1e-9); - assertEquals(List.of(PlayerCashflow.WAGES), economy.getLedger(soldier).getIncomeFlows()); - assertEquals(0.0, economy.getLedger(soldier).getAmount(PlayerCashflow.DIVIDEND_PAYOUT), 1e-9); - } - - @Test - void aClampedWageIsWrittenAtTheAmountActuallyPaid() { - Guild guild = mock(Guild.class); - Bank bank = mock(Bank.class); - when(guild.getBank()).thenReturn(bank); - when(guild.isBankrupt()).thenReturn(false); - when(bank.getWealth()).thenReturn(5.0); - - UUID soldier = UUID.randomUUID(); - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.addPlayerPayout(guild, soldier, 20.0); - - PlayerEconomyManager economy = new PlayerEconomyManager(); - RecordingBank playerBank = new RecordingBank(); - PostSettlementPayouts.apply(buffer, playerBank, economy, name -> null); - - // The ledger line and the bank deposit cannot disagree, or the soldier reads - // a wage they never received. - assertEquals(5.0, playerBank.balance(soldier), 1e-9); - assertEquals(5.0, economy.getLedger(soldier).getAmount(PlayerCashflow.WAGES), 1e-9); - } - - @Test - void severalGuildsAggregateToOnePlayer() { - Guild one = mock(Guild.class); - Guild two = mock(Guild.class); - Bank bankOne = mock(Bank.class); - Bank bankTwo = mock(Bank.class); - when(one.getBank()).thenReturn(bankOne); - when(two.getBank()).thenReturn(bankTwo); - when(one.isBankrupt()).thenReturn(false); - when(two.isBankrupt()).thenReturn(false); - when(bankOne.getWealth()).thenReturn(50.0); - when(bankTwo.getWealth()).thenReturn(50.0); - - UUID player = UUID.randomUUID(); - DailyGuildTransfers buffer = new DailyGuildTransfers(); - buffer.addPlayerPayout(one, player, 4.0); - buffer.addPlayerPayout(two, player, 6.0); - - RecordingBank playerBank = new RecordingBank(); - PostSettlementPayouts.apply(buffer, playerBank, new PlayerEconomyManager(), name -> null); - - assertEquals(10.0, playerBank.balance(player), 1e-9); - verify(bankOne).withdraw(4.0); - verify(bankTwo).withdraw(6.0); - } - - private static final class RecordingBank implements PlayerBank { - private final Map<UUID, Double> balances = new HashMap<>(); - - double balance(UUID playerUuid) { - return balances.getOrDefault(playerUuid, 0.0); - } - - @Override - public double getBankBalance(UUID playerUuid) { - return balance(playerUuid); - } - - @Override - public boolean withdrawFromBank(UUID playerUuid, double amount) { - return false; - } - - @Override - public boolean depositToBank(UUID playerUuid, double amount) { - if (playerUuid == null || amount <= 0) { - return false; - } - balances.put(playerUuid, balance(playerUuid) + amount); - return true; - } - - @Override - public UUID resolve(String playerName) { - return null; - } - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java index f60f5638..3d194905 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java @@ -12,7 +12,6 @@ import net.tfminecraft.simplefactions.player.PlayerEconomyManager; import net.tfminecraft.simplefactions.player.income.PlayerCashflow; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -32,7 +31,7 @@ void setup() { guild = fixture.guild(faction, "traders", "Bob"); faction.getOrCreateMainGuild(); faction.getTaxHandler().setDividendTax(0); - guild.getBank().setWealth(100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 100.0); previousEligibility = Cache.dividendRequirePreviousTickMembership; Cache.dividendRequirePreviousTickMembership = false; } @@ -43,64 +42,6 @@ void close() { fixture.close(); } - private DailyGuildTransfers dividend(double amount) { - DailyGuildTransfers transfers = new DailyGuildTransfers(); - transfers.setPendingDividendPool(guild, amount); - return transfers; - } - - private DailyGuildTransfers wage(double amount) { - DailyGuildTransfers transfers = new DailyGuildTransfers(); - transfers.addPlayerPayout(guild, player, amount); - return transfers; - } - - @Test - void unavailablePlayerBankCannotConsumeDividendOrReportIncome() { - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(dividend(25), null, economy, name -> player); - assertEquals(100, guild.getBank().getWealth()); - assertEquals(0, economy.getLedger(player).getNetDaily()); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void rejectedExternalDepositCannotConsumeGuildFundsOrReportIncome(boolean dividends) { - RecordingBank bank = new RecordingBank(false); - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(dividends ? dividend(25) : wage(25), bank, economy, name -> player); - assertEquals(1, bank.attempts); - assertEquals(0, bank.total()); - assertEquals(100, guild.getBank().getWealth()); - assertEquals(0, economy.getLedger(player).getNetDaily()); - } - - @Test - void successfulDividendsAndWagesBalanceAllAccountsAndLedger() { - DailyGuildTransfers transfers = dividend(30); - transfers.addPlayerPayout(guild, player, 20); - RecordingBank bank = new RecordingBank(true); - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(transfers, bank, economy, name -> player); - assertEquals(50, guild.getBank().getWealth()); - assertEquals(50, bank.total()); - assertEquals(30, economy.getLedger(player).getAmount(PlayerCashflow.DIVIDEND_PAYOUT)); - assertEquals(20, economy.getLedger(player).getAmount(PlayerCashflow.WAGES)); - } - - @Test - void noBudgetAndNoKnownRecipientsPreserveMoney() { - RecordingBank bank = new RecordingBank(true); - PostSettlementPayouts.apply(null, bank, null, null); - PostSettlementPayouts.apply(new DailyGuildTransfers(), bank, null, null); - PostSettlementPayouts.apply(dividend(25), bank, null, name -> null); - assertEquals(100, guild.getBank().getWealth()); - guild.getBank().setWealth(0.0); - PostSettlementPayouts.apply(wage(25), bank, null, name -> player); - assertEquals(0, bank.attempts); - assertEquals(0, guild.getBank().getWealth()); - } - @Test void publicBufferRejectsEmptyTransfersAndClearDropsAllQueues() { DailyGuildTransfers transfers = new DailyGuildTransfers(); @@ -122,28 +63,6 @@ void publicBufferRejectsEmptyTransfersAndClearDropsAllQueues() { assertTrue(transfers.getPendingDividendPools().isEmpty()); } - @Test - void corruptPublicBufferedRowsDoNotBlockValidWages() { - DailyGuildTransfers transfers = wage(10); - Map<UUID, Double> payouts = transfers.getPlayerPayouts().get(guild); - payouts.put(null, 5.0); - payouts.put(UUID.randomUUID(), null); - payouts.put(UUID.randomUUID(), -10.0); - transfers.getPendingDividendPools().put(null, 20.0); - RecordingBank bank = new RecordingBank(true); - PostSettlementPayouts.apply(transfers, bank, null, null); - assertEquals(10, bank.total()); - assertEquals(90, guild.getBank().getWealth()); - } - - @Test - void roundedSubCentWagesKeepMoneyInGuild() { - RecordingBank bank = new RecordingBank(true); - PostSettlementPayouts.apply(wage(0.001), bank, null, null); - assertEquals(0, bank.attempts); - assertEquals(100, guild.getBank().getWealth()); - } - @Test void restoredLoanRejectsUnknownAccountsAndOverdueCreditIsChargedOncePerDay() { LoanData invalid = @@ -184,72 +103,4 @@ void paymentOfAccruedInterestReducesDebtWithoutDoubleChargingTodaysInterest() { assertEquals(100, loan.getTotalOwed(), 0.00001); } - @Test - void dividendTaxAndMemberPaymentConserveTheBudget() { - faction.getTaxHandler().setDividendTax(20); - Guild capital = faction.getOrCreateMainGuild(); - double before = capital.getBank().getWealth(); - RecordingBank bank = new RecordingBank(true); - PlayerEconomyManager economy = new PlayerEconomyManager(); - PostSettlementPayouts.apply(dividend(40), bank, economy, name -> player); - double tax = capital.getBank().getWealth() - before; - assertTrue(tax > 0); - assertEquals(40, tax + bank.total(), 0.00001); - assertEquals(60, guild.getBank().getWealth(), 0.00001); - assertEquals(bank.total(), economy.getLedger(player).getAmount(PlayerCashflow.DIVIDEND_PAYOUT)); - } - - @Test - void invalidMemberNamesAndEmptyQueuedPayoutsCannotMoveMoney() { - RecordingBank bank = new RecordingBank(true); - guild.getMembers().add(""); - guild.getMembers().add(null); - PostSettlementPayouts.apply( - dividend(20), - bank, - null, - name -> { - assertNotNull(name); - assertFalse(name.isBlank()); - return null; - }); - DailyGuildTransfers empty = new DailyGuildTransfers(); - empty.getPlayerPayouts().put(guild, new HashMap<>()); - PostSettlementPayouts.apply(empty, bank, null, null); - assertEquals(100, guild.getBank().getWealth()); - assertEquals(0, bank.attempts); - } - - private static final class RecordingBank implements PlayerBank { - final Map<UUID, Double> paid = new HashMap<>(); - final boolean accepting; - int attempts; - - RecordingBank(boolean accepting) { - this.accepting = accepting; - } - - double total() { - return paid.values().stream().mapToDouble(Double::doubleValue).sum(); - } - - public double getBankBalance(UUID id) { - return paid.getOrDefault(id, 0.0); - } - - public boolean withdrawFromBank(UUID id, double amount) { - return false; - } - - public boolean depositToBank(UUID id, double amount) { - attempts++; - if (!accepting) return false; - paid.merge(id, amount, Double::sum); - return true; - } - - public UUID resolve(String name) { - return null; - } - } } diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java index 7480fd40..67fe9ed9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java @@ -23,58 +23,50 @@ class SharedDomainUtilitiesCoverageTest { @BeforeEach void setUp() { fixture=new FactionDomainFixture(); new Wealth();new Represents();new RandomRGB(); } @AfterEach void close() { fixture.close(); } + private String character(String name) { + return java.util.UUID.nameUUIDFromBytes(name.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString(); + } + + private void fund(String key, long pouch, long bank) { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set("char:" + key + ":pouch", pouch); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set("char:" + key + ":bank", bank); + } + @Test void personalWealthIncludesBothAccountsAndTheApplicableShareOfGuildAssets() { - Faction realm=fixture.saved("realm","Alice");realm.addMember("Citizen"); - Guild guild=fixture.guild(realm,"merchants","Bob");guild.addMember("Dana"); - realm.getBank().deposit(100.0);guild.getBank().deposit(60.0); - try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { - accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.POUCH))).thenReturn(3.0); - accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.BANK))).thenReturn(7.0); - assertEquals(110,Wealth.wealth("Alice")); - assertEquals(10,Wealth.wealth("Citizen")); - assertEquals(40,Wealth.wealth("Bob")); - assertEquals(40,Wealth.wealth("Dana")); - assertEquals(10,Wealth.wealth("Visitor")); - } + String alice=character("Alice"),citizen=character("Citizen"),bob=character("Bob"),dana=character("Dana"),visitor=character("Visitor"); + Faction realm=fixture.saved("realm",alice);realm.addMember(citizen); + Guild guild=fixture.guild(realm,"merchants",bob);guild.addMember(dana); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(realm.getBank(),100); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(),60); + for (String key:List.of(alice,citizen,bob,dana,visitor)) fund(key,300,700); + assertEquals(110,Wealth.wealth(alice)); + assertEquals(10,Wealth.wealth(citizen)); + assertEquals(40,Wealth.wealth(bob)); + assertEquals(40,Wealth.wealth(dana)); + assertEquals(10,Wealth.wealth(visitor)); + assertEquals(0,Wealth.wealth("quarantine:unknown")); } - @Test void characterWealthReadsTheAccountPlayingTheCharacterAndUnknownCharactersHaveNone() { - java.util.UUID account=java.util.UUID.randomUUID(), character=java.util.UUID.randomUUID(); - String member=net.tfminecraft.simplefactions.identity.Members.key(character); - String stranger=net.tfminecraft.simplefactions.identity.Members.key(java.util.UUID.randomUUID()); - var names=net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.INSTANCE; - net.tfminecraft.simplefactions.identity.Members.setResolver(new net.tfminecraft.simplefactions.identity.Members.Resolver() { - @Override public java.util.Optional<net.tfminecraft.tlibs.character.CharacterSession> session(org.bukkit.entity.Player p) { return names.session(p); } - @Override public boolean isCurrent(net.tfminecraft.tlibs.character.CharacterSession s) { return true; } - @Override public String key(org.bukkit.entity.Player p) { return names.key(p); } - @Override public java.util.Optional<org.bukkit.entity.Player> online(String key) { return java.util.Optional.empty(); } - @Override public java.util.Optional<net.tfminecraft.tlibs.character.CharacterInfo> info(String key) { - return key.equals(member) ? java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo( - new net.tfminecraft.tlibs.character.CharacterRef(account,character),"Lady Alice", - net.tfminecraft.tlibs.character.CharacterStatus.ALIVE,null,java.util.Set.of(),0,null)) : java.util.Optional.empty(); - } - }); - try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { - accounts.when(() -> OfflineModifier.balance(eq(account),eq(Accounts.POUCH))).thenReturn(3.0); - accounts.when(() -> OfflineModifier.balance(eq(account),eq(Accounts.BANK))).thenReturn(7.0); - assertEquals(10,Wealth.wealth(member)); - assertEquals(0,Wealth.wealth(stranger)); - accounts.verify(() -> OfflineModifier.balance(anyString(),any(Accounts.class)),never()); - } + @Test void characterWealthReadsItsOwnLedgerWithoutAnOnlineSession() { + String member=character("first"),other=character("other"); + fund(member,300,700); + fund(other,100,200); + assertEquals(10,Wealth.wealth(member)); + assertEquals(3,Wealth.wealth(other)); + assertEquals(0,Wealth.wealth(character("unfunded"))); } @Test void wealthRankingIncludesSubjectsOnlyWhenRequestedAndSortsByTotalAssets() { - Faction realm=fixture.saved("realm","Alice");realm.addMember("Citizen"); - Guild guild=fixture.guild(realm,"merchants","Bob");guild.addMember("Dana"); - Faction subject=fixture.saved("subject","Cara");fixture.subject(realm,subject); - realm.getBank().deposit(100.0);guild.getBank().deposit(60.0);subject.getBank().deposit(200.0); - Map<String,Double> pouch=Map.of("Alice",1.0,"Bob",10.0,"Dana",5.0,"Citizen",0.0,"Cara",2.0); - try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { - accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.POUCH))) - .thenAnswer(call -> pouch.getOrDefault(call.getArgument(0),0.0)); - assertEquals(List.of("Alice","Bob","Dana","Citizen"),Wealth.topWealth(realm,false)); - assertEquals(List.of("Cara","Alice","Bob","Dana","Citizen"),Wealth.topWealth(realm,true)); - } + String alice=character("Alice"),citizen=character("Citizen"),bob=character("Bob"),dana=character("Dana"),cara=character("Cara"); + Faction realm=fixture.saved("realm",alice);realm.addMember(citizen); + Guild guild=fixture.guild(realm,"merchants",bob);guild.addMember(dana); + Faction subject=fixture.saved("subject",cara);fixture.subject(realm,subject); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(realm.getBank(),100); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(),60); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(subject.getBank(),200); + fund(alice,100,0);fund(bob,1000,0);fund(dana,500,0);fund(cara,200,0); + assertEquals(List.of(alice,bob,dana,citizen),Wealth.topWealth(realm,false)); + assertEquals(List.of(cara,alice,bob,dana,citizen),Wealth.topWealth(realm,true)); } @Test void representationDistinguishesCapitalGuildSubjectsAndUnrelatedPlayers() { diff --git a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java index b4b9d93e..7011e178 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java @@ -138,7 +138,7 @@ void aFailedConfiguredLawPreservesTheRebelGuildItsMembersAndItsTreasury() { Guild guild = fixture.guild(home, "traders", "Bob"); guild.addMember("Cara"); guild.setCapital(2); - guild.getBank().deposit(37.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 37.0); var bank = guild.getBank(); var type = guild.getType(); guild.setName("The Merchant Company"); @@ -291,8 +291,8 @@ void failureAfterSplittingReturnsEveryGuildAndCitizenWithoutChangingTreasuries() Guild second = fixture.guild(home, "crafters", "Cara"); main.setCapital(2); second.setCapital(2); - main.getBank().deposit(37.0); - second.getBank().deposit(21.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(main.getBank(), 37.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(second.getBank(), 21.0); home.getOrCreateMainGuild().addMember("Dave"); Movement movement = movement(home, "Bob", Action.LAW_CHANGE); movement.getSupporters().addGuild(second); @@ -461,7 +461,7 @@ void selectingAnotherGuildsMemberPreservesItsLeaderAndRollbackRestoresTheOrigina home.getOrCreateMainGuild().addMember("Bob"); Guild guild = fixture.guild(home, "merchants", "Cara"); guild.addMember("Dave"); - guild.getBank().deposit(19.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 19.0); Movement movement = movement(home, "Bob", Action.CHANGE_LEADER); movement.getCauses().getFirst().getProposal().setTarget("Dave"); assertTrue(home.canBecomeLeader("Dave")); @@ -682,7 +682,7 @@ void failureToPublishTemporaryFactionDeletionStillRestoresActualGuildState() { twoProvinceHome(home); Guild guild = fixture.guild(home, "traders", "Bob"); guild.setCapital(2); - guild.getBank().deposit(23.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(guild.getBank(), 23.0); Movement movement = movement(home, "Bob", Action.LAW_CHANGE); AtomicBoolean unavailable = new AtomicBoolean(); doAnswer( diff --git a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarWartimeBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarWartimeBoundaryCoverageTest.java index edd67335..fa3d957e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarWartimeBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/wartime/CivilWarWartimeBoundaryCoverageTest.java @@ -244,9 +244,9 @@ void untanglingRestoresActualLandArmiesTreasuriesGuildsAndMembers(boolean attack var origin = fixture.guild(host, "origin", "ActingLeader"); var extra = fixture.guild(rebels, "extra", "Elena"); extra.setCapital(2, false); - extra.getBank().deposit(20.0); - host.getOrCreateMainGuild().getBank().deposit(100.0); - rebels.getOrCreateMainGuild().getBank().deposit(30.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(extra.getBank(), 20.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(host.getOrCreateMainGuild().getBank(), 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(rebels.getOrCreateMainGuild().getBank(), 30.0); rebels.getOrCreateMainGuild().addMember("Cara"); host.getMilitary().getRegiment("guard").setCurrentSlots(7); rebels.getMilitary().getRegiment("guard").setCurrentSlots(3); @@ -370,9 +370,9 @@ void restoringWithOnlyABaseGuildTypeCannotLeaveMembersOrFundsBoundToTheDeletedRe rebels.addProvince(2); host.setCapital(1, true, false); rebels.setCapital(2, true, false); - host.getOrCreateMainGuild().getBank().deposit(100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(host.getOrCreateMainGuild().getBank(), 100.0); var base = rebels.getOrCreateMainGuild(); - base.getBank().deposit(30.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(base.getBank(), 30.0); base.addMember("Cara"); base.setName("Rebel Company"); base.setFavoured(true); @@ -415,7 +415,7 @@ void publicationFailureStillRetiresAnEmptyTemporaryRealmAndKeepsRestoredFundsAnd var host = fixture.saved("host", "Alice"); var rebels = fixture.saved("rebels", "Bob"); rebels.setRGB("71,72,73"); - rebels.getOrCreateMainGuild().getBank().deposit(19.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.add(rebels.getOrCreateMainGuild().getBank(), 19.0); var restored = rebels.getOrCreateMainGuild(); var subject = fixture.saved("subject", "Cara"); fixture.subject(rebels, subject); From 8ecd5ed0f579b353452ae1309f3e18d6d8641c02 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:34:19 +0000 Subject: [PATCH 26/35] feat: journal character treasury caller actions --- .../economy/TreasuryActions.java | 281 +++++++++++++++++ .../economy/TreasuryPhysical.java | 86 ++++++ .../guild/income/GuildBankGrant.java | 4 +- .../managers/CommandManager.java | 49 ++- .../managers/InventoryManager.java | 12 +- .../managers/PlayerManager.java | 47 +-- .../managers/inventory/GuildView.java | 53 ++-- .../managers/inventory/LoanPayment.java | 11 + .../company/MercenaryCompanyService.java | 4 +- .../war/resolution/PillageApplyService.java | 43 ++- .../economy/TreasuryActionsTest.java | 289 ++++++++++++++++++ .../guild/income/GuildBankGrantTest.java | 22 +- .../managers/CommandManagerCoverageTest.java | 48 +-- .../InventoryManagerCoverageTest.java | 24 +- .../MercenaryCommandsCoverageTest.java | 4 +- .../managers/PlayerManagerCoverageTest.java | 59 ++-- .../inventory/GuildMenusCoverageTest.java | 15 +- .../inventory/MercenaryMenusCoverageTest.java | 2 +- ...GovernmentEconomyReviewRegressionTest.java | 2 +- .../mercenary/company/CompanyFixture.java | 25 +- .../mercenary/company/CompanyGuiTest.java | 4 +- .../MercenaryCompanyFormationTest.java | 33 +- .../company/MercenaryEligibilityTest.java | 5 +- .../company/MercenaryHomeManpowerTest.java | 2 +- .../mercenary/contract/ContractFixture.java | 2 +- .../contract/MercenaryContractTest.java | 3 +- .../testsupport/GuiTestFixture.java | 22 ++ .../PillageMissingBankRegressionTest.java | 4 +- .../war/resolution/PillageTreasuryTest.java | 67 ++++ 29 files changed, 981 insertions(+), 241 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/TreasuryPhysical.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageTreasuryTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java new file mode 100644 index 00000000..3ac95828 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java @@ -0,0 +1,281 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.database.MercenaryCompanyData; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.bukkit.entity.Player; + +/** Durable treasury postings with captured ownership and exact, replayable domain changes. */ +public final class TreasuryActions { + private static OperationJournal journal = new OperationJournal(Path.of("plugins/SimpleFactions/economy/callers")); + private TreasuryActions() {} + + public static void recoverTopologyFiles(Path folder) throws IOException { + new OperationJournal(folder).recoverAppliedEffects(intent -> { + for (JsonElement element : intent.changes().getAsJsonArray("effects")) { + JsonObject effect = element.getAsJsonObject(); + if ("topology".equals(effect.get("kind").getAsString())) TreasuryTopology.restoreFiles(effect); + } + }); + } + public static void recover(Path folder) throws IOException { + journal = new OperationJournal(folder); + journal.recover(TreasuryActions::apply); + } + + public static final class Plan { + private final JsonObject changes = new JsonObject(); + private final JsonArray effects = new JsonArray(); + private final JsonArray guilds = new JsonArray(); + private final List<Leg> legs = new ArrayList<>(); + private final CharacterSession session; + private final String id; + private final String reason; + private OperationJournal.Effects physical; + public Plan(String id, String reason, Player actor, Guild... participants) { + this.id = id; + this.reason = reason; + session = actor == null ? null : Members.session(actor).orElseThrow(() -> new IllegalStateException("Character unavailable")); + changes.addProperty("schema", 1); + changes.add("actor", JsonUtil.GSON.toJsonTree(session)); + changes.add("guilds", guilds); + changes.add("effects", effects); + for (Guild guild : participants) capture(guild); + } + public Plan capture(Guild guild) { + JsonObject ref = new JsonObject(); + ref.addProperty("id", guild.getId()); + ref.addProperty("incarnation", guild.getIncarnationId().toString()); + if (!guilds.contains(ref)) guilds.add(ref); + return this; + } + public Plan transfer(String from, String to, long cents) { + if (cents < 0) throw new IllegalArgumentException("Negative transfer"); + legs.add(new Leg(from, -cents)); + legs.add(new Leg(to, cents)); + return this; + } + public Plan effect(String kind, Guild guild, String key, Object before, Object after) { + JsonObject effect = new JsonObject(); + effect.addProperty("kind", kind); + effect.addProperty("guild", guild.getId()); + effect.addProperty("key", key); + effect.add("before", JsonUtil.GSON.toJsonTree(before)); + effect.add("after", JsonUtil.GSON.toJsonTree(after)); + effects.add(effect); + return this; + } + public Plan physical(JsonObject snapshot, OperationJournal.Effects delivery) { + changes.add("physical", snapshot.deepCopy()); + physical = delivery; + return this; + } + public boolean commit(Player actor) { + if (!EconomyRuntime.ready() || session != null && !Members.isCurrent(session)) return unavailable(actor); + try { + Result result = journal.execute(new OperationJournal.Intent(id, reason, legs, changes), intent -> { + apply(intent, physical); + }); + if (result == Result.APPLIED) return true; + if (result == Result.REFUSED) { + if (actor != null) actor.sendMessage("§cThe treasury could not fund this action."); + return false; + } + throw new IOException("Treasury intent " + id + " remains " + result); + } catch (IOException | RuntimeException error) { + EconomyRuntime.failClosed(error); + if (actor != null) actor.sendMessage("§cTreasury action pending recovery: " + id); + return false; + } + } + } + + public static Boolean resume(String id, Player actor) { + if (!EconomyRuntime.ready()) return unavailable(actor); + try { + var result = journal.resume(id, TreasuryActions::apply); + if (result.isEmpty()) return null; + if (result.get() == Result.APPLIED) return true; + if (result.get() == Result.REFUSED) return false; + throw new IOException("Treasury intent " + id + " remains " + result.get()); + } catch (IOException | RuntimeException error) { + EconomyRuntime.failClosed(error); + if (actor != null) actor.sendMessage("§cTreasury action pending recovery: " + id); + return false; + } + } + + public static String newId() { return "sf:treasury:" + UUID.randomUUID(); } + public static String account(Guild guild) { return Denars.guild(guild.getIncarnationId()); } + private static boolean unavailable(Player actor) { + if (actor != null) actor.sendMessage("§cThe treasury is not ready. Please try again later."); + return false; + } + public static double pouch(Player actor) { + return Members.session(actor).map(s -> Money.denars(Denars.balance(Denars.pouch(s.ref())))).orElse(0d); + } + public static boolean bank(Player actor, Guild guild, double amount, boolean deposit) { + if (!EconomyRuntime.ready()) return unavailable(actor); + CharacterSession session = Members.session(actor).orElse(null); + if (session == null) return unavailable(actor); + if (deposit ? !(guild.isBase() ? guild.getFaction().isMember(actor) : guild.isMember(actor)) : !guild.isLeader(actor)) return false; + String pouch = Denars.pouch(session.ref()); + Plan plan = new Plan(newId(), deposit ? "Guild deposit" : "Guild withdrawal", actor, guild); + plan.transfer(deposit ? pouch : account(guild), deposit ? account(guild) : pouch, Money.cents(amount)); + if (deposit) history(plan, guild, Members.key(session.ref()), amount); + return plan.commit(actor); + } + public static void history(Plan plan, Guild guild, String key, double amount) { + Double before = guild.getLedger().getHistory().getDepositsToday().get(key); + plan.effect("history", guild, key, before, (before == null ? 0 : before) + amount); + } + public static boolean grant(Guild guild, double amount) { + if (!EconomyRuntime.ready()) return false; + Plan plan = new Plan(newId(), "Staff guild grant", null, guild).transfer(Denars.MINT, account(guild), Money.cents(amount)); + history(plan, guild, "Compensation", amount); + return plan.commit(null); + } + public static boolean branch(Player actor, Guild guild, Branch branch, boolean upgrade, String id) { + if (!EconomyRuntime.ready() || !guild.isLeader(actor)) return unavailable(actor); + if (!upgrade && branch.getLevel() == 0) return false; + double amount = upgrade ? guild.getExpansionCost() : guild.getRefund(); + if (!Double.isFinite(amount) || amount < 0 || upgrade && guild.getBank().getWealth() < amount) { + actor.sendMessage("§cCannot afford to upgrade"); + return false; + } + return new Plan(id, "Guild branch level", actor, guild) + .transfer(upgrade ? account(guild) : Denars.MINT, upgrade ? Denars.MINT : account(guild), Money.cents(amount)) + .effect("branch", guild, branch.getId(), branch.getLevel(), branch.getLevel() + (upgrade ? 1 : -1)).commit(actor); + } + public static boolean acceptLoan(Player actor, Loan loan, String offer, long expires) { + Guild lender = loan.getIssuer(), borrower = loan.getBorrower(); + if (!EconomyRuntime.ready() || !borrower.isLeader(actor)) return unavailable(actor); + if (lender.getLoanHandler().getUsedOffers().containsKey(offer) || lender.getLoanHandler().getLoanById(offer) != null) return false; + return new Plan("sf:loan-offer:" + lender.getIncarnationId() + ":" + offer, "Accept guild loan", actor, lender, borrower) + .transfer(account(lender), account(borrower), Money.cents(loan.getAmount())) + .effect("loan", lender, loan.getId(), null, new LoanData(loan)) + .effect("offer", lender, offer, null, expires).commit(actor); + } + public static double repay(Player actor, Loan loan, double amount, String operationId) { + Guild borrower = loan.getBorrower(), lender = loan.getIssuer(); + if (!EconomyRuntime.ready() || !borrower.isLeader(actor)) { unavailable(actor); return -1; } + int beforeCredit = borrower.getLoanHandler().getCreditScore(); + int[] afterCredit = { beforeCredit }; + Loan planned = loan.copyForSettlement(System.currentTimeMillis(), delta -> afterCredit[0] = Math.max(0, Math.min(100, afterCredit[0] + delta))); + double effective = planned.makePayment(Math.min(amount, loan.getTotalOwed()), false); + if (effective <= 0 || borrower.getBank().getWealth() < effective) return -1; + Plan plan = new Plan(operationId, "Guild loan repayment", actor, borrower, lender) + .transfer(account(borrower), account(lender), Money.cents(effective)) + .effect("loan", lender, loan.getId(), new LoanData(loan), new LoanData(planned)) + .effect("credit", borrower, "", beforeCredit, afterCredit[0]); + return plan.commit(actor) ? effective : -1; + } + public static boolean formation(Guild guild, String member, MercenaryCompany company, double amount) { + Player actor = Members.player(member); + if (actor == null || !guild.isLeader(actor) || !EconomyRuntime.ready()) return false; + Plan plan = new Plan(newId(), "Mercenary company formation", actor, guild) + .transfer(account(guild), Denars.MINT, Money.cents(amount)); + plan.effect("company", guild, "", null, company.serialize()); + return plan.commit(actor); + } + + private static void apply(OperationJournal.Intent intent) throws IOException { apply(intent, null); } + private static void apply(OperationJournal.Intent intent, OperationJournal.Effects physical) throws IOException { + JsonObject changes = intent.changes(); + if (changes.get("schema").getAsInt() != 1) throw new IOException("Unsupported treasury schema"); + List<Guild> participants = new ArrayList<>(); + for (JsonElement element : changes.getAsJsonArray("guilds")) { + JsonObject ref = element.getAsJsonObject(); + Guild guild = FactionManager.getGuildByString(ref.get("id").getAsString()); + if (guild == null || !guild.getIncarnationId().toString().equals(ref.get("incarnation").getAsString())) + throw new IOException("Guild incarnation unavailable: " + ref); + participants.add(guild); + } + if (changes.has("physical") && physical == null) + throw new IOException("Physical treasury delivery requires staff reconciliation: " + intent.id()); + LinkedHashSet<Faction> dirty = new LinkedHashSet<>(); + LinkedHashSet<Guild> changedBranches = new LinkedHashSet<>(); + for (JsonElement element : changes.getAsJsonArray("effects")) { + JsonObject e = element.getAsJsonObject(); + Guild guild = FactionManager.getGuildByString(e.get("guild").getAsString()); + String kind = e.get("kind").getAsString(), key = e.get("key").getAsString(); + JsonElement before = e.get("before"), after = e.get("after"); + switch (kind) { + case "history" -> { + var deposits = guild.getLedger().getHistory().getDepositsToday(); + check(deposits.get(key), before, after); + deposits.put(key, after.getAsDouble()); + } + case "branch" -> { + Branch branch = guild.getBranch(key); + if (branch == null) throw new IOException("Missing branch " + key); + check(branch.getLevel(), before, after); + while (branch.getLevel() < after.getAsInt()) branch.levelUp(); + while (branch.getLevel() > after.getAsInt()) branch.levelDown(); + changedBranches.add(guild); + } + case "loan" -> { + Loan existing = guild.getLoanHandler().getLoanById(key); + check(existing == null ? null : new LoanData(existing), before, after); + LoanData data = JsonUtil.GSON.fromJson(after, LoanData.class); + if (existing == null) guild.getLoanHandler().issueLoan(new Loan(data)); + else existing.restoreSettlement(data); + } + case "offer" -> { + // Expired markers may have been pruned; the captured loan still prevents a second acceptance. + Long current = guild.getLoanHandler().getUsedOffers().get(key); + if (current != null) check(current, before, after); + guild.getLoanHandler().restoreUsedOffers(java.util.Map.of(key, after.getAsLong())); + } + case "credit" -> { + check(guild.getLoanHandler().getCreditScore(), before, after); + guild.getLoanHandler().restoreCreditScore(after.getAsInt()); + } + case "company" -> { + check(guild.getCompany() == null ? null : guild.getCompany().serialize(), before, after); + if (guild.getCompany() == null) guild.setCompany(new MercenaryCompany(guild, + JsonUtil.GSON.fromJson(after, MercenaryCompanyData.class), MercenaryCompany.cloneMercenaryRegiment())); + } + case "pillage" -> { + check(guild.getPillageHits(), before, after); + guild.getPillageHits().clear(); + for (JsonElement hit : after.getAsJsonArray()) guild.getPillageHits().add(JsonUtil.GSON.fromJson(hit, StabilityModifier.class)); + } + case "topology" -> TreasuryTopology.apply(e); + default -> throw new IOException("Unknown treasury effect " + kind); + } + dirty.add(guild.getFaction()); + } + if (physical != null) physical.apply(intent); + for (Guild guild : participants) if (changedBranches.contains(guild) + || intent.legs().stream().anyMatch(leg -> leg.cents() != 0 && account(guild).equals(leg.account()))) guild.updateWealth(); + for (Faction faction : dirty) if (!new Database().saveFactionDurably(faction)) + throw new IOException("Cannot save treasury effects for " + faction.getId()); + } + public static void check(Object current, JsonElement before, JsonElement after) throws IOException { + JsonElement value = JsonUtil.GSON.toJsonTree(current); + if (!value.equals(before) && !value.equals(after)) throw new IOException("Treasury effect state changed: " + value); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryPhysical.java b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryPhysical.java new file mode 100644 index 00000000..6df4b472 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryPhysical.java @@ -0,0 +1,86 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.TaxLeg; +import net.tfminecraft.denareconomy.api.TaxPolicy; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** Records physical boundaries; an interrupted delivery is retained for staff, never redelivered. */ +public final class TreasuryPhysical { + private TreasuryPhysical() {} + public static boolean material(Player player, CharacterRef earner, ItemStack item, double gross, Guild guild) { + var session = Members.session(player).orElse(null); + if (!EconomyRuntime.ready() || !Denars.taxReady() || session == null || !session.ref().equals(earner)) return false; + long cents = Money.cents(gross); + if (cents <= 0) return false; + TaxPolicy policy = Bukkit.getServicesManager().load(TaxPolicy.class); + Optional<TaxLeg> tax = policy == null ? Optional.empty() : policy.tax(earner, cents); + long withheld = tax.map(TaxLeg::cents).orElse(0L); + if (withheld < 0 || withheld > cents) throw new IllegalArgumentException("Invalid material tax"); + String inventory = inventory(player), source = encode(item); + int slot = player.getInventory().getHeldItemSlot(); + JsonObject snapshot = snapshot("material", player, inventory); + snapshot.addProperty("slot", slot); + snapshot.addProperty("beforeItem", source); + snapshot.addProperty("afterItem", "empty"); + snapshot.addProperty("grossCents", cents); + snapshot.addProperty("withheldCents", withheld); + snapshot.addProperty("taxAccount", tax.map(TaxLeg::account).orElse(null)); + TreasuryActions.Plan plan = new TreasuryActions.Plan(TreasuryActions.newId(), "Material bank deposit", player, guild) + .transfer(Denars.MINT, Denars.bank(earner), cents - withheld); + if (withheld > 0) plan.transfer(Denars.MINT, tax.orElseThrow().account(), withheld); + return plan.physical(snapshot, intent -> { + if (!Members.isCurrent(session) || !inventory.equals(inventory(player)) || !source.equals(encode(item)) + || slot != player.getInventory().getHeldItemSlot() || !source.equals(encode(player.getInventory().getItem(slot)))) + throw new IOException("Material source changed after posting: " + intent.id()); + item.setAmount(0); + player.getInventory().setItem(slot, null); + player.saveData(); + }).commit(player); + } + public static boolean withdraw(Player player, Guild guild, double amount, List<ItemStack> items) { + var session = Members.session(player).orElse(null); + if (!EconomyRuntime.ready() || session == null) return false; + String before = inventory(player); + List<ItemStack> captured = items.stream().map(ItemStack::clone).toList(); + JsonObject snapshot = snapshot("force-withdraw", player, before); + snapshot.addProperty("delivery", Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(captured))); + snapshot.addProperty("world", player.getWorld().getUID().toString()); + snapshot.addProperty("x", player.getLocation().getX()); + snapshot.addProperty("y", player.getLocation().getY()); + snapshot.addProperty("z", player.getLocation().getZ()); + snapshot.addProperty("cents", Money.cents(amount)); + return new TreasuryActions.Plan(TreasuryActions.newId(), "Staff physical treasury withdrawal", player, guild) + .transfer(TreasuryActions.account(guild), Denars.MINT, Money.cents(amount)) + .physical(snapshot, intent -> { + if (!Members.isCurrent(session) || !before.equals(inventory(player))) + throw new IOException("Withdrawal inventory changed after posting: " + intent.id()); + for (ItemStack item : captured) for (ItemStack leftover : player.getInventory().addItem(item).values()) + player.getWorld().dropItemNaturally(player.getLocation(), leftover); + player.saveData(); + }).commit(player); + } + private static JsonObject snapshot(String kind, Player player, String before) { + JsonObject data = new JsonObject(); + data.addProperty("kind", kind); + data.addProperty("account", player.getUniqueId().toString()); + data.addProperty("inventoryBefore", before); + return data; + } + private static String inventory(Player player) { + return Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(player.getInventory().getContents())); + } + private static String encode(ItemStack item) { + return item == null || item.getType().isAir() ? "empty" : Base64.getEncoder().encodeToString(item.serializeAsBytes()); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrant.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrant.java index 4d233fd8..cb007bcd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrant.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrant.java @@ -15,8 +15,6 @@ public static boolean grant(Guild guild, double amount) { Bank bank = guild.getBank(); if (bank == null || guild.getLedger() == null) return false; - bank.deposit(amount); - guild.getLedger().getHistory().addDeposit("Compensation", amount); - return true; + return net.tfminecraft.simplefactions.economy.TreasuryActions.grant(guild, amount); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 2d89b8dc..de743c1c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.managers; import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.economy.TreasuryActions; import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.utils.BankAmount; @@ -56,7 +57,6 @@ import net.tfminecraft.simplefactions.enums.Terrain; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; import net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.VehicleCommandRoute; -import net.tfminecraft.denareconomy.data.Account; import net.tfminecraft.denareconomy.DenarEconomy; public class CommandManager implements Listener, CommandExecutor{ @@ -353,18 +353,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return false; } double amount = parsed; - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); - if(pouch.getBal() < amount) { + double pouch = TreasuryActions.pouch(p); + if(pouch < amount) { p.sendMessage("§cNot enough funds"); return false; } - pouch.change(amount*-1); - g.getBank().deposit(amount); - g.getLedger().getHistory().addDeposit(Members.key(p), amount); + if (!TreasuryActions.bank(p, g, amount, true)) return false; + pouch = TreasuryActions.pouch(p); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Guild Balance: #b39122"+b.getWealth()+"#dbaf1dd")); - p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch.getBal()+"#dbaf1dd")); + p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch+"#dbaf1dd")); p.sendMessage("§e====================================="); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } else { @@ -391,17 +390,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return false; } double amount = parsed; - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); + double pouch = TreasuryActions.pouch(p); if(b.getWealth() < amount) { p.sendMessage("§cNot enough funds in the guild bank"); return false; } - pouch.change(amount); - g.getBank().withdraw(amount); + if (!TreasuryActions.bank(p, g, amount, false)) return false; + pouch = TreasuryActions.pouch(p); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aWithdrew: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Guild Balance: #b39122"+b.getWealth()+"#dbaf1dd")); - p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch.getBal()+"#dbaf1dd")); + p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch+"#dbaf1dd")); p.sendMessage("§e====================================="); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } else { @@ -1165,18 +1164,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return false; } double amount = parsed; - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); - if(pouch.getBal() < amount) { + double pouch = TreasuryActions.pouch(p); + if(pouch < amount) { p.sendMessage("§cNot enough funds"); return false; } - pouch.change(amount*-1); - f.getBank().deposit(amount); - f.getOrCreateMainGuild().getLedger().getHistory().addDeposit(Members.key(p), amount); + if (!TreasuryActions.bank(p, f.getOrCreateMainGuild(), amount, true)) return false; + pouch = TreasuryActions.pouch(p); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Faction Balance: #b39122"+b.getWealth()+"#dbaf1dd")); - p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch.getBal()+"#dbaf1dd")); + p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch+"#dbaf1dd")); p.sendMessage("§e====================================="); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } else { @@ -1201,17 +1199,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return false; } double amount = parsed; - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); + double pouch = TreasuryActions.pouch(p); if(b.getWealth() < amount) { p.sendMessage("§cNot enough funds in the faction bank"); return false; } - pouch.change(amount); - f.getBank().withdraw(amount); + if (!TreasuryActions.bank(p, f.getOrCreateMainGuild(), amount, false)) return false; + pouch = TreasuryActions.pouch(p); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aWithdrew: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Faction Balance: #b39122"+b.getWealth()+"#dbaf1dd")); - p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch.getBal()+"#dbaf1dd")); + p.sendMessage(StringFormatter.formatHex("#3ce8c9New Pouch Balance: #b39122"+pouch+"#dbaf1dd")); p.sendMessage("§e====================================="); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } else { @@ -1505,13 +1503,8 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§cBank does not have enough wealth"); return true; } - bank.withdraw(amount); - List<ItemStack> items = DenarEconomy.getMoneyManager().amountToItems(amount); - for(ItemStack i : items) { - for(ItemStack leftover : p.getInventory().addItem(i).values()) { - p.getWorld().dropItemNaturally(p.getLocation(), leftover); - } - } + List<ItemStack> items = DenarEconomy.getMoneyManager().amountToItems(amount); + if (!net.tfminecraft.simplefactions.economy.TreasuryPhysical.withdraw(p, f.getOrCreateMainGuild(), amount, items)) return false; p.sendMessage("§6"+amount+" §cwas withdrawn from the bank of "+f.getName()); for(String s : f.getMembers()) { Player member = Members.player(s); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index db725175..d8b66438 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -445,7 +445,7 @@ public boolean isPayingLoan(Player p) { public void setPayingLoan(Player p, Loan loan) { Guild guild = FactionManager.getGuildByLeader(Members.key(p));; if(guild == null) return; - loanPayments.put(p, new LoanPayment(guild, loan)); + loanPayments.put(p, new LoanPayment(guild, loan, p)); p.closeInventory(); p.sendMessage(StringFormatter.formatHex("#d6cf69Enter the amount you want to pay (you have #87d65c" + String.format("%.2f", loan.getBorrower().getBank().getWealth()) + "d#d6cf69, loan owes #d65c5c" + @@ -645,7 +645,7 @@ public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) { loanPayments.remove(p); //player left guild return; } - if(!g.getId().equalsIgnoreCase(compare.getId())) { + if (!g.getIncarnationId().equals(compare.getIncarnationId()) || !loanPayments.get(p).isCurrent()) { loanPayments.remove(p); //player changed guild return; } @@ -662,6 +662,7 @@ public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) { double amount = 0; try { amount = Double.parseDouble(e.getMessage()); + if (!Double.isFinite(amount)) throw new NumberFormatException(); } catch (Exception ex) { p.sendMessage("§cError inputting the amount, use the format §e1500.00 §cfor 1500.00d (example)"); p.sendMessage("§4Type 'cancel' to cancel."); @@ -669,7 +670,7 @@ public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) { } amount = Math.round(amount*100.0)/100.0; - if(amount <= 0) { + if (!Double.isFinite(amount) || amount <= 0) { p.sendMessage("§cYou must pay a positive amount."); p.sendMessage("§4Type 'cancel' to cancel."); return; @@ -685,8 +686,8 @@ public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) { amount = loan.getTotalOwed(); } - double finalAmount = loan.makePayment(amount, true); - g.getBank().withdraw(finalAmount); + double finalAmount = net.tfminecraft.simplefactions.economy.TreasuryActions.repay(p, loan, amount, payment.operationId()); + if (finalAmount < 0) return; p.sendMessage(StringFormatter.formatHex("#d6cf69You have paid off #87d65c" + String.format("%.2f", finalAmount) + "d #d6cf69of the loan.")); p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); loanPayments.remove(p); @@ -758,6 +759,7 @@ public void donationChat(Player p, AsyncPlayerChatEvent e) { double amount; try { amount = Double.parseDouble(e.getMessage()); + if (!Double.isFinite(amount)) throw new NumberFormatException(); } catch (Exception ex) { p.sendMessage("§cError inputting the amount, use the format §e100 §cfor 100 denars (example)"); p.sendMessage("§4Type 'cancel' to cancel."); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 7a4dac93..6450b8de 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -24,7 +24,6 @@ import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.guild.loans.LoanBook; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.government.proposal.TaxTarget; import net.tfminecraft.simplefactions.keys.Keys; import net.tfminecraft.simplefactions.mercenary.MercenaryResult; import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; @@ -32,15 +31,10 @@ import net.tfminecraft.simplefactions.mercenary.contract.ContractTerms; import net.tfminecraft.simplefactions.mercenary.contract.ContractValidator; import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; -import net.tfminecraft.simplefactions.player.PlayerEconomyManager; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; -import net.tfminecraft.simplefactions.player.income.PlayerLedger; import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.enums.Accounts; import net.tfminecraft.denareconomy.item.Coin; import net.tfminecraft.denareconomy.event.PlayerBankPulseEvent; import net.tfminecraft.denareconomy.event.PlayerDepositMaterialsEvent; -import net.tfminecraft.denareconomy.event.PlayerEarnMoneyEvent; public class PlayerManager implements Listener{ @EventHandler @@ -170,13 +164,10 @@ public void run() { // Each agreement pays out once. The book is only swapped a few ticks later, so a // second signature before then, or after a restart, must not take the loan again. long bookExpiresAt = time != null ? time : System.currentTimeMillis() + 86400000L; - if(!issuer.getLoanHandler().useOffer(offerId, bookExpiresAt)) { - p.sendMessage("§cThis loan agreement has already been signed."); + if (!net.tfminecraft.simplefactions.economy.TreasuryActions.acceptLoan(p, loan, offerId, bookExpiresAt)) { + p.sendMessage("§cLoan agreement could not be accepted."); return; } - borrowerGuild.getBank().deposit(loan.getAmount()); - issuer.getBank().withdraw(loan.getAmount()); - issuer.getLoanHandler().issueLoan(loan); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); p.sendMessage("§aLoan taken!"); Player lender = Members.player(issuer.getLeader()); @@ -323,32 +314,6 @@ private ItemStack signedAgreement( return i; } - @EventHandler - public void earnMoney(PlayerEarnMoneyEvent e) { - String playerName = e.getPlayer(); - double paidTax = 0; - double gross = e.getAmount(); - // The earner's realm is the one of the character they are playing now. - String member = Members.ofAccountName(playerName); - if(FactionManager.getByMember(member) != null) { - Faction f = FactionManager.getByMember(member); - if(f.getTaxRate(TaxTarget.CITIZENS, null, true) > 0) { - if(f.getBank() != null) { - paidTax = f.getTaxRate(TaxTarget.CITIZENS, null, true)/100.0*gross; - f.giveTax(member, paidTax); - } - } - } - PlayerLedger ledger = PlayerEconomyManager.get().getLedger(playerName); - if (gross > 0) { - ledger.add(PlayerCashflow.EARNINGS, gross); - } - if (paidTax > 0) { - ledger.add(PlayerCashflow.CITIZEN_TAX, -paidTax); - } - e.setAmount(paidTax); - } - @EventHandler public void depositMaterials(PlayerDepositMaterialsEvent e) { ItemStack i = e.getItem(); @@ -357,9 +322,11 @@ public void depositMaterials(PlayerDepositMaterialsEvent e) { if(c == null) return; if(c.canWithdraw()) return; if(!inFactionOrGuildBankChunk(p)) return; - DenarEconomy.getMoneyManager().addMoneyToAccount(p.getUniqueId().toString(), c.getValue()*i.getAmount(), false, true, Accounts.BANK); - p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); - i.setAmount(0); + if (e.isCancelled()) return; + Guild guild = FactionManager.getGuildByMember(Members.key(p)); + if (guild == null) return; + if (net.tfminecraft.simplefactions.economy.TreasuryPhysical.material(p, e.getCharacter(), i, c.getValue()*i.getAmount(), guild)) + p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } @EventHandler diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 8486c6e6..c6b7c188 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -68,6 +68,24 @@ public class GuildView { public static final int HOST_FACTION_SLOT = 37; + private static final org.bukkit.NamespacedKey TREASURY_ID = new org.bukkit.NamespacedKey("simplefactions", "treasury-operation"); + private static final org.bukkit.NamespacedKey TREASURY_ACTOR = new org.bukkit.NamespacedKey("simplefactions", "treasury-actor"); + private static final org.bukkit.NamespacedKey TREASURY_GUILD = new org.bukkit.NamespacedKey("simplefactions", "treasury-guild"); + private static final org.bukkit.NamespacedKey TREASURY_LEVEL = new org.bukkit.NamespacedKey("simplefactions", "treasury-level"); + + ItemStack treasuryButton(Player player, Guild guild, Branch branch, ItemStack item) { + var session = Members.session(player).orElse(null); + if (session == null) return item; + var meta = item.getItemMeta(); + var data = meta.getPersistentDataContainer(); + data.set(TREASURY_ID, PersistentDataType.STRING, net.tfminecraft.simplefactions.economy.TreasuryActions.newId()); + data.set(TREASURY_ACTOR, PersistentDataType.STRING, net.tfminecraft.simplefactions.database.JsonUtil.GSON.toJson(session)); + data.set(TREASURY_GUILD, PersistentDataType.STRING, guild.getIncarnationId().toString()); + data.set(TREASURY_LEVEL, PersistentDataType.INTEGER, branch.getLevel()); + item.setItemMeta(meta); + return item; + } + public GuildView(InventoryManager inv) { this.inv = inv; } @@ -215,8 +233,8 @@ public void guildView(Player player, Guild guild, Inventory i) { if(guild.isLeader(player)) { int upgradeSlot = group + 19; int downgradeSlot = group + 37; - i.setItem(upgradeSlot, creator.createBranchUpgradeItem(player, guild, b)); - i.setItem(downgradeSlot, creator.createBranchDowngradeItem(player, guild, b)); + i.setItem(upgradeSlot, treasuryButton(player, guild, b, creator.createBranchUpgradeItem(player, guild, b))); + i.setItem(downgradeSlot, treasuryButton(player, guild, b, creator.createBranchDowngradeItem(player, guild, b))); if (tradePreview != null) { BranchIncomePreviewService.schedule(player, i, upgradeSlot, tradePreview, guild, b, 1); if (b.getLevel() > 0) { @@ -486,25 +504,18 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.sendMessage("§cYour guild needs a bank to change a branch level."); return; } - if(!upgrade) { - if(b.getLevel() == 0) return; - double refund = guild.getRefund(); - b.levelDown(); - guild.getBank().deposit(refund); - p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - p.sendMessage("§cDowngraded "+b.getName()+ "§c to level §e"+b.getLevel()); - } else { - double cost = guild.getExpansionCost(); - if(guild.getBank() == null || !Double.isFinite(cost) || cost < 0 || guild.getBank().getWealth() < cost) { - p.sendMessage("§cCannot afford to upgrade"); - p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - b.levelUp(); - p.sendMessage("§aUpgraded "+b.getName()+ "§a to level §e"+b.getLevel()); - p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - guild.getBank().withdraw(cost); - } + String operation = meta.getPersistentDataContainer().get(TREASURY_ID, PersistentDataType.STRING); + String captured = meta.getPersistentDataContainer().get(TREASURY_ACTOR, PersistentDataType.STRING); + String incarnation = meta.getPersistentDataContainer().get(TREASURY_GUILD, PersistentDataType.STRING); + Integer level = meta.getPersistentDataContainer().get(TREASURY_LEVEL, PersistentDataType.INTEGER); + if (operation == null || captured == null || level == null || !guild.getIncarnationId().toString().equals(incarnation)) return; + var session = net.tfminecraft.simplefactions.database.JsonUtil.GSON.fromJson(captured, net.tfminecraft.tlibs.character.CharacterSession.class); + if (!Members.isCurrent(session) || !session.ref().ownerId().equals(p.getUniqueId())) return; + Boolean resumed = net.tfminecraft.simplefactions.economy.TreasuryActions.resume(operation, p); + if (resumed != null) return; + if (b.getLevel() != level || !net.tfminecraft.simplefactions.economy.TreasuryActions.branch(p, guild, b, upgrade, operation)) return; + p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); + p.sendMessage((upgrade ? "§aUpgraded " : "§cDowngraded ") + b.getName() + " to level §e" + b.getLevel()); // Refresh prestige after the final branch and bank state has been applied. if (!upgrade && guild.getFaction() != null) { guild.getFaction().updatePrestige(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanPayment.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanPayment.java index 8007c5c2..f04bb360 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanPayment.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanPayment.java @@ -9,6 +9,17 @@ public class LoanPayment { private Guild guild; private Loan loan; private int time; + private final String operationId = net.tfminecraft.simplefactions.economy.TreasuryActions.newId(); + private net.tfminecraft.tlibs.character.CharacterSession actor; + + public LoanPayment(Guild guild, Loan loan, org.bukkit.entity.Player player) { + this(guild, loan); + actor = net.tfminecraft.simplefactions.identity.Members.session(player).orElse(null); + } + + public boolean isCurrent() { return net.tfminecraft.simplefactions.identity.Members.isCurrent(actor); } + public String operationId() { return operationId; } + public LoanPayment(Guild guild, Loan loan) { this.guild = guild; diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java index dd21d921..cdd9dbb9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java @@ -71,11 +71,11 @@ public static MercenaryResult requestFormation(Guild guild, String actor, String } Regiment regiment = MercenaryCompany.cloneMercenaryRegiment(); double cost = Cache.mercenaryFormationCost; - guild.getBank().withdraw(cost); MercenaryCompany company = new MercenaryCompany( guild, displayName(name), regiment, Cache.mercenaryFormationSeconds); - guild.setCompany(company); if (company.isFormed()) company.enlistLeader(); + if (!net.tfminecraft.simplefactions.economy.TreasuryActions.formation(guild, actor, company, cost)) + return MercenaryResult.deny("Company formation is awaiting treasury recovery or could not be funded."); return MercenaryResult.ok("Founding " + company.getName() + "§a for " + money(cost) + " from the guild bank. It will be ready in " + hours(Cache.mercenaryFormationSeconds) + "."); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java b/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java index 32013c7a..5d22ad31 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java @@ -27,9 +27,7 @@ public static void apply(War war) { return; } List<Guild> hit = guildsInSettlement(settlement, FactionManager.getAllGuilds()); - double loot = snapshotLoot(hit, PillageApplyService::liveTradeIncome, Cache.pillageLootDays); - depositLoot(war, loot); - attachHits(hit, Cache.pillageTradeHitPercent, PillageTradeHit.decayFromConfig()); + applyTreasury(war, hit, snapshotLoot(hit, PillageApplyService::liveTradeIncome, Cache.pillageLootDays), true); } static List<Guild> guildsInSettlement(Settlement settlement, List<Guild> all) { @@ -68,19 +66,32 @@ static void attachHits(List<Guild> guilds, double percent, double decay) { } } - static void depositLoot(War war, double loot) { - if (war == null || loot <= 0 || war.getAttackers() == null) { - return; - } - Faction attacker = war.getAttackers().getLeader(); - Bank bank = attacker.getBank(); - if (bank == null) { - java.util.logging.Logger.getLogger(PillageApplyService.class.getName()).warning( - "Skipping pillage loot for faction " + attacker.getId() + ": its bank was removed."); - return; - } - bank.deposit(loot); - } + static void depositLoot(War war, double loot) { applyTreasury(war, List.of(), loot, false); } + + private static void applyTreasury(War war, List<Guild> hit, double loot, boolean penalties) { + if (war == null || war.getAttackers() == null || !Double.isFinite(loot) || loot < 0) return; + Guild attacker = war.getAttackers().getLeader().getOrCreateMainGuild(); + if (attacker.getBank() == null && !penalties) return; + double payment = attacker.getBank() == null ? 0 : loot; + String id = "sf:pillage:" + war.getId() + ":" + war.getStartedAt() + ":" + attacker.getIncarnationId(); + Boolean resumed = net.tfminecraft.simplefactions.economy.TreasuryActions.resume(id, null); + if (resumed != null) { + if (!resumed) throw new IllegalStateException("Pillage treasury recovery is pending: " + id); + return; + } + var plan = new net.tfminecraft.simplefactions.economy.TreasuryActions.Plan(id, "Pillage spoils", null, attacker) + .transfer(net.tfminecraft.denareconomy.api.Denars.MINT, + net.tfminecraft.simplefactions.economy.TreasuryActions.account(attacker), + net.tfminecraft.simplefactions.economy.Money.cents(payment)); + if (penalties) for (Guild guild : hit) { + List<net.tfminecraft.simplefactions.government.StabilityModifier> after = new ArrayList<>(guild.getPillageHits()); + after.removeIf(modifier -> modifier != null && PillageTradeHit.NAME.equals(modifier.getName())); + after.add(new net.tfminecraft.simplefactions.government.StabilityModifier(PillageTradeHit.NAME, + Cache.pillageTradeHitPercent, PillageTradeHit.decayFromConfig())); + plan.capture(guild).effect("pillage", guild, "", guild.getPillageHits(), after); + } + if (!plan.commit(null)) throw new IllegalStateException("Pillage treasury action did not complete: " + id); + } static double liveTradeIncome(Guild guild) { if (guild == null) { diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java new file mode 100644 index 00000000..8395c4d3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java @@ -0,0 +1,289 @@ +package net.tfminecraft.simplefactions.economy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.denareconomy.api.TaxLeg; +import net.tfminecraft.denareconomy.api.TaxPolicy; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.GuildBankGrant; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.mercenary.company.CompanyFixture; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; + +class TreasuryActionsTest { + @TempDir Path folder; + FactionDomainFixture fixture; + Guild guild; + Player actor; + MockedConstruction<Database> saves; + final AtomicBoolean save = new AtomicBoolean(true); + @BeforeEach void setup() throws IOException { + fixture = new FactionDomainFixture(); + guild = fixture.saved("realm", "Leader").getOrCreateMainGuild(); + actor = fixture.player("Leader"); + TestTreasuries.set(guild.getBank(), 100); + TestTreasuries.set(Denars.pouch(Members.session(actor).orElseThrow().ref()), 5000); + TreasuryActions.recover(folder); + saves = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionDurably(any())).thenAnswer(call -> save.get())); + } + @AfterEach void close() { if (saves != null) saves.close(); if (fixture != null) fixture.close(); } + + @Test void depositAndWithdrawalUseCapturedCharacterAndGuildAccounts() { + assertTrue(TreasuryActions.bank(actor, guild, 12.34, true)); + assertEquals(112.34, guild.getBank().getWealth()); + assertEquals(37.66, TreasuryActions.pouch(actor)); + assertEquals(12.34, guild.getLedger().getHistory().getDepositsToday().get("Leader")); + assertTrue(TreasuryActions.bank(actor, guild, 2.34, false)); + assertEquals(110, guild.getBank().getWealth()); + assertEquals(40, TreasuryActions.pouch(actor)); + assertEquals(12.34, guild.getLedger().getHistory().getDepositsToday().get("Leader")); + } + @Test void otherCharacterOnAccountCannotWithdrawLeadersTreasury() { + Player other = fixture.player("Other"); + var account = actor.getUniqueId(); + when(other.getUniqueId()).thenReturn(account); + assertFalse(TreasuryActions.bank(other, guild, 10, false)); + assertFalse(TreasuryActions.bank(other, guild, 10, true)); + assertEquals(100, guild.getBank().getWealth()); + } + @Test void grantRecordsActualAmountDurably() { + assertTrue(GuildBankGrant.grant(guild, 2.75)); + assertEquals(102.75, guild.getBank().getWealth()); + assertEquals(2.75, guild.getLedger().getHistory().getDepositsToday().get("Compensation")); + verify(saves.constructed().getFirst()).saveFactionDurably(guild.getFaction()); + } + @Test void noOverdraftOrHistoryOnRefusedDeposit() { + assertFalse(TreasuryActions.bank(actor, guild, 60, true)); + assertEquals(100, guild.getBank().getWealth()); + assertEquals(50, TreasuryActions.pouch(actor)); + assertTrue(guild.getLedger().getHistory().getDepositsToday().isEmpty()); + assertTrue(EconomyRuntime.ready()); + } + @Test void pendingSaveDisablesNewActionsAndRecoversExactHistoryOnce() throws IOException { + save.set(false); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + assertEquals(110, guild.getBank().getWealth()); + assertEquals(40, TreasuryActions.pouch(actor)); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + assertEquals(110, guild.getBank().getWealth()); + save.set(true); + TreasuryActions.recover(folder); + EconomyRuntime.recovered(); + assertEquals(10, guild.getLedger().getHistory().getDepositsToday().get("Leader")); + TreasuryActions.recover(folder); + assertEquals(110, guild.getBank().getWealth()); + } + @Test void completedIntentDoesNotOverwriteLaterDomainChanges() throws IOException { + String id = TreasuryActions.newId(); + var plan = new TreasuryActions.Plan(id, "grant", actor, guild).transfer(Denars.MINT, TreasuryActions.account(guild), 100); + TreasuryActions.history(plan, guild, "Leader", 1); + assertTrue(plan.commit(actor)); + guild.getLedger().getHistory().getDepositsToday().put("Leader", 25d); + assertEquals(Boolean.TRUE, TreasuryActions.resume(id, actor)); + TreasuryActions.recover(folder); + assertEquals(25, guild.getLedger().getHistory().getDepositsToday().get("Leader")); + assertEquals(101, guild.getBank().getWealth()); + } + @Test void driftCannotOverwriteHistoryOnRecovery() { + save.set(false); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + guild.getLedger().getHistory().getDepositsToday().put("Leader", 22d); + save.set(true); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + assertEquals(22, guild.getLedger().getHistory().getDepositsToday().get("Leader")); + assertEquals(110, guild.getBank().getWealth()); + } + @Test void reusedGuildIdCannotReceivePendingDomainEffects() { + save.set(false); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + Guild replacement = mock(Guild.class); + when(replacement.getIncarnationId()).thenReturn(UUID.randomUUID()); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getGuildByString(guild.getId())).thenReturn(replacement); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + } + verify(replacement, never()).getLedger(); + } + @Test void unavailableCreatesNoIntentOrEffect() throws IOException { + when(fixture.ui.plugin.isStarted()).thenReturn(false); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + assertFalse(GuildBankGrant.grant(guild, 10)); + assertFalse(new TreasuryActions.Plan("wait", "wait", actor, guild) + .transfer(Denars.MINT, TreasuryActions.account(guild), 1).commit(actor)); + assertEquals(Boolean.FALSE, TreasuryActions.resume("wait", actor)); + try (var files = Files.list(folder)) { assertEquals(0, files.count()); } + assertEquals(100, guild.getBank().getWealth()); + } + @Test void inabilityToWriteIntentPostsNothing() throws IOException { + Path file = folder.resolve("not-directory"); Files.writeString(file, "blocked"); + assertThrows(IOException.class, () -> TreasuryActions.recover(file)); + assertFalse(TreasuryActions.bank(actor, guild, 10, true)); + assertEquals(100, guild.getBank().getWealth()); + assertEquals(50, TreasuryActions.pouch(actor)); + } + @Test void alteredOperationPayloadFailsClosedWithoutSecondPayment() { + String id = TreasuryActions.newId(); + assertTrue(new TreasuryActions.Plan(id, "same", actor, guild).transfer(Denars.MINT, TreasuryActions.account(guild), 100).commit(actor)); + assertFalse(new TreasuryActions.Plan(id, "same", actor, guild).transfer(Denars.MINT, TreasuryActions.account(guild), 200).commit(actor)); + assertEquals(101, guild.getBank().getWealth()); + assertFalse(EconomyRuntime.ready()); + } + @Test void loanAcceptanceAndClampedRepaymentMoveActualFundsOnce() { + Guild borrower = fixture.guild(guild.getFaction(), "borrower", "Borrower"); + Player debtor = fixture.player("Borrower"); + Loan loan = new Loan("offer", 25, guild, borrower, System.currentTimeMillis(), 10, 0, 0, false); + assertTrue(TreasuryActions.acceptLoan(debtor, loan, "offer", System.currentTimeMillis() + 60000)); + assertFalse(TreasuryActions.acceptLoan(debtor, loan, "offer", System.currentTimeMillis() + 60000)); + assertEquals(75, guild.getBank().getWealth()); assertEquals(25, borrower.getBank().getWealth()); + Loan accepted = guild.getLoanHandler().getLoanById("offer"); + assertEquals(25, TreasuryActions.repay(debtor, accepted, 100, "repay")); + assertTrue(accepted.isPaidOff()); + assertEquals(100, guild.getBank().getWealth()); assertEquals(0, borrower.getBank().getWealth()); + assertEquals(-1, TreasuryActions.repay(debtor, accepted, 100, "repay")); + assertEquals(0, guild.getLedger().getHistory().getDepositsToday().size()); + } + @Test void companyFormationSnapshotsRosterWithoutEarlyMutation() { + CompanyFixture.installMercenaryPrototype(); + try { + MercenaryCompany company = new MercenaryCompany(guild, "Company", CompanyFixture.companyRegiment(), 0); + company.enlistLeader(); + assertTrue(TreasuryActions.formation(guild, "Leader", company, 10)); + assertEquals(90, guild.getBank().getWealth()); + assertTrue(guild.getCompany().isEnlisted("Leader")); + } finally { CompanyFixture.clearRegiments(); } + } + @ParameterizedTest @ValueSource(longs = {0, 125, 875}) + void materialWithholdingAndActualDestroyedItemsCommitTogether(long tax) { + ItemStack item = new ItemStack(Material.GOLD_INGOT, 7); + actor.getInventory().setItem(0, item); + var ref = Members.session(actor).orElseThrow().ref(); + String holding = Denars.guildTax(guild.getIncarnationId()); + TaxPolicy policy = (earner, gross) -> tax == 0 ? Optional.empty() : Optional.of(new TaxLeg(holding, tax)); + Bukkit.getServicesManager().register(TaxPolicy.class, policy, fixture.ui.plugin, ServicePriority.Normal); + assertTrue(TreasuryPhysical.material(actor, ref, item, 8.75, guild)); + assertEquals(0, item.getAmount()); + assertEquals(875 - tax, Denars.balance(Denars.bank(ref))); + assertEquals(tax, Denars.balance(holding)); + } + @Test void physicalFailureAfterPostingIsRetainedAndNeverDeliveredOnRestart() throws IOException { + String id = TreasuryActions.newId(); + JsonObject snapshot = new JsonObject(); snapshot.addProperty("item", "captured"); + assertFalse(new TreasuryActions.Plan(id, "physical", actor, guild) + .transfer(TreasuryActions.account(guild), Denars.MINT, 100) + .physical(snapshot, intent -> { throw new IOException("ambiguous delivery"); }).commit(actor)); + assertEquals(Result.APPLIED, Denars.status(id).orElseThrow()); + assertEquals(99, guild.getBank().getWealth()); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + assertEquals(99, guild.getBank().getWealth()); + try (var files = Files.list(folder)) { assertEquals(1, files.count()); } + } + @Test void refusedResumeIsTerminalAndUnavailableResumeFailsClosed() { + var ref = Members.session(actor).orElseThrow().ref(); + assertFalse(new TreasuryActions.Plan("refused", "too much", actor, guild) + .transfer(Denars.pouch(ref), TreasuryActions.account(guild), 6000).commit(actor)); + assertEquals(Boolean.FALSE, TreasuryActions.resume("refused", actor)); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.UNAVAILABLE); + assertFalse(new TreasuryActions.Plan("pending", "wait", actor, guild) + .transfer(Denars.MINT, TreasuryActions.account(guild), 100).commit(actor)); + EconomyRuntime.recovered(); + assertEquals(Boolean.FALSE, TreasuryActions.resume("pending", actor)); + assertFalse(EconomyRuntime.ready()); + } + assertEquals(100, guild.getBank().getWealth()); + } + @Test void unrecognizedEffectRetainsPaymentForReviewAndRawPrepassDoesNotApplyOtherEffects() throws IOException { + assertFalse(new TreasuryActions.Plan("unknown", "unsupported", actor, guild) + .transfer(Denars.MINT, TreasuryActions.account(guild), 100) + .effect("unsupported", guild, "", null, "future").commit(actor)); + assertEquals(101, guild.getBank().getWealth()); + TreasuryActions.recoverTopologyFiles(folder); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + assertEquals(101, guild.getBank().getWealth()); + } + @Test void capturedSessionCannotBeUsedAfterCharacterSwitch() throws IOException { + var identity = net.tfminecraft.simplefactions.managers.inventory.CharacterKeyFixture.install(); + identity.play(actor, "Original"); + var plan = new TreasuryActions.Plan("captured", "captured", actor, guild) + .transfer(Denars.MINT, TreasuryActions.account(guild), 100); + identity.play(actor, "Replacement"); + assertFalse(plan.commit(actor)); + assertEquals(100, guild.getBank().getWealth()); + try (var files = Files.list(folder)) { assertEquals(0, files.count()); } + } + @Test void branchChangesAndRefundsUpdateLevelMoneyAndCachedWealthTogether() { + var branch = guild.getBranch(0); + int old = branch.getLevel(); + assertTrue(TreasuryActions.branch(actor, guild, branch, true, "upgrade")); + assertEquals(old + 1, branch.getLevel()); + assertEquals(0, guild.getBank().getWealth()); + assertTrue(TreasuryActions.branch(actor, guild, branch, false, "downgrade")); + assertEquals(old, branch.getLevel()); + assertEquals(80, guild.getBank().getWealth()); + assertEquals(80, guild.getWealth()); + } + @Test void materialChangingDuringPostingIsQuarantinedWithoutDestroyingReplacement() { + ItemStack item = new ItemStack(Material.GOLD_INGOT, 7); + actor.getInventory().setItem(0, item); + var ref = Members.session(actor).orElseThrow().ref(); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenAnswer(call -> { + Result posted = (Result) call.callRealMethod(); + item.setAmount(8); + return posted; + }); + assertFalse(TreasuryPhysical.material(actor, ref, item, 8.75, guild)); + } + assertEquals(8, item.getAmount()); + assertEquals(875, Denars.balance(Denars.bank(ref))); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + } + @Test void withdrawalInventoryChangingDuringPostingNeverDeliversTwice() { + when(actor.getWorld()).thenReturn(fixture.ui.world); + when(fixture.ui.world.getUID()).thenReturn(UUID.randomUUID()); + ItemStack delivered = new ItemStack(Material.GOLD_NUGGET, 1); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenAnswer(call -> { + Result posted = (Result) call.callRealMethod(); + actor.getInventory().setItem(0, new ItemStack(Material.DIAMOND, 1)); + return posted; + }); + assertFalse(TreasuryPhysical.withdraw(actor, guild, 1, List.of(delivered))); + } + assertEquals(99, guild.getBank().getWealth()); + assertEquals(Material.DIAMOND, actor.getInventory().getItem(0).getType()); + verify(actor.getInventory(), never()).addItem(any(ItemStack.class)); + assertThrows(IOException.class, () -> TreasuryActions.recover(folder)); + } + +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrantTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrantTest.java index 8f912fa0..1319ef3e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrantTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildBankGrantTest.java @@ -23,15 +23,17 @@ class GuildBankGrantTest { - private final Guild guild = mock(Guild.class); - private final Bank bank = new Bank(guild, 100.0, null); - private final Ledger ledger = new Ledger(guild); - - @BeforeEach - void setUp() { - when(guild.getBank()).thenReturn(bank); - when(guild.getLedger()).thenReturn(ledger); + private net.tfminecraft.simplefactions.testsupport.FactionDomainFixture fixture; + private Guild guild; + private Bank bank; + private Ledger ledger; + @BeforeEach void setUp() { + fixture = new net.tfminecraft.simplefactions.testsupport.FactionDomainFixture(); + guild = fixture.saved("realm", "Leader").getOrCreateMainGuild(); + bank = guild.getBank(); ledger = guild.getLedger(); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(bank, 100); } + @AfterEach void close() { fixture.close(); } @Test void positiveWholeCentAmountIsAddedAndRecordedAsCompensation() { @@ -43,9 +45,9 @@ void positiveWholeCentAmountIsAddedAndRecordedAsCompensation() { @Test void missingBankAndInvalidAmountsDoNotChangeTheBalance() { - when(guild.getBank()).thenReturn(null); + guild.setBank(null); assertFalse(GuildBankGrant.grant(guild, 25.0)); - when(guild.getBank()).thenReturn(bank); + guild.setBank(bank); assertFalse(GuildBankGrant.grant(guild, 0)); assertFalse(GuildBankGrant.grant(guild, -1)); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index 16c2c5c2..21cc3fa0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -125,6 +125,7 @@ void setUp() { when(faction.getGovernment()).thenReturn(government); guild = mock(Guild.class, RETURNS_DEEP_STUBS); when(guild.getId()).thenReturn("merchants"); + when(guild.getIncarnationId()).thenReturn(java.util.UUID.randomUUID()); when(guild.getName()).thenReturn("Merchants"); when(guild.getLeader()).thenReturn("Leader"); when(guild.getFaction()).thenReturn(faction); @@ -537,16 +538,18 @@ void forcedWithdrawalDropsPhysicalCoinsThatCannotFitInTheInventoryExactlyOnce() when(bank.getWealth()).thenReturn(100.0); var coin = new ItemStack(Material.GOLD_NUGGET, 10); when(player.getWorld()).thenReturn(gui.world); + when(gui.world.getUID()).thenReturn(java.util.UUID.randomUUID()); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(net.tfminecraft.denareconomy.api.Denars.guild(guild.getIncarnationId()), 10000); for (int slot = 0; slot < player.getInventory().getSize(); slot++) { player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); } - try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class, CALLS_REAL_METHODS)) { var money = mock(MoneyManager.class); economy.when(DenarEconomy::getMoneyManager).thenReturn(money); when(money.amountToItems(10.0)).thenReturn(List.of(coin)); assertTrue(run("faction", "forcewithdraw", "realm", "10")); - verify(bank).withdraw(10.0); - verify(player.getInventory()).addItem(coin); + assertEquals(9000, net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.guild(guild.getIncarnationId()))); + verify(player.getInventory()).addItem(any(ItemStack.class)); ArgumentCaptor<ItemStack> dropped = ArgumentCaptor.forClass(ItemStack.class); verify(gui.world).dropItemNaturally(eq(player.getLocation()), dropped.capture()); assertEquals(Material.GOLD_NUGGET, dropped.getValue().getType()); @@ -582,23 +585,15 @@ void bankTransfersValidateMembershipLocationAmountAndFunds(String name, String a when(bank.getChunk()).thenReturn(here); assertFalse(run(name, action, "bad")); verify(player).sendMessage(contains("whole cents")); - try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { - var players = mock(PlayerManager.class); - var data = mock(PlayerData.class); - var pouch = mock(Account.class); - economy.when(DenarEconomy::getPlayerManager).thenReturn(players); - when(players.get(player)).thenReturn(data); - when(data.getPouch()).thenReturn(pouch); + try (var treasury = mockStatic(net.tfminecraft.simplefactions.economy.TreasuryActions.class)) { + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.pouch(player)).thenReturn(0d); assertFalse(run(name, action, "10")); verify(player).sendMessage(contains("Not enough funds")); when(bank.getWealth()).thenReturn(100.0); - when(pouch.getBal()).thenReturn(100.0); + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.pouch(player)).thenReturn(100d); + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.bank(player, guild, 10.25, deposit)).thenReturn(true); assertTrue(run(name, action, "10.25")); - verify(pouch).change(deposit ? -10.25 : 10.25); - if (deposit) { - verify(bank).deposit(10.25); - verify(guild.getLedger().getHistory()).addDeposit("Leader", 10.25); - } else verify(bank).withdraw(10.25); + treasury.verify(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.bank(player, guild, 10.25, deposit)); verify(player).sendMessage(contains("Bank Report")); verify(player).playSound(player, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } @@ -645,22 +640,25 @@ void validForcedWithdrawalPaysPhysicalCoinsAndNotifiesOnlineMembers() { Bank bank = mock(Bank.class); when(faction.getBank()).thenReturn(bank); when(bank.getWealth()).thenReturn(100.0); + when(gui.world.getUID()).thenReturn(java.util.UUID.randomUUID()); + when(player.getWorld()).thenReturn(gui.world); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(net.tfminecraft.denareconomy.api.Denars.guild(guild.getIncarnationId()), 10000); Player alice = gui.player("Alice"); when(alice.isOnline()).thenReturn(true); when(Bukkit.getPlayer("Alice")).thenReturn(alice); - try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class, CALLS_REAL_METHODS)) { var money = mock(MoneyManager.class); economy.when(DenarEconomy::getMoneyManager).thenReturn(money); var coin = new ItemStack(Material.GOLD_NUGGET, 10); when(money.amountToItems(10.0)).thenReturn(List.of(coin)); assertTrue(run("faction", "forcewithdraw", "realm", "10")); - verify(bank).withdraw(10.0); - verify(player.getInventory()).addItem(coin); + assertEquals(9000, net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.guild(guild.getIncarnationId()))); + verify(player.getInventory()).addItem(any(ItemStack.class)); verify(alice).sendMessage(contains("withdrawn from the faction by admins")); assertTrue(run("faction", "forcewithdraw", "realm", "101")); verify(player).sendMessage(contains("enough wealth")); assertFalse(run("faction", "forcewithdraw", "realm", "-1")); - verify(bank, times(1)).withdraw(anyDouble()); + assertEquals(9000, net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.guild(guild.getIncarnationId()))); } } @@ -1621,10 +1619,12 @@ void administratorGuildBankGrantsValidateTheGuildAmountAndLedger() { assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); verify(console, times(2)).sendMessage(contains("does not have a bank")); } - assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); - verify(bank).deposit(10.0); - verify(guild.getLedger().getHistory()).addDeposit("Compensation", 10.0); - verify(console).sendMessage(contains("New balance:")); + try (var treasury = mockStatic(net.tfminecraft.simplefactions.economy.TreasuryActions.class)) { + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.grant(guild, 10.0)).thenReturn(true); + assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); + treasury.verify(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.grant(guild, 10.0)); + verify(console).sendMessage(contains("New balance:")); + } } @ParameterizedTest diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java index bcbb4d93..ae26f7a3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java @@ -97,6 +97,7 @@ void setUp() { when(faction.getGovernment()).thenReturn(government); when(government.getFaction()).thenReturn(faction); when(guild.getId()).thenReturn("guild"); + when(guild.getIncarnationId()).thenReturn(java.util.UUID.randomUUID()); when(guild.getFaction()).thenReturn(faction); when(guild.isLeader(player)).thenReturn(true); taxes = new TaxHandler(faction, 10, 0, 0, 0, 0); @@ -380,13 +381,14 @@ void loanChatWithdrawsOnlyTheActualRoundedPayment( assertTrue(manager.isPayingLoan(player)); manager.setRate(chat(text)); assertEquals(200, guild.getBank().getWealth()); - gui.runTasks(); + try (var saves = mockConstruction(net.tfminecraft.simplefactions.database.Database.class, + (database, context) -> when(database.saveFactionDurably(any())).thenReturn(true))) { gui.runTasks(); } assertEquals(paid, loan.getPaid()); assertEquals(balance, guild.getBank().getWealth(), 0.001); assertEquals(owed, loan.getTotalOwed(), 0.001); assertFalse(manager.isPayingLoan(player)); - verify(loan.getIssuer().getLedger()).addLoanPaymentEntry("guild", paid); - verify(guild).updateWealth(); + assertEquals(paid, net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.guild(loan.getIssuer().getIncarnationId())) / 100d); + verify(loan.getIssuer().getLedger(), never()).addLoanPaymentEntry(anyString(), anyDouble()); } @ParameterizedTest @@ -1105,7 +1107,9 @@ void politicalMovementPickerReturnsToItsCauseOrSurvivingGovernment(boolean survi @ParameterizedTest @CsvSource({"true,25,2", "false,0,0"}) void capitalConfirmationDisplaysCostBalanceAndLosses(boolean rename, double cost, int losses) { - when(faction.getBank()).thenReturn(new Bank(guild, 100, mock(Chunk.class))); + Bank capitalBank = new Bank(guild, 100, mock(Chunk.class)); + when(faction.getBank()).thenReturn(capitalBank); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capitalBank, 100); manager.confirmCapitalMoveView(player, faction, rename, cost, losses); Inventory inventory = player.getOpenInventory().getTopInventory(); assertEquals("§7Confirm Action", player.getOpenInventory().getTitle()); @@ -1731,12 +1735,20 @@ private AsyncPlayerChatEvent chat(String text) { } private Loan loan(double amount, double balance) { - Bank bank = new Bank(guild, balance, mock(Chunk.class)); + Bank bank = new Bank(guild, 0, mock(Chunk.class)); when(guild.getBank()).thenReturn(bank); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(bank, balance); when(guild.getLoanHandler()).thenReturn(new LoanHandler(guild)); Guild issuer = mock(Guild.class); + when(issuer.getId()).thenReturn("issuer"); + when(issuer.getIncarnationId()).thenReturn(java.util.UUID.randomUUID()); + when(issuer.getFaction()).thenReturn(faction); + when(issuer.getLoanHandler()).thenReturn(new LoanHandler(issuer)); when(issuer.getLedger()).thenReturn(mock(Ledger.class)); - return new Loan("loan", amount, issuer, guild, System.currentTimeMillis(), 7, 0, 0, false); + factions.when(() -> FactionManager.getGuildByString("issuer")).thenReturn(issuer); + Loan loan = new Loan("loan", amount, issuer, guild, System.currentTimeMillis(), 7, 0, 0, false); + issuer.getLoanHandler().issueLoan(loan); + return loan; } private AtomicReference<Double> donation(double amount) { diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java index 4ecb3104..56262e5f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java @@ -91,7 +91,7 @@ void setup() throws Exception { host.addProvince(10); guild = fixture.guild(host, "blades", "Captain"); guild.setCapital(10, false); - guild.getBank().setWealth(1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 1000.0); hirer = fixture.saved("hirer", "Hirer"); hirer.getOrCreateMainGuild(); captain = fixture.player("Captain"); @@ -229,7 +229,7 @@ void missingRequiredArgumentsDoNotMutateCompanyState(String subcommand) { strings = {"valid", "name_warning", "poor", "outsider", "nonleader", "already_formed"}) void foundingChecksTheNameAndCurrentGuildBeforeRequestingFinancialConfirmation(String state) { if (!state.equals("already_formed")) guild.setCompany(null); - if (state.equals("poor")) guild.getBank().setWealth(50.0); + if (state.equals("poor")) net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 50.0); Player actor = state.equals("outsider") ? fixture.player("Outsider") : captain; if (state.equals("nonleader")) { guild.addMember("Successor"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java index 3c46309d..b987fbd8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java @@ -203,7 +203,8 @@ void draftingAndReviewingValidateExpiryAndTermsBeforeProducingABook(int stage, b @Test void aLeaderCanReviewAndPublishValidLoanTermsWithoutMovingMoney() { - lender.setBank(new Bank(lender, 10000, null)); + lender.setBank(new Bank(lender, 10000, fixture.ui.world.getChunkAt(0, 0))); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(lender.getBank(), 10000); ItemStack draft = LoanBook.getBaseBook(lender); leader.getInventory().setItem(0, draft); sign(leader, draft); @@ -267,7 +268,7 @@ void refusedLoanAgreementsNeverMoveMoneyOrCreateDebt(String reason) { edit(book, meta -> meta.setPage(2, meta.getPage(2).replace("500.0", "900.0"))); case "no_lender_bank" -> lender.setBank(null); case "no_borrower_bank" -> borrower.setBank(null); - case "insufficient" -> lender.getBank().setWealth(20.0); + case "insufficient" -> net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(lender.getBank(), 20.0); case "used_offer" -> lender .getLoanHandler() @@ -350,24 +351,11 @@ void bankPulsesRequireAnOwnedBankAtThePlayersCurrentChunk(String state) { else if (!allowed) verify(actor).sendMessage(contains("must be at your bank")); } - @ParameterizedTest - @ValueSource(strings = {"taxed", "zero_rate", "no_bank", "outsider", "zero_income"}) - void earningsRecordGrossAndTaxesWithoutLosingMoneyOrSettlingTheGuildEarly(String state) { - lender.setBank(new Bank(lender, 100, null)); - faction.setTaxRate(state.equals("zero_rate") ? 0 : 10); - if (state.equals("no_bank")) lender.setBank(null); - String name = state.equals("outsider") ? "Stranger" : "Leader"; - double gross = state.equals("zero_income") ? 0 : 200; - double expectedTax = state.equals("taxed") ? 20 : 0; - PlayerEarnMoneyEvent event = new PlayerEarnMoneyEvent(name, gross, null); - listener.earnMoney(event); - PlayerLedger ledger = economy.getLedger(name); - assertEquals(expectedTax, event.getAmount()); - assertEquals(gross, ledger.getAmount(PlayerCashflow.EARNINGS)); - assertEquals(-expectedTax, ledger.getAmount(PlayerCashflow.CITIZEN_TAX), 1e-9); - assertEquals(gross, ledger.getNetDaily() + expectedTax); - assertEquals(expectedTax, lender.getLedger().getCitizenTaxesCopy().getOrDefault(name, 0.0)); - if (lender.getBank() != null) assertEquals(100, lender.getBank().getWealth()); + @Test + void oldEarnEventCannotTaxTheSameEarningAgain() { + assertTrue(java.util.Arrays.stream(PlayerManager.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(org.bukkit.event.EventHandler.class)) + .noneMatch(method -> java.util.Arrays.asList(method.getParameterTypes()).contains(PlayerEarnMoneyEvent.class))); } @ParameterizedTest @@ -381,25 +369,16 @@ void materialDepositsConsumeOnlySupportedMaterialsAtAnOwnedBank(String state) { Coin coin = new Coin("gold", config); MoneyManager money = mock(MoneyManager.class); scoped(DenarEconomy.class).when(DenarEconomy::getMoneyManager).thenReturn(money); + when(DenarEconomy.getLedger()).thenCallRealMethod(); when(money.getCoin(material)).thenReturn(state.equals("unknown_item") ? null : coin); if (!state.equals("away")) lender.setBank(new Bank(lender, leader.getLocation().getChunk())); - double[] credited = {0}; - doAnswer( - call -> { - credited[0] += (Double) call.getArgument(1); - return null; - }) - .when(money) - .addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); - listener.depositMaterials(new PlayerDepositMaterialsEvent(leader, material, net.tfminecraft.simplefactions.identity.Members.session(leader).orElseThrow().ref())); + leader.getInventory().setItem(0, material); + var ref = net.tfminecraft.simplefactions.identity.Members.session(leader).orElseThrow().ref(); + listener.depositMaterials(new PlayerDepositMaterialsEvent(leader, material, ref)); assertEquals(state.equals("deposit") ? 0 : 7, material.getAmount()); - assertEquals(87.5, credited[0] + material.getAmount() * coin.getValue()); - if (state.equals("deposit")) - verify(money) - .addMoneyToAccount(leader.getUniqueId().toString(), 87.5, false, true, Accounts.BANK); - else - verify(money, never()) - .addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); + long credited = net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.bank(ref)); + assertEquals(87.5, credited / 100d + material.getAmount() * coin.getValue()); + verify(money, never()).addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); } @ParameterizedTest @@ -515,7 +494,9 @@ void acceptingMercenaryServiceActivatesTheOfferAndCreatesASignedAgreement(String Faction hirer = fixture.saved("hirer", "Hirer"); Guild treasury = hirer.getOrCreateMainGuild(); treasury.setBank(new Bank(treasury, 1000, null)); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(treasury.getBank(), 1000); company.getGuild().setBank(new Bank(company.getGuild(), 500, null)); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(company.getGuild().getBank(), 500); Player signer = player("Hirer"); Player captain = player("Captain"); if (!notification.equals("absent")) { @@ -654,10 +635,12 @@ void loanBookSignedInTheOffHandNeverReplacesTheMainHandItem(int eventSlot) { } private Guild borrower() { - lender.setBank(new Bank(lender, 10000, null)); + lender.setBank(new Bank(lender, 10000, fixture.ui.world.getChunkAt(0, 0))); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(lender.getBank(), 10000); Faction other = fixture.saved("borrowers", "Borrower"); Guild borrower = other.getOrCreateMainGuild(); - borrower.setBank(new Bank(borrower, 100, null)); + borrower.setBank(new Bank(borrower, 100, fixture.ui.world.getChunkAt(0, 0))); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(borrower.getBank(), 100); return borrower; } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java index ed7bce53..3b00569b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java @@ -85,6 +85,7 @@ class GuildMenusCoverageTest { private GuildFixture fixture; + private MockedConstruction<net.tfminecraft.simplefactions.database.Database> treasurySaves; private Guild guild; private InventoryManager manager; private GuildView view; @@ -109,6 +110,8 @@ private <T> MockedStatic<T> scoped(Class<T> type) { @BeforeEach void setUp() { fixture = new GuildFixture(); + treasurySaves = mockConstruction(net.tfminecraft.simplefactions.database.Database.class, + (database, context) -> when(database.saveFactionDurably(any())).thenReturn(true)); guild = fixture.guild("guild", 1); fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); manager = new InventoryManager(); @@ -165,6 +168,7 @@ void setUp() { void close() { try { for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + treasurySaves.close(); access.close(); intelligence.close(); } finally { @@ -239,7 +243,7 @@ Faction.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (f, context) -> @Test void branchDowngradeImmediatelyRemovesTheOldExpansionValueFromWealth() { - guild.getBank().setWealth(1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 1000.0); guild.updateWealth(); assertEquals(1100, guild.getWealth()); view = spy(view); @@ -247,7 +251,7 @@ void branchDowngradeImmediatelyRemovesTheOldExpansionValueFromWealth() { doNothing().when(view).guildView(any(), any(), any(Inventory.class)); Inventory inventory = open(); inventory.setItem( - 38, creator.createBranchDowngradeItem(fixture.player, guild, guild.getBranch(0))); + 38, view.treasuryButton(fixture.player, guild, guild.getBranch(0), creator.createBranchDowngradeItem(fixture.player, guild, guild.getBranch(0)))); manager.clickButton(fixture.ui.click(fixture.player, 38)); assertEquals(0, guild.getSize()); assertEquals(1080, guild.getBank().getWealth()); @@ -471,7 +475,7 @@ void dividendDonationAndLedgerSummariesSeparateIncomeExpensesAndAffordability() assertLore(dividends, "20.00%", true); assertLore(dividends, "9.00d", true); assertLore(dividends, "limited by the guild bank", true); - display.getBank().setWealth(100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(display.getBank(), 100.0); display.setLeader("Other"); assertLore(creator.createDividendItem(fixture.player, display), "Only the guild leader", true); assertLore( @@ -926,7 +930,7 @@ void bannerTakeCopiesTheGuildBannerAndRejectsStaleMembership() { }) void branchClicksApplyOnlyAuthorizedAffordableWellFormedActions(String scenario) { quietRendering(); - guild.getBank().setWealth(scenario.equals("insufficient") ? 0.0 : 1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), scenario.equals("insufficient") ? 0.0 : 1000.0); guild.updateWealth(); Branch branch = guild.getBranch(0); if (scenario.equals("downgrade_zero")) branch.levelDown(); @@ -936,6 +940,7 @@ void branchClicksApplyOnlyAuthorizedAffordableWellFormedActions(String scenario) scenario.equals("downgrade_zero") ? creator.createBranchDowngradeItem(fixture.player, guild, branch) : creator.createBranchUpgradeItem(fixture.player, guild, branch); + item = view.treasuryButton(fixture.player, guild, branch, item); ItemMeta meta = item.getItemMeta(); if (scenario.equals("no_flag")) meta.getPersistentDataContainer().remove(Keys.BOOLEAN_FLAG); if (scenario.equals("no_branch_id")) meta.getPersistentDataContainer().remove(Keys.BRANCH_ID); @@ -1087,7 +1092,7 @@ void relocationClicksValidateTheCurrentDestinationFundsAndNamingPrompt(String sc fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); } quietRendering(); - guild.getBank().setWealth(scenario.equals("poor") ? 0.0 : 1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), scenario.equals("poor") ? 0.0 : 1000.0); if (scenario.equals("disabled")) Cache.provincesEnabled = false; Faction target = scenario.startsWith("cross") ? mock(Faction.class) : fixture.host; if (target != fixture.host) when(target.getId()).thenReturn("other"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java index 426d7825..520c4869 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java @@ -101,7 +101,7 @@ void setup() throws Exception { host.addProvince(10); guild = fixture.guild(host, "blades", "Captain"); guild.setCapital(10, false); - guild.getBank().setWealth(1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 1000.0); hirer = fixture.saved("hirer", "Hirer"); hirer.getOrCreateMainGuild(); captain = fixture.player("Captain"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java index 230d0148..99c8864e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java @@ -86,7 +86,7 @@ void setUp() throws Exception { guild = domain.guild(home, "traders", "Alice"); for (int id : List.of(1, 2)) domain.provinceData.put(id, new Province(id, "PLAINS", 50)); guild.setCapital(1); - guild.getBank().setWealth(500.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 500.0); home.getSettlementHandler().found("Original", 1, 0, 0); home.getGovernment().setPower(50); ruler = domain.player("Ruler"); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyFixture.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyFixture.java index 81d42299..85c5e4dc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyFixture.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyFixture.java @@ -51,14 +51,23 @@ public CompanyFixture(double balance) { }).when(guild).setCompany(any()); when(bank.getWealth()).thenAnswer(i -> balance()); - doAnswer(i -> { - this.balance -= (double) (Double) i.getArgument(0); - return null; - }).when(bank).withdraw(anyDouble()); - doAnswer(i -> { - this.balance += (double) (Double) i.getArgument(0); - return null; - }).when(bank).deposit(anyDouble()); + + } + + /** Domain tests replace the financial boundary; TreasuryActionsTest covers the real ledger. */ + public net.tfminecraft.simplefactions.mercenary.MercenaryResult requestFormation(String actor, String name) { + try (var treasury = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.economy.TreasuryActions.class)) { + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.formation( + org.mockito.ArgumentMatchers.eq(guild), org.mockito.ArgumentMatchers.eq(actor), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyDouble())).thenAnswer(call -> { + double cost = call.getArgument(3); + if (balance < cost) return false; + balance -= cost; + guild.setCompany(call.getArgument(2)); + return true; + }); + return MercenaryCompanyService.requestFormation(guild, actor, name); + } } public double balance() { diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyGuiTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyGuiTest.java index 0427f0b5..0ab9a886 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyGuiTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/CompanyGuiTest.java @@ -46,7 +46,7 @@ void tearDown() { } private MercenaryCompany formedCompany() { - MercenaryCompanyService.requestFormation(fixture.guild, fixture.leader(), "Hired Blades"); + fixture.requestFormation(fixture.leader(), "Hired Blades"); MercenaryCompany company = fixture.company(); for (int i = 0; i < Cache.mercenaryFormationSeconds; i++) { company.tick(); @@ -212,7 +212,7 @@ void foundingButtonQuotesTheChartersCostAndTime() { line.contains("Cost") && line.contains(Formatter.formatMoney(100.0)))); assertTrue(lore.stream().anyMatch(line -> line.contains("/company found"))); - MercenaryCompanyService.requestFormation(fixture.guild, fixture.leader(), "Hired Blades"); + fixture.requestFormation(fixture.leader(), "Hired Blades"); List<String> forming = creator.buildFormingLore(fixture.company()); assertTrue(forming.stream().anyMatch(line -> line.contains("Being founded"))); assertTrue(forming.stream().anyMatch(line -> line.contains("Ready in"))); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java index 249239cb..b5c82ac3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java @@ -37,8 +37,7 @@ void tearDown() { void formationDebitsOnceAndOpensWithOneSlot() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryResult result = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Hired Blades"); + MercenaryResult result = fixture.requestFormation("Ivar", "Hired Blades"); assertTrue(result.ok()); assertTrue(result.message().startsWith("Founding "), result.message()); @@ -66,7 +65,7 @@ void instantFormationEnlistsTheLeader() { Cache.mercenaryFormationSeconds = 0; CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); MercenaryCompany company = fixture.company(); assertTrue(company.isFormed()); @@ -77,13 +76,13 @@ void instantFormationEnlistsTheLeader() { @Test void leaderServingElsewhereLeavesTheFoundingSlotOpen() { CompanyFixture other = new CompanyFixture(500.0); - MercenaryCompanyService.requestFormation(other.guild, "Ivar", "Other Blades"); + other.requestFormation("Ivar", "Other Blades"); other.company().tick(); other.company().tick(); CompanyFixture fixture = new CompanyFixture(500.0); CompanyFixture.registerGlobally(other.guild, fixture.guild); try { - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); fixture.company().tick(); fixture.company().tick(); @@ -119,10 +118,9 @@ void canFoundRefusesAPoorGuild() { @Test void secondRequestWhileFoundingIsRefusedWithoutCharging() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); - MercenaryResult second = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Second Blades"); + MercenaryResult second = fixture.requestFormation("Ivar", "Second Blades"); assertFalse(second.ok()); assertEquals("Your guild is already founding a mercenary company.", second.message()); @@ -132,12 +130,11 @@ void secondRequestWhileFoundingIsRefusedWithoutCharging() { @Test void secondCompanyRefusedOnceTheFirstExists() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); fixture.company().tick(); fixture.company().tick(); - MercenaryResult second = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Second Blades"); + MercenaryResult second = fixture.requestFormation("Ivar", "Second Blades"); assertFalse(second.ok()); assertEquals("Your guild already has a mercenary company.", second.message()); @@ -148,8 +145,7 @@ void secondCompanyRefusedOnceTheFirstExists() { void onlyTheGuildLeaderMayFound() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryResult result = MercenaryCompanyService - .requestFormation(fixture.guild, "Sigrun", "Hired Blades"); + MercenaryResult result = fixture.requestFormation("Sigrun", "Hired Blades"); assertFalse(result.ok()); assertEquals("Only the guild leader can found a mercenary company.", result.message()); @@ -161,8 +157,7 @@ void onlyTheGuildLeaderMayFound() { void tooPoorToFoundIsRefusedWithoutCharging() { CompanyFixture fixture = new CompanyFixture(99.0); - MercenaryResult result = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Hired Blades"); + MercenaryResult result = fixture.requestFormation("Ivar", "Hired Blades"); assertFalse(result.ok()); assertEquals("Your guild bank needs 100.00d to found a company.", result.message()); @@ -174,8 +169,7 @@ void tooPoorToFoundIsRefusedWithoutCharging() { void unnamedCompanyIsRefused() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryResult result = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", " "); + MercenaryResult result = fixture.requestFormation("Ivar", " "); assertFalse(result.ok()); assertEquals("Give the company a name.", result.message()); @@ -187,8 +181,7 @@ void missingRegimentConfigRefusesFormation() { CompanyFixture.clearRegiments(); CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryResult result = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Hired Blades"); + MercenaryResult result = fixture.requestFormation("Ivar", "Hired Blades"); assertFalse(result.ok()); assertEquals("Mercenary companies are not configured on this server.", result.message()); @@ -198,7 +191,7 @@ void missingRegimentConfigRefusesFormation() { @Test void leadershipFollowsTheGuildLeader() { CompanyFixture fixture = new CompanyFixture(500.0); - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); MercenaryCompany company = fixture.company(); assertEquals("Ivar", company.getLeader()); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibilityTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibilityTest.java index beedf3f8..24c6a9f0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibilityTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryEligibilityTest.java @@ -37,8 +37,7 @@ void createAndJoinRefuseAnIneligibleCharacter() { CompanyFixture fixture = new CompanyFixture(500.0); CompanyFixture.installMercenaryPrototype(); - MercenaryResult create = MercenaryCompanyService - .requestFormation(fixture.guild, "Ivar", "Hired Blades"); + MercenaryResult create = fixture.requestFormation("Ivar", "Hired Blades"); assertFalse(create.ok()); assertEquals("You are not the sort to run a mercenary company.", create.message()); assertNull(fixture.company()); @@ -60,7 +59,7 @@ void createAndJoinRefuseAnIneligibleCharacter() { void anIneligibleLeaderDisbandsEvenWhileFounding() { CompanyFixture fixture = new CompanyFixture(500.0); CompanyFixture.installMercenaryPrototype(); - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); MercenaryCompany company = fixture.company(); assertTrue(company.isForming()); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryHomeManpowerTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryHomeManpowerTest.java index 25ebaf4c..b3c48fe2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryHomeManpowerTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryHomeManpowerTest.java @@ -45,7 +45,7 @@ void tearDown() { } private MercenaryCompany found() { - MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + fixture.requestFormation("Ivar", "Hired Blades"); return fixture.company(); } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/ContractFixture.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/ContractFixture.java index 00220fc2..f824fa9a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/ContractFixture.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/ContractFixture.java @@ -37,7 +37,7 @@ public static ContractFixture formed(int slots) { CompanyFixture.installCompanyUpgrades(); CompanyFixture host = new CompanyFixture(10000); - MercenaryCompanyService.requestFormation(host.guild, host.leader(), "Hired Blades"); + host.requestFormation(host.leader(), "Hired Blades"); MercenaryCompany company = host.company(); for (int i = 0; i < Cache.mercenaryFormationSeconds; i++) { company.tick(); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractTest.java index 3df30611..e7d0c543 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractTest.java @@ -123,8 +123,7 @@ void anUnformedCompanyCannotBeHired() { CompanyFixture.installMercenaryPrototype(); CompanyFixture.installCompanyUpgrades(); CompanyFixture host = new CompanyFixture(10000); - net.tfminecraft.simplefactions.mercenary.company.MercenaryCompanyService - .requestFormation(host.guild, host.leader(), "Still Forming"); + host.requestFormation(host.leader(), "Still Forming"); MercenaryResult result = ContractValidator.validate( ContractFixture.validTerms(1), host.company(), now); diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java index 35225868..7e0ef766 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java @@ -82,6 +82,7 @@ public final class GuiTestFixture implements AutoCloseable { private final MockedConstruction<ItemStack> itemConstruction; private final MockedStatic<Bukkit> bukkit; private final TestTreasuries treasuries; + private java.nio.file.Path callerJournal; public GuiTestFixture() { when(world.getName()).thenReturn("world"); @@ -133,6 +134,17 @@ public GuiTestFixture() { bind(item, state); }); SimpleFactions.plugin = plugin; + when(plugin.isStarted()).thenReturn(true); + var plugins = mock(org.bukkit.plugin.PluginManager.class); + when(server.getPluginManager()).thenReturn(plugins); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + var identity = mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); + services.register(net.tfminecraft.tlibs.character.CharacterIdentity.class, identity, plugin, org.bukkit.plugin.ServicePriority.Normal); + net.tfminecraft.simplefactions.economy.EconomyRuntime.recovered(); + try { + callerJournal = java.nio.file.Files.createTempDirectory("sf-test-callers-"); + net.tfminecraft.simplefactions.economy.TreasuryActions.recover(callerJournal); + } catch (java.io.IOException error) { throw new java.io.UncheckedIOException(error); } } private BukkitTask schedule(List<Runnable> queue, Runnable runnable) { @@ -315,6 +327,13 @@ private ItemState state(ItemStack item) { private void bind(ItemStack item, ItemState state) { items.put(item, state); + when(item.serializeAsBytes()).thenAnswer(call -> { + var value = new java.util.LinkedHashMap<String, Object>(); + value.put("type", state.type.name()); value.put("amount", state.amount); + value.put("name", state.meta.name); value.put("lore", state.meta.lore); + value.put("model", state.meta.model); value.put("data", state.meta.data.toString()); + return value.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + }); when(item.getType()).thenAnswer(call -> state.type); doAnswer( call -> { @@ -691,6 +710,9 @@ public void close() { try { itemConstruction.close(); treasuries.close(); + try (var paths = java.nio.file.Files.walk(callerJournal)) { + for (var path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) java.nio.file.Files.delete(path); + } catch (java.io.IOException error) { throw new java.io.UncheckedIOException(error); } } finally { try { bukkit.close(); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java index 40f8d76f..85c2600f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java @@ -141,7 +141,7 @@ void persistedBankChoiceCannotInterruptWinningPillageTeardown(boolean bankRemove merchant.setCapital(2, false); attacker.getMilitary().getRegiment("guard").setCurrentSlots(3); defender.getMilitary().getRegiment("guard").setCurrentSlots(3); - attacker.getBank().setWealth(100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(attacker.getBank(), 100.0); if (bankRemoved) attacker.setBank(null); assertTrue(database.saveFactionChecked(attacker)); assertTrue(database.saveFactionChecked(defender)); @@ -209,7 +209,7 @@ void persistedBankChoiceCannotInterruptWinningPillageTeardown(boolean bankRemove assertEquals(-60, PillageTradeHit.percent(loadedMerchant)); if (bankRemoved) assertNull(loadedAttacker.getBank(), "Pillage must not recreate a removed bank"); - else assertEquals(100.0 + expectedLoot, loadedAttacker.getBank().getWealth(), 0.00001); + else assertEquals(100.0 + net.tfminecraft.simplefactions.economy.Money.denars(net.tfminecraft.simplefactions.economy.Money.cents(expectedLoot)), loadedAttacker.getBank().getWealth(), 0.00001); assertTrue(database.saveFactionChecked(loadedAttacker)); assertTrue(database.saveFactionChecked(loadedDefender)); FactionData finalAttacker = diff --git a/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageTreasuryTest.java b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageTreasuryTest.java new file mode 100644 index 00000000..b978c0b2 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageTreasuryTest.java @@ -0,0 +1,67 @@ +package net.tfminecraft.simplefactions.war.resolution; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; +import net.tfminecraft.simplefactions.economy.TreasuryActions; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PillageTreasuryTest { + @TempDir Path folder; + @Test void completedPillageCannotPayOrResetHitsAgainWhenQuoteChanges() throws Exception { + try (var domain = new FactionDomainFixture(); var saves = mockConstruction(Database.class, + (database, context) -> when(database.saveFactionDurably(any())).thenReturn(true))) { + var attacker = domain.saved("attacker", "A"); + var defender = domain.saved("defender", "D"); + defender.addProvince(10); + assertTrue(defender.getSettlementHandler().found("Victim", 10, 0, 0).isSuccess()); + Guild victim = domain.guild(defender, "victim", "V"); victim.setCapital(10, false); + when(domain.provinces.getIncome(victim, false)).thenReturn(50d); + War war = new War(77, attacker, defender); war.setTargetSettlementId("Victim"); + int days = Cache.pillageLootDays; double hit = Cache.pillageTradeHitPercent; + try { + Cache.pillageLootDays = 2; Cache.pillageTradeHitPercent = -40; + TreasuryActions.recover(folder); + PillageApplyService.apply(war); + assertEquals(100, attacker.getBank().getWealth()); + assertEquals(-40, PillageTradeHit.percent(victim)); + Cache.pillageLootDays = 4; Cache.pillageTradeHitPercent = -80; + PillageApplyService.apply(war); + TreasuryActions.recover(folder); + assertEquals(100, attacker.getBank().getWealth()); + assertEquals(-40, PillageTradeHit.percent(victim)); + assertEquals(1, victim.getPillageHits().size()); + } finally { Cache.pillageLootDays = days; Cache.pillageTradeHitPercent = hit; } + } + } + @Test void uncertainPillageStopsResolutionAndRecoveringSaveDoesNotMintAgain() throws Exception { + AtomicBoolean save = new AtomicBoolean(false); + try (var domain = new FactionDomainFixture(); var saves = mockConstruction(Database.class, + (database, context) -> when(database.saveFactionDurably(any())).thenAnswer(call -> save.get()))) { + var attacker = domain.saved("attacker", "A"); + var defender = domain.saved("defender", "D"); + defender.addProvince(10); + assertTrue(defender.getSettlementHandler().found("Victim", 10, 0, 0).isSuccess()); + Guild victim = domain.guild(defender, "victim", "V"); victim.setCapital(10, false); + when(domain.provinces.getIncome(victim, false)).thenReturn(50d); + War war = new War(77, attacker, defender); war.setTargetSettlementId("Victim"); + TreasuryActions.recover(folder); + assertThrows(IllegalStateException.class, () -> PillageApplyService.apply(war)); + double paid = attacker.getBank().getWealth(); + assertThrows(IllegalStateException.class, () -> PillageApplyService.apply(war)); + save.set(true); TreasuryActions.recover(folder); EconomyRuntime.recovered(); + PillageApplyService.apply(war); + assertEquals(paid, attacker.getBank().getWealth()); + assertEquals(1, victim.getPillageHits().size()); + } + } +} From d8c25ccc41f5265ce3dcd1b29d410ff1eb6eadec Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:36:06 +0000 Subject: [PATCH 27/35] feat: journal treasury charges and exact capital relocation effects --- .../economy/TreasuryTopology.java | 309 ++++++++++++++++++ .../simplefactions/guild/Guild.java | 6 + .../managers/CapitalMovePrompt.java | 33 +- .../managers/FactionManager.java | 13 +- .../managers/RelocationPrompt.java | 44 +-- .../objects/handler/GuildHandler.java | 7 + .../objects/handler/ProvinceHandler.java | 34 +- .../objects/request/RelocateRequest.java | 17 +- .../economy/TreasuryTopologyTest.java | 185 +++++++++++ .../CapitalMoveLifecycleCoverageTest.java | 14 +- .../CapitalMovePromptConfirmTest.java | 81 ++--- .../RelocationLifecycleCoverageTest.java | 40 +-- .../StaleRequestsReviewRegressionTest.java | 16 +- 13 files changed, 687 insertions(+), 112 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/economy/TreasuryTopology.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/TreasuryTopologyTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryTopology.java b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryTopology.java new file mode 100644 index 00000000..89431bd8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryTopology.java @@ -0,0 +1,309 @@ +package net.tfminecraft.simplefactions.economy; + +import com.google.gson.*; +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import org.bukkit.entity.Player; + +/** Pure topology plans; money commits once, then exact realm files and live topology follow. */ +public final class TreasuryTopology { + static Path directory = Path.of("plugins/SimpleFactions/Data"); + private TreasuryTopology() {} + + public static boolean capital(Player actor, Faction faction, int province, String name, + boolean rename, double cost, String operationId) { + Boolean prior = TreasuryActions.resume(operationId, actor); + if (prior != null) return prior; + var result = rename ? faction.getSettlementHandler().rename(province, name, true) + : faction.getSettlementHandler().validateFactionCapital(actor, province, name); + if (!result.isSuccess()) { actor.sendMessage(result.getMessage()); return false; } + JsonObject before = capture(), after = before.deepCopy(); + JsonObject state = realm(after, faction); + if (rename) { + settlementAt(state, province).addProperty("name", StringFormatter.formatHex(Formatter.formatName(name))); + } else { + found(state, province, name, actor); + int old = state.get("capital").getAsInt(); + setCapital(state, faction.getId(), province); + depart(after, state, old); + revalidate(after, faction); + } + state.addProperty("capital moves", faction.getCapitalMoves() + 1); + return commit(actor, SettlementGuilds.capital(faction), cost, operationId, "Capital change", before, after); + } + + public static boolean relocate(Player actor, Guild guild, Faction target, int province, + String name, double cost, String operationId) { + return relocate(actor, guild, target, province, name, cost, operationId, actor); + } + + public static boolean relocate(Player actor, Guild guild, Faction target, int province, + String name, double cost, String operationId, Player founder) { + Boolean prior = TreasuryActions.resume(operationId, actor); + if (prior != null) return prior; + JsonObject before = capture(), after = before.deepCopy(); + Faction origin = guild.getFaction(); + JsonObject from = realm(after, origin), to = realm(after, target); + int old = guild.getCapital(); + if (origin != target) { + JsonObject moving = guild(from, guild.getId()); + from.getAsJsonArray("guilds").remove(moving); + depart(after, from, old); + revalidate(after, origin); + to.getAsJsonArray("guilds").add(moving); + } else if (!target.hasProvince(province)) { + // A province acquired by somebody else while the prompt was open is a stale destination. + if (FactionManager.getByProvince(province) != null + || net.tfminecraft.simplefactions.war.civilwar.wartime.CivilWarBorderLock.isLocked(target) + || !target.getProvinceHandler().canClaim(province, true, guild) + || target.getPrestige() < TitleManager.getClaimCost(target) + || TitleLoader.getByProvince(province) == null + && target.getUntitledProvinces().size() >= net.tfminecraft.simplefactions.Cache.maxUntitledProvinces) { + actor.sendMessage("§cRelocation failed, cannot claim province!"); return false; + } + to.getAsJsonArray("provinces").add(province); + } + setCapital(to, guild.getId(), province); + found(to, province, name, founder); + if (origin == target) depart(after, from, old); + try { + if (origin != target) { + // Preserve the complete moving guild even if a crash leaves neither realm file holding it. + save(origin); + JsonObject file = DurableJson.read(directory.resolve(origin.getId() + ".json"), JsonObject.class); + for (JsonElement value : file.getAsJsonArray("guilds")) { + if (guild.getIncarnation().equals(value.getAsJsonObject().get("incarnation").getAsString())) { + after.getAsJsonObject("movingGuilds").add(guild.getIncarnation(), value.deepCopy()); + } + } + if (!after.getAsJsonObject("movingGuilds").has(guild.getIncarnation())) throw new IOException("Moving guild was not persisted"); + } + } catch (IOException error) { EconomyRuntime.failClosed(error); return false; } + return commit(actor, guild, cost, operationId, "Guild relocation", before, after); + } + + private static boolean commit(Player actor, Guild payer, double cost, String id, String reason, + JsonObject before, JsonObject after) { + JsonObject oldRealms = before.getAsJsonObject("realms"), newRealms = after.getAsJsonObject("realms"); + for (JsonElement state : newRealms.asMap().values()) state.getAsJsonObject().add("guilds", sorted(state.getAsJsonObject().get("guilds"))); + for (String key : new ArrayList<>(oldRealms.keySet())) { + if (oldRealms.get(key).equals(newRealms.get(key))) { oldRealms.remove(key); newRealms.remove(key); } + } + TreasuryActions.Plan plan = new TreasuryActions.Plan(id, reason, actor, payer) + .transfer(TreasuryActions.account(payer), Denars.MINT, Money.cents(cost)); + try { + for (String key : newRealms.keySet()) { + Faction faction = FactionManager.getByString(key); + save(faction); + plan.capture(SettlementGuilds.capital(faction)); + } + } catch (IOException error) { EconomyRuntime.failClosed(error); return false; } + return plan.effect("topology", payer, "", before, after).commit(actor); + } + + static JsonObject capture() { + JsonObject result = new JsonObject(), realms = new JsonObject(); + result.add("realms", realms); result.add("movingGuilds", new JsonObject()); + for (Faction faction : FactionManager.getCopy()) realms.add(faction.getId(), snapshot(faction)); + return result; + } + + static JsonObject snapshot(Faction faction) { + JsonObject data = new JsonObject(); + data.addProperty("incarnation", SettlementGuilds.capital(faction).getIncarnation()); + data.addProperty("capital", faction.getCapital()); data.addProperty("capital moves", faction.getCapitalMoves()); + data.add("provinces", JsonUtil.GSON.toJsonTree(faction.getProvinces())); + data.add("titles", JsonUtil.GSON.toJsonTree(faction.getTitles().stream().map(Title::getId).toList())); + data.add("settlements", sorted(faction.getSettlementHandler().serialize())); + data.add("installations", sorted(faction.getInstallationHandler().serialize())); + data.add("installation queue", JsonUtil.GSON.toJsonTree(faction.getInstallationHandler().serializeConstruction())); + JsonArray guilds = new JsonArray(); + for (Guild guild : faction.getGuildHandler().getGuilds().stream().sorted(Comparator.comparing(Guild::getId)).toList()) { + JsonObject ref = new JsonObject(); ref.addProperty("id", guild.getId()); + ref.addProperty("incarnation", guild.getIncarnation()); ref.addProperty("capital", guild.getCapital()); guilds.add(ref); + } + data.add("guilds", guilds); return data; + } + + private static JsonArray sorted(Object values) { + List<JsonElement> list = new ArrayList<>(); JsonUtil.GSON.toJsonTree(values).getAsJsonArray().forEach(list::add); + list.sort(Comparator.comparing(v -> v.getAsJsonObject().get("id").getAsString())); + JsonArray result = new JsonArray(); list.forEach(result::add); return result; + } + private static JsonObject realm(JsonObject plan, Faction faction) { return plan.getAsJsonObject("realms").getAsJsonObject(faction.getId()); } + private static JsonObject guild(JsonObject realm, String id) { + for (JsonElement g : realm.getAsJsonArray("guilds")) if (id.equals(g.getAsJsonObject().get("id").getAsString())) return g.getAsJsonObject(); + throw new IllegalStateException("Missing topology guild " + id); + } + private static JsonObject settlementAt(JsonObject realm, int province) { + for (JsonElement s : realm.getAsJsonArray("settlements")) if (s.getAsJsonObject().get("centerProvince").getAsInt() == province) return s.getAsJsonObject(); + return null; + } + private static void found(JsonObject realm, int province, String name, Player actor) { + if (settlementAt(realm, province) != null) return; + Settlement city = new Settlement(Formatter.formatId(name), StringFormatter.formatHex(Formatter.formatName(name)), + province, actor.getLocation().getBlockX(), actor.getLocation().getBlockZ()); + realm.getAsJsonArray("settlements").add(JsonUtil.GSON.toJsonTree(city.toData())); + realm.add("settlements", sorted(realm.getAsJsonArray("settlements"))); + } + private static void setCapital(JsonObject state, String id, int province) { + JsonObject g = guild(state, id); g.addProperty("capital", province); + if (g.get("incarnation").equals(state.get("incarnation"))) state.addProperty("capital", province); + } + private static void depart(JsonObject all, JsonObject state, int province) { + if (province == -1 || settlementAt(state, province) == null) return; + for (JsonElement g : state.getAsJsonArray("guilds")) if (g.getAsJsonObject().get("capital").getAsInt() == province) return; + dissolve(all, state, province); + } + private static void dissolve(JsonObject all, JsonObject state, int province) { + JsonObject city = settlementAt(state, province); + if (city == null) return; + state.getAsJsonArray("settlements").remove(city); + for (JsonElement r : all.getAsJsonObject("realms").asMap().values()) { + JsonObject other = r.getAsJsonObject(); + for (JsonElement g : other.getAsJsonArray("guilds")) { + JsonObject ref = g.getAsJsonObject(); + if (ref.get("capital").getAsInt() == province) setCapital(other, ref.get("id").getAsString(), -1); + } + } + } + private static List<Integer> integers(JsonElement values) { + List<Integer> result = new ArrayList<>(); values.getAsJsonArray().forEach(v -> result.add(v.getAsInt())); return result; + } + private static void revalidate(JsonObject all, Faction faction) { + JsonObject state = realm(all, faction); + Map<String,Integer> capitals = new LinkedHashMap<>(); + for (JsonElement value : state.getAsJsonArray("guilds")) { + JsonObject g = value.getAsJsonObject(); capitals.put(g.get("id").getAsString(), g.get("capital").getAsInt()); + } + List<Integer> owned = integers(state.get("provinces")); + var claims = faction.getProvinceHandler().previewTopology(state.get("capital").getAsInt(), owned, capitals); + for (var entry : claims.capitals().entrySet()) { + int old = guild(state, entry.getKey()).get("capital").getAsInt(); + setCapital(state, entry.getKey(), entry.getValue()); + if (old != entry.getValue()) depart(all, state, old); + } + for (int province : owned) { + if (claims.provinces().contains(province)) continue; + state.getAsJsonArray("provinces").remove(new JsonPrimitive(province)); + destroyTitle(all, faction, TitleLoader.getByProvince(province)); + dissolve(all, state, province); + JsonArray installations = state.getAsJsonArray("installations"); + for (JsonElement item : new ArrayList<>(installations.asList())) if (item.getAsJsonObject().get("province").getAsInt() == province) installations.remove(item); + JsonElement queue = state.get("installation queue"); + if (!queue.isJsonNull() && queue.getAsJsonObject().get("province").getAsInt() == province) state.add("installation queue", JsonNull.INSTANCE); + } + } + private static void destroyTitle(JsonObject all, Faction faction, Title title) { + JsonArray own = realm(all, faction).getAsJsonArray("titles"); + if (title == null || !own.contains(new JsonPrimitive(title.getId()))) return; + List<Integer> provinces = new ArrayList<>(); List<Title> titles = new ArrayList<>(); + collectRealm(all, faction, provinces, titles, new HashSet<>()); + long counties = own.asList().stream().map(v -> TitleLoader.getById(v.getAsString())).filter(t -> t.getTier().getTier() == 2).count(); + int members = 0; + for (JsonElement g : realm(all, faction).getAsJsonArray("guilds")) members += FactionManager.getGuildByString(g.getAsJsonObject().get("id").getAsString()).getMembers().size(); + if (title.getCurrentAmount(faction, provinces, titles) > 0 && (title.getTier().getTier() != 2 || counties <= members)) return; + own.remove(new JsonPrimitive(title.getId())); destroyTitle(all, faction, TitleManager.getParent(title)); + } + private static void collectRealm(JsonObject all, Faction faction, List<Integer> provinces, List<Title> titles, Set<String> seen) { + if (!seen.add(faction.getId())) return; + JsonObject state = realm(all, faction); provinces.addAll(integers(state.get("provinces"))); + state.getAsJsonArray("titles").forEach(v -> titles.add(TitleLoader.getById(v.getAsString()))); + for (Faction subject : RelationManager.getSubjects(faction)) collectRealm(all, subject, provinces, titles, seen); + } + + public static void apply(JsonObject effect) throws IOException { + JsonObject before = effect.getAsJsonObject("before").getAsJsonObject("realms"); + JsonObject after = effect.getAsJsonObject("after").getAsJsonObject("realms"); + Map<String,Guild> guilds = new HashMap<>(); + for (Faction f : FactionManager.getCopy()) for (Guild g : f.getGuildHandler().getGuilds()) guilds.put(g.getIncarnation(), g); + for (String id : after.keySet()) { + Faction f = requireRealm(id, after.getAsJsonObject(id)); + JsonObject current = snapshot(f); + for (String key : before.getAsJsonObject(id).keySet()) TreasuryActions.check(current.get(key), before.getAsJsonObject(id).get(key), after.getAsJsonObject(id).get(key)); + for (JsonElement g : after.getAsJsonObject(id).getAsJsonArray("guilds")) if (!guilds.containsKey(g.getAsJsonObject().get("incarnation").getAsString())) throw new IOException("Missing moving guild"); + } + for (String id : after.keySet()) { + JsonObject state = after.getAsJsonObject(id); Faction f = FactionManager.getByString(id); + List<Guild> restored = new ArrayList<>(); + for (JsonElement value : state.getAsJsonArray("guilds")) { + JsonObject ref = value.getAsJsonObject(); Guild g = guilds.get(ref.get("incarnation").getAsString()); + g.restoreTopology(f, ref.get("capital").getAsInt()); restored.add(g); + } + f.getGuildHandler().restoreTopology(restored); + f.getProvinceHandler().restoreTopology(state.get("capital").getAsInt(), integers(state.get("provinces"))); + f.setCapitalMoves(state.get("capital moves").getAsInt()); f.getTitles().clear(); + state.getAsJsonArray("titles").forEach(v -> f.getTitles().add(TitleLoader.getById(v.getAsString()))); + f.getSettlementHandler().load(Arrays.asList(JsonUtil.GSON.fromJson(state.get("settlements"), SettlementData[].class))); + f.getInstallationHandler().restoreSettlement(Arrays.asList(JsonUtil.GSON.fromJson(state.get("installations"), InstallationData[].class)), + JsonUtil.GSON.fromJson(state.get("installation queue"), InstallationConstructionData.class)); + } + for (String id : after.keySet()) { Faction f = FactionManager.getByString(id); f.updateWealth(); f.updateTier(); save(f); FactionManager.getMap().enqueue("nation", f.getRGB()); } + SimpleFactions.getInstance().getProvinceManager().recalculate(); + } + + private static Faction requireRealm(String id, JsonObject state) throws IOException { + Faction f = FactionManager.getByString(id); + if (f == null || !SettlementGuilds.capital(f).getIncarnation().equals(state.get("incarnation").getAsString())) throw new IOException("Realm incarnation changed: " + id); + return f; + } + private static void save(Faction f) throws IOException { if (!new Database().saveFactionDurably(f)) throw new IOException("Cannot persist topology: " + f.getId()); } + + /** Repair cross-file publication before guilds and loan references are loaded. No posting or acknowledgment here. */ + public static void restoreFiles(JsonObject effect) throws IOException { + JsonObject before = effect.getAsJsonObject("before").getAsJsonObject("realms"); + JsonObject after = effect.getAsJsonObject("after").getAsJsonObject("realms"); + Map<String,JsonObject> files = new LinkedHashMap<>(), guilds = new HashMap<>(); + effect.getAsJsonObject("after").getAsJsonObject("movingGuilds").entrySet().forEach(e -> guilds.put(e.getKey(), e.getValue().getAsJsonObject())); + for (String id : after.keySet()) { + if (!id.matches("[A-Za-z0-9_-]+")) throw new IOException("Invalid realm filename"); + JsonObject file = DurableJson.read(directory.resolve(id + ".json"), JsonObject.class); files.put(id, file); + for (JsonElement value : file.getAsJsonArray("guilds")) { + JsonObject g = value.getAsJsonObject(); guilds.put(g.get("incarnation").getAsString(), g); + } + JsonObject state = after.getAsJsonObject(id), original = before.getAsJsonObject(id); + if (!file.get("id").getAsString().equals(id)) throw new IOException("Realm file identity changed"); + for (String key : state.keySet()) { + if (key.equals("incarnation") || key.equals("guilds")) continue; + JsonElement current = file.has(key) ? file.get(key) : JsonNull.INSTANCE; + if (key.equals("settlements") || key.equals("installations")) current = sorted(current); + TreasuryActions.check(current, original.get(key), state.get(key)); + } + JsonObject base = null; + JsonArray currentGuilds = new JsonArray(); + for (JsonElement value : file.getAsJsonArray("guilds")) { + JsonObject g = value.getAsJsonObject(), ref = new JsonObject(); + for (String key : List.of("id", "incarnation", "capital")) ref.add(key, g.get(key)); + currentGuilds.add(ref); + } + TreasuryActions.check(sorted(currentGuilds), original.get("guilds"), state.get("guilds")); + for (JsonElement value : file.getAsJsonArray("guilds")) if (value.getAsJsonObject().get("id").getAsString().equals(id)) base = value.getAsJsonObject(); + if (base == null || !base.get("incarnation").equals(state.get("incarnation"))) throw new IOException("Realm incarnation changed"); + } + for (String id : after.keySet()) { + JsonObject file = files.get(id), state = after.getAsJsonObject(id); + for (String key : state.keySet()) if (!key.equals("incarnation") && !key.equals("guilds")) file.add(key, state.get(key).deepCopy()); + JsonArray restored = new JsonArray(); + for (JsonElement value : state.getAsJsonArray("guilds")) { + JsonObject ref = value.getAsJsonObject(), data = guilds.get(ref.get("incarnation").getAsString()); + if (data == null || !data.get("id").equals(ref.get("id"))) throw new IOException("Missing original moving guild"); + data = data.deepCopy(); data.add("capital", ref.get("capital")); restored.add(data); + } + file.add("guilds", restored); + } + for (var entry : files.entrySet()) DurableJson.write(directory.resolve(entry.getKey() + ".json"), entry.getValue()); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index e68647b1..78039508 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -417,6 +417,12 @@ public void dummyLeader(Player p) { public void setHost(Faction host) { this.host = host; } + + /** Exact journal after-values; derived trade data is refreshed once all affected realms agree. */ + public void restoreTopology(Faction faction, int restoredCapital) { + host = faction; + capital = restoredCapital; + } public List<String> getInvites() { return invites; } public boolean isInvited(String p) { return find(invites, p) != null; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java index 12c914e3..1247bd68 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java @@ -26,7 +26,14 @@ public record CapitalMovePending( int province, String settlementName, boolean rename, - double cost) {} + double cost, + String operationId, + net.tfminecraft.tlibs.character.CharacterSession session) { + CapitalMovePending(Faction faction, int fromCapital, int province, String settlementName, boolean rename, double cost) { + this(faction, fromCapital, province, settlementName, rename, cost, + net.tfminecraft.simplefactions.economy.TreasuryActions.newId(), null); + } + } /** A faction's first capital move or rename is free; later ones cost {@link Cache#capitalMoveCost}. */ public static double costFor(Faction faction) { @@ -57,6 +64,8 @@ public static void begin( String settlementName, boolean rename) { double cost = costFor(faction); + var session = Members.session(player).orElse(null); + if (session == null) { player.sendMessage("§cYour character is not ready."); return; } String funds = checkFunds(faction, cost); if (funds != null) { player.sendMessage(funds); @@ -66,7 +75,8 @@ public static void begin( int provincesLost = rename ? 0 : faction.getProvinceHandler().previewProvincesLostIfCapitalMoved(province).size(); pending.put(player, new CapitalMovePending( - faction, faction.getCapital(), province, settlementName, rename, cost)); + faction, faction.getCapital(), province, settlementName, rename, cost, + net.tfminecraft.simplefactions.economy.TreasuryActions.newId(), session)); FactionManager.getInv().confirming.put(player, faction); FactionManager.getInv().confirmCapitalMoveView(player, faction, rename, cost, provincesLost); } @@ -94,6 +104,12 @@ static Outcome confirm(Player player, CapitalMovePending state, boolean confirme return Outcome.CANCELLED; } Faction faction = state.faction(); + if (state.session() != null && !Members.isCurrent(state.session())) { + player.sendMessage("§cYour character changed. Start the capital change again."); + return Outcome.REFUSED; + } + Boolean resumed = net.tfminecraft.simplefactions.economy.TreasuryActions.resume(state.operationId(), player); + if (resumed != null) return resumed ? Outcome.CHANGED : Outcome.REFUSED; // The leader or the capital may have changed while the menu was open. if (FactionManager.getByLeader(Members.key(player)) != faction || faction.getCapital() != state.fromCapital()) { @@ -111,15 +127,12 @@ static Outcome confirm(Player player, CapitalMovePending state, boolean confirme player.sendMessage(funds); return Outcome.REFUSED; } - boolean changed = state.rename() - ? applyRename(player, faction, state.province(), state.settlementName()) - : applyMove(player, faction, state.province(), state.settlementName()); + boolean changed = net.tfminecraft.simplefactions.economy.TreasuryTopology.capital(player, faction, + state.province(), state.settlementName(), state.rename(), cost, state.operationId()); if (!changed) { return Outcome.REFUSED; } - faction.setCapitalMoves(faction.getCapitalMoves() + 1); if (cost > 0) { - faction.getBank().withdraw(cost); player.sendMessage("§7Paid §e" + Formatter.formatMoney(cost) + "d §7from the faction bank."); } else { player.sendMessage("§7That was your free capital change. Later moves and renames cost §e" @@ -145,12 +158,6 @@ private static boolean applyMove(Player player, Faction faction, int claim, Stri return true; } - private static boolean applyRename(Player player, Faction faction, int province, String name) { - CapitalResult result = faction.getSettlementHandler().rename(province, name, false); - player.sendMessage(result.getMessage()); - return result.isSuccess(); - } - @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { if (pending.remove(event.getPlayer()) != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 5aca7036..53b46ce2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -941,7 +941,14 @@ public static void requestRelocation(Player sender, Guild g, Faction target, int p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); sender.sendMessage("§aRelocation request sent to "+target.getName()); - RequestManager.addRequest(sender, p, new RelocateRequest(g, capital, settlementName, target)); + var sendingSession = Members.session(sender).orElse(null); + var receivingSession = Members.session(p).orElse(null); + if (sendingSession == null || receivingSession == null) { + sender.sendMessage("§cBoth characters must be ready before requesting relocation."); + return; + } + RequestManager.addRequest(sender, p, new RelocateRequest(g, capital, settlementName, target, + sendingSession, receivingSession)); } public static void acceptRelocateRequest(Player p) { @@ -984,8 +991,8 @@ public static void acceptRelocateRequest(Player p) { p.sendMessage(validation.getMessage()); return; } - sender.relocate(reciever, capital, actor, req.getSettlementName()); - sender.getBank().withdraw(cost); + if (!net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate(p, sender, reciever, + capital, req.getSettlementName(), cost, req.getOperationId(), actor)) return; if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request to relocate to their faction"); p.sendMessage(sender.getName()+"§a has been relocated to your faction"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java index ff07f77c..c5985312 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java @@ -32,6 +32,8 @@ public static class RelocationPending { private final Faction origin; private final int fromCapital; private final AtomicBoolean submitted = new AtomicBoolean(); + private final String operationId = net.tfminecraft.simplefactions.economy.TreasuryActions.newId(); + private net.tfminecraft.tlibs.character.CharacterSession session; public RelocationPending( Guild guild, @@ -63,6 +65,8 @@ public static boolean begin( return false; } RelocationPending state = new RelocationPending(guild, target, province, crossFaction, cost); + state.session = net.tfminecraft.simplefactions.identity.Members.session(player).orElse(null); + if (state.session == null) { player.sendMessage("§cYour character is not ready."); return true; } pending.put(player, state); player.sendMessage("§eEnter a name for the new city in chat:"); new BukkitRunnable() { @@ -83,6 +87,12 @@ public static boolean completeIntraFactionRelocate( int province, String settlementName, double cost) { + return completeIntraFactionRelocate(player, guild, target, province, settlementName, cost, + net.tfminecraft.simplefactions.economy.TreasuryActions.newId()); + } + + private static boolean completeIntraFactionRelocate(Player player, Guild guild, Faction target, + int province, String settlementName, double cost, String operationId) { if (!guild.isLeader(player) || guild.getFaction() != target) { player.sendMessage("§cYour guild changed while you were deciding. Start relocation again."); return false; @@ -91,33 +101,14 @@ public static boolean completeIntraFactionRelocate( player.sendMessage("§cCannot afford to relocate"); return false; } - int old = guild.getCapital(); - if (!target.hasProvince(province)) { - CapitalResult validation = target.getSettlementHandler() - .validateRelocationCapital(player, province, settlementName); - if (!validation.isSuccess()) { - player.sendMessage(validation.getMessage()); - player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return false; - } - guild.setCapital(-1, false); - FactionManager.getMap().claim(player, target, province, true); - guild.setCapital(old, false); - if (!target.hasProvince(province)) { - player.sendMessage("§cRelocation failed, cannot claim province!"); - player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return false; - } - } - - CapitalResult result = guild.relocateWithinFaction(player, province, settlementName); - if (!result.isSuccess()) { - if (guild.getCapital() != old) guild.setCapital(old, false); + CapitalResult validation = target.getSettlementHandler().validateRelocationCapital(player, province, settlementName); + if (!validation.isSuccess()) { + player.sendMessage(validation.getMessage()); player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return false; } - - guild.getBank().withdraw(cost); + if (!net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate(player, guild, target, + province, settlementName, cost, operationId)) return false; player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return true; } @@ -153,7 +144,8 @@ public void run() { return; } pending.remove(player, state); - if (!state.guild.isLeader(player) + if (!net.tfminecraft.simplefactions.identity.Members.isCurrent(state.session) + || !state.guild.isLeader(player) || state.guild.getFaction() != state.origin || state.guild.getCapital() != state.fromCapital || FactionManager.getGuildByString(state.guild.getId()) != state.guild @@ -165,7 +157,7 @@ public void run() { FactionManager.requestRelocation( player, state.guild, state.target, state.province, name); } else if (completeIntraFactionRelocate( - player, state.guild, state.target, state.province, name, state.cost)) { + player, state.guild, state.target, state.province, name, state.cost, state.operationId)) { FactionManager.getInv().guildView(player, state.guild); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java index f56b5c82..db3e7033 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java @@ -97,6 +97,13 @@ public void addGuild(Guild g) { guilds.put(g.getId(), g); } + /** Journal replay supplies the full membership of this realm without rerunning departure effects. */ + public void restoreTopology(List<Guild> restored) { + for (String id : new ArrayList<>(guilds.keySet())) clearProvinceData(id); + guilds.clear(); + for (Guild guild : restored) guilds.put(guild.getId(), guild); + } + public List<String> getAllMembers() { List<String> members = new ArrayList<>(); for(Guild g : guilds.values()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java index 09d9f60b..970b6fad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java @@ -420,13 +420,15 @@ public List<Integer> previewProvincesLostIfCapitalMoved(int newCapital) { } private Set<Integer> computeLegalProvinces(ProvinceManager pm, int factionCapital) { + return computeLegalProvinces(pm, factionCapital, f.getGuildHandler().getGuilds().stream() + .filter(Guild::hasCapital).map(Guild::getCapital).toList()); + } + + private Set<Integer> computeLegalProvinces(ProvinceManager pm, int factionCapital, java.util.Collection<Integer> capitals) { Set<Integer> legal = new HashSet<>(); Set<Integer> capitalBlob = capitalLandBlob(pm, factionCapital); - for (Guild g : f.getGuildHandler().getGuilds()) { - if (!g.hasCapital()) continue; - - int cap = g.getCapital(); + for (int cap : capitals) { if (!isCapitalStillLegal(pm, cap, factionCapital)) { continue; } @@ -458,6 +460,30 @@ private Set<Integer> computeLegalProvinces(ProvinceManager pm, int factionCapita return legal; } + /** Detached claim calculation for a persisted capital/relocation intent. */ + public record TopologyClaims(List<Integer> provinces, java.util.Map<String, Integer> capitals) {} + public TopologyClaims previewTopology(int proposedCapital, List<Integer> owned, + java.util.Map<String, Integer> capitals) { + ProvinceHandler copy = new ProvinceHandler(f); + copy.capital = proposedCapital; + copy.provinces = new ArrayList<>(owned); + ProvinceManager pm = SimpleFactions.getInstance().getProvinceManager(); + java.util.Map<String, Integer> after = new java.util.LinkedHashMap<>(capitals); + after.replaceAll((id, cap) -> cap != -1 && !copy.isCapitalStillLegal(pm, cap, proposedCapital) ? -1 : cap); + int effectiveCapital = after.getOrDefault(f.getId(), proposedCapital); + copy.capital = effectiveCapital; + Set<Integer> legal = copy.computeLegalProvinces(pm, effectiveCapital, + after.values().stream().filter(cap -> cap != -1).toList()); + return new TopologyClaims(owned.stream().filter(legal::contains).toList(), java.util.Map.copyOf(after)); + } + + /** Apply the exact saved topology; callers rebuild derived province data after all realms are restored. */ + public void restoreTopology(int restoredCapital, List<Integer> restoredProvinces) { + capital = restoredCapital; + provinces.clear(); + provinces.addAll(restoredProvinces); + } + private boolean isCapitalStillLegal(ProvinceManager pm, int guildCapitalId, int factionCapitalId) { Province guildCapital = pm.get(guildCapitalId); if (!provinces.contains(guildCapitalId) || guildCapital == null || !guildCapital.isValid() diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java index 9f6a0ae1..f1ace384 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java @@ -10,13 +10,24 @@ public class RelocateRequest extends Request{ private final Faction originalHost; private final String originalLeader; private final Faction destination; + private final net.tfminecraft.tlibs.character.CharacterSession sendingSession; + private final net.tfminecraft.tlibs.character.CharacterSession receivingSession; + private final String operationId = net.tfminecraft.simplefactions.economy.TreasuryActions.newId(); public RelocateRequest(Guild sender, int newCapital, String settlementName) { this(sender, newCapital, settlementName, null); } public RelocateRequest(Guild sender, int newCapital, String settlementName, Faction destination) { + this(sender, newCapital, settlementName, destination, null, null); + } + + public RelocateRequest(Guild sender, int newCapital, String settlementName, Faction destination, + net.tfminecraft.tlibs.character.CharacterSession sendingSession, + net.tfminecraft.tlibs.character.CharacterSession receivingSession) { super(sender); + this.sendingSession = sendingSession; + this.receivingSession = receivingSession; this.originalHost = sender.getFaction(); this.originalLeader = sender.getLeader(); this.destination = destination; @@ -25,7 +36,9 @@ public RelocateRequest(Guild sender, int newCapital, String settlementName, Fact } public boolean isCurrentFor(Faction receiver) { - return originalHost != null + return (sendingSession == null || net.tfminecraft.simplefactions.identity.Members.isCurrent(sendingSession)) + && (receivingSession == null || net.tfminecraft.simplefactions.identity.Members.isCurrent(receivingSession)) + && originalHost != null && FactionManager.getByString(originalHost.getId()) == originalHost && FactionManager.getGuildByString(sender.getId()) == sender && sender.getFaction() == originalHost @@ -39,6 +52,8 @@ public int getNewCapital() { return newCapital; } + public String getOperationId() { return operationId; } + public String getSettlementName() { return settlementName; } diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryTopologyTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryTopologyTest.java new file mode 100644 index 00000000..d2acb9df --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryTopologyTest.java @@ -0,0 +1,185 @@ +package net.tfminecraft.simplefactions.economy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +import com.google.gson.*; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.database.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TreasuryTopologyTest { + private PersistenceFilesFixture files; + private FactionDomainFixture domain; + private Faction origin, target; + private Guild moving; + private Player actor; + private Path journal; + + @BeforeEach void setup() throws Exception { + files = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + domain.provincesEnabled(true); + for (int id : List.of(1,2,3,4)) domain.provinceData.put(id, new Province(id,"PLAINS",50)); + domain.provinceData.get(1).addNeighbour(2); + domain.provinceData.get(2).addNeighbour(1); + var a = domain.data("origin", "Alice"); a.capital=1; a.provinces.addAll(List.of(1,2)); + var b = domain.data("target", "Bob"); b.capital=3; b.provinces.add(3); + origin=domain.saved(a); target=domain.saved(b); + origin.getOrCreateMainGuild(); target.getOrCreateMainGuild(); + moving=domain.guild(origin,"traders","Cara"); moving.setCapital(2); + origin.getSettlementHandler().found("Origin",1,10,10); + origin.getSettlementHandler().found("Old_Guild_City",2,20,20); + target.getSettlementHandler().found("Target",3,30,30); + TestTreasuries.set(moving.getBank(),100); + TestTreasuries.set(origin.getBank(),100); + actor=domain.player("Bob"); domain.player("Cara"); + journal=files.root.resolve("economy/callers"); TreasuryActions.recover(journal); + } + @AfterEach void close() throws Exception { + try { if(domain!=null) domain.close(); } finally { if(files!=null) files.close(); } + } + private boolean move(String id) { return TreasuryTopology.relocate(actor,moving,target,3,null,20,id); } + private JsonObject read(String id) throws IOException { return DurableJson.read(files.root.resolve("Data/"+id+".json"), JsonObject.class); } + private void write(String id, JsonObject json) throws IOException { DurableJson.write(files.root.resolve("Data/"+id+".json"),json); } + private JsonObject effect() throws IOException { + try(var paths=Files.list(journal)) { + return DurableJson.read(paths.findFirst().orElseThrow(),JsonObject.class).getAsJsonObject("intent") + .getAsJsonObject("changes").getAsJsonArray("effects").get(0).getAsJsonObject(); + } + } + private JsonObject guild(JsonObject file,String id) { + for(JsonElement item:file.getAsJsonArray("guilds")) if(id.equals(item.getAsJsonObject().get("id").getAsString())) return item.getAsJsonObject(); + return null; + } + private void interruptTargetPublication(String id) throws Exception { + try(var writes=mockStatic(DurableJson.class,CALLS_REAL_METHODS)) { + writes.when(()->DurableJson.write(any(Path.class),any())).thenAnswer(call->{ + Path path=call.getArgument(0); + if(path.getFileName().toString().equals("target.json") && Denars.status(id).orElse(null)==Result.APPLIED) + throw new IOException("simulated interrupted target publication"); + return call.callRealMethod(); + }); + assertFalse(move(id)); + } + assertEquals(Result.APPLIED,Denars.status(id).orElseThrow()); + assertEquals(80,moving.getBank().getWealth()); + assertFalse(EconomyRuntime.ready()); + assertNull(guild(read("origin"),"traders")); + assertNull(guild(read("target"),"traders")); + } + @Test void retryUsesTheOriginalIntentAndChargesOnlyOnce() throws Exception { + String id=TreasuryActions.newId(); assertTrue(move(id)); + assertSame(target,moving.getFaction()); assertEquals(3,moving.getCapital()); + assertNull(origin.getSettlementHandler().getByProvince(2)); + assertEquals(80,moving.getBank().getWealth()); + assertTrue(TreasuryTopology.relocate(actor,moving,origin,1,"Different",99,id)); + assertSame(target,moving.getFaction()); assertEquals(80,moving.getBank().getWealth()); + assertNotNull(guild(read("target"),"traders")); + } + @Test void appliedMoveRepairsBothFilesBeforeLoadingAndReplaysWithoutAnotherDebit() throws Exception { + String id=TreasuryActions.newId(); interruptTargetPublication(id); + TreasuryActions.recoverTopologyFiles(journal); + assertNull(guild(read("origin"),"traders")); + assertEquals(moving.getIncarnation(),guild(read("target"),"traders").get("incarnation").getAsString()); + assertEquals("Cara",guild(read("target"),"traders").get("leader").getAsString()); + TreasuryActions.recoverTopologyFiles(journal); + TreasuryActions.recover(journal); EconomyRuntime.recovered(); + assertTrue(move(id)); assertEquals(80,moving.getBank().getWealth()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void rawRecoveryCanResumeAfterEitherSideWasPublished(boolean targetFirst) throws Exception { + assertTrue(new Database().saveFactionDurably(origin)); assertTrue(new Database().saveFactionDurably(target)); + JsonObject oldOrigin=read("origin"), oldTarget=read("target"); + assertTrue(move(TreasuryActions.newId())); JsonObject effect=effect(); + if(targetFirst) write("origin",oldOrigin); else write("target",oldTarget); + TreasuryTopology.restoreFiles(effect); TreasuryTopology.restoreFiles(effect); + assertNull(guild(read("origin"),"traders")); + assertEquals(3,guild(read("target"),"traders").get("capital").getAsInt()); + } + @Test void unrelatedFileFieldsAndMovingGuildSettingsSurviveRecovery() throws Exception { + String id=TreasuryActions.newId(); interruptTargetPublication(id); + JsonObject targetFile=read("target"); targetFile.addProperty("future setting","retained"); write("target",targetFile); + TreasuryActions.recoverTopologyFiles(journal); + assertEquals("retained",read("target").get("future setting").getAsString()); + assertEquals(JsonUtil.GSON.toJsonTree(moving.getMembers()),guild(read("target"),"traders").get("members")); + } + @ParameterizedTest @ValueSource(strings={"incarnation","capital","memberships","file-id","missing-guild","filename"}) + void changedIdentityOrTopologyCannotBeOverwritten(String damage) throws Exception { + assertTrue(move(TreasuryActions.newId())); JsonObject effect=effect(), data=read("target"); + switch(damage) { + case "incarnation" -> guild(data,"target").addProperty("incarnation",UUID.randomUUID().toString()); + case "capital" -> data.addProperty("capital",999); + case "memberships" -> data.getAsJsonArray("guilds").remove(guild(data,"traders")); + case "file-id" -> data.addProperty("id","imposter"); + case "missing-guild" -> { + effect.getAsJsonObject("after").getAsJsonObject("movingGuilds").remove(moving.getIncarnation()); + data.getAsJsonArray("guilds").remove(guild(data,"traders")); + } + case "filename" -> { + var realms=effect.getAsJsonObject("after").getAsJsonObject("realms"); + realms.add("../outside",realms.remove("target")); + } + } + // A before membership is valid: to reject changed membership, inject a third unknown guild instead. + if(damage.equals("memberships")) { + JsonObject extra=guild(data,"target").deepCopy();extra.addProperty("id","unrelated");data.getAsJsonArray("guilds").add(extra); + } + write("target",data); String bytes=Files.readString(files.root.resolve("Data/target.json")); + assertThrows(IOException.class,()->TreasuryTopology.restoreFiles(effect)); + assertEquals(bytes,Files.readString(files.root.resolve("Data/target.json"))); + } + @Test void beforePublicationFailureDoesNotDebitOrChangeTopology() throws Exception { + JsonObject before=TreasuryTopology.capture(); + try(var writes=mockStatic(DurableJson.class,CALLS_REAL_METHODS)) { + writes.when(()->DurableJson.write(any(Path.class),any())).thenThrow(new IOException("disk unavailable")); + assertFalse(move(TreasuryActions.newId())); + } + assertEquals(before,TreasuryTopology.capture()); assertEquals(100,moving.getBank().getWealth()); + } + @Test void calculatingClaimsIsDetachedAndMissingRulingGuildCannotBeCreated() { + JsonObject before=TreasuryTopology.capture(); + var claims=origin.getProvinceHandler().previewTopology(1,List.of(1,2,4),Map.of("origin",1,"traders",4)); + assertEquals(List.of(1,2),claims.provinces());assertEquals(-1,claims.capitals().get("traders")); + assertEquals(before,TreasuryTopology.capture()); + origin.getGuildHandler().restoreTopology(List.of(moving)); + assertThrows(IllegalStateException.class,()->TreasuryTopology.capture()); + assertNull(origin.getGuildHandler().getGuild("origin")); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void capitalLossRevalidatesTitlesUsingTheProjectedRealmAndSubjects(boolean subjectRetainsLand) { + origin.addProvince(4); + var title=domain.title("old_county","county",1,2,3); + origin.getTitles().add(title); + if(subjectRetainsLand) domain.subject(origin,target); + assertTrue(TreasuryTopology.capital(domain.player("Alice"),origin,4,"New_Capital",false,20,TreasuryActions.newId())); + assertEquals(List.of(4),origin.getProvinces()); + assertEquals(-1,moving.getCapital()); + assertEquals(subjectRetainsLand,origin.getTitles().contains(title)); + assertNull(origin.getSettlementHandler().getByProvince(1)); + assertNull(origin.getSettlementHandler().getByProvince(2)); + assertNotNull(origin.getSettlementHandler().getByProvince(4)); + assertEquals(80,origin.getBank().getWealth()); + } + @Test void anUnregisteredGuildCannotPublishARelocationIntent() { + origin.getGuildHandler().restoreTopology(List.of(origin.getOrCreateMainGuild())); + assertThrows(IllegalStateException.class,()->move(TreasuryActions.newId())); + assertEquals(100,moving.getBank().getWealth()); + assertSame(origin,moving.getFaction()); + assertFalse(Files.exists(journal)); + } + +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java index 6643223e..2edb8e8b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java @@ -8,6 +8,7 @@ import net.tfminecraft.simplefactions.map.provinces.Province; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerQuitEvent; @@ -44,7 +45,7 @@ void setup() throws Exception { data.capital = 1; faction = fixture.saved(data); faction.getOrCreateMainGuild(); - faction.getBank().setWealth(100.0); + TestTreasuries.set(faction.getBank(), 100.0); faction.getSettlementHandler().found("Old_City", 1, 0, 0); player = fixture.player("Alice"); fixture.inventory.confirming = new HashMap<>(); @@ -98,7 +99,7 @@ void cancelClosesTheMenuAndKeepsCapitalBankAndName() { @Test void insufficientBankBalanceDoesNotOpenConfirmation() { faction.setCapitalMoves(1); - faction.getBank().setWealth(5.0); + TestTreasuries.set(faction.getBank(), 5.0); CapitalMovePrompt.begin(player, faction, 1, "New_City", true); assertTrue(pending.isEmpty()); assertTrue(fixture.inventory.confirming.isEmpty()); @@ -164,4 +165,13 @@ private void assertUnchanged() { assertTrue(faction.getSettlementHandler().getByProvince(1).getName().contains("Old City")); assertEquals(0, faction.getCapitalMoves()); } + @Test void changingCharacterAfterOpeningConfirmationLeavesBothTreasuryAndTopologyUntouched() { + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + try (var identity = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + identity.when(() -> net.tfminecraft.simplefactions.identity.Members.isCurrent(any())).thenReturn(false); + CapitalMovePrompt.handleConfirm(player, true); + } + assertUnchanged(); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMovePromptConfirmTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMovePromptConfirmTest.java index 04b11d56..481119f6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMovePromptConfirmTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMovePromptConfirmTest.java @@ -20,6 +20,9 @@ import net.tfminecraft.simplefactions.managers.CapitalMovePrompt.CapitalMovePending; import net.tfminecraft.simplefactions.managers.CapitalMovePrompt.Outcome; import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; +import net.tfminecraft.simplefactions.map.provinces.Province; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.settlement.Settlement; import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; @@ -27,7 +30,7 @@ class CapitalMovePromptConfirmTest { private static final int CAPITAL = 395; - private MockedStatic<FactionManager> factionManager; + private FactionDomainFixture fixture; private Player player; private Faction faction; private Bank bank; @@ -35,24 +38,24 @@ class CapitalMovePromptConfirmTest { @BeforeEach void setUp() { - player = mock(Player.class); - when(player.getName()).thenReturn("leader"); - faction = mock(Faction.class); - bank = mock(Bank.class); - when(faction.getBank()).thenReturn(bank); - when(faction.getCapital()).thenReturn(CAPITAL); - SettlementHandler settlements = new SettlementHandler(faction); - capital = settlements.found("Arclock Cove", CAPITAL, 0, 0).getSettlement(); - settlements.found("Fort Yuri", 393, 0, 0); - when(faction.getSettlementHandler()).thenReturn(settlements); - - factionManager = mockStatic(FactionManager.class); - factionManager.when(() -> FactionManager.getByLeader("leader")).thenReturn(faction); + fixture = new FactionDomainFixture(); + fixture.provincesEnabled(true); + fixture.provinceData.put(CAPITAL, new Province(CAPITAL, "PLAINS", 50)); + fixture.provinceData.put(393, new Province(393, "PLAINS", 50)); + var data = fixture.data("realm", "leader"); + data.provinces.addAll(java.util.List.of(CAPITAL, 393)); + data.capital = CAPITAL; + faction = fixture.saved(data); + faction.getOrCreateMainGuild(); + bank = faction.getBank(); + capital = faction.getSettlementHandler().found("Arclock Cove", CAPITAL, 0, 0).getSettlement(); + faction.getSettlementHandler().found("Fort Yuri", 393, 0, 0); + player = fixture.player("leader"); } @AfterEach void tearDown() { - factionManager.close(); + fixture.close(); } /** The pending rename as begin() stores it, priced when the menu opens. */ @@ -63,85 +66,89 @@ private CapitalMovePending renameTo(String name) { @Test void laterRenameChargesTheShownPriceAndCountsIt() { - when(faction.getCapitalMoves()).thenReturn(1); - when(bank.getWealth()).thenReturn(500.0); + faction.setCapitalMoves(1); + TestTreasuries.set(bank, 500.0); assertEquals(Outcome.CHANGED, CapitalMovePrompt.confirm(player, renameTo("The_Ashen_Rose"), true)); - assertTrue(capital.getName().endsWith("The Ashen Rose")); - verify(bank).withdraw(Cache.capitalMoveCost); - verify(faction).setCapitalMoves(2); + assertTrue(faction.getSettlementHandler().getByProvince(CAPITAL).getName().endsWith("The Ashen Rose")); + assertEquals(500.0 - Cache.capitalMoveCost, bank.getWealth()); + assertEquals(2, faction.getCapitalMoves()); } @Test void firstRenameIsFreeAndCounted() { - when(faction.getCapitalMoves()).thenReturn(0); + faction.setCapitalMoves(0); assertEquals(Outcome.CHANGED, CapitalMovePrompt.confirm(player, renameTo("The_Ashen_Rose"), true)); - verify(bank, never()).withdraw(anyDouble()); - verify(faction).setCapitalMoves(1); + assertEquals(0.0, bank.getWealth()); + assertEquals(1, faction.getCapitalMoves()); } @Test void cancelChargesNothing() { - when(faction.getCapitalMoves()).thenReturn(1); - when(bank.getWealth()).thenReturn(500.0); + faction.setCapitalMoves(1); + TestTreasuries.set(bank, 500.0); assertEquals(Outcome.CANCELLED, CapitalMovePrompt.confirm(player, renameTo("The_Ashen_Rose"), false)); assertUnchangedAndUncharged(); + assertEquals(500.0, bank.getWealth()); } @Test void bankEmptiedWhileOpenChargesNothing() { - when(faction.getCapitalMoves()).thenReturn(1); + faction.setCapitalMoves(1); CapitalMovePending state = renameTo("The_Ashen_Rose"); - when(bank.getWealth()).thenReturn(10.0); + TestTreasuries.set(bank, 10.0); assertEquals(Outcome.REFUSED, CapitalMovePrompt.confirm(player, state, true)); assertUnchangedAndUncharged(); + assertEquals(10.0, bank.getWealth()); } @Test void priceChangedWhileOpenChargesNothing() { - when(faction.getCapitalMoves()).thenReturn(0); + faction.setCapitalMoves(0); CapitalMovePending state = renameTo("The_Ashen_Rose"); - when(faction.getCapitalMoves()).thenReturn(1); - when(bank.getWealth()).thenReturn(500.0); + faction.setCapitalMoves(1); + TestTreasuries.set(bank, 500.0); assertEquals(Outcome.REFUSED, CapitalMovePrompt.confirm(player, state, true)); assertUnchangedAndUncharged(); + assertEquals(500.0, bank.getWealth()); } @Test void capitalMovedWhileOpenChargesNothing() { - when(faction.getCapitalMoves()).thenReturn(1); - when(bank.getWealth()).thenReturn(500.0); + faction.setCapitalMoves(1); + TestTreasuries.set(bank, 500.0); CapitalMovePending state = renameTo("The_Ashen_Rose"); - when(faction.getCapital()).thenReturn(393); + faction.setCapital(393, true, false); assertEquals(Outcome.REFUSED, CapitalMovePrompt.confirm(player, state, true)); assertUnchangedAndUncharged(); + assertEquals(500.0, bank.getWealth()); } @Test void failedRenameChargesNothing() { - when(faction.getCapitalMoves()).thenReturn(1); - when(bank.getWealth()).thenReturn(500.0); + faction.setCapitalMoves(1); + TestTreasuries.set(bank, 500.0); // Fort Yuri's id belongs to the other settlement, so the rename fails. assertEquals(Outcome.REFUSED, CapitalMovePrompt.confirm(player, renameTo("Fort_Yuri"), true)); assertUnchangedAndUncharged(); + assertEquals(500.0, bank.getWealth()); } private void assertUnchangedAndUncharged() { assertTrue(capital.getName().endsWith("Arclock Cove")); - verify(bank, never()).withdraw(anyDouble()); - verify(faction, never()).setCapitalMoves(anyInt()); + assertEquals(1, faction.getCapitalMoves()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java index 73f8846e..d18d91af 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java @@ -10,6 +10,7 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.objects.request.RelocateRequest; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; import net.tfminecraft.simplefactions.utils.DisplayNameGate; import org.bukkit.entity.Player; import org.bukkit.event.player.AsyncPlayerChatEvent; @@ -53,7 +54,7 @@ void setup() throws Exception { fixture.provinceData.put(id, new Province(id, "PLAINS", 50)); guild = fixture.guild(host, "traders", "Alice"); guild.setCapital(1); - guild.getBank().setWealth(100.0); + TestTreasuries.set(guild.getBank(), 100.0); host.getSettlementHandler().found("Old_City", 1, 0, 0); player = fixture.player("Alice"); receiver = fixture.player("Bob"); @@ -132,7 +133,7 @@ void leadershipMustStillBelongToThePersonAnswering() { @Test void moneySpentWhilePromptWasOpenCannotBeSpentAgain() { begin(2); - guild.getBank().setWealth(5.0); + TestTreasuries.set(guild.getBank(), 5.0); assertTrue(chat("New_City").isCancelled()); fixture.ui.runTasks(); assertEquals(1, guild.getCapital()); @@ -249,18 +250,15 @@ void failedClaimRestoresTheOldCapitalWithoutCharging() { assertEquals(1, guild.getCapital()); assertSame(old, host.getSettlementHandler().getByProvince(1)); assertEquals(100.0, guild.getBank().getWealth()); - verify(fixture.map).claim(player, host, 4, true); + assertFalse(host.hasProvince(4)); } @Test void successfulClaimMovesAndChargesAfterFounding() { - doAnswer( - call -> { - host.addProvince(4); - return null; - }) - .when(fixture.map) - .claim(player, host, 4, true); + host.setCapital(2, true, false); + host.setPrestige(100000.0); + fixture.provinceData.get(2).addNeighbour(4); + fixture.provinceData.get(4).addNeighbour(2); assertTrue( RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "New_City", 20)); assertEquals(4, guild.getCapital()); @@ -280,13 +278,10 @@ void directRelocationRequiresTheCurrentGuildLeader() { @Test void successfulNewClaimAlsoRemovesTheVacatedCity() { - doAnswer( - call -> { - host.addProvince(4); - return null; - }) - .when(fixture.map) - .claim(player, host, 4, true); + host.setCapital(2, true, false); + host.setPrestige(100000.0); + fixture.provinceData.get(2).addNeighbour(4); + fixture.provinceData.get(4).addNeighbour(2); assertTrue( RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "New_City", 20)); assertNull(host.getSettlementHandler().getByProvince(1)); @@ -296,13 +291,10 @@ void successfulNewClaimAlsoRemovesTheVacatedCity() { @Test void anInvalidNameCannotClaimLandBeforeRelocationIsRejected() { host.getSettlementHandler().found("Taken", 3, 0, 0); - doAnswer( - call -> { - host.addProvince(4); - return null; - }) - .when(fixture.map) - .claim(player, host, 4, true); + host.setCapital(2, true, false); + host.setPrestige(100000.0); + fixture.provinceData.get(2).addNeighbour(4); + fixture.provinceData.get(4).addNeighbour(2); assertFalse(RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "Taken", 20)); assertFalse(host.hasProvince(4)); assertEquals(1, guild.getCapital()); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java index bf5231eb..35b27377 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java @@ -14,6 +14,7 @@ import net.tfminecraft.simplefactions.objects.request.RelationRequest; import net.tfminecraft.simplefactions.objects.request.RelocateRequest; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; import org.bukkit.entity.Player; import org.junit.jupiter.api.AfterEach; @@ -141,7 +142,7 @@ private void requestRelocation() { guild = domain.guild(origin, "traders", "GuildLeader"); Player sender = domain.player("GuildLeader"); guild.setCapital(1); - guild.getBank().setWealth(500.0); + TestTreasuries.set(guild.getBank(), 500.0); FactionManager.requestRelocation(sender, guild, target, 2, "Destination"); assertSame( guild, assertInstanceOf(RelocateRequest.class, RequestManager.getRequest(bob)).getSender()); @@ -184,7 +185,7 @@ void relocationConsentDoesNotFollowAChangedOrRemovedGuild(String change) { @Test void aBaseGuildCannotBeChargedForARelocationThatDoesNothing() { guild = origin.getOrCreateMainGuild(); - guild.getBank().setWealth(500.0); + TestTreasuries.set(guild.getBank(), 500.0); FactionManager.requestRelocation(alice, guild, target, 2, "Destination"); assertDoesNotThrow(() -> RequestManager.accept(bob)); assertEquals(500.0, guild.getBank().getWealth()); @@ -277,4 +278,15 @@ void unchangedConsentMovesExactlyTheRequestedGuildAndChargesOnce() { RequestManager.accept(bob); assertEquals(400.0, guild.getBank().getWealth()); } + @Test void aRelocationRequestCapturesBothCharacterSessions() { + requestRelocation(); + try (var identity = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.identity.Members.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + identity.when(() -> net.tfminecraft.simplefactions.identity.Members.isCurrent(org.mockito.ArgumentMatchers.any())).thenReturn(false); + RequestManager.accept(bob); + } + assertSame(origin, guild.getFaction()); + assertEquals(500, guild.getBank().getWealth()); + } + } From 3134e221a83c9662f68672672a525b6cc497913a Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:38:00 +0000 Subject: [PATCH 28/35] feat: journal character vehicle payments and bootstrap legacy payers --- .../request/VehicleHandoverRequest.java | 17 +- .../vehicles/VehicleFactionCommands.java | 4 +- .../berth/FactionVehicleReleaseService.java | 11 +- .../vehicles/fees/VehicleFeeService.java | 73 +---- .../vehicles/fees/VehicleFeeStore.java | 113 ++++---- .../vehicles/fees/VehiclePayments.java | 209 +++++++++++++ .../fees/VehicleReclaimFeeListener.java | 22 +- .../fees/VehicleRegistrationFeeListener.java | 64 ++-- .../handover/VehicleHandoverService.java | 33 ++- .../maintenance/DenarEconomyPlayerBank.java | 60 +--- .../VehicleMaintenancePayListener.java | 7 +- .../VehicleMaintenancePayService.java | 59 +--- .../VehicleMaintenancePaySessionManager.java | 11 + .../VehicleMaintenancePersistence.java | 6 +- .../maintenance/VehicleSettlement.java | 22 +- .../maintenance/VehicleUpkeepProjection.java | 50 ++-- .../maintenance/VehicleUpkeepService.java | 180 +----------- .../registry/VehicleOwnershipQueries.java | 2 +- .../registry/VehiclePayerMigration.java | 79 +++++ .../registry/VehicleRegistryPersistence.java | 56 ++-- .../berth/FactionVehicleGiveServiceTest.java | 5 +- .../FactionVehicleReleaseServiceTest.java | 13 +- ...InstallationVehicleUnberthServiceTest.java | 11 +- .../VehicleInteractionsCoverageTest.java | 128 ++++---- ...ehicleTransactionBoundaryCoverageTest.java | 69 +---- .../vehicles/fees/VehicleFeeServiceTest.java | 28 -- .../vehicles/fees/VehiclePaymentsTest.java | 175 +++++++++++ .../maintenance/CharacterVehicleBankTest.java | 33 +++ .../VehicleMaintenanceBankCommandTest.java | 7 +- .../VehicleMaintenancePayAndRepairTest.java | 209 ++++--------- .../VehicleRuntimeLifecycleCoverageTest.java | 273 ++--------------- .../maintenance/VehicleSettlementTest.java | 17 ++ .../VehicleUpkeepProjectionTest.java | 3 +- .../maintenance/VehicleUpkeepServiceTest.java | 274 +----------------- .../maintenance/VehicleUpkeepTaxTest.java | 166 ----------- .../registry/PlayerVehicleRegistryTest.java | 4 +- .../registry/VehicleOwnerKeysTest.java | 2 +- .../registry/VehiclePayerMigrationTest.java | 85 ++++++ .../VehicleRegistryBoundaryCoverageTest.java | 19 +- 39 files changed, 1075 insertions(+), 1524 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/CharacterVehicleBankTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index c7beaad3..be9cf61a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -1,6 +1,9 @@ package net.tfminecraft.simplefactions.objects.request; import java.util.UUID; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.bukkit.Bukkit; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; @@ -19,6 +22,10 @@ public final class VehicleHandoverRequest extends Request { private final UUID recipientUuid; private final String feeFactionId; private final double feeAmount; + private final UUID feeIncarnation; + private final CharacterRef ownerCharacter; + private final CharacterRef recipientCharacter; + private final String operationId = "sf:vehicle:handover:" + UUID.randomUUID(); /** The sender guild is null when the owner is in no faction. */ public VehicleHandoverRequest( @@ -34,9 +41,12 @@ public VehicleHandoverRequest( this.ownerUuid = ownerUuid; this.ownerName = ownerName; this.recipientUuid = recipientUuid; - this.ownerKey = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.ownerKey(ownerName); + this.ownerCharacter = Members.session(Bukkit.getPlayer(ownerUuid)).map(value -> value.ref()).orElse(null); + this.recipientCharacter = Members.session(Bukkit.getPlayer(recipientUuid)).map(value -> value.ref()).orElse(null); + this.ownerKey = Members.key(ownerCharacter); Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerKey, vehicleTypeId); this.feeFactionId = quote == null ? null : quote.faction().getId(); + this.feeIncarnation = quote == null ? null : quote.faction().getOrCreateMainGuild().getIncarnationId(); this.feeAmount = quote == null ? 0.0 : quote.amount(); this.time = System.currentTimeMillis() + InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L; @@ -66,8 +76,13 @@ public UUID getRecipientUuid() { return recipientUuid; } + public CharacterRef getOwnerCharacter() { return ownerCharacter; } + public CharacterRef getRecipientCharacter() { return recipientCharacter; } + public String getOperationId() { return operationId; } + public boolean matchesFee(Quote quote) { return java.util.Objects.equals(feeFactionId, quote == null ? null : quote.faction().getId()) + && java.util.Objects.equals(feeIncarnation, quote == null ? null : quote.faction().getOrCreateMainGuild().getIncarnationId()) && Double.compare(feeAmount, quote == null ? 0.0 : quote.amount()) == 0; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java index 4b9374e6..ff731433 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java @@ -176,6 +176,8 @@ public static void armMaintenancePay(Player player) { } public static void armMaintenancePay(Player player, PaymentSource source) { + var character = Members.session(player).orElse(null); + if (character == null) { player.sendMessage("§cSelect a living character first."); return; } if (source == PaymentSource.POUCH && FactionManager.getByLeader(Members.key(player)) == null) { player.sendMessage(VehicleMaintenanceMessages.notLeader()); return; @@ -187,7 +189,7 @@ public static void armMaintenancePay(Player player, PaymentSource source) { plugin.getVehicleHandoverSessionManager().clear(player.getUniqueId()); plugin.getVehicleMaintenancePaySessionManager().put( player.getUniqueId(), - new VehicleMaintenancePaySession(System.currentTimeMillis() + timeoutMillis, source)); + new VehicleMaintenancePaySession(System.currentTimeMillis() + timeoutMillis, source, character)); player.sendMessage(VehicleMaintenanceMessages.payArmed(source)); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java index fe74cba8..86706e61 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java @@ -1,7 +1,11 @@ package net.tfminecraft.simplefactions.vehicles.berth; +import net.tfminecraft.simplefactions.identity.Members; + import java.time.Instant; import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; import java.util.function.BooleanSupplier; import org.json.simple.JSONObject; @@ -142,9 +146,10 @@ private Outcome release( if (!apply) { return outcome(Status.OK, null, typeId); } - // Drop the faction record and save it before the owner changes, so a failed save can - // never leave a personal vehicle that still loads as a faction vehicle. - registry.unregister(vehicleUuid); + // Record the captured recipient as payer before changing the external ownership tag. + UUID recipientCharacter = Members.session(Bukkit.getPlayerExact(recipientName)).map(session -> session.ref().characterId()).orElse(null); + if (recipientCharacter == null) return outcome(Status.OWNERSHIP_UNAVAILABLE, null, typeId); + registry.register(new PlayerVehicleRecord(recipientCharacter, vehicleUuid, typeId, OwnershipMode.PERSONAL, null)); if (!registrySaver.getAsBoolean()) { // The file still holds the record, so restoring it in memory is enough. registry.register(record); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java index b66ef6ea..ed5bb46d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java @@ -3,15 +3,10 @@ import java.util.UUID; import java.util.function.Function; -import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; -import net.tfminecraft.simplefactions.player.income.PlayerLedger; -import net.tfminecraft.simplefactions.utils.MemberAccounts; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; @@ -56,70 +51,6 @@ private static boolean isLeader(Faction faction, String payer) { return faction.getLeader() != null && faction.getLeader().equals(payer); } - /** - * Takes the quote from the payer's bank into the faction bank. Returns false, moving - * nothing, when the payer cannot cover it. - */ - public static boolean collect(UUID payerUuid, Quote quote) { - if (payerUuid == null || quote == null) { - return false; - } - if (quote.faction().getBank() == null || !playerBank.withdrawFromBank(payerUuid, quote.amount())) { - return false; - } - credit(payerUuid, quote); - return true; - } - - /** Pays an already withdrawn quote into the faction bank and records it on both ledgers. */ - public static void credit(UUID payerUuid, Quote quote) { - Bank bank = quote.faction().getBank(); - if (bank != null) { - bank.deposit(quote.amount()); - } - quote.faction().getOrCreateMainGuild().getLedger().addVehicleFeeEntry(quote.amount()); - recordPlayer(payerUuid, quote.kind(), -quote.amount()); - } - - /** - * Pays a collected charge back from the faction bank, as far as the bank covers it. - * Returns the denars refunded. - */ - public static double refund(UUID payerUuid, String factionId, FeeKind kind, double amount) { - if (payerUuid == null || amount <= 0.0) { - return 0.0; - } - Faction faction = factionId == null ? null : FactionManager.getByString(factionId); - Bank bank = faction == null ? null : faction.getBank(); - Double wealth = bank == null ? null : bank.getWealth(); - if (wealth == null || wealth <= 0.0) { - return 0.0; - } - double refunded = Math.min(amount, wealth); - if (!playerBank.depositToBank(payerUuid, refunded)) { - return 0.0; - } - bank.withdraw(refunded); - faction.getOrCreateMainGuild().getLedger().addVehicleFeeEntry(-refunded); - recordPlayer(payerUuid, kind, refunded); - return refunded; - } - - public static UUID resolve(String playerName) { - return playerBank.resolve(playerName); - } - - public static double bankBalance(UUID playerUuid) { - return playerUuid == null ? 0.0 : playerBank.getBankBalance(playerUuid); - } - - private static void recordPlayer(UUID payerUuid, FeeKind kind, double amount) { - if (SimpleFactions.getInstance() == null) { - return; - } - PlayerLedger ledger = SimpleFactions.getPlayerEconomyManager().getLedger(MemberAccounts.accountOf(payerUuid)); - if (ledger != null) { - ledger.add(kind == FeeKind.VEHICLE_TAX ? PlayerCashflow.VEHICLE_TAX : PlayerCashflow.VEHICLE_FEES, amount); - } - } + public static UUID resolve(String member) { return playerBank.resolve(member); } + public static double bankBalance(UUID character) { return character == null ? 0 : playerBank.getBankBalance(character); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java index 91fdbc0b..96961a06 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java @@ -1,20 +1,13 @@ package net.tfminecraft.simplefactions.vehicles.fees; import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; import java.io.IOException; -import java.io.Reader; -import java.io.Writer; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; +import java.io.UncheckedIOException; import java.util.HashMap; import java.util.Map; import java.util.UUID; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.Receipt; /** * State the vehicle fees need across restarts: who last owned each personal vehicle, so @@ -22,14 +15,26 @@ * paid for constructions still in progress, so a cancelled build can be refunded. */ public final class VehicleFeeStore { - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); /** A registration fee paid for a construction that has not finished yet. */ public record PaidBuild(UUID payerUuid, String factionId, double amount) {} private final Map<String, String> lastOwners = new HashMap<>(); private final Map<String, PaidBuild> paidBuilds = new HashMap<>(); + private final Map<String, String> buildOwners = new HashMap<>(); + public String buildOwner(String station) { return buildOwners.get(station); } + public void beginBuild(String station, String character) { buildOwners.put(station, character); } + public void endBuild(String station) { buildOwners.remove(station); } + + private final Map<String, Receipt> receipts = new HashMap<>(); private File file; + private boolean loadFailed; + + public Receipt receipt(String scope) { return receipts.get(scope); } + public void putReceipt(String scope, Receipt receipt) { receipts.put(scope, receipt); } + public void removeReceipt(String scope) { receipts.remove(scope); } + public Map<String, PaidBuild> pendingBuilds() { return Map.copyOf(paidBuilds); } + public PaidBuild getPaidBuild(String stationKey) { return paidBuilds.get(stationKey); } public String getLastOwner(String vehicleUuid) { return vehicleUuid == null ? null : lastOwners.get(vehicleUuid); @@ -66,65 +71,63 @@ public void bind(File cacheFolder) { } public void load() { - if (file == null || !file.exists()) { - return; - } - try (Reader reader = new FileReader(file)) { - Data data = GSON.fromJson(reader, Data.class); - if (data == null) { - return; - } - lastOwners.clear(); - if (data.lastOwners != null) { - lastOwners.putAll(data.lastOwners); - } - paidBuilds.clear(); + if (file == null || !file.exists()) return; + try { + Data data = DurableJson.read(file.toPath(), Data.class); + if (data == null) throw new IOException("Vehicle fee state must be an object"); + Map<String, PaidBuild> loadedBuilds = new HashMap<>(); if (data.paidBuilds != null) { - for (Map.Entry<String, BuildData> entry : data.paidBuilds.entrySet()) { + for (var entry : data.paidBuilds.entrySet()) { PaidBuild build = entry.getValue() == null ? null : entry.getValue().toBuild(); - if (build != null) { - paidBuilds.put(entry.getKey(), build); - } + if (build == null) throw new IOException("Invalid pending vehicle build: " + entry.getKey()); + loadedBuilds.put(entry.getKey(), build); + } + } + Map<String, Receipt> loadedReceipts = new HashMap<>(); + if (data.receipts != null) { + for (var entry : data.receipts.entrySet()) { + Receipt receipt = entry.getValue(); + if (receipt == null || receipt.id() == null || receipt.payer() == null + || receipt.guildIncarnation() == null || receipt.cents() <= 0 || receipt.kind() == null) + throw new IOException("Invalid vehicle payment receipt: " + entry.getKey()); + loadedReceipts.put(entry.getKey(), receipt); } } - } catch (IOException | RuntimeException e) { - e.printStackTrace(); + lastOwners.clear(); + if (data.lastOwners != null) lastOwners.putAll(data.lastOwners); + buildOwners.clear(); + if (data.buildOwners != null) buildOwners.putAll(data.buildOwners); + paidBuilds.clear(); paidBuilds.putAll(loadedBuilds); + receipts.clear(); receipts.putAll(loadedReceipts); + loadFailed = false; + } catch (IOException | RuntimeException error) { + loadFailed = true; + throw new IllegalStateException("Cannot load vehicle fees", error); } } - public void save() { - if (file == null || (lastOwners.isEmpty() && paidBuilds.isEmpty() && !file.exists())) { - return; - } + public void saveDurably() throws IOException { + if (loadFailed || file == null) throw new IOException("Vehicle fee store is unavailable"); Data data = new Data(); data.lastOwners = new HashMap<>(lastOwners); data.paidBuilds = new HashMap<>(); - for (Map.Entry<String, PaidBuild> entry : paidBuilds.entrySet()) { - data.paidBuilds.put(entry.getKey(), BuildData.from(entry.getValue())); - } - // Written beside the file and moved into place, so an interrupted save keeps the old file. - File temp = new File(file.getParentFile(), file.getName() + ".tmp"); - try (Writer writer = new FileWriter(temp)) { - GSON.toJson(data, writer); - } catch (IOException e) { - e.printStackTrace(); - return; - } - try { - try { - Files.move(temp.toPath(), file.toPath(), - StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException e) { - e.printStackTrace(); - } + paidBuilds.forEach((key, value) -> data.paidBuilds.put(key, BuildData.from(value))); + data.receipts = new HashMap<>(receipts); + data.buildOwners = new HashMap<>(buildOwners); + DurableJson.write(file.toPath(), data); + } + + public void save() { + if (file == null || (lastOwners.isEmpty() && paidBuilds.isEmpty() && receipts.isEmpty() && buildOwners.isEmpty() && !file.exists())) return; + try { saveDurably(); } + catch (IOException error) { throw new UncheckedIOException(error); } } private static final class Data { Map<String, String> lastOwners; Map<String, BuildData> paidBuilds; + Map<String, Receipt> receipts; + Map<String, String> buildOwners; } private static final class BuildData { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java new file mode 100644 index 00000000..75cb25ec --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java @@ -0,0 +1,209 @@ +package net.tfminecraft.simplefactions.vehicles.fees; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryPersistence; +import net.tfminecraft.simplefactions.economy.OperationJournal; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** Durable vehicle payment intents. An unfinished physical handover/build stays quarantined by scope. */ +public final class VehiclePayments { + public record Receipt(String id, CharacterRef payer, String factionId, UUID guildIncarnation, FeeKind kind, long cents) {} + public record Refund(Result result, double amount) {} + private static VehiclePayments active; + private final Path folder; + private final VehicleFeeStore store; + private final VehicleMaintenanceStore maintenance; + private final OperationJournal journal; + private boolean ready; + + public VehiclePayments(Path folder, VehicleFeeStore store, VehicleMaintenanceStore maintenance) { + this.folder = folder; + this.store = store; + this.maintenance = maintenance; + journal = new OperationJournal(folder.resolve("vehicle-payments")); + } + + public static void startup(Path folder, VehicleFeeStore store, VehicleMaintenanceStore maintenance) throws IOException { + active = new VehiclePayments(folder, store, maintenance); + active.recover(); + } + public static VehiclePayments current() { return active; } + public static void stop() { active = null; } + + public void recover() throws IOException { + ready = false; + journal.recover(this::apply); + // Legacy paid builds already belong to imported treasuries. Capture their incarnation once. + for (var entry : store.pendingBuilds().entrySet()) { + String scope = "build:" + entry.getKey(); + if (store.receipt(scope) != null) continue; + var old = entry.getValue(); + var payer = Members.ref(old.payerUuid().toString()).orElseThrow(() -> new IOException("Unresolved legacy vehicle refund payer")); + var faction = FactionManager.getByString(old.factionId()); + if (faction == null) throw new IOException("Unresolved legacy vehicle refund treasury"); + Guild guild = faction.getGuildHandler().getGuild(faction.getId()); + if (guild == null) throw new IOException("Missing legacy vehicle refund treasury"); + store.putReceipt(scope, new Receipt("sf:vehicle:legacy:" + entry.getKey(), payer, old.factionId(), + guild.getIncarnationId(), FeeKind.REGISTRATION_FEE, Money.cents(old.amount()))); + } + store.saveDurably(); + ready = true; + } + + public Receipt receipt(String scope) { return store.receipt(scope); } + public boolean ready() { return ready; } + public boolean blockedVehicle(String vehicle) { return receipt("claim:" + vehicle) != null || receipt("handover:" + vehicle) != null; } + public void fail(Throwable error) { ready = false; EconomyRuntime.failClosed(error); } + public boolean personal(String vehicle, String type, UUID character) { + if (!ready) return false; + try { + VehicleRegistryPersistence.personal(folder, SimpleFactions.getVehicleRegistry(), vehicle, type, character); + return true; + } catch (IOException | RuntimeException error) { fail(error); return false; } + } + + public Result charge(String scope, String operationId, CharacterRef payer, VehicleFeeService.Quote quote) { + if (!ready) return Result.UNAVAILABLE; + if (store.receipt(scope) != null) return Result.CONFLICT; + if (payer == null || quote == null || quote.faction().getBank() == null) return Result.REFUSED; + Guild guild = quote.faction().getGuildHandler().getGuild(quote.faction().getId()); + if (guild == null) return Result.REFUSED; + long cents = Money.cents(quote.amount()); + if (cents <= 0) return Result.REFUSED; + Receipt receipt = new Receipt(operationId, payer, quote.faction().getId(), guild.getIncarnationId(), quote.kind(), cents); + JsonObject changes = new JsonObject(); + changes.addProperty("action", "charge"); + changes.addProperty("scope", scope); + changes.add("receipt", JsonUtil.GSON.toJsonTree(receipt)); + bookkeeping(changes, receipt, guild, cents); + return execute(new OperationJournal.Intent(operationId, "vehicle:" + quote.kind().name(), + List.of(new Leg(Denars.bank(payer), -cents), new Leg(Denars.guild(receipt.guildIncarnation()), cents)), changes)); + } + + public Refund refund(String scope) { + if (!ready) return new Refund(Result.UNAVAILABLE, 0); + Receipt receipt = store.receipt(scope); + if (receipt == null) return new Refund(Result.REFUSED, 0); + long cents; + try { cents = Math.min(receipt.cents(), Math.max(0L, Denars.balance(Denars.guild(receipt.guildIncarnation())))); } + catch (RuntimeException error) { fail(error); return new Refund(Result.UNAVAILABLE, 0); } + if (cents == 0) return new Refund(complete(scope, null) ? Result.APPLIED : Result.UNAVAILABLE, 0); + JsonObject changes = new JsonObject(); + changes.addProperty("action", "refund"); + changes.addProperty("scope", scope); + changes.add("receipt", JsonUtil.GSON.toJsonTree(receipt)); + bookkeeping(changes, receipt, guild(receipt), -cents); + Result result = execute(new OperationJournal.Intent(receipt.id() + ":refund", "vehicle:refund", + List.of(new Leg(Denars.guild(receipt.guildIncarnation()), -cents), new Leg(Denars.bank(receipt.payer()), cents)), changes)); + return new Refund(result, result == Result.APPLIED ? Money.denars(cents) : 0); + } + + public Result maintain(String id, CharacterRef payer, String vehicle, boolean bank, long cents) { + if (!ready) return Result.UNAVAILABLE; + if (payer == null || !maintenance.isUnpaid(vehicle)) return Result.REFUSED; + JsonObject changes = new JsonObject(); + changes.addProperty("action", "maintenance"); + var after = new LinkedHashMap<>(maintenance.snapshot()); + after.remove(vehicle); + changes.add("maintenance", JsonUtil.GSON.toJsonTree(after)); + if (cents <= 0) { + try { applyMaintenance(changes); return Result.APPLIED; } + catch (IOException error) { fail(error); return Result.UNAVAILABLE; } + } + return execute(new OperationJournal.Intent(id, "vehicle:maintenance", + List.of(new Leg(bank ? Denars.bank(payer) : Denars.pouch(payer), -cents), new Leg(Denars.MINT, cents)), changes)); + } + + /** Called only after a successful physical action; a failed save leaves the scope blocked on restart. */ + public boolean complete(String scope, String lastOwner) { + if (!ready) return false; + try { + if (lastOwner != null && scope.startsWith("claim:")) store.setLastOwner(scope.substring(6), lastOwner); + if (lastOwner != null && scope.startsWith("handover:")) store.setLastOwner(scope.substring(9), lastOwner); + clear(scope); + store.saveDurably(); + return true; + } catch (IOException error) { fail(error); return false; } + } + + private Result execute(OperationJournal.Intent intent) { + try { + Result result = journal.execute(intent, this::apply); + if (result == Result.UNAVAILABLE || result == Result.CONFLICT) fail(new IOException("Vehicle payment " + intent.id() + " remains " + result)); + return result; + } catch (IOException | RuntimeException error) { + fail(error); + return Result.UNAVAILABLE; + } + } + + private void apply(OperationJournal.Intent intent) throws IOException { + JsonObject changes = intent.changes(); + String action = changes.get("action").getAsString(); + if (action.equals("maintenance")) { applyMaintenance(changes); return; } + Receipt receipt = JsonUtil.GSON.fromJson(changes.get("receipt"), Receipt.class); + String scope = changes.get("scope").getAsString(); + Guild guild = guild(receipt); + if (guild != null && changes.has("feeIncome")) { + guild.getLedger().setVehicleFeeIncome(changes.get("feeIncome").getAsDouble()); + if (!new Database().saveFactionDurably(guild.getFaction())) throw new IOException("Could not save vehicle fee income"); + } + if (action.equals("charge")) store.putReceipt(scope, receipt); + else if (action.equals("refund")) clear(scope); + else throw new IOException("Unknown vehicle payment action: " + action); + store.saveDurably(); + if (SimpleFactions.getInstance() != null && changes.has("playerAmount")) { + var ledger = SimpleFactions.getPlayerEconomyManager().getLedger(receipt.payer().characterId()); + PlayerCashflow kind = receipt.kind() == FeeKind.VEHICLE_TAX ? PlayerCashflow.VEHICLE_TAX : PlayerCashflow.VEHICLE_FEES; + ledger.add(kind, changes.get("playerAmount").getAsDouble() - ledger.getAmount(kind)); + } + } + + private void applyMaintenance(JsonObject changes) throws IOException { + java.util.Map<String, Long> after = new LinkedHashMap<>(); + changes.getAsJsonObject("maintenance").entrySet().forEach(entry -> after.put(entry.getKey(), entry.getValue().getAsLong())); + DurableJson.write(folder.resolve("Cache/vehicle_maintenance.json"), after); + maintenance.replaceAll(after); + } + + private void clear(String scope) { + store.removeReceipt(scope); + if (scope.startsWith("build:")) { store.takePaidBuild(scope.substring(6)); store.endBuild(scope.substring(6)); } + } + + private static Guild guild(Receipt receipt) { + var faction = FactionManager.getByString(receipt.factionId()); + if (faction == null) return null; + Guild guild = faction.getGuildHandler().getGuild(faction.getId()); + return guild != null && guild.getIncarnationId().equals(receipt.guildIncarnation()) ? guild : null; + } + + private static void bookkeeping(JsonObject changes, Receipt receipt, Guild guild, long cents) { + if (guild != null) changes.addProperty("feeIncome", Money.denars(Math.addExact(Money.cents(guild.getLedger().getVehicleFeeIncome()), cents))); + if (SimpleFactions.getInstance() != null) { + var ledger = SimpleFactions.getPlayerEconomyManager().getLedger(receipt.payer().characterId()); + var kind = receipt.kind() == FeeKind.VEHICLE_TAX ? PlayerCashflow.VEHICLE_TAX : PlayerCashflow.VEHICLE_FEES; + changes.addProperty("playerAmount", ledger.getAmount(kind) - Money.denars(cents)); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java index 1a7509d7..c26f82ca 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.vehicles.fees; import org.bukkit.entity.Player; +import java.util.UUID; +import net.tfminecraft.denareconomy.api.Result; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -38,6 +40,12 @@ public void onVehicleOwnerClaimed(VehicleOwnerClaimedEvent event) { if (player == null || vehicle == null || vehicle.getUUID() == null) { return; } + var session = Members.session(player).orElse(null); + var payments = VehiclePayments.current(); + String scope = "claim:" + vehicle.getUUID(); + if (session == null || payments == null || !payments.ready() || payments.blockedVehicle(vehicle.getUUID())) { + event.setCancelled(true); player.sendMessage("§cVehicle payment needs a living character or staff recovery."); return; + } String lastOwner = store.getLastOwner(vehicle.getUUID()); if (lastOwner == null || Members.is(player, lastOwner) || Permissions.isAdmin(player)) { return; @@ -47,14 +55,14 @@ public void onVehicleOwnerClaimed(VehicleOwnerClaimedEvent event) { return; } long now = System.currentTimeMillis(); - String target = "claim:" + vehicle.getUUID(); + String target = scope + ":" + session.ref().characterId() + ":" + quote.faction().getOrCreateMainGuild().getIncarnationId(); if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) { event.setCancelled(true); confirmations.ask(player.getUniqueId(), target, quote.amount(), now); player.sendMessage(VehicleFeeMessages.claimConfirm(quote, Members.display(lastOwner))); return; } - if (!VehicleFeeService.collect(VehicleRegistrationFeeListener.payerUuid(player), quote)) { + if (payments.charge(scope, "sf:vehicle:claim:" + UUID.randomUUID(), session.ref(), quote) != Result.APPLIED) { event.setCancelled(true); player.sendMessage(VehicleFeeMessages.claimUnaffordable(quote)); return; @@ -67,8 +75,14 @@ public void onClaimed(VehicleOwnerClaimedEvent event) { if (event.getPlayer() == null || event.getVehicle() == null) { return; } - store.setLastOwner(event.getVehicle().getUUID(), Members.key(event.getPlayer())); - saver.run(); + String scope = "claim:" + event.getVehicle().getUUID(); + var payments = VehiclePayments.current(); + var receipt = payments == null ? null : payments.receipt(scope); + String owner = receipt == null ? Members.session(event.getPlayer()).map(session -> session.ref().characterId().toString()).orElse(null) : receipt.payer().characterId().toString(); + if (owner == null || payments == null || !payments.personal(event.getVehicle().getUUID(), event.getVehicle().getId(), UUID.fromString(owner))) return; + String member = Members.key(UUID.fromString(owner)); + if (receipt != null) payments.complete(scope, member); + else { store.setLastOwner(event.getVehicle().getUUID(), member); saver.run(); } } @EventHandler(priority = EventPriority.MONITOR) diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index fdb3551c..2829d67d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -12,7 +12,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.simplefactions.utils.MemberAccounts; +import net.tfminecraft.denareconomy.api.Result; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; @@ -48,14 +48,25 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { if (player == null || blueprint == null) { return; } + var session = Members.session(player).orElse(null); + if (session == null) { event.setCancelled(true); player.sendMessage("§cSelect a living character first."); return; } String vehicleTypeId = VehicleIntegrationListener.resolveVehicleTypeId(blueprint); + String stationKey = stationKey(event.getStation()); + String scope = "build:" + stationKey; + var payments = VehiclePayments.current(); + if (payments == null || !payments.ready() || payments.receipt(scope) != null || store.buildOwner(stationKey) != null || stationKey.equals("unknown")) { + event.setCancelled(true); player.sendMessage("§cVehicle payment needs staff review or recovery."); return; + } Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, Members.key(player), vehicleTypeId); if (quote == null) { + store.beginBuild(stationKey, session.ref().characterId().toString()); + try { store.saveDurably(); } + catch (java.io.IOException error) { payments.fail(error); event.setCancelled(true); } return; } - String stationKey = stationKey(event.getStation()); long now = System.currentTimeMillis(); - String target = "build:" + stationKey + ":" + vehicleTypeId; + String target = scope + ":" + vehicleTypeId + ":" + session.ref().characterId() + + ":" + quote.faction().getOrCreateMainGuild().getIncarnationId(); if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) { event.setCancelled(true); event.setKeepPlacement(true); @@ -63,33 +74,33 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { player.sendMessage(VehicleFeeMessages.registrationConfirm(quote, vehicleTypeId)); return; } - UUID payer = payerUuid(player); - if (!VehicleFeeService.collect(payer, quote)) { + if (payments.charge(scope, "sf:vehicle:build:" + UUID.randomUUID(), session.ref(), quote) != Result.APPLIED) { event.setCancelled(true); player.sendMessage(VehicleFeeMessages.registrationUnaffordable(quote)); return; } - store.putPaidBuild(stationKey, new PaidBuild(payer, quote.faction().getId(), quote.amount())); - saver.run(); player.sendMessage(VehicleFeeMessages.registrationPaid(quote)); } @EventHandler(priority = EventPriority.MONITOR) public void onConstructionCancel(VehicleConstructionCancelEvent event) { - PaidBuild build = store.takePaidBuild(stationKey(event.getStation())); + String scope = "build:" + stationKey(event.getStation()); + var payments = VehiclePayments.current(); + var build = payments == null ? null : payments.receipt(scope); if (build == null) { + if (store.buildOwner(stationKey(event.getStation())) != null && payments != null) payments.complete(scope, null); return; } - saver.run(); - double refunded = VehicleFeeService.refund( - build.payerUuid(), build.factionId(), FeeKind.REGISTRATION_FEE, build.amount()); - double shortfall = Formatter.formatDouble(build.amount() - refunded); + var refund = payments.refund(scope); + if (refund.result() != Result.APPLIED) return; + double refunded = refund.amount(); + double shortfall = Formatter.formatDouble(net.tfminecraft.simplefactions.economy.Money.denars(build.cents()) - refunded); if (shortfall > 0.0 && SimpleFactions.getInstance() != null) { // The faction bank may be short or the deposit may have failed; either way staff are told. SimpleFactions.getInstance().getLogger().warning("Registration fee refund short by " + shortfall - + " for " + build.payerUuid() + " from faction " + build.factionId()); + + " for " + build.payer().characterId() + " from faction " + build.factionId()); } - Player payer = Bukkit.getPlayer(MemberAccounts.accountOf(build.payerUuid())); + Player payer = Members.online(build.payer().characterId().toString()).orElse(null); if (payer != null && payer.isOnline()) { if (refunded > 0.0) { payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded)); @@ -102,24 +113,17 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVehicleConstruct(VehicleConstructEvent event) { - PaidBuild paid = store.takePaidBuild(stationKey(event.getStation())); - boolean changed = paid != null; - if (event.getVehicle() != null && event.getConstructorUuid() != null) { - // The builder's character; an offline builder is known only by the fee they paid. - String owner = event.getConstructor() != null - ? Members.key(event.getConstructor()) - : paid == null ? null : Members.key(paid.payerUuid()); - if (owner != null) { - store.setLastOwner(event.getVehicle().getUUID(), owner); - changed = true; - } - } - if (changed) { - saver.run(); - } + String scope = "build:" + stationKey(event.getStation()); + var payments = VehiclePayments.current(); + var paid = payments == null ? null : payments.receipt(scope); + if (event.getVehicle() == null || event.getVehicle().getUUID() == null || payments == null) return; + String owner = paid != null ? paid.payer().characterId().toString() : store.buildOwner(stationKey(event.getStation())); + if (owner == null || !payments.personal(event.getVehicle().getUUID(), event.getVehicle().getId(), UUID.fromString(owner))) return; + store.setLastOwner(event.getVehicle().getUUID(), Members.key(UUID.fromString(owner))); + payments.complete(scope, null); } - /** The paying character; its account is charged. */ + /** The paying character, without an account fallback. */ static UUID payerUuid(Player player) { return VehicleOwnershipQueries.characterId(player); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java index 53f15c77..32e5a3f1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.vehicles.handover; import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; import java.util.Objects; import java.util.UUID; @@ -96,6 +98,9 @@ private String personalVehicleType(String ownerName, String vehicleUuid) { } public void offer(Player owner, Player recipient, String vehicleUuid, String vehicleTypeId) { + if (Members.session(owner).isEmpty() || Members.session(recipient).isEmpty()) { + owner.sendMessage("§cBoth players must select a living character."); return; + } Guild guild = FactionManager.getGuildByMember(Members.key(owner)); VehicleHandoverRequest request = new VehicleHandoverRequest( guild, @@ -125,6 +130,15 @@ public void acceptRequest(Player recipient) { notifyExpired(req, recipient); return; } + if (req.getOwnerCharacter() == null || !Members.session(recipient).map(value -> value.ref()) + .filter(value -> value.equals(req.getRecipientCharacter())).isPresent()) { + recipient.sendMessage("§cThis request belongs to another character."); return; + } + var payments = VehiclePayments.current(); + String scope = "handover:" + req.getVehicleUuid(); + if (payments == null || !payments.ready() || payments.blockedVehicle(req.getVehicleUuid())) { + tellBoth(req, recipient, "§cVehicle payment needs staff review or recovery."); return; + } Outcome outcome = evaluate(req.getOwnerName(), recipient.getName(), req.getVehicleUuid()); if (outcome.status() != Status.OK) { tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); @@ -136,26 +150,19 @@ public void acceptRequest(Player recipient) { tellBoth(req, recipient, VehicleHandoverMessages.feeChanged()); return; } - UUID payer = null; - if (quote != null) { - payer = VehicleFeeService.resolve(req.getOwnerName()); - if (payer == null) { - payer = req.getOwnerUuid(); - } - if (!VehicleFeeService.collect(payer, quote)) { - tellBoth(req, recipient, VehicleFeeMessages.handoverUnaffordable(quote)); - return; - } + if (quote != null && payments.charge(scope, req.getOperationId(), req.getOwnerCharacter(), quote) != Result.APPLIED) { + tellBoth(req, recipient, VehicleFeeMessages.handoverUnaffordable(quote)); return; } if (!ownerAssigner.test(req.getVehicleUuid(), recipient.getName())) { if (quote != null) { - VehicleFeeService.refund(payer, quote.faction().getId(), FeeKind.TRANSFER_FEE, quote.amount()); + payments.refund(scope); } tellBoth(req, recipient, VehicleHandoverMessages.ownershipUnavailable()); return; } - store.setLastOwner(req.getVehicleUuid(), Members.key(recipient)); - saver.run(); + if (!payments.personal(req.getVehicleUuid(), outcome.vehicleTypeId(), req.getRecipientCharacter().characterId())) return; + if (quote != null) payments.complete(scope, Members.key(req.getRecipientCharacter())); + else { store.setLastOwner(req.getVehicleUuid(), Members.key(req.getRecipientCharacter())); saver.run(); } recipient.sendMessage(VehicleHandoverMessages.successRecipient(Members.display(ownerKey))); Player owner = Bukkit.getPlayer(req.getOwnerUuid()); if (owner != null && owner.isOnline()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java index 635678c5..8e00c49e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/DenarEconomyPlayerBank.java @@ -1,65 +1,31 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; import java.util.UUID; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; -import net.tfminecraft.denareconomy.enums.Accounts; -import net.tfminecraft.simplefactions.utils.MemberAccounts; - +/** Read-only projections. Every payment is posted through a durable vehicle intent. */ public final class DenarEconomyPlayerBank { - /** Payer IDs may be character IDs (vehicle registry and fees); money reaches their account. */ public interface PlayerBank { - double getBankBalance(UUID playerUuid); - - boolean withdrawFromBank(UUID playerUuid, double amount); - - boolean depositToBank(UUID playerUuid, double amount); - - /** Account id for this member key (or legacy name), whether or not they are logged in. */ + double getBankBalance(UUID character); UUID resolve(String member); } - - public interface PlayerPouch { - double getPouchBalance(UUID playerUuid); - - boolean withdrawFromPouch(UUID playerUuid, double amount); - } - + public interface PlayerPouch { double getPouchBalance(UUID character); } public static final Impl INSTANCE = new Impl(); - private DenarEconomyPlayerBank() {} public static final class Impl implements PlayerBank, PlayerPouch { private Impl() {} - - @Override - public UUID resolve(String member) { - return MemberAccounts.accountId(member); + @Override public UUID resolve(String member) { + String key = Members.characterId(member) != null ? member : Members.ofAccountName(member); + return Members.ref(key).map(ref -> ref.characterId()).orElse(null); } - - @Override - public double getBankBalance(UUID playerUuid) { - return OfflineModifier.balance(MemberAccounts.accountOf(playerUuid), Accounts.BANK); + @Override public double getBankBalance(UUID character) { + return Members.ref(Members.key(character)).map(ref -> Money.denars(Denars.balance(Denars.bank(ref)))).orElse(0.0); } - - @Override - public boolean withdrawFromBank(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.BANK, -amount); - } - - @Override - public boolean depositToBank(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.BANK, amount); - } - - @Override - public double getPouchBalance(UUID playerUuid) { - return OfflineModifier.balance(MemberAccounts.accountOf(playerUuid), Accounts.POUCH); - } - - @Override - public boolean withdrawFromPouch(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(MemberAccounts.accountOf(playerUuid), Accounts.POUCH, -amount); + @Override public double getPouchBalance(UUID character) { + return Members.ref(Members.key(character)).map(ref -> Money.denars(Denars.balance(Denars.pouch(ref)))).orElse(0.0); } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayListener.java index 90c7e456..718e72d9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayListener.java @@ -4,6 +4,7 @@ import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePaySessionManager.VehicleMaintenancePaySession; import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.VehicleMaintenancePayResult; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.identity.Members; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -43,8 +44,11 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { } void pay(Player leader, VehicleMaintenancePaySession session, String vehicleUuid, String vehicleTypeId) { + if (!Members.isCurrent(session.getCharacter())) { + sessionManager.clear(leader.getUniqueId()); leader.sendMessage("§cThis payment belongs to an earlier character session."); return; + } VehicleMaintenancePayResult result = payService.tryPay( - leader.getUniqueId(), + session.getOperationId(), session.getCharacter().ref(), vehicleUuid, vehicleTypeId, session.getPaymentSource()); @@ -62,6 +66,7 @@ void pay(Player leader, VehicleMaintenancePaySession session, String vehicleUuid case NOT_UNPAID -> leader.sendMessage(VehicleMaintenanceMessages.notUnpaid()); case INSUFFICIENT_POUCH -> leader.sendMessage(VehicleMaintenanceMessages.insufficientPouch()); case INSUFFICIENT_BANK -> leader.sendMessage(VehicleMaintenanceMessages.insufficientBank()); + case UNAVAILABLE -> leader.sendMessage("§cVehicle payment is unavailable pending recovery."); case UNKNOWN_TYPE -> leader.sendMessage(VehicleMaintenanceMessages.unknownType()); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayService.java index 9a00d0cf..ed3dbcfa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayService.java @@ -1,58 +1,30 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; -import java.util.UUID; - +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.economy.Money; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerPouch; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; +import net.tfminecraft.tlibs.character.CharacterRef; public final class VehicleMaintenancePayService { private final VehicleMaintenanceStore store; - private final PlayerPouch playerPouch; - private final PlayerBank playerBank; - public enum PaymentSource { POUCH, BANK } - public VehicleMaintenancePayService(VehicleMaintenanceStore store, PlayerPouch playerPouch) { - this(store, playerPouch, DenarEconomyPlayerBank.INSTANCE); - } - - public VehicleMaintenancePayService(VehicleMaintenanceStore store, PlayerPouch playerPouch, PlayerBank playerBank) { - this.store = store; - this.playerPouch = playerPouch; - this.playerBank = playerBank; - } + public VehicleMaintenancePayService(VehicleMaintenanceStore store) { this.store = store; } public double payAmount(String vehicleTypeId) { return VehiclesConfigLoader.getUpkeep(vehicleTypeId); } - public VehicleMaintenancePayResult tryPay(UUID playerUuid, String vehicleUuid, String vehicleTypeId) { - return tryPay(playerUuid, vehicleUuid, vehicleTypeId, PaymentSource.POUCH); - } - - public VehicleMaintenancePayResult tryPay( - UUID playerUuid, String vehicleUuid, String vehicleTypeId, PaymentSource source) { - if (vehicleUuid == null || !store.isUnpaid(vehicleUuid)) { - return VehicleMaintenancePayResult.NOT_UNPAID; - } - if (!VehiclesConfigLoader.isKnownType(vehicleTypeId)) { - return VehicleMaintenancePayResult.UNKNOWN_TYPE; - } - double amount = payAmount(vehicleTypeId); - if (amount > 0.0) { - boolean paid = switch (source) { - case BANK -> playerBank.withdrawFromBank(playerUuid, amount); - case POUCH -> playerPouch.withdrawFromPouch(playerUuid, amount); - }; - if (!paid) { - return source == PaymentSource.BANK - ? VehicleMaintenancePayResult.INSUFFICIENT_BANK - : VehicleMaintenancePayResult.INSUFFICIENT_POUCH; - } - } - store.clearUnpaid(vehicleUuid); - return VehicleMaintenancePayResult.SUCCESS; + public VehicleMaintenancePayResult tryPay(String operationId, CharacterRef payer, String vehicleUuid, String vehicleTypeId, PaymentSource source) { + if (vehicleUuid == null || !store.isUnpaid(vehicleUuid)) return VehicleMaintenancePayResult.NOT_UNPAID; + if (!VehiclesConfigLoader.isKnownType(vehicleTypeId)) return VehicleMaintenancePayResult.UNKNOWN_TYPE; + var payments = VehiclePayments.current(); + if (payments == null || payer == null) return VehicleMaintenancePayResult.UNAVAILABLE; + Result result = payments.maintain(operationId, payer, vehicleUuid, source == PaymentSource.BANK, Money.cents(payAmount(vehicleTypeId))); + if (result == Result.APPLIED) return VehicleMaintenancePayResult.SUCCESS; + if (result != Result.REFUSED) return VehicleMaintenancePayResult.UNAVAILABLE; + return source == PaymentSource.BANK ? VehicleMaintenancePayResult.INSUFFICIENT_BANK : VehicleMaintenancePayResult.INSUFFICIENT_POUCH; } public enum VehicleMaintenancePayResult { @@ -60,6 +32,7 @@ public enum VehicleMaintenancePayResult { NOT_UNPAID, INSUFFICIENT_POUCH, INSUFFICIENT_BANK, - UNKNOWN_TYPE + UNKNOWN_TYPE, + UNAVAILABLE } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePaySessionManager.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePaySessionManager.java index 6e7578f4..7d5dc3ef 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePaySessionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePaySessionManager.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.PaymentSource; @@ -41,16 +42,26 @@ public void clear(UUID leaderUuid) { public static final class VehicleMaintenancePaySession { private final long expiresAtMillis; private final PaymentSource paymentSource; + private final CharacterSession character; + private final String operationId = "sf:vehicle:maintenance:" + UUID.randomUUID(); public VehicleMaintenancePaySession(long expiresAtMillis) { this(expiresAtMillis, PaymentSource.POUCH); } public VehicleMaintenancePaySession(long expiresAtMillis, PaymentSource paymentSource) { + this(expiresAtMillis, paymentSource, null); + } + + public VehicleMaintenancePaySession(long expiresAtMillis, PaymentSource paymentSource, CharacterSession character) { + this.character = character; this.expiresAtMillis = expiresAtMillis; this.paymentSource = paymentSource; } + public CharacterSession getCharacter() { return character; } + public String getOperationId() { return operationId; } + public PaymentSource getPaymentSource() { return paymentSource; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePersistence.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePersistence.java index eebad53c..bcace503 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePersistence.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePersistence.java @@ -10,7 +10,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; -import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.DurableJson; public final class VehicleMaintenancePersistence { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -38,7 +38,7 @@ public void load() { saveBlocked = false; } catch (IOException | JsonParseException e) { saveBlocked = true; - e.printStackTrace(); + throw new IllegalStateException("Cannot load vehicle maintenance", e); } } @@ -49,7 +49,7 @@ public void save() { return; } try { - JsonUtil.writeJsonAtomic(file, data); + DurableJson.write(file.toPath(), data); } catch (IOException e) { e.printStackTrace(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java index db4f08de..38a4cc98 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java @@ -13,11 +13,13 @@ import net.tfminecraft.simplefactions.database.JsonUtil; import net.tfminecraft.simplefactions.economy.Money; import net.tfminecraft.simplefactions.economy.settlement.RealmSettlementChanges; +import net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds; import net.tfminecraft.simplefactions.government.proposal.FeeKind; import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; import net.tfminecraft.simplefactions.player.income.PlayerCashflow; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; @@ -29,8 +31,11 @@ public final class VehicleSettlement { private VehicleSettlement() {} public static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes) { - calculate(balances, legs, changes, SimpleFactions.getVehicleRegistry(), - SimpleFactions.getInstance().getVehicleMaintenanceStore(), System.currentTimeMillis()); + calculate(balances, legs, changes, System.currentTimeMillis()); + } + + public static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes, long now) { + calculate(balances, legs, changes, SimpleFactions.getVehicleRegistry(), SimpleFactions.getInstance().getVehicleMaintenanceStore(), now); } static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmSettlementChanges changes, @@ -45,6 +50,11 @@ static void calculate(Map<String, Long> balances, Map<String, Long> legs, RealmS } var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); String payer = record == null || record.getPlayerUuid() == null ? null : record.getPlayerUuid().toString(); + var payments = VehiclePayments.current(); + if (payments != null && payments.blockedVehicle(vehicle.getUuid())) { + markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + continue; + } var character = Members.info(payer).filter(CharacterInfo::alive); if (character.isEmpty()) { markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); @@ -55,14 +65,14 @@ var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); long fee = tax == null ? 0 : Money.cents(tax.amount()); long total = Math.addExact(upkeep, fee); String account = Denars.bank(ref); - if ((tax != null && tax.faction().getBank() == null) || balance(balances, legs, account) < total) { + if ((tax != null && SettlementGuilds.capital(tax.faction()).getBank() == null) || balance(balances, legs, account) < total) { markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); continue; } transfer(balances, legs, account, Denars.MINT, upkeep); income(changes, ref.characterId(), PlayerCashflow.VEHICLE_UPKEEP, -upkeep); if (tax != null) { - var guild = tax.faction().getOrCreateMainGuild(); + var guild = SettlementGuilds.capital(tax.faction()); var saved = changes.guilds.stream().filter(value -> guild.getIncarnationId().equals(value.incarnation)) .findFirst().orElseThrow(() -> new IllegalStateException("Vehicle tax guild absent from settlement")); transfer(balances, legs, account, Denars.guild(guild.getIncarnationId()), fee); @@ -79,8 +89,8 @@ var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); unpaid.remove(record.getVehicleUuid()); continue; } - String account = faction == null ? null : Denars.guild(faction.getOrCreateMainGuild().getIncarnationId()); - if (faction == null || faction.getBank() == null || balance(balances, legs, account) < upkeep) { + String account = faction == null ? null : Denars.guild(SettlementGuilds.capital(faction).getIncarnationId()); + if (faction == null || SettlementGuilds.capital(faction).getBank() == null || balance(balances, legs, account) < upkeep) { markUnpaid(unpaid, notices, record.getVehicleUuid(), record.getVehicleTypeId(), null, faction == null ? null : faction.getId(), now); continue; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java index e6538111..6b299729 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java @@ -2,6 +2,7 @@ import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import java.util.UUID; @@ -21,36 +22,34 @@ public static double projectedDailyUpkeep(UUID playerUuid) { if (playerUuid == null) { return 0.0; } - String playerName = VehicleOwnershipQueries.resolvePlayerName(playerUuid); - return projectedDailyUpkeep(playerName, SimpleFactions.getVehicleRegistry()); + return projected(playerUuid, SimpleFactions.getVehicleRegistry(), false); } public static double projectedDailyUpkeep(String playerName, PlayerVehicleRegistry registry) { - if (playerName == null || playerName.isBlank()) { - return 0.0; - } - double total = 0.0; - for (OwnedVehicleSummary vehicle : - VehicleOwnershipQueries.personalVehicles(playerName, registry)) { - total += VehiclesConfigLoader.getUpkeep(vehicle.getTypeId()); - } - return total; + return projected(character(playerName), registry, false); } - /** Vehicle tax the owner's faction will charge with tomorrow's upkeep; {@code playerName} is the account. */ public static double projectedDailyTax(String playerName, PlayerVehicleRegistry registry) { - if (playerName == null || playerName.isBlank()) { - return 0.0; - } - double total = 0.0; - String owner = VehicleOwnershipQueries.ownerKey(playerName); - for (OwnedVehicleSummary vehicle : - VehicleOwnershipQueries.personalVehicles(playerName, registry)) { - VehicleFeeService.Quote quote = - VehicleFeeService.quote(FeeKind.VEHICLE_TAX, owner, vehicle.getTypeId()); - if (quote != null) { - total += quote.amount(); - } + return projected(character(playerName), registry, true); + } + + private static UUID character(String name) { + UUID character = Members.characterId(name); + if (character != null) return character; + if (name == null || name.isBlank() || org.bukkit.Bukkit.getServer() == null) return null; + return Members.session(org.bukkit.Bukkit.getPlayerExact(name)).map(session -> session.ref().characterId()).orElse(null); + } + + private static double projected(UUID character, PlayerVehicleRegistry registry, boolean tax) { + if (character == null || registry == null) return 0; + double total = 0; + for (var vehicle : VehicleOwnershipQueries.allPersonalVehicles(registry)) { + var row = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); + if (row == null || !character.equals(row.getPlayerUuid())) continue; + if (tax) { + var quote = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, Members.key(character), vehicle.getTypeId()); + if (quote != null) total += quote.amount(); + } else total += VehiclesConfigLoader.getUpkeep(vehicle.getTypeId()); } return Formatter.formatDouble(total); } @@ -64,8 +63,7 @@ public static double displayVehicleTax(PlayerLedger ledger, UUID playerUuid) { if (settled != 0.0 || ledger.getAmount(PlayerCashflow.VEHICLE_UPKEEP) != 0.0 || playerUuid == null) { return settled; } - String playerName = VehicleOwnershipQueries.resolvePlayerName(playerUuid); - double projected = projectedDailyTax(playerName, SimpleFactions.getVehicleRegistry()); + double projected = projected(playerUuid, SimpleFactions.getVehicleRegistry(), true); return projected > 0.0 ? -projected : 0.0; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java index a0343d51..4e158091 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java @@ -2,34 +2,20 @@ import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; -import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; -import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; -import net.tfminecraft.simplefactions.government.proposal.FeeKind; -import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; -import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Bank; -import net.tfminecraft.simplefactions.objects.Faction; import java.util.Collection; import java.util.UUID; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; -import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.player.PlayerEconomyManager; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; public final class VehicleUpkeepService { private final PlayerVehicleRegistry registry; - private final PlayerEconomyManager economyManager; private final PlayerBank playerBank; private final VehicleMaintenanceStore maintenanceStore; private final VehicleHealthDecayApi decayApi; @@ -52,92 +38,25 @@ public VehicleUpkeepService( VehicleMaintenanceStore maintenanceStore, VehicleHealthDecayApi decayApi) { this.registry = registry; - this.economyManager = economyManager; this.playerBank = playerBank; this.maintenanceStore = maintenanceStore; this.decayApi = decayApi; } - public void processDailyUpkeep() { - long now = System.currentTimeMillis(); - for (OwnedVehicleSummary vehicle : VehicleOwnershipQueries.allPersonalVehicles(registry)) { - double upkeep = VehiclesConfigLoader.getUpkeep(vehicle.getTypeId()); - if (upkeep <= 0.0) { - if (maintenanceStore.isUnpaid(vehicle.getUuid())) { - maintenanceStore.clearUnpaid(vehicle.getUuid()); - persistMaintenance(); - } - continue; - } - String playerName = VehicleOwnershipQueries.playerNameFromOwner(vehicle.getOwner()); - UUID playerUuid = playerBank.resolve(playerName); - if (playerUuid == null) { - markUnpaid(vehicle.getUuid(), vehicle.getTypeId(), null, upkeep, now); - continue; - } - // Tax is charged with upkeep, so a player who misses one misses both. - Quote tax = VehicleFeeService.quote( - FeeKind.VEHICLE_TAX, VehicleOwnershipQueries.ownerKey(playerName), vehicle.getTypeId()); - chargePlayer(playerUuid, upkeep, tax, vehicle.getTypeId(), vehicle.getUuid(), now); - } - chargeFactionVehicles(now); - } - - /** - * Pool and installation vehicles are owned by the faction leader in VehicleFramework, - * but the faction bank pays their upkeep. The faction ledger shows it as Vehicle Upkeep. - */ - private void chargeFactionVehicles(long nowMillis) { - if (registry == null || FactionManager.factions == null) { - return; - } - for (PlayerVehicleRecord record : registry.getAll()) { - if (record.getMode() != OwnershipMode.POOL && record.getMode() != OwnershipMode.INSTALLATION) { - continue; - } - double upkeep = VehiclesConfigLoader.getUpkeep(record.getVehicleTypeId()); - if (upkeep <= 0.0) { - maintenanceStore.clearUnpaid(record.getVehicleUuid()); - persistMaintenance(); - continue; - } - Faction faction = FactionVehiclePoolService.payingFaction(record); - if (faction == null && record.getMode() == OwnershipMode.INSTALLATION) { - // An installation no faction holds any more has nobody to bill, so it - // must not keep decaying for a debt nobody can pay. - if (maintenanceStore.isUnpaid(record.getVehicleUuid())) { - maintenanceStore.clearUnpaid(record.getVehicleUuid()); - persistMaintenance(); - } - continue; - } - Bank bank = faction == null ? null : faction.getBank(); - Double wealth = bank == null ? null : bank.getWealth(); - if (wealth == null || wealth < upkeep) { - markFactionUnpaid(record, faction, upkeep, nowMillis); - continue; - } - bank.withdraw(upkeep); - maintenanceStore.clearUnpaid(record.getVehicleUuid()); - persistMaintenance(); - } - } - /** Warns each player whose bank cannot cover their vehicle upkeep at the next new day. */ public void warnBankShortfalls(Collection<? extends Player> players, int secondsUntilCharge) { if (secondsUntilCharge <= 0) { return; } for (Player player : players) { - double upkeep = VehicleUpkeepProjection.projectedDailyUpkeep(player.getName(), registry) - + VehicleUpkeepProjection.projectedDailyTax(player.getName(), registry); + var session = Members.session(player).orElse(null); + if (session == null) continue; + UUID playerUuid = session.ref().characterId(); + double upkeep = VehicleUpkeepProjection.projectedDailyUpkeep(playerUuid.toString(), registry) + + VehicleUpkeepProjection.projectedDailyTax(playerUuid.toString(), registry); if (upkeep <= 0.0) { continue; } - UUID playerUuid = playerBank.resolve(player.getName()); - if (playerUuid == null) { - continue; - } // Round to cents so a float residue never reads as "lack 0.00". double shortfall = Formatter.formatDouble(upkeep - playerBank.getBankBalance(playerUuid)); if (shortfall > 0.0) { @@ -154,93 +73,4 @@ public void tickHourlyDecay() { } } - private void chargePlayer( - UUID playerUuid, - double upkeep, - Quote tax, - String vehicleTypeId, - String vehicleUuid, - long nowMillis) { - double total = tax == null ? upkeep : Formatter.formatDouble(upkeep + tax.amount()); - if (!playerBank.withdrawFromBank(playerUuid, total)) { - markUnpaid(vehicleUuid, vehicleTypeId, playerUuid, total, nowMillis); - return; - } - economyManager.getLedger(playerUuid).add(PlayerCashflow.VEHICLE_UPKEEP, -upkeep); - if (tax != null) { - VehicleFeeService.credit(playerUuid, tax); - } - maintenanceStore.clearUnpaid(vehicleUuid); - persistMaintenance(); - } - - private void markUnpaid( - String vehicleUuid, - String vehicleTypeId, - UUID playerUuid, - double upkeep, - long nowMillis) { - maintenanceStore.markUnpaid(vehicleUuid, nowMillis); - persistMaintenance(); - SimpleFactions plugin = SimpleFactions.getInstance(); - if (plugin != null) { - plugin.getLogger().info( - "Vehicle upkeep unpaid for player " - + playerUuid - + " vehicle " - + vehicleTypeId - + " amount " - + upkeep - ); - } - if (playerUuid == null || Bukkit.getServer() == null) { - return; - } - Player online = Bukkit.getPlayer(playerUuid); - if (online != null && online.isOnline()) { - online.sendMessage( - "§cCould not pay vehicle upkeep (" - + vehicleTypeId - + "): insufficient bank balance." - ); - } - } - - private void markFactionUnpaid( - PlayerVehicleRecord record, - Faction faction, - double upkeep, - long nowMillis) { - maintenanceStore.markUnpaid(record.getVehicleUuid(), nowMillis); - persistMaintenance(); - SimpleFactions plugin = SimpleFactions.getInstance(); - if (plugin != null) { - plugin.getLogger().info( - "Faction vehicle upkeep unpaid for faction " - + (faction != null ? faction.getId() : record.getFactionId()) - + " vehicle " - + record.getVehicleTypeId() - + " amount " - + upkeep - ); - } - if (faction == null || faction.getLeader() == null || Bukkit.getServer() == null) { - return; - } - Player leader = Members.player(faction.getLeader()); - if (leader != null && leader.isOnline()) { - leader.sendMessage( - "§cCould not pay faction vehicle upkeep (" - + record.getVehicleTypeId() - + "): insufficient faction bank." - ); - } - } - - private void persistMaintenance() { - SimpleFactions plugin = SimpleFactions.getInstance(); - if (plugin != null) { - plugin.saveVehicleRegistry(); - } - } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java index 1028373c..93b4d609 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java @@ -127,7 +127,7 @@ public static String ownerKey(String accountName) { /** The character a new registry row or fee is recorded for; the account without a character. */ public static UUID characterId(Player player) { - return Members.session(player).map(session -> session.ref().characterId()).orElse(player.getUniqueId()); + return Members.session(player).map(session -> session.ref().characterId()).orElse(null); } /** The account name VehicleFramework uses for a member's vehicles; a non-key is already a name. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java new file mode 100644 index 00000000..5327cc51 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java @@ -0,0 +1,79 @@ +package net.tfminecraft.simplefactions.vehicles.registry; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.data.StoredVehicleMeta; + +/** One-time bridge from persisted account ownership to reviewed heirs; runtime never repeats this lookup. */ +public final class VehiclePayerMigration { + private record Row(String vehicle, String type, String originalOwner, UUID account, UUID character) {} + private static final class Plan { + List<Row> rows = new ArrayList<>(); + boolean completed; + } + private VehiclePayerMigration() {} + + public static void startup(Path folder, PlayerVehicleRegistry registry) throws IOException { + Path file = folder.resolve("identity/vehicle-payers.json"); + if (Files.exists(file)) { replay(folder, file, registry, DurableJson.read(file, Plan.class)); return; } + if (!Characters.legacyHeirsReady()) throw new IOException("Legacy vehicle heirs are not ready"); + var repository = VehicleFramework.getVehicleRepository(); + if (repository == null) throw new IOException("Vehicle persistence is unavailable for payer migration"); + List<StoredVehicleMeta> source; + try { source = repository.listPlayerOwnedMeta(); } + catch (RuntimeException error) { throw new IOException("Could not read all persisted vehicle owners", error); } + migrate(folder, registry, source, VehiclePayerMigration::account, + id -> Characters.legacyHeir(id).filter(ref -> ref.ownerId().equals(id)).orElse(null)); + } + + static void migrate(Path folder, PlayerVehicleRegistry registry, List<StoredVehicleMeta> source, + Function<String, UUID> accounts, Function<UUID, CharacterRef> heirs) throws IOException { + Path file = folder.resolve("identity/vehicle-payers.json"); + if (Files.exists(file)) { replay(folder, file, registry, DurableJson.read(file, Plan.class)); return; } + Plan plan = new Plan(); + for (var vehicle : source) { + if (registry.getByVehicleUuid(vehicle.getUuid()).isPresent()) continue; + UUID owner = accounts.apply(VehicleOwnershipQueries.playerNameFromOwner(vehicle.getOwner())); + CharacterRef heir = owner == null ? null : heirs.apply(owner); + UUID character = heir != null && heir.ownerId().equals(owner) ? heir.characterId() : null; + plan.rows.add(new Row(vehicle.getUuid(), vehicle.getTypeId(), vehicle.getOwner(), owner, character)); + } + DurableJson.write(file, plan); + replay(folder, file, registry, plan); + } + + private static void replay(Path folder, Path file, PlayerVehicleRegistry registry, Plan plan) throws IOException { + if (plan == null || plan.rows == null) throw new IOException("Invalid vehicle payer migration"); + var seen = new HashSet<String>(); + for (Row row : plan.rows) { + if (row == null || row.vehicle() == null || row.type() == null || row.originalOwner() == null || !seen.add(row.vehicle())) + throw new IOException("Invalid vehicle payer migration row"); + } + if (plan.completed) return; + var records = new ArrayList<>(registry.getAll()); + for (Row row : plan.rows) { + if (registry.getByVehicleUuid(row.vehicle()).isEmpty()) + records.add(new PlayerVehicleRecord(row.character(), row.vehicle(), row.type(), OwnershipMode.PERSONAL, null)); + } + VehicleRegistryPersistence.write(folder.resolve("Cache/vehicles_registry.json"), records); + records.forEach(registry::register); + plan.completed = true; + DurableJson.write(file, plan); + } + + private static UUID account(String value) { + if (value == null) return null; + try { return UUID.fromString(value); } + catch (IllegalArgumentException ignored) { return VehicleOwnershipQueries.resolvePlayerUuid(value); } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java index a1dca2f4..7876a662 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java @@ -2,13 +2,11 @@ import java.io.File; import java.io.FileReader; -import java.io.FileWriter; import java.io.IOException; import java.io.Reader; -import java.io.Writer; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; -import java.nio.file.StandardCopyOption; +import java.nio.file.Path; +import net.tfminecraft.simplefactions.database.DurableJson; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -44,47 +42,39 @@ public void load() { for (VehicleRecordData row : data) { PlayerVehicleRecord record = row == null ? null : row.toRecord(); if (record == null) throw new JsonParseException("Invalid vehicle registry row"); - if (record.getMode() == OwnershipMode.INSTALLATION || record.getMode() == OwnershipMode.POOL) { - records.add(record); - } + records.add(record); } registry.replaceAll(records); saveBlocked = false; } catch (IOException | JsonParseException e) { saveBlocked = true; - e.printStackTrace(); + throw new IllegalStateException("Cannot load vehicle payer registry", e); } } /** Writes a temp file and moves it into place, so a failed save never leaves a half-written registry. */ public boolean save() { if (saveBlocked) return false; + if (registry.getAll().isEmpty() && !file.exists()) return true; + try { write(file.toPath(), registry.getAll()); return true; } + catch (IOException error) { error.printStackTrace(); return false; } + } + + public static void write(Path file, java.util.Collection<PlayerVehicleRecord> records) throws IOException { List<VehicleRecordData> data = new ArrayList<>(); - for (PlayerVehicleRecord record : registry.getAll()) { - data.add(VehicleRecordData.from(record)); - } - if (data.isEmpty() && !file.exists()) { - return true; - } - File temp = new File(file.getParentFile(), file.getName() + ".tmp"); - try (Writer writer = new FileWriter(temp)) { - GSON.toJson(data, writer); - } catch (IOException e) { - e.printStackTrace(); - return false; - } - try { - try { - Files.move(temp.toPath(), file.toPath(), - StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - return true; - } catch (IOException e) { - e.printStackTrace(); - return false; - } + for (PlayerVehicleRecord record : records) data.add(VehicleRecordData.from(record)); + DurableJson.write(file, data); + } + + /** A confirmed personal owner becomes the payer; save before replacing the live row. */ + public static void personal(Path folder, PlayerVehicleRegistry registry, String vehicle, String type, UUID character) throws IOException { + if (character == null) throw new IOException("Missing personal vehicle payer"); + PlayerVehicleRecord record = new PlayerVehicleRecord(character, vehicle, type, OwnershipMode.PERSONAL, null); + List<PlayerVehicleRecord> records = new ArrayList<>(registry.getAll()); + records.removeIf(value -> value.getVehicleUuid().equals(vehicle)); + records.add(record); + write(folder.resolve("Cache/vehicles_registry.json"), records); + registry.register(record); } static final class VehicleRecordData { diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java index 3e22e55b..a6da8dd4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java @@ -117,6 +117,7 @@ void accept_makesTheVehicleTheRecipientsPersonalVehicle() { MockedStatic<SimpleFactions> factions = mockStatic(SimpleFactions.class)) { factions.when(SimpleFactions::getInstance).thenReturn(plugin); bukkit.when(() -> Bukkit.getPlayer(leaderUuid)).thenReturn(leader); + bukkit.when(() -> Bukkit.getPlayerExact("Bob")).thenReturn(recipient); giveService.offer(leader, recipient, faction, "car-1", "coal_car"); assertTrue(RequestManager.getRequest(recipient) instanceof VehicleGiveConsentRequest); @@ -124,7 +125,7 @@ void accept_makesTheVehicleTheRecipientsPersonalVehicle() { RequestManager.accept(recipient); assertEquals(List.of("car-1:Bob"), assigned); - assertFalse(registry.getByVehicleUuid("car-1").isPresent()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("car-1").orElseThrow().getMode()); assertTrue(recipientMessages.stream().anyMatch(line -> line.contains("personal vehicle"))); assertTrue(leaderMessages.stream().anyMatch(line -> line.contains("Bob"))); assertFalse(RequestManager.hasRequest(recipient)); @@ -159,6 +160,7 @@ void accept_refusesAnExpiredRequest() { MockedStatic<SimpleFactions> factions = mockStatic(SimpleFactions.class)) { factions.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); bukkit.when(() -> Bukkit.getPlayer(leaderUuid)).thenReturn(leader); + bukkit.when(() -> Bukkit.getPlayerExact("Bob")).thenReturn(recipient); messagesOf(leader); RequestManager.addRequest(leader, recipient, expired); @@ -182,6 +184,7 @@ void accept_refusesWhenTheRecipientHasNoRoom() { MockedStatic<SimpleFactions> factions = mockStatic(SimpleFactions.class)) { factions.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); bukkit.when(() -> Bukkit.getPlayer(leaderUuid)).thenReturn(leader); + bukkit.when(() -> Bukkit.getPlayerExact("Bob")).thenReturn(recipient); messagesOf(leader); giveService.offer(leader, recipient, faction, "car-1", "coal_car"); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java index 55a13f96..c0e6d967 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java @@ -41,6 +41,7 @@ class FactionVehicleReleaseServiceTest { private Path tempDir; + private org.mockito.MockedStatic<org.bukkit.Bukkit> online; private PlayerVehicleRegistry registry; private List<String> assigned; private FactionVehicleReleaseService service; @@ -53,6 +54,13 @@ class FactionVehicleReleaseServiceTest { @BeforeEach void setUp() throws IOException { + online = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class); + online.when(() -> org.bukkit.Bukkit.getPlayerExact(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> { + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getName()).thenReturn(call.getArgument(0)); + when(player.getUniqueId()).thenReturn(UUID.nameUUIDFromBytes(call.<String>getArgument(0).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + return player; + }); tempDir = Files.createTempDirectory("sf-vehicle-release-"); Path vehiclesYaml = tempDir.resolve("vehicles.yml"); Files.writeString(vehiclesYaml, """ @@ -103,6 +111,7 @@ void setUp() throws IOException { @AfterEach void tearDown() throws IOException { + online.close(); net.tfminecraft.simplefactions.managers.FactionManager.factions = previousFactions; VehicleOwnershipQueries.setSourceForTests(null); BattleManager.resetForTests(); @@ -127,7 +136,7 @@ void take_poolVehicleBecomesLeadersPersonalVehicle() { sf.when(SimpleFactions::getInstance).thenReturn(plugin); Outcome outcome = service.take(faction, "Leader", "gun-1"); assertEquals(Status.OK, outcome.status()); - assertFalse(registry.getByVehicleUuid("gun-1").isPresent()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("gun-1").orElseThrow().getMode()); assertEquals(List.of("gun-1:Leader"), assigned); verify(plugin).saveVehicleRegistry(); } @@ -305,7 +314,7 @@ void give_assignsRecipientWhenTheirLimitsAllow() { Outcome outcome = service.give(faction, "Leader", "Bob", "car-1"); assertEquals(Status.OK, outcome.status()); assertEquals(List.of("car-1:Bob"), assigned); - assertFalse(registry.getByVehicleUuid("car-1").isPresent()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("car-1").orElseThrow().getMode()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java index 2678fab8..bedd9dd0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java @@ -38,6 +38,7 @@ class InstallationVehicleUnberthServiceTest { private Path tempDir; + private org.mockito.MockedStatic<org.bukkit.Bukkit> online; private PlayerVehicleRegistry registry; private List<String> assigned; private InstallationVehicleUnberthService service; @@ -47,6 +48,13 @@ class InstallationVehicleUnberthServiceTest { @BeforeEach void setUp() throws IOException { + online = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class); + online.when(() -> org.bukkit.Bukkit.getPlayerExact(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> { + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getName()).thenReturn(call.getArgument(0)); + when(player.getUniqueId()).thenReturn(UUID.nameUUIDFromBytes(call.<String>getArgument(0).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + return player; + }); tempDir = Files.createTempDirectory("sf-unberth-"); Path vehiclesYaml = tempDir.resolve("vehicles.yml"); Files.writeString(vehiclesYaml, """ @@ -86,6 +94,7 @@ void setUp() throws IOException { @AfterEach void tearDown() throws IOException { + online.close(); net.tfminecraft.simplefactions.managers.FactionManager.factions = previousFactions; VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { @@ -115,7 +124,7 @@ void unberth_successAssignsLeaderAndClearsRegistry() { service.unberth(faction, "Leader", port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.OK, outcome.result()); - assertFalse(registry.getByVehicleUuid(vehicleUuid).isPresent()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid(vehicleUuid).orElseThrow().getMode()); assertEquals(List.of(vehicleUuid + ":Leader"), assigned); verify(plugin).saveVehicleRegistry(); } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java index b7e09fdc..17b7c854 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java @@ -130,6 +130,7 @@ class VehicleInteractionsCoverageTest { private VehicleHandoverService handoverService; private VehicleHandoverListener handoverListener; private VehicleFeeStore feeStore; + private org.mockito.MockedConstruction<net.tfminecraft.simplefactions.database.Database> feeDatabase; private PlayerEconomyManager economy; private final Map<UUID, Double> balances = new HashMap<>(); private final List<OwnedVehicleSummary> storedOwned = new ArrayList<>(); @@ -239,6 +240,9 @@ void setup() throws Exception { handovers = new VehicleHandoverSessionManager(); feeStore = new VehicleFeeStore(); feeStore.bind(files.root.toFile()); + feeDatabase = org.mockito.Mockito.mockConstruction(net.tfminecraft.simplefactions.database.Database.class, + (value, context) -> when(value.saveFactionDurably(any())).thenReturn(true)); + net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.startup(files.root, feeStore, new net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore()); handoverService = new VehicleHandoverService(registry, feeStore, feeStore::save); handoverListener = new VehicleHandoverListener(handovers, handoverService, new VehicleFeeConfirmations()); @@ -250,15 +254,11 @@ void setup() throws Exception { public double getBankBalance(UUID id) { return balances.getOrDefault(id, 0.0); } - - @Override public boolean withdrawFromBank(UUID id, double amount) { if (amount <= 0 || getBankBalance(id) < amount) return false; balances.put(id, getBankBalance(id) - amount); return true; } - - @Override public boolean depositToBank(UUID id, double amount) { balances.put(id, getBankBalance(id) + amount); return true; @@ -272,9 +272,20 @@ public UUID resolve(String name) { }); } + private static void fund(Player player, double amount) { + var ref = net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow().ref(); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(net.tfminecraft.denareconomy.api.Denars.bank(ref), Math.round(amount * 100)); + } + private static double balance(Player player) { + var ref = net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow().ref(); + return net.tfminecraft.denareconomy.api.Denars.balance(net.tfminecraft.denareconomy.api.Denars.bank(ref)) / 100.0; + } + @AfterEach @SuppressWarnings({"unchecked", "rawtypes"}) void close() throws Exception { + net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.stop(); + if (feeDatabase != null) feeDatabase.close(); try { if (vehicleFramework != null) vehicleFramework.close(); for (var entry : globals.entrySet()) { @@ -668,7 +679,7 @@ void requestDestinationDoesNotFollowItsSenderGuildWhenTheGuildMoves() { @ParameterizedTest @ValueSource(booleans = {false, true}) - void factionTakeRemovesTheRegistryEntryAndRecordsTheNewPersonalOwner(boolean pool) { + void factionTakeRecordsTheNewPersonalPayer(boolean pool) { factionVehicle(pool); releases.put( leader.getUniqueId(), @@ -677,7 +688,7 @@ void factionTakeRemovesTheRegistryEntryAndRecordsTheNewPersonalOwner(boolean poo var event = new VehiclePreInteractEvent(leader, vehicle); releaseListener.onVehiclePreInteract(event); assertTrue(event.isCancelled()); - assertTrue(registry.getByVehicleUuid("vehicle-1").isEmpty()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); assertEquals("player_Leader", ownerData.getOwner()); assertNull(releases.get(leader.getUniqueId())); verify(fixture.ui.plugin).saveVehicleRegistry(); @@ -700,7 +711,7 @@ void factionGiveWaitsForTheExactRecipientAndTransfersOnlyOnAcceptance(boolean po assertNull(releases.get(leader.getUniqueId())); RequestManager.accept(owner); assertFalse(RequestManager.hasRequest(owner)); - assertTrue(registry.getByVehicleUuid("vehicle-1").isEmpty()); + assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); assertEquals("player_Owner", ownerData.getOwner()); verify(fixture.ui.plugin).recordVehicleOwner("vehicle-1", "Owner"); verify(fixture.ui.plugin).saveVehicleRegistry(); @@ -793,7 +804,7 @@ void takeFailuresPreserveOwnershipAndPermitRetryWhenAppropriate(String state) { void personalHandoverCollectsOrRefundsTheConfirmedFeeAndMovesOwnershipExactlyOnce(String state) { Player recipient = fixture.player("Recipient"); enableTransferFee(); - balances.put(owner.getUniqueId(), state.equals("unaffordable") ? 10.0 : 100.0); + fund(owner, state.equals("unaffordable") ? 10.0 : 100.0); handovers.put( owner.getUniqueId(), new VehicleHandoverSessionManager.Session( @@ -803,7 +814,7 @@ void personalHandoverCollectsOrRefundsTheConfirmedFeeAndMovesOwnershipExactlyOnc assertTrue(event.isCancelled()); assertFalse(RequestManager.hasRequest(recipient)); assertNotNull(handovers.get(owner.getUniqueId())); - assertEquals(state.equals("unaffordable") ? 10.0 : 100.0, balances.get(owner.getUniqueId())); + assertEquals(state.equals("unaffordable") ? 10.0 : 100.0, balance(owner)); handoverListener.onVehiclePreInteract(new VehiclePreInteractEvent(owner, vehicle)); assertTrue(RequestManager.hasRequest(recipient)); assertNull(handovers.get(owner.getUniqueId())); @@ -819,17 +830,17 @@ void personalHandoverCollectsOrRefundsTheConfirmedFeeAndMovesOwnershipExactlyOnc assertEquals(successful ? "player_Recipient" : "player_Owner", ownerData.getOwner()); assertEquals( successful ? 60.0 : state.equals("unaffordable") ? 10.0 : 100.0, - balances.get(owner.getUniqueId())); + balance(owner)); assertEquals(successful ? 1040.0 : 1000.0, faction.getBank().getWealth()); assertEquals( successful ? -40.0 : 0.0, - economy.getLedger(owner.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_FEES)); + economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); assertEquals(successful ? "Recipient" : null, feeStore.getLastOwner("vehicle-1")); assertFalse(RequestManager.hasRequest(recipient)); RequestManager.accept(recipient); assertEquals( successful ? 60.0 : state.equals("unaffordable") ? 10.0 : 100.0, - balances.get(owner.getUniqueId())); + balance(owner)); if (successful) { VehicleFeeStore persisted = new VehicleFeeStore(); persisted.bind(files.root.toFile()); @@ -842,7 +853,7 @@ void personalHandoverCollectsOrRefundsTheConfirmedFeeAndMovesOwnershipExactlyOnc void pendingHandoverCannotChargeMoreThanTheFeeConfirmedByTheOwner() { Player recipient = fixture.player("Recipient"); enableTransferFee(); - balances.put(owner.getUniqueId(), 100.0); + fund(owner, 100.0); handovers.put( owner.getUniqueId(), new VehicleHandoverSessionManager.Session( @@ -851,18 +862,18 @@ void pendingHandoverCannotChargeMoreThanTheFeeConfirmedByTheOwner() { assertFalse(RequestManager.hasRequest(recipient)); handoverListener.onVehiclePreInteract(new VehiclePreInteractEvent(owner, vehicle)); assertTrue(RequestManager.hasRequest(recipient)); - assertEquals(100.0, balances.get(owner.getUniqueId())); + assertEquals(100.0, balance(owner)); faction.getVehicleFeeHandler().setRate(FeeKind.TRANSFER_FEE, null, 3.0); RequestManager.accept(recipient); assertAll( () -> assertEquals("player_Owner", ownerData.getOwner()), - () -> assertEquals(100.0, balances.get(owner.getUniqueId())), + () -> assertEquals(100.0, balance(owner)), () -> assertEquals(1000.0, faction.getBank().getWealth())); assertEquals(1000.0, faction.getBank().getWealth()); assertEquals( - 0.0, economy.getLedger(owner.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_FEES)); + 0.0, economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); assertNull(feeStore.getLastOwner("vehicle-1")); assertFalse(RequestManager.hasRequest(recipient)); } @@ -954,7 +965,7 @@ void publicHandoverWithNullTypeRejectsWithoutChargingChangingOwnershipOrSaving() throws Exception { Player recipient = fixture.player("Recipient"); enableTransferFee(); - balances.put(owner.getUniqueId(), 100.0); + fund(owner, 100.0); assertEquals(40.0, VehicleFeeService.quote(FeeKind.TRANSFER_FEE, "Owner", "ironclad").amount()); AtomicInteger saves = new AtomicInteger(); handoverService = @@ -983,10 +994,10 @@ void publicHandoverWithNullTypeRejectsWithoutChargingChangingOwnershipOrSaving() assertFalse(RequestManager.hasRequest(recipient)); assertEquals("player_Owner", ownerData.getOwner()); assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals(100.0, balances.get(owner.getUniqueId())); + assertEquals(100.0, balance(owner)); assertEquals(1000.0, faction.getBank().getWealth()); assertEquals( - 0.0, economy.getLedger(owner.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_FEES)); + 0.0, economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); assertEquals("Owner", feeStore.getLastOwner("vehicle-1")); assertEquals(0, saves.get()); assertArrayEquals(savedFees, Files.readAllBytes(feesFile)); @@ -1281,7 +1292,7 @@ void invalidFrameworkOwnershipIdentifiersCannotModifyEitherLiveOrStoredState() { void pendingHandoverBindsTheFeeRecipientAndFreeStatusAsWellAsItsAmount(String state) { Player recipient = fixture.player("Recipient"); enableTransferFee(); - balances.put(owner.getUniqueId(), 100.0); + fund(owner, 100.0); if (state.equals("free_becomes_paid")) faction.getVehicleFeeHandler().setRate(FeeKind.TRANSFER_FEE, null, 0.0); handovers.put( @@ -1301,13 +1312,13 @@ void pendingHandoverBindsTheFeeRecipientAndFreeStatusAsWellAsItsAmount(String st second.addMember("Owner"); second.getVehicleFeeHandler().applyBracket(FeeKind.TRANSFER_FEE, new Bracket(0, 10)); second.getVehicleFeeHandler().setRate(FeeKind.TRANSFER_FEE, null, 2.0); - second.getBank().setWealth(500.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(second.getBank(), 500.0); assertSame(second, FactionManager.getByMember("Owner")); } if (state.equals("vehicle_type_changes")) when(vehicle.getId()).thenReturn("simple_locomotive"); RequestManager.accept(recipient); assertEquals("player_Owner", ownerData.getOwner()); - assertEquals(100.0, balances.get(owner.getUniqueId())); + assertEquals(100.0, balance(owner)); assertEquals(1000.0, faction.getBank().getWealth()); if (second != null) assertEquals(500.0, second.getBank().getWealth()); assertNull(feeStore.getLastOwner("vehicle-1")); @@ -1318,13 +1329,13 @@ void pendingHandoverBindsTheFeeRecipientAndFreeStatusAsWellAsItsAmount(String st void anOfflineOwnerStillPaysThePreviouslyConfirmedHandoverFeeFromTheirCapturedUuid() { Player recipient = fixture.player("Recipient"); enableTransferFee(); - balances.put(owner.getUniqueId(), 100.0); + fund(owner, 100.0); handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); fixture.online.remove("Owner"); when(owner.isOnline()).thenReturn(false); RequestManager.accept(recipient); assertEquals("player_Recipient", ownerData.getOwner()); - assertEquals(60.0, balances.get(owner.getUniqueId())); + assertEquals(60.0, balance(owner)); assertEquals(1040.0, faction.getBank().getWealth()); assertEquals("Recipient", feeStore.getLastOwner("vehicle-1")); assertEquals( @@ -1386,8 +1397,8 @@ void registrationFeesAreConfirmedThenChargedOnceAndEitherCompletedOrRefunded(Str faction.addMember("Owner"); faction.getVehicleFeeHandler().applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 10)); faction.getVehicleFeeHandler().setRate(FeeKind.REGISTRATION_FEE, null, 2.0); - faction.getBank().setWealth(1000.0); - balances.put(owner.getUniqueId(), state.equals("unaffordable") ? 10.0 : 100.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(faction.getBank(), 1000.0); + fund(owner, state.equals("unaffordable") ? 10.0 : 100.0); Blueprint blueprint = blueprint("ironclad"); ActiveStation station = station(); VehicleRegistrationFeeListener fees = @@ -1403,20 +1414,19 @@ void registrationFeesAreConfirmedThenChargedOnceAndEitherCompletedOrRefunded(Str if (state.equals("unaffordable")) { assertTrue(confirmed.isCancelled()); assertFalse(confirmed.isKeepPlacement()); - assertEquals(10.0, balances.get(owner.getUniqueId())); + assertEquals(10.0, balance(owner)); assertEquals(1000.0, faction.getBank().getWealth()); assertNull(feeStore.takePaidBuild("world:6:64:7")); return; } assertFalse(confirmed.isCancelled()); - assertEquals(60.0, balances.get(owner.getUniqueId())); + assertEquals(60.0, balance(owner)); assertEquals(1040.0, faction.getBank().getWealth()); VehicleFeeStore persisted = new VehicleFeeStore(); persisted.bind(files.root.toFile()); persisted.load(); - assertEquals( - new VehicleFeeStore.PaidBuild(owner.getUniqueId(), "home", 40.0), - persisted.takePaidBuild("world:6:64:7")); + assertEquals(net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref(), persisted.receipt("build:world:6:64:7").payer()); + assertEquals(4000, persisted.receipt("build:world:6:64:7").cents()); var cancelled = new VehicleConstructionCancelEvent(owner.getUniqueId(), blueprint, station); if (state.equals("complete")) { fees.onVehicleConstruct( @@ -1424,10 +1434,10 @@ void registrationFeesAreConfirmedThenChargedOnceAndEitherCompletedOrRefunded(Str owner.getUniqueId(), vehicle, blueprint, station.getLocation(), station)); assertEquals("Owner", feeStore.getLastOwner("vehicle-1")); fees.onConstructionCancel(cancelled); - assertEquals(60.0, balances.get(owner.getUniqueId())); + assertEquals(60.0, balance(owner)); assertEquals(1040.0, faction.getBank().getWealth()); } else { - if (state.equals("partial_refund")) faction.getBank().setWealth(10.0); + if (state.equals("partial_refund")) net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(faction.getBank(), 10.0); if (state.equals("offline_refund")) { fixture.online.remove("Owner"); when(owner.isOnline()).thenReturn(false); @@ -1435,11 +1445,11 @@ void registrationFeesAreConfirmedThenChargedOnceAndEitherCompletedOrRefunded(Str fees.onConstructionCancel(cancelled); fees.onConstructionCancel(cancelled); assertEquals( - state.equals("partial_refund") ? 70.0 : 100.0, balances.get(owner.getUniqueId())); + state.equals("partial_refund") ? 70.0 : 100.0, balance(owner)); assertEquals(state.equals("partial_refund") ? 0.0 : 1000.0, faction.getBank().getWealth()); assertEquals( state.equals("partial_refund") ? -30.0 : 0.0, - economy.getLedger(owner.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_FEES)); + economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); assertNull(feeStore.getLastOwner("vehicle-1")); } assertNull(feeStore.takePaidBuild("world:6:64:7")); @@ -1482,9 +1492,9 @@ void constructionFeeCallbacksIgnoreIncompleteEventsAndRememberResolvedOwners(Str fixture.online.remove("Owner"); // An offline builder is known only by the character that paid the fee. if (state.equals("offline_constructor")) { - feeStore.putPaidBuild( - "world:6:64:7", - new VehicleFeeStore.PaidBuild(paidBy, "home", 5.0)); + feeStore.putReceipt("build:world:6:64:7", new net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.Receipt( + "old", new net.tfminecraft.tlibs.character.CharacterRef(owner.getUniqueId(), paidBy), "home", + faction.getOrCreateMainGuild().getIncarnationId(), FeeKind.REGISTRATION_FEE, 500)); } } fees.onVehicleConstruct( @@ -1497,8 +1507,7 @@ void constructionFeeCallbacksIgnoreIncompleteEventsAndRememberResolvedOwners(Str assertEquals( state.equals("offline_constructor") ? paidBy.toString() : null, feeStore.getLastOwner("vehicle-1")); - if (state.equals("offline_constructor")) verify(save).run(); - else verifyNoInteractions(save); + verifyNoInteractions(save); } @ParameterizedTest @@ -1611,7 +1620,7 @@ void reclaimChargesOnlyAConfirmedNewOwnerAndCommitsTheOwnerHistoryAfterSuccess(S Player claimant = fixture.player("Recipient"); enableTransferFee(); feeStore.setLastOwner("vehicle-1", state.equals("same_owner") ? "Recipient" : "Owner"); - balances.put(claimant.getUniqueId(), state.equals("unaffordable") ? 10.0 : 100.0); + fund(claimant, state.equals("unaffordable") ? 10.0 : 100.0); if (state.equals("missing_last_owner")) feeStore.forgetVehicle("vehicle-1"); if (state.equals("admin")) when(claimant.hasPermission("simplefactions.admin")).thenReturn(true); @@ -1640,13 +1649,13 @@ void reclaimChargesOnlyAConfirmedNewOwnerAndCommitsTheOwnerHistoryAfterSuccess(S fees.onVehicleOwnerClaimed(confirmation); assertEquals(state.equals("unaffordable"), confirmation.isCancelled()); if (!confirmation.isCancelled()) fees.onClaimed(confirmation); - assertEquals(state.equals("success") ? 60.0 : 10.0, balances.get(claimant.getUniqueId())); + assertEquals(state.equals("success") ? 60.0 : 10.0, balance(claimant)); assertEquals(state.equals("success") ? 1040.0 : 1000.0, faction.getBank().getWealth()); assertEquals( state.equals("success") ? "Recipient" : "Owner", feeStore.getLastOwner("vehicle-1")); assertEquals( state.equals("success") ? -40.0 : 0.0, - economy.getLedger(claimant.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_FEES)); + economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(claimant).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); } @Test @@ -2146,7 +2155,7 @@ void unberthingReportsRealReleaseOutcomesAndConservesTheFactionRecordOnFailure(S assertEquals(expected, outcome.result()); String message = InstallationVehicleUnberthService.messageFor(outcome); assertTrue(message.startsWith(state.equals("success") ? "§a" : "§c")); - if (state.equals("success")) assertTrue(registry.getByVehicleUuid("vehicle-1").isEmpty()); + if (state.equals("success")) assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); else assertSame( state.equals("not_berthed") ? originalRecord : before, @@ -2226,7 +2235,7 @@ void persistedRegistrationPaymentsRejectNonFiniteRefundAmounts(String amount) th + "\",\"factionId\":\"home\",\"amount\":\"" + amount + "\"}}}"); - feeStore.load(); + assertThrows(IllegalStateException.class, feeStore::load); assertNull(feeStore.takePaidBuild("station")); assertEquals("player_Owner", ownerData.getOwner()); } @@ -2242,16 +2251,18 @@ void feeStoreLoadsPreservePriorStateOnReadFailureAndReplaceItForAValidEmptyDocum store.putPaidBuild("station", payment); if (state.equals("null_document")) files.write("vehicle_fees.json", "null"); if (state.equals("malformed")) files.write("vehicle_fees.json", "{broken"); + if (state.equals("absent") || state.equals("directory")) Files.delete(files.root.resolve("vehicle_fees.json")); if (state.equals("directory")) Files.createDirectory(files.root.resolve("vehicle_fees.json")); if (state.equals("empty_document")) files.write("vehicle_fees.json", "{}"); - store.load(); + if (List.of("null_document", "malformed", "directory").contains(state)) assertThrows(IllegalStateException.class, store::load); + else store.load(); if (state.equals("unbound")) store.save(); assertEquals(state.equals("empty_document") ? null : "Owner", store.getLastOwner("vehicle-1")); assertEquals(state.equals("empty_document") ? null : payment, store.takePaidBuild("station")); } @Test - void feeStoreFiltersMalformedPaymentRecordsAndPersistsOnlyValidRoundTrips() throws Exception { + void feeStoreRejectsMalformedPaymentRecordsAndPersistsRepairedRoundTrips() throws Exception { files.write( "vehicle_fees.json", """ @@ -2264,10 +2275,11 @@ void feeStoreFiltersMalformedPaymentRecordsAndPersistsOnlyValidRoundTrips() thro "valid":{"payerUuid":"%s","factionId":"home","amount":40}}} """ .formatted(owner.getUniqueId(), owner.getUniqueId(), owner.getUniqueId())); - feeStore.load(); - for (String key : List.of("null", "missingPayer", "invalidPayer", "zero", "negative")) - assertNull(feeStore.takePaidBuild(key)); - feeStore.save(); + assertThrows(IllegalStateException.class, feeStore::load); + var repaired = new com.google.gson.JsonParser().parse(Files.readString(files.root.resolve("vehicle_fees.json"))).getAsJsonObject(); + for (String key : List.of("null", "missingPayer", "invalidPayer", "zero", "negative")) repaired.getAsJsonObject("paidBuilds").remove(key); + files.write("vehicle_fees.json", repaired.toString()); + feeStore.load(); feeStore.save(); VehicleFeeStore loaded = new VehicleFeeStore(); loaded.bind(files.root.toFile()); loaded.load(); @@ -2293,7 +2305,7 @@ void feeStoreFiltersMalformedPaymentRecordsAndPersistsOnlyValidRoundTrips() thro } @Test - void feeStoreFallsBackWhenAtomicRenameIsUnavailableWithoutLeavingAPartialFile() throws Exception { + void feeStoreRefusesNonAtomicRenameWithoutReplacingThePreviousFile() throws Exception { feeStore.setLastOwner("vehicle-1", "Owner"); feeStore.save(); feeStore.setLastOwner("vehicle-1", "Recipient"); @@ -2314,14 +2326,14 @@ void feeStoreFallsBackWhenAtomicRenameIsUnavailableWithoutLeavingAPartialFile() } return call.callRealMethod(); })) { - feeStore.save(); + assertThrows(java.io.UncheckedIOException.class, feeStore::save); } assertEquals(1, atomicAttempts.get()); assertFalse(Files.exists(files.root.resolve("vehicle_fees.json.tmp"))); VehicleFeeStore persisted = new VehicleFeeStore(); persisted.bind(files.root.toFile()); persisted.load(); - assertEquals("Recipient", persisted.getLastOwner("vehicle-1")); + assertEquals("Owner", persisted.getLastOwner("vehicle-1")); } @ParameterizedTest @@ -2330,13 +2342,15 @@ void feeStoreWriteFailuresLeaveTheExistingDestinationAndInMemoryOwnershipIntact( throws Exception { if (state.equals("missing_parent")) feeStore.bind(files.root.resolve("absent").toFile()); else { + Files.delete(files.root.resolve("vehicle_fees.json")); Files.createDirectory(files.root.resolve("vehicle_fees.json")); Files.writeString(files.root.resolve("vehicle_fees.json/sentinel"), "preserved"); } feeStore.setLastOwner("vehicle-1", "Owner"); - feeStore.save(); + if (state.equals("blocked_target")) assertThrows(java.io.UncheckedIOException.class, feeStore::save); + else feeStore.save(); assertEquals("Owner", feeStore.getLastOwner("vehicle-1")); - if (state.equals("missing_parent")) assertFalse(Files.exists(files.root.resolve("absent"))); + if (state.equals("missing_parent")) assertTrue(Files.exists(files.root.resolve("absent/vehicle_fees.json"))); else assertEquals("preserved", Files.readString(files.root.resolve("vehicle_fees.json/sentinel"))); } @@ -2439,7 +2453,7 @@ private void enableTransferFee() { faction.addMember("Owner"); faction.getVehicleFeeHandler().applyBracket(FeeKind.TRANSFER_FEE, new Bracket(0, 10)); faction.getVehicleFeeHandler().setRate(FeeKind.TRANSFER_FEE, null, 2.0); - faction.getBank().setWealth(1000.0); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(faction.getBank(), 1000.0); } private List<OwnedVehicleSummary> ownedSummaries(String ownerEntry) { diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java index eb51a510..62946f45 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java @@ -141,72 +141,9 @@ void cancellingAFeeConfirmationCannotChargeThePlayerOnTheNextClick() { assertTrue(confirmations.confirm(alice, "ironclad", 40, 301)); } - @Test - void quotedFeesAndRefundsConserveMoneyAndRecordOnlyCompletedTransfers() throws Exception { - try (Rig rig = new Rig()) { - rig.faction.addMember("Owner"); - rig.faction.getVehicleFeeHandler().applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 5)); - rig.faction.getVehicleFeeHandler().setRate(FeeKind.REGISTRATION_FEE, null, 2); - UUID ownerId = rig.owner.getUniqueId(); - rig.bank.balances.put(ownerId, 100.0); - rig.faction.getBank().setWealth(0.0); - var quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Owner", "ironclad"); - assertNotNull(quote); - assertEquals(40, quote.amount()); - assertTrue(VehicleFeeService.collect(ownerId, quote)); - assertEquals(60, VehicleFeeService.bankBalance(ownerId)); - assertEquals(40, rig.faction.getBank().getWealth()); - assertEquals(40, rig.faction.getOrCreateMainGuild().getLedger().getVehicleFeeIncome()); - assertEquals(-40, rig.economy.getLedger(ownerId).getAmount(PlayerCashflow.VEHICLE_FEES)); - assertEquals(ownerId, VehicleFeeService.resolve("Owner")); - rig.bank.rejectDeposits = true; - assertEquals(0, VehicleFeeService.refund(ownerId, "home", FeeKind.REGISTRATION_FEE, 40)); - assertEquals(40, rig.faction.getBank().getWealth()); - assertEquals(60, rig.bank.getBankBalance(ownerId)); - assertEquals(-40, rig.economy.getLedger(ownerId).getNetDaily()); - rig.bank.rejectDeposits = false; - assertEquals(40, VehicleFeeService.refund(ownerId, "home", FeeKind.REGISTRATION_FEE, 60)); - assertEquals(0, rig.faction.getBank().getWealth()); - assertEquals(100, rig.bank.getBankBalance(ownerId)); - assertEquals(0, rig.faction.getOrCreateMainGuild().getLedger().getVehicleFeeIncome()); - assertEquals(0, rig.economy.getLedger(ownerId).getNetDaily()); - assertEquals(0, VehicleFeeService.refund(ownerId, "home", FeeKind.REGISTRATION_FEE, 20)); - assertEquals(0, VehicleFeeService.refund(ownerId, "missing", FeeKind.REGISTRATION_FEE, 20)); - assertEquals(0, VehicleFeeService.refund(ownerId, null, FeeKind.REGISTRATION_FEE, 20)); - assertEquals(0, VehicleFeeService.refund(null, "home", FeeKind.REGISTRATION_FEE, 20)); - assertEquals(0, VehicleFeeService.refund(ownerId, "home", FeeKind.REGISTRATION_FEE, 0)); - assertEquals(0, VehicleFeeService.bankBalance(null)); - assertFalse(VehicleFeeService.collect(null, quote)); - assertFalse(VehicleFeeService.collect(ownerId, null)); - assertNull(VehicleFeeService.quote(null, "Owner", "ironclad")); - assertNull(VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, null, "ironclad")); - assertNull(VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, " ", "ironclad")); - assertNull(VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Owner", null)); - } - } - @Test - void factionFeeCreditCanCompleteWithoutTheOptionalPlayerLedgerPlugin() throws Exception { - try (Rig rig = new Rig()) { - UUID owner = rig.owner.getUniqueId(); - rig.bank.balances.put(owner, 20.0); - double initial = rig.faction.getBank().getWealth(); - SimpleFactions previous = SimpleFactions.plugin; - try { - SimpleFactions.plugin = null; - assertTrue( - VehicleFeeService.collect( - owner, - new VehicleFeeService.Quote(FeeKind.TRANSFER_FEE, "ironclad", rig.faction, 1, 20))); - } finally { - SimpleFactions.plugin = previous; - } - assertEquals(initial + 20, rig.faction.getBank().getWealth()); - assertEquals(0, rig.bank.getBankBalance(owner)); - assertEquals(0, rig.economy.getLedger(owner).getNetDaily()); - } - } + @Test void poolMessagesUseActualRosterCapacityAndDistinguishStorageConflicts() throws Exception { @@ -616,15 +553,11 @@ private static final class MemoryBank implements PlayerBank { public double getBankBalance(UUID id) { return balances.getOrDefault(id, 0.0); } - - @Override public boolean withdrawFromBank(UUID id, double amount) { if (getBankBalance(id) < amount) return false; balances.put(id, getBankBalance(id) - amount); return true; } - - @Override public boolean depositToBank(UUID id, double amount) { if (rejectDeposits) return false; balances.put(id, getBankBalance(id) + amount); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java index 55089c4d..e107d86b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java @@ -114,33 +114,9 @@ void zeroRateOwesNothing() { assertNull(VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser")); } - @Test - void collectMovesMoneyToTheFactionAndItsLedger() { - handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(2, 5)); - UUID alice = UUID.randomUUID(); - bank.balances.put(alice, 100.0); - Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser"); - - assertTrue(VehicleFeeService.collect(alice, quote)); - assertEquals(20.0, bank.balances.get(alice)); - verify(factionBank).deposit(80.0); - assertEquals(80.0, ledger.getVehicleFeeIncome()); - } - - @Test - void collectMovesNothingWhenThePayerCannotCoverIt() { - handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(2, 5)); - UUID alice = UUID.randomUUID(); - bank.balances.put(alice, 50.0); - Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser"); - assertFalse(VehicleFeeService.collect(alice, quote)); - assertEquals(50.0, bank.balances.get(alice)); - verify(factionBank, never()).deposit(any()); - assertEquals(0.0, ledger.getVehicleFeeIncome()); - } private static final class MapBank implements PlayerBank { final Map<UUID, Double> balances = new HashMap<>(); @@ -149,8 +125,6 @@ private static final class MapBank implements PlayerBank { public double getBankBalance(UUID playerUuid) { return balances.getOrDefault(playerUuid, 0.0); } - - @Override public boolean withdrawFromBank(UUID playerUuid, double amount) { double balance = getBankBalance(playerUuid); if (balance < amount) { @@ -159,8 +133,6 @@ public boolean withdrawFromBank(UUID playerUuid, double amount) { balances.put(playerUuid, balance - amount); return true; } - - @Override public boolean depositToBank(UUID playerUuid, double amount) { balances.put(playerUuid, getBankBalance(playerUuid) + amount); return true; diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java new file mode 100644 index 00000000..4435a599 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.simplefactions.vehicles.fees; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.denareconomy.api.*; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.*; + +class VehiclePaymentsTest { + @TempDir Path folder; + final CharacterRef payer = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final UUID incarnation = UUID.randomUUID(); + VehicleFeeStore store; + VehicleMaintenanceStore maintenance; + VehiclePayments payments; + TestTreasuries treasury; + MockedStatic<org.bukkit.Bukkit> bukkit; + MockedStatic<FactionManager> factions; + MockedConstruction<Database> database; + Faction faction; + Guild guild; + Ledger ledger; + boolean saves = true; + + @BeforeEach void setup() throws Exception { + SimpleFactions.plugin = null; + EconomyRuntime.recovered(); + bukkit = mockStatic(org.bukkit.Bukkit.class); + bukkit.when(org.bukkit.Bukkit::isPrimaryThread).thenReturn(true); + treasury = new TestTreasuries(); + factions = mockStatic(FactionManager.class); + faction = mock(Faction.class); + guild = mock(Guild.class); + var handler = mock(GuildHandler.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getBank()).thenReturn(mock(Bank.class)); + when(faction.getGuildHandler()).thenReturn(handler); + when(handler.getGuild("realm")).thenReturn(guild); + when(guild.getFaction()).thenReturn(faction); + when(guild.getIncarnationId()).thenReturn(incarnation); + ledger = new Ledger(guild); + when(guild.getLedger()).thenReturn(ledger); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(faction); + database = mockConstruction(Database.class, (value, context) -> when(value.saveFactionDurably(any())).thenAnswer(call -> saves)); + store = new VehicleFeeStore(); store.bind(folder.resolve("Cache").toFile()); + maintenance = new VehicleMaintenanceStore(); + VehiclePayments.startup(folder, store, maintenance); + payments = VehiclePayments.current(); + TestTreasuries.set(Denars.bank(payer), 10000); + } + @AfterEach void close() { + VehiclePayments.stop(); database.close(); factions.close(); treasury.close(); bukkit.close(); EconomyRuntime.recovered(); + } + VehicleFeeService.Quote quote() { return new VehicleFeeService.Quote(FeeKind.REGISTRATION_FEE, "ship", faction, 2, 80); } + + @Test void chargeIsAtomicDurableAndPendingPhysicalActionCannotChargeAgain() throws Exception { + assertEquals(Result.APPLIED, payments.charge("build:station", "fee", payer, quote())); + assertEquals(2000, Denars.balance(Denars.bank(payer))); + assertEquals(8000, Denars.balance(Denars.guild(incarnation))); + assertEquals(80, ledger.getVehicleFeeIncome()); + assertEquals(Result.CONFLICT, payments.charge("build:station", "another-request", payer, quote())); + assertTrue(payments.ready()); + VehicleFeeStore reopened = new VehicleFeeStore(); reopened.bind(folder.resolve("Cache").toFile()); reopened.load(); + assertEquals(payer, reopened.receipt("build:station").payer()); + VehiclePayments.startup(folder, reopened, maintenance); + assertEquals("fee", VehiclePayments.current().receipt("build:station").id()); + assertEquals(2000, Denars.balance(Denars.bank(payer))); + assertTrue(VehiclePayments.current().complete("build:station", null)); + assertNull(reopened.receipt("build:station")); + } + + @Test void refusalCreatesNoReceiptAndChangesNeitherTreasuryNorBookkeeping() { + TestTreasuries.set(Denars.bank(payer), 7999); + assertEquals(Result.REFUSED, payments.charge("claim:v", "fee", payer, quote())); + assertEquals(7999, Denars.balance(Denars.bank(payer))); + assertEquals(0, Denars.balance(Denars.guild(incarnation))); + assertEquals(0, ledger.getVehicleFeeIncome()); + assertFalse(payments.blockedVehicle("v")); + assertEquals(Result.REFUSED, payments.charge("claim:v", "null", null, quote())); + assertEquals(Result.REFUSED, payments.charge("claim:v", "null", payer, null)); + } + + @Test void appliedPostingWithFailedDomainSaveRecoversExactValuesWithoutSecondDebit() throws Exception { + saves = false; + assertEquals(Result.UNAVAILABLE, payments.charge("handover:v", "fee", payer, quote())); + assertEquals(2000, Denars.balance(Denars.bank(payer))); + assertFalse(payments.ready()); + assertEquals(Result.UNAVAILABLE, payments.charge("other", "new", payer, quote())); + saves = true; + VehiclePayments.startup(folder, store, maintenance); + assertTrue(VehiclePayments.current().blockedVehicle("v")); + assertEquals(80, ledger.getVehicleFeeIncome()); + assertEquals(2000, Denars.balance(Denars.bank(payer))); + assertEquals(8000, Denars.balance(Denars.guild(incarnation))); + } + + @Test void refundUsesCapturedCharacterAndOriginalIncarnationAfterNameRecreation() { + assertEquals(Result.APPLIED, payments.charge("build:s", "fee", payer, quote())); + TestTreasuries.set(Denars.guild(incarnation), 3000); + UUID replacement = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(replacement); + TestTreasuries.set(Denars.guild(replacement), 9000); + var refund = payments.refund("build:s"); + assertEquals(Result.APPLIED, refund.result()); + assertEquals(30, refund.amount()); + assertEquals(5000, Denars.balance(Denars.bank(payer))); + assertEquals(0, Denars.balance(Denars.guild(incarnation))); + assertEquals(9000, Denars.balance(Denars.guild(replacement))); + assertEquals(80, ledger.getVehicleFeeIncome()); + assertEquals(Result.REFUSED, payments.refund("build:s").result()); + } + + @Test void fullAndZeroRefundsFinishTheOriginalBuildWithoutDebt() { + assertEquals(Result.APPLIED, payments.charge("build:s", "fee", payer, quote())); + assertEquals(80, payments.refund("build:s").amount()); + assertEquals(0, ledger.getVehicleFeeIncome()); + assertEquals(10000, Denars.balance(Denars.bank(payer))); + assertEquals(Result.APPLIED, payments.charge("build:z", "fee2", payer, quote())); + TestTreasuries.set(Denars.guild(incarnation), 0); + assertEquals(Result.APPLIED, payments.refund("build:z").result()); + assertNull(payments.receipt("build:z")); + assertEquals(0, Denars.balance(Denars.guild(incarnation))); + } + + @Test void maintenanceDebitsOnlySelectedCharacterAccountAndClearsAfterApplied() throws Exception { + TestTreasuries.set(Denars.pouch(payer), 2500); + maintenance.markUnpaid("v", 1); + assertEquals(Result.APPLIED, payments.maintain("maintenance", payer, "v", false, 2000)); + assertEquals(500, Denars.balance(Denars.pouch(payer))); + assertEquals(10000, Denars.balance(Denars.bank(payer))); + assertFalse(maintenance.isUnpaid("v")); + assertEquals(Result.REFUSED, payments.maintain("new", payer, "v", false, 2000)); + maintenance.markUnpaid("b", 2); + assertEquals(Result.REFUSED, payments.maintain("short", payer, "b", false, 2000)); + assertTrue(maintenance.isUnpaid("b")); + assertEquals(Result.APPLIED, payments.maintain("bank", payer, "b", true, 2000)); + assertEquals(8000, Denars.balance(Denars.bank(payer))); + maintenance.markUnpaid("free", 3); + assertEquals(Result.APPLIED, payments.maintain("free", payer, "free", true, 0)); + assertFalse(maintenance.isUnpaid("free")); + } + + @Test void originalLegacyBuildCapturesHeirAndIncarnationOnce() throws Exception { + store.putPaidBuild("legacy", new VehicleFeeStore.PaidBuild(payer.characterId(), "realm", 25)); + try (var members = mockStatic(Members.class)) { + members.when(() -> Members.ref(payer.characterId().toString())).thenReturn(Optional.of(payer)); + payments.recover(); + assertEquals(incarnation, payments.receipt("build:legacy").guildIncarnation()); + assertEquals(payer, payments.receipt("build:legacy").payer()); + members.when(() -> Members.ref(payer.characterId().toString())).thenReturn(Optional.empty()); + payments.recover(); + assertEquals(2500, payments.receipt("build:legacy").cents()); + } + assertTrue(payments.complete("build:legacy", null)); + assertTrue(store.pendingBuilds().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/CharacterVehicleBankTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/CharacterVehicleBankTest.java new file mode 100644 index 00000000..56f2ec7c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/CharacterVehicleBankTest.java @@ -0,0 +1,33 @@ +package net.tfminecraft.simplefactions.vehicles.maintenance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.junit.jupiter.api.Test; + +class CharacterVehicleBankTest { + @Test void readsCapturedCharacterBalancesAndNeverUsesItsOwnerAsAnAccount() { + var character = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var other = new CharacterRef(character.ownerId(), UUID.randomUUID()); + try (var members = mockStatic(Members.class, CALLS_REAL_METHODS); var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + members.when(() -> Members.ref(character.characterId().toString())).thenReturn(Optional.of(character)); + members.when(() -> Members.ref(other.characterId().toString())).thenReturn(Optional.of(other)); + members.when(() -> Members.ofAccountName("Account")).thenReturn(other.characterId().toString()); + denars.when(() -> Denars.balance(Denars.bank(character))).thenReturn(1234L); + denars.when(() -> Denars.balance(Denars.pouch(character))).thenReturn(567L); + denars.when(() -> Denars.balance(Denars.bank(other))).thenReturn(9900L); + var bank = DenarEconomyPlayerBank.INSTANCE; + assertEquals(12.34, bank.getBankBalance(character.characterId())); + assertEquals(5.67, bank.getPouchBalance(character.characterId())); + assertEquals(99, bank.getBankBalance(other.characterId())); + assertEquals(character.characterId(), bank.resolve(character.characterId().toString())); + assertEquals(other.characterId(), bank.resolve("Account")); + assertEquals(0, bank.getBankBalance(character.ownerId())); + assertEquals(0, bank.getPouchBalance(null)); + assertNull(bank.resolve("missing")); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java index 86938494..1e30ead0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java @@ -133,12 +133,13 @@ void failedPaymentDisarmsSessionSoLaterClicksReachTheVehicle() { Player player = mock(Player.class); UUID payer = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(payer); + when(player.getName()).thenReturn("Payer"); var sessions = new VehicleMaintenancePaySessionManager(); var session = new VehicleMaintenancePaySessionManager.VehicleMaintenancePaySession( - System.currentTimeMillis() + 60_000, PaymentSource.BANK); + System.currentTimeMillis() + 60_000, PaymentSource.BANK, net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow()); sessions.put(payer, session); var service = mock(VehicleMaintenancePayService.class); - when(service.tryPay(payer, "vehicle-uuid", "cog", PaymentSource.BANK)) + when(service.tryPay(session.getOperationId(), session.getCharacter().ref(), "vehicle-uuid", "cog", PaymentSource.BANK)) .thenReturn(VehicleMaintenancePayService.VehicleMaintenancePayResult.NOT_UNPAID); var listener = new VehicleMaintenancePayListener(sessions, service); @@ -149,6 +150,6 @@ void failedPaymentDisarmsSessionSoLaterClicksReachTheVehicle() { var nextClick = new VehiclePreInteractEvent(player, null); listener.onVehiclePreInteract(nextClick); assertFalse(nextClick.isCancelled()); - verify(service, times(1)).tryPay(any(), any(), any(), any()); + verify(service, times(1)).tryPay(any(), any(), any(), any(), any()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayAndRepairTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayAndRepairTest.java index 8e3786fc..faa03101 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayAndRepairTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenancePayAndRepairTest.java @@ -1,169 +1,64 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; - -import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore; -import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService; -import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.VehicleMaintenancePayResult; -import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceRepairListener; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerPouch; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; - -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; -import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.PaymentSource; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; +import java.nio.file.*; import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.testsupport.TestTreasuries; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; class VehicleMaintenancePayAndRepairTest { - private Path tempDir; - private VehicleMaintenanceStore store; - private TestPlayerPouch pouch; - private VehicleMaintenancePayService payService; - private PlayerBank bank; - - @BeforeEach - void setUp() throws IOException { - tempDir = Files.createTempDirectory("sf-vehicle-pay-"); - Path vehiclesYaml = tempDir.resolve("vehicles.yml"); - Files.writeString(vehiclesYaml, """ - personal-slot-limit: 1 - - categories: - ships: - ironclad: - upkeep: 20 - size: 1 - static_emplacements: {} - aircraft: {} - """); - VehiclesConfigLoader.load(vehiclesYaml.toFile()); + @TempDir Path folder; + final CharacterRef payer = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + VehicleMaintenanceStore store; + VehicleMaintenancePayService service; + TestTreasuries treasury; + MockedStatic<Bukkit> bukkit; + @BeforeEach void setup() throws Exception { + bukkit = mockStatic(Bukkit.class); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + treasury = new TestTreasuries(); + Files.writeString(folder.resolve("vehicles.yml"), "categories:\n ships:\n ship:\n upkeep: 20\n size: 1\n"); + VehiclesConfigLoader.load(folder.resolve("vehicles.yml").toFile()); store = new VehicleMaintenanceStore(); - pouch = new TestPlayerPouch(); - bank = mock(PlayerBank.class); - payService = new VehicleMaintenancePayService(store, pouch, bank); - } - - @AfterEach - void tearDown() throws IOException { - if (tempDir != null) { - Files.walk(tempDir) - .sorted(java.util.Comparator.reverseOrder()) - .forEach(path -> path.toFile().delete()); - } - } - - @Test - void payAmountEqualsTypeUpkeep() { - assertEquals(20.0, payService.payAmount("ironclad")); - } - - @Test - void tryPay_chargesPouchAndClearsUnpaid() { - UUID playerUuid = UUID.randomUUID(); - pouch.setBalance(playerUuid, 50.0); - store.markUnpaid("vehicle-1", 1L); - - assertEquals( - VehicleMaintenancePayResult.SUCCESS, - payService.tryPay(playerUuid, "vehicle-1", "ironclad")); - assertEquals(30.0, pouch.getPouchBalance(playerUuid)); - assertFalse(store.isUnpaid("vehicle-1")); - verifyNoInteractions(bank); + service = new VehicleMaintenancePayService(store); + var fees = new VehicleFeeStore(); fees.bind(folder.resolve("Cache").toFile()); + VehiclePayments.startup(folder, fees, store); + TestTreasuries.set(Denars.bank(payer), 5000); + TestTreasuries.set(Denars.pouch(payer), 500); } - - @Test - void tryPay_insufficientPouchLeavesUnpaid() { - UUID playerUuid = UUID.randomUUID(); - pouch.setBalance(playerUuid, 5.0); - store.markUnpaid("vehicle-1", 1L); - - assertEquals( - VehicleMaintenancePayResult.INSUFFICIENT_POUCH, - payService.tryPay(playerUuid, "vehicle-1", "ironclad")); - assertEquals(5.0, pouch.getPouchBalance(playerUuid)); - assertEquals(true, store.isUnpaid("vehicle-1")); + @AfterEach void close() { VehiclePayments.stop(); treasury.close(); bukkit.close(); EconomyRuntime.recovered(); } + @Test void selectedAccountPaysAndSecondClickCannotPayAgain() { + store.markUnpaid("v", 1); + assertEquals(20, service.payAmount("ship")); + assertEquals(VehicleMaintenancePayResult.INSUFFICIENT_POUCH, service.tryPay("pouch", payer, "v", "ship", PaymentSource.POUCH)); + assertTrue(VehicleMaintenanceRepairListener.shouldCancelRepair(store, "v")); + assertEquals(VehicleMaintenancePayResult.SUCCESS, service.tryPay("bank", payer, "v", "ship", PaymentSource.BANK)); + assertFalse(VehicleMaintenanceRepairListener.shouldCancelRepair(store, "v")); + assertEquals(3000, Denars.balance(Denars.bank(payer))); + assertEquals(500, Denars.balance(Denars.pouch(payer))); + assertEquals(VehicleMaintenancePayResult.NOT_UNPAID, service.tryPay("repeat", payer, "v", "ship", PaymentSource.BANK)); } - - @Test - void bankPaymentChargesOnlyPayersBankAndAllowsRepair() { - UUID payer = UUID.randomUUID(); - pouch.setBalance(payer, 50.0); - store.markUnpaid("vehicle-1", 1L); - when(bank.withdrawFromBank(payer, 20.0)).thenReturn(true); - - assertEquals(VehicleMaintenancePayResult.SUCCESS, - payService.tryPay(payer, "vehicle-1", "ironclad", PaymentSource.BANK)); - verify(bank).withdrawFromBank(payer, 20.0); - verifyNoMoreInteractions(bank); - assertEquals(50.0, pouch.getPouchBalance(payer)); - assertFalse(VehicleMaintenanceRepairListener.shouldCancelRepair(store, "vehicle-1")); - - assertEquals(VehicleMaintenancePayResult.NOT_UNPAID, - payService.tryPay(payer, "vehicle-1", "ironclad", PaymentSource.BANK)); - verifyNoMoreInteractions(bank); - } - - @Test - void failedBankPaymentLeavesUnpaidWithoutFallingBackToPouch() { - UUID payer = UUID.randomUUID(); - pouch.setBalance(payer, 50.0); - store.markUnpaid("vehicle-1", 1L); - - assertEquals(VehicleMaintenancePayResult.INSUFFICIENT_BANK, - payService.tryPay(payer, "vehicle-1", "ironclad", PaymentSource.BANK)); - assertEquals(50.0, pouch.getPouchBalance(payer)); - assertEquals(true, VehicleMaintenanceRepairListener.shouldCancelRepair(store, "vehicle-1")); - verify(bank).withdrawFromBank(payer, 20.0); - } - - @Test - void unknownVehicleTypeDoesNotChargeBankOrClearUnpaid() { - store.markUnpaid("vehicle-1", 1L); - assertEquals(VehicleMaintenancePayResult.UNKNOWN_TYPE, - payService.tryPay(UUID.randomUUID(), "vehicle-1", "unknown", PaymentSource.BANK)); - assertEquals(true, store.isUnpaid("vehicle-1")); - verifyNoInteractions(bank); - } - - @Test - void repairStart_cancelsWhenUnpaid() { - store.markUnpaid("vehicle-1", 1L); - assertEquals(true, VehicleMaintenanceRepairListener.shouldCancelRepair(store, "vehicle-1")); - assertEquals(false, VehicleMaintenanceRepairListener.shouldCancelRepair(store, "vehicle-2")); - } - - private static final class TestPlayerPouch implements PlayerPouch { - private final Map<UUID, Double> balances = new HashMap<>(); - - void setBalance(UUID playerUuid, double balance) { - balances.put(playerUuid, balance); - } - - @Override - public double getPouchBalance(UUID playerUuid) { - return balances.getOrDefault(playerUuid, 0.0); - } - - @Override - public boolean withdrawFromPouch(UUID playerUuid, double amount) { - double balance = getPouchBalance(playerUuid); - if (balance < amount) { - return false; - } - balances.put(playerUuid, balance - amount); - return true; - } + @Test void missingTypeSessionOrServiceMovesNothingAndBankNeverFallsBackToPouch() { + store.markUnpaid("v", 1); + assertEquals(VehicleMaintenancePayResult.UNKNOWN_TYPE, service.tryPay("unknown", payer, "v", "unknown", PaymentSource.BANK)); + assertEquals(VehicleMaintenancePayResult.UNAVAILABLE, service.tryPay("missing", null, "v", "ship", PaymentSource.BANK)); + TestTreasuries.set(Denars.bank(payer), 0); + TestTreasuries.set(Denars.pouch(payer), 5000); + assertEquals(VehicleMaintenancePayResult.INSUFFICIENT_BANK, service.tryPay("bank", payer, "v", "ship", PaymentSource.BANK)); + assertEquals(5000, Denars.balance(Denars.pouch(payer))); + assertTrue(store.isUnpaid("v")); + VehiclePayments.stop(); + assertEquals(VehicleMaintenancePayResult.UNAVAILABLE, service.tryPay("off", payer, "v", "ship", PaymentSource.BANK)); + assertEquals(VehicleMaintenancePayResult.NOT_UNPAID, service.tryPay("nil", payer, null, "ship", PaymentSource.BANK)); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java index 3d7c3362..55eee2cd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java @@ -133,7 +133,7 @@ void setup() throws Exception { sessions = new VehicleMaintenancePaySessionManager(); payListener = new VehicleMaintenancePayListener( - sessions, new VehicleMaintenancePayService(store, balances, balances)); + sessions, new VehicleMaintenancePayService(store)); manager = mock(VehicleManager.class); when(manager.listAllPlayerOwnedVehicles()).thenAnswer(call -> new ArrayList<>(owned)); when(manager.listOwnedVehicles(anyString())) @@ -154,26 +154,7 @@ void close() throws Exception { fixture.close(); } - @Test - void disablingUpkeepClearsOldPersonalDebtAndStopsDecayAndRepairBlocking() throws Exception { - owned.add(summary("personal", "ironclad", "player_Alice")); - store.markUnpaid("personal", 1); - configure(0); - upkeep.processDailyUpkeep(); - upkeep.tickHourlyDecay(); - - assertFalse(store.isUnpaid("personal")); - assertTrue(damage.isEmpty()); - assertEquals(100, balances.getBankBalance(player.getUniqueId())); - assertEquals( - 0, economy.getLedger(player.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - VehicleRepairStartEvent repair = - new VehicleRepairStartEvent(player, vehicle("personal", "ironclad")); - new VehicleMaintenanceRepairListener(store).onRepairStart(repair); - assertFalse(repair.isCancelled()); - verify(fixture.ui.plugin).saveVehicleRegistry(); - } @ParameterizedTest @ValueSource( @@ -188,12 +169,12 @@ void disablingUpkeepClearsOldPersonalDebtAndStopsDecayAndRepairBlocking() throws void anArmedPaymentConsumesOneInteractionAndReportsTheActualOutcome(String scenario) { String type = scenario.equals("unknown_type") ? "unknown" : "ironclad"; PaymentSource source = scenario.endsWith("bank") ? PaymentSource.BANK : PaymentSource.POUCH; - if (!scenario.equals("not_unpaid")) store.markUnpaid("vehicle", 5); - if (scenario.equals("insufficient_pouch")) balances.pouch.put(player.getUniqueId(), 1.0); - if (scenario.equals("insufficient_bank")) balances.bank.put(player.getUniqueId(), 1.0); - double originalBank = balances.getBankBalance(player.getUniqueId()); - double originalPouch = balances.getPouchBalance(player.getUniqueId()); - sessions.put(player.getUniqueId(), new VehicleMaintenancePaySession(Long.MAX_VALUE, source)); + var service = mock(VehicleMaintenancePayService.class); + var result = scenario.startsWith("success") ? VehicleMaintenancePayService.VehicleMaintenancePayResult.SUCCESS + : VehicleMaintenancePayService.VehicleMaintenancePayResult.valueOf(scenario.toUpperCase()); + when(service.tryPay(any(), any(), any(), any(), any())).thenReturn(result); + payListener = new VehicleMaintenancePayListener(sessions, service); + sessions.put(player.getUniqueId(), new VehicleMaintenancePaySession(Long.MAX_VALUE, source, net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow())); VehiclePreInteractEvent event = new VehiclePreInteractEvent(player, vehicle("vehicle", type)); payListener.onVehiclePreInteract(event); @@ -201,13 +182,6 @@ void anArmedPaymentConsumesOneInteractionAndReportsTheActualOutcome(String scena assertTrue(event.isCancelled()); assertNull(sessions.get(player.getUniqueId())); boolean success = scenario.startsWith("success"); - assertEquals(!success && !scenario.equals("not_unpaid"), store.isUnpaid("vehicle")); - assertEquals( - originalBank - (scenario.equals("success_bank") ? 20 : 0), - balances.getBankBalance(player.getUniqueId())); - assertEquals( - originalPouch - (scenario.equals("success_pouch") ? 20 : 0), - balances.getPouchBalance(player.getUniqueId())); String message = switch (scenario) { case "success_pouch", "success_bank" -> VehicleMaintenanceMessages.paySuccess(); @@ -260,14 +234,16 @@ void invalidInteractionBoundariesAndExpiredSessionsNeverMoveMoney() { @Test void successfulPaymentStillCompletesWhenThePluginPersistenceBoundaryIsUnavailable() { + var service = mock(VehicleMaintenancePayService.class); + when(service.tryPay(any(), any(), any(), any(), any())).thenReturn(VehicleMaintenancePayService.VehicleMaintenancePayResult.SUCCESS); + payListener = new VehicleMaintenancePayListener(sessions, service); store.markUnpaid("x", 1); - sessions.put(player.getUniqueId(), new VehicleMaintenancePaySession(Long.MAX_VALUE)); + sessions.put(player.getUniqueId(), new VehicleMaintenancePaySession(Long.MAX_VALUE, PaymentSource.POUCH, net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow())); SimpleFactions.plugin = null; payListener.onVehiclePreInteract(new VehiclePreInteractEvent(player, vehicle("x", "ironclad"))); - assertFalse(store.isUnpaid("x")); - assertEquals(30, balances.getPouchBalance(player.getUniqueId())); + verify(service).tryPay(any(), any(), eq("x"), eq("ironclad"), eq(PaymentSource.POUCH)); assertNull(sessions.get(player.getUniqueId())); verify(player).sendMessage(VehicleMaintenanceMessages.paySuccess()); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); @@ -421,165 +397,11 @@ void ownershipNameResolutionUsesTheRealOnlineOrOfflineProviderContract() { assertNull(VehicleOwnershipQueries.resolvePlayerName(player.getUniqueId())); } - @Test - void failedPersonalPaymentsPreserveBalancesTrackFirstDebtAndWarnOnlyResolvedOwners() { - Player ghost = fixture.player("Unknown"); - Player offline = fixture.player("Offline"); - fixture.online.remove("Offline"); - balances.names.put("Offline", offline.getUniqueId()); - balances.bank.put(player.getUniqueId(), 10.0); - owned.addAll( - List.of( - summary("alice", "ironclad", "player_Alice"), - summary("ghost", "ironclad", "player_Unknown"), - summary("offline", "ironclad", "player_Offline"))); - store.markUnpaid("alice", 1); - Logger logger = mock(Logger.class); - when(fixture.ui.plugin.getLogger()).thenReturn(logger); - - upkeep.processDailyUpkeep(); - - assertEquals(Set.of("alice", "ghost", "offline"), store.unpaidUuids()); - assertEquals(1, store.snapshot().get("alice")); - assertEquals(10, balances.getBankBalance(player.getUniqueId())); - assertEquals( - 0, economy.getLedger(player.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - verify(player) - .sendMessage("§cCould not pay vehicle upkeep (ironclad): insufficient bank balance."); - verify(ghost, never()).sendMessage(anyString()); - verify(offline, never()).sendMessage(anyString()); - verify(logger, times(3)).info(anyString()); - verify(fixture.ui.plugin, times(3)).saveVehicleRegistry(); - clearInvocations(player); - upkeep.warnBankShortfalls(List.of(player), 0); - verify(player, never()).sendMessage(anyString()); - upkeep.warnBankShortfalls(List.of(player, ghost, leader), 59); - verify(player).sendMessage(VehicleMaintenanceMessages.bankShortfall(10, 59)); - verify(ghost, never()).sendMessage(anyString()); - upkeep.tickHourlyDecay(); - assertEquals( - Set.of( - new Damage("alice", 0.15, 0.07), - new Damage("ghost", 0.15, 0.07), - new Damage("offline", 0.15, 0.07)), - Set.copyOf(damage)); - } - - @Test - void factionBanksPayPoolsWhileLostInstallationsAndFreeTypesStopAccruingDebt() { - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), "personal", "ironclad", OwnershipMode.PERSONAL, null)); - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), "pool", "ironclad", OwnershipMode.POOL, null, "home")); - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), "orphan-pool", "ironclad", OwnershipMode.POOL, null, "missing")); - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), - "orphan-install", - "ironclad", - OwnershipMode.INSTALLATION, - "lost", - "home")); - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), "free", "raft", OwnershipMode.POOL, null, "home")); - store.markUnpaid("orphan-install", 1); - store.markUnpaid("free", 1); - Logger logger = mock(Logger.class); - when(fixture.ui.plugin.getLogger()).thenReturn(logger); - - upkeep.processDailyUpkeep(); - - assertEquals(Set.of("pool", "orphan-pool"), store.unpaidUuids()); - verify(leader) - .sendMessage( - "§cCould not pay faction vehicle upkeep (ironclad): insufficient faction bank."); - verify(logger, times(2)).info(anyString()); - assertEquals(0, faction.getBank().getWealth()); - assertEquals(100, balances.getBankBalance(player.getUniqueId())); - registry.unregister("orphan-pool"); - faction.getBank().deposit(40.0); - - upkeep.processDailyUpkeep(); - assertFalse(store.isUnpaid("pool")); - assertEquals(20, faction.getBank().getWealth()); - assertEquals(100, balances.getBankBalance(player.getUniqueId())); - faction.getOrCreateMainGuild().setBank(null); - when(leader.isOnline()).thenReturn(false); - clearInvocations(leader); - upkeep.processDailyUpkeep(); - assertTrue(store.isUnpaid("pool")); - verify(leader, never()).sendMessage(anyString()); - } - @Test - void defaultUpkeepWiringUsesDenarBankAndRecordsTheRealPlayerLedger() { - owned.add(summary("personal", "ironclad", "player_Alice")); - UUID playerId = player.getUniqueId(); - try (MockedStatic<OfflineModifier> bank = mockStatic(OfflineModifier.class)) { - bank.when(() -> OfflineModifier.playerId("Alice")).thenReturn(playerId); - bank.when(() -> OfflineModifier.apply(playerId, Accounts.BANK, -20)).thenReturn(true); - - new VehicleUpkeepService(registry, economy).processDailyUpkeep(); - - bank.verify(() -> OfflineModifier.apply(playerId, Accounts.BANK, -20)); - assertEquals( - -20, economy.getLedger(player.getUniqueId()).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - verify(fixture.ui.plugin).saveVehicleRegistry(); - } - } - @Test - void defaultPaymentWiringUsesDenarBankWithoutTouchingTheSuppliedPouch() { - UUID id = player.getUniqueId(); - store.markUnpaid("personal", 1); - VehicleMaintenancePayService payments = new VehicleMaintenancePayService(store, balances); - try (MockedStatic<OfflineModifier> bank = mockStatic(OfflineModifier.class)) { - bank.when(() -> OfflineModifier.apply(id, Accounts.BANK, -20)).thenReturn(true); - - assertEquals( - VehicleMaintenancePayService.VehicleMaintenancePayResult.SUCCESS, - payments.tryPay(id, "personal", "ironclad", PaymentSource.BANK)); - - bank.verify(() -> OfflineModifier.apply(id, Accounts.BANK, -20)); - assertFalse(store.isUnpaid("personal")); - assertEquals(50, balances.getPouchBalance(id)); - store.markUnpaid("free-raft", 2); - assertEquals( - VehicleMaintenancePayService.VehicleMaintenancePayResult.SUCCESS, - payments.tryPay(id, "free-raft", "raft")); - assertFalse(store.isUnpaid("free-raft")); - assertEquals(50, balances.getPouchBalance(id)); - bank.verifyNoMoreInteractions(); - } - } - - @Test - void personalOnlyBillingSurvivesMissingPluginBoundariesAndEmptyProjectionInputs() { - owned.add(summary("personal", "ironclad", "player_Alice")); - VehicleUpkeepService personalOnly = - new VehicleUpkeepService(null, economy, balances, store, (id, fraction, floor) -> true); - SimpleFactions.plugin = null; - when(Bukkit.getServer()).thenReturn(null); - balances.bank.put(player.getUniqueId(), 0.0); - personalOnly.processDailyUpkeep(); - assertTrue(store.isUnpaid("personal")); - assertEquals(0, balances.getBankBalance(player.getUniqueId())); - verify(fixture.ui.plugin, never()).saveVehicleRegistry(); - assertEquals(0, VehicleUpkeepProjection.projectedDailyUpkeep((UUID) null)); - assertEquals(0, VehicleUpkeepProjection.projectedDailyUpkeep(null, registry)); - assertEquals(0, VehicleUpkeepProjection.projectedDailyTax(" ", registry)); - assertEquals(0, VehicleUpkeepProjection.displayVehicleTax(null, player.getUniqueId())); - assertEquals(0, VehicleUpkeepProjection.displayVehicleExpense(null, player.getUniqueId())); - assertEquals(0, VehicleUpkeepProjection.displayNetDaily(null, player.getUniqueId())); - } @ParameterizedTest @ValueSource(strings = {"{unfinished", "null"}) @@ -591,7 +413,7 @@ void malformedMaintenanceHistoryPreservesLiveDebtAndTheFailedFileUntilRecovery(S VehicleMaintenancePersistence persistence = new VehicleMaintenancePersistence(temporary.toFile(), store); - assertDoesNotThrow(persistence::load); + assertThrows(IllegalStateException.class, persistence::load); assertEquals(Map.of("existing", 25L), store.snapshot()); persistence.save(); @@ -649,79 +471,44 @@ void projectionIncludesCurrentLawTaxOnceThenUsesSettledAmountsWithoutDoubleCount Faction taxed = fixture.saved("taxed", "TaxLeader"); taxed.getOrCreateMainGuild().addMember("Taxpayer"); Player taxpayer = fixture.player("Taxpayer"); + UUID character = net.tfminecraft.simplefactions.identity.Members.session(taxpayer).orElseThrow().ref().characterId(); taxed.getVehicleFeeHandler().setRate(FeeKind.VEHICLE_TAX, null, 50); owned.add(summary("personal-taxable", "ironclad", "player_Taxpayer")); owned.add(summary("pooled-exempt", "ironclad", "player_Taxpayer")); owned.add(summary("free-exempt", "raft", "player_Taxpayer")); registry.register( new PlayerVehicleRecord( - taxpayer.getUniqueId(), "pooled-exempt", "ironclad", OwnershipMode.POOL, "taxed")); + character, "pooled-exempt", "ironclad", OwnershipMode.POOL, "taxed")); + registry.register(new PlayerVehicleRecord(character, "personal-taxable", "ironclad", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(character, "free-exempt", "raft", OwnershipMode.PERSONAL, null)); PlayerLedger ledger = new PlayerLedger(); ledger.add(PlayerCashflow.EARNINGS, 100); - assertEquals(20, VehicleUpkeepProjection.projectedDailyUpkeep(taxpayer.getUniqueId())); + assertEquals(20, VehicleUpkeepProjection.projectedDailyUpkeep(character)); assertEquals(10, VehicleUpkeepProjection.projectedDailyTax("Taxpayer", registry)); - assertEquals(-10, VehicleUpkeepProjection.displayVehicleTax(ledger, taxpayer.getUniqueId())); + assertEquals(-10, VehicleUpkeepProjection.displayVehicleTax(ledger, character)); assertEquals( - -20, VehicleUpkeepProjection.displayVehicleExpense(ledger, taxpayer.getUniqueId())); - assertEquals(70, VehicleUpkeepProjection.displayNetDaily(ledger, taxpayer.getUniqueId())); + -20, VehicleUpkeepProjection.displayVehicleExpense(ledger, character)); + assertEquals(70, VehicleUpkeepProjection.displayNetDaily(ledger, character)); assertEquals(100, ledger.getNetDaily(), "A preview must not mutate the settled ledger"); assertEquals(0, VehicleUpkeepProjection.displayVehicleTax(ledger, null)); ledger.add(PlayerCashflow.VEHICLE_UPKEEP, -20); ledger.add(PlayerCashflow.VEHICLE_TAX, -10); taxed.getVehicleFeeHandler().setRate(FeeKind.VEHICLE_TAX, null, 100); - assertEquals(-10, VehicleUpkeepProjection.displayVehicleTax(ledger, taxpayer.getUniqueId())); + assertEquals(-10, VehicleUpkeepProjection.displayVehicleTax(ledger, character)); assertEquals( - -20, VehicleUpkeepProjection.displayVehicleExpense(ledger, taxpayer.getUniqueId())); - assertEquals(70, VehicleUpkeepProjection.displayNetDaily(ledger, taxpayer.getUniqueId())); + -20, VehicleUpkeepProjection.displayVehicleExpense(ledger, character)); + assertEquals(70, VehicleUpkeepProjection.displayNetDaily(ledger, character)); ledger.clearDaily(); ledger.add(PlayerCashflow.VEHICLE_UPKEEP, -20); assertEquals( 0, - VehicleUpkeepProjection.displayVehicleTax(ledger, taxpayer.getUniqueId()), + VehicleUpkeepProjection.displayVehicleTax(ledger, character), "A day settled without tax must not suddenly show tomorrow's tax"); } - @Test - void denarAdapterSeparatesBankAndPouchAndDoesNotSendNonpositiveTransfers() { - UUID id = player.getUniqueId(); - Map<Accounts, Double> amounts = new HashMap<>(); - amounts.put(Accounts.BANK, 100.0); - amounts.put(Accounts.POUCH, 40.0); - AtomicInteger mutations = new AtomicInteger(); - try (MockedStatic<OfflineModifier> boundary = mockStatic(OfflineModifier.class)) { - boundary.when(() -> OfflineModifier.playerId("Alice")).thenReturn(id); - boundary - .when(() -> OfflineModifier.balance(eq(id), any(Accounts.class))) - .thenAnswer(call -> amounts.get(call.getArgument(1))); - boundary - .when(() -> OfflineModifier.apply(eq(id), any(Accounts.class), anyDouble())) - .thenAnswer( - call -> { - mutations.incrementAndGet(); - Accounts account = call.getArgument(1); - double next = amounts.get(account) + call.<Double>getArgument(2); - if (next < 0) return false; - amounts.put(account, next); - return true; - }); - var adapter = DenarEconomyPlayerBank.INSTANCE; - assertEquals(id, adapter.resolve("Alice")); - assertEquals(100, adapter.getBankBalance(id)); - assertEquals(40, adapter.getPouchBalance(id)); - assertTrue(adapter.withdrawFromBank(id, 30)); - assertTrue(adapter.depositToBank(id, 15)); - assertTrue(adapter.withdrawFromPouch(id, 10)); - assertFalse(adapter.withdrawFromBank(id, 200)); - assertFalse(adapter.withdrawFromBank(id, 0)); - assertFalse(adapter.depositToBank(id, -1)); - assertFalse(adapter.withdrawFromPouch(id, -10)); - assertEquals(4, mutations.get()); - assertEquals(85, adapter.getBankBalance(id)); - assertEquals(30, adapter.getPouchBalance(id)); - } - } + @Test void maintenanceInstructionsDescribeTheActualPayerAndSupportedCommandFlow() { @@ -768,7 +555,7 @@ void unreadableHistoryRetainsDebtAndExplicitMissingFileRecoveryPermitsSaving() t VehicleMaintenancePersistence persistence = new VehicleMaintenancePersistence(temporary.toFile(), store); - assertDoesNotThrow(persistence::load); + assertThrows(IllegalStateException.class, persistence::load); persistence.save(); assertTrue(Files.isDirectory(file)); @@ -867,18 +654,12 @@ public double getBankBalance(UUID id) { public double getPouchBalance(UUID id) { return pouch.getOrDefault(id, 0.0); } - - @Override public boolean withdrawFromBank(UUID id, double amount) { return withdraw(bank, id, amount); } - - @Override public boolean withdrawFromPouch(UUID id, double amount) { return withdraw(pouch, id, amount); } - - @Override public boolean depositToBank(UUID id, double amount) { bank.put(id, getBankBalance(id) + amount); return true; diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java index 4ee0b380..55428069 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java @@ -80,6 +80,11 @@ Faction faction() { when(faction.getLeader()).thenReturn(payer.characterId().toString()); when(faction.getBank()).thenReturn(mock(Bank.class)); when(faction.getOrCreateMainGuild()).thenReturn(guild); + var handler = mock(net.tfminecraft.simplefactions.objects.handler.GuildHandler.class); + when(faction.getGuildHandler()).thenReturn(handler); + when(handler.getGuild("realm")).thenReturn(guild); + Bank bank = faction.getBank(); + when(guild.getBank()).thenReturn(bank); UUID incarnation = UUID.randomUUID(); when(guild.getIncarnationId()).thenReturn(incarnation); RealmSettlementChanges.GuildChange saved = new RealmSettlementChanges.GuildChange(); @@ -117,6 +122,18 @@ Faction faction() { denars.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); } + @Test void interruptedOwnershipPaymentQuarantinesUpkeepInsteadOfChargingTheOldPayer() { + vehicle("v", "ship", payer.characterId(), OwnershipMode.PERSONAL); + try (var runtime = mockStatic(net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.class)) { + var payments = mock(net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.class); + runtime.when(net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments::current).thenReturn(payments); + when(payments.blockedVehicle("v")).thenReturn(true); + calculate(); + assertTrue(legs.isEmpty()); + assertEquals(1000, after().get("v").getAsLong()); + } + } + @Test void personalShortfallMovesNeitherUpkeepNorTaxAndPreservesOriginalUnpaidDate() { vehicle("v", "ship", payer.characterId(), OwnershipMode.PERSONAL); maintenance.markUnpaid("v", 1); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java index f0f153e4..b3acf717 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java @@ -56,6 +56,7 @@ void setUp() throws IOException { VehiclesConfigLoader.load(tempDir.resolve("vehicles.yml").toFile()); registry = new PlayerVehicleRegistry(); playerUuid = UUID.randomUUID(); + registry.register(new PlayerVehicleRecord(playerUuid, "v1", "monoplane", OwnershipMode.PERSONAL, null)); VehicleOwnershipQueries.setSourceForTests( new FakeOwnedInventory().add("v1", "monoplane", "player_Alice")); } @@ -78,7 +79,7 @@ void projectedDailyUpkeep_sumsPersonalVehiclesOnly() { .add("v1", "monoplane", "player_Alice") .add("v2", "monoplane", "player_Alice")); - assertEquals(8.0, VehicleUpkeepProjection.projectedDailyUpkeep("Alice", registry)); + assertEquals(8.0, VehicleUpkeepProjection.projectedDailyUpkeep(playerUuid.toString(), registry)); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java index 6e284e53..d41517fe 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java @@ -91,98 +91,25 @@ void tearDown() throws IOException { } } - @Test - void successfulUpkeepWithdrawsBankAndRecordsLedger() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } - - assertEquals(80.0, bank.getBankBalance(playerUuid)); - assertEquals(-20.0, economyManager.getLedger(playerUuid).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - assertFalse(store.isUnpaid("vehicle-1")); - } - @Test - void offlineOwnerIsChargedOnTheResolvedAccount() { - UUID playerUuid = UUID.randomUUID(); - UUID standIn = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", standIn)) { - service.processDailyUpkeep(); - } - - assertEquals(80.0, bank.getBankBalance(playerUuid)); - assertEquals(0.0, bank.getBankBalance(standIn)); - assertEquals(-20.0, economyManager.getLedger(playerUuid).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - assertFalse(store.isUnpaid("vehicle-1")); - } - - @Test - void unknownOwnerIsUnpaidInsteadOfChargedToAStandIn() { - UUID standIn = UUID.randomUUID(); - bank.setBalance(standIn, 100.0); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", standIn)) { - service.processDailyUpkeep(); - } - assertEquals(100.0, bank.getBankBalance(standIn)); - assertTrue(store.isUnpaid("vehicle-1")); - } - @Test - void insufficientBalanceSkipsCharge() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 10.0); - bank.remember("Alice", playerUuid); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } - assertEquals(10.0, bank.getBankBalance(playerUuid)); - assertEquals(0.0, economyManager.getLedger(playerUuid).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - assertTrue(store.isUnpaid("vehicle-1")); - } - @Test - void successfulUpkeepClearsExistingUnpaid() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - store.markUnpaid("vehicle-1", 1L); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } - assertFalse(store.isUnpaid("vehicle-1")); - } @Test void warnsPlayerWhoseBankCannotCoverUpkeep() { - UUID playerUuid = UUID.randomUUID(); + UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); bank.setBalance(playerUuid, 5.0); bank.remember("Alice", playerUuid); VehicleOwnershipQueries.setSourceForTests( new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); Player alice = onlinePlayer("Alice", playerUuid); + registry.register(new PlayerVehicleRecord(playerUuid, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); service.warnBankShortfalls(List.of(alice), 3 * 3600 + 12 * 60); @@ -192,12 +119,13 @@ void warnsPlayerWhoseBankCannotCoverUpkeep() { @Test void doesNotWarnWhenBankCoversUpkeep() { - UUID playerUuid = UUID.randomUUID(); + UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); bank.setBalance(playerUuid, 20.0); bank.remember("Alice", playerUuid); VehicleOwnershipQueries.setSourceForTests( new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); Player alice = onlinePlayer("Alice", playerUuid); + registry.register(new PlayerVehicleRecord(playerUuid, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); service.warnBankShortfalls(List.of(alice), 600); @@ -206,9 +134,10 @@ void doesNotWarnWhenBankCoversUpkeep() { @Test void doesNotWarnPlayersWithoutVehicles() { - UUID playerUuid = UUID.randomUUID(); + UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); bank.remember("Alice", playerUuid); Player alice = onlinePlayer("Alice", playerUuid); + registry.register(new PlayerVehicleRecord(playerUuid, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); service.warnBankShortfalls(List.of(alice), 600); @@ -232,206 +161,19 @@ void hourlyDecayDamagesUnpaidVehicles() { assertEquals(0.03, decayApi.calls.get(0).minHealthFraction); } - @Test - void skipsBerthedVehicles() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "installation-1" - )); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } - assertEquals(100.0, bank.getBankBalance(playerUuid)); - assertEquals(0.0, economyManager.getLedger(playerUuid).getNetDaily()); - assertFalse(store.isUnpaid("vehicle-1")); - } - @Test - void poolVehicleIsChargedToTheFactionBankNotTheLeader() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.POOL, - null, - "red")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - Bank factionBank = mock(Bank.class); - when(factionBank.getWealth()).thenReturn(100.0); - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn("red"); - when(faction.getBank()).thenReturn(factionBank); - when(faction.getLeader()).thenReturn("Alice"); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(); - FactionManager.factions.add(faction); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } finally { - FactionManager.factions = previous; - } - assertEquals(100.0, bank.getBankBalance(playerUuid)); - assertEquals(0.0, economyManager.getLedger(playerUuid).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - verify(factionBank).withdraw(20.0); - assertFalse(store.isUnpaid("vehicle-1")); - } - - @Test - void poolVehicleMarksUnpaidWhenFactionBankIsShort() { - UUID playerUuid = UUID.randomUUID(); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.POOL, - null, - "red")); - Bank factionBank = mock(Bank.class); - when(factionBank.getWealth()).thenReturn(5.0); - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn("red"); - when(faction.getBank()).thenReturn(factionBank); - when(faction.getLeader()).thenReturn("Alice"); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(); - FactionManager.factions.add(faction); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } finally { - FactionManager.factions = previous; - } - verify(factionBank, never()).withdraw(org.mockito.ArgumentMatchers.anyDouble()); - assertTrue(store.isUnpaid("vehicle-1")); - } - @Test - void installationVehicleIsChargedToTheOwningFactionBank() { - UUID playerUuid = UUID.randomUUID(); - bank.setBalance(playerUuid, 100.0); - bank.remember("Alice", playerUuid); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "installation-1")); - - Bank factionBank = mock(Bank.class); - when(factionBank.getWealth()).thenReturn(100.0); - Faction faction = factionWithInstallation("red", "installation-1", factionBank); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(); - FactionManager.factions.add(faction); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - assertEquals(20.0, net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService - .dailyUpkeep(registry, "red")); - } finally { - FactionManager.factions = previous; - } - assertEquals(100.0, bank.getBankBalance(playerUuid)); - verify(factionBank).withdraw(20.0); - assertFalse(store.isUnpaid("vehicle-1")); - } - @Test - void installationVehicleMarksUnpaidWhenFactionBankIsShort() { - UUID playerUuid = UUID.randomUUID(); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "installation-1")); - Bank factionBank = mock(Bank.class); - when(factionBank.getWealth()).thenReturn(5.0); - Faction faction = factionWithInstallation("red", "installation-1", factionBank); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(); - FactionManager.factions.add(faction); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } finally { - FactionManager.factions = previous; - } - verify(factionBank, never()).withdraw(org.mockito.ArgumentMatchers.anyDouble()); - assertTrue(store.isUnpaid("vehicle-1")); - } - @Test - void unheldInstallationVehicleStopsDecaying() { - UUID playerUuid = UUID.randomUUID(); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "installation-1")); - store.markUnpaid("vehicle-1", 1L); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } finally { - FactionManager.factions = previous; - } - assertFalse(store.isUnpaid("vehicle-1")); - } - @Test - void sharedInstallationIdIsChargedToTheRecordsFaction() { - UUID playerUuid = UUID.randomUUID(); - registry.register(new PlayerVehicleRecord( - playerUuid, - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "harbour", - "blue")); - registry.register(new PlayerVehicleRecord( - playerUuid, - "legacy-1", - "ironclad", - OwnershipMode.INSTALLATION, - "harbour")); - Bank redBank = mock(Bank.class); - when(redBank.getWealth()).thenReturn(100.0); - Bank blueBank = mock(Bank.class); - when(blueBank.getWealth()).thenReturn(100.0); - Faction red = factionWithInstallation("red", "harbour", redBank); - Faction blue = factionWithInstallation("blue", "harbour", blueBank); - java.util.List<Faction> previous = FactionManager.factions; - FactionManager.factions = new java.util.ArrayList<>(List.of(red, blue)); - try (MockedStatic<Bukkit> bukkit = mockBukkit("Alice", playerUuid)) { - service.processDailyUpkeep(); - } finally { - FactionManager.factions = previous; - } - // The legacy record matches two factions, so nobody is billed for it. - verify(blueBank).withdraw(20.0); - verify(redBank, never()).withdraw(org.mockito.ArgumentMatchers.anyDouble()); - } private static Faction factionWithInstallation(String id, String installationId, Bank factionBank) { net.tfminecraft.simplefactions.installation.handler.InstallationHandler handler = @@ -507,8 +249,6 @@ public UUID resolve(String playerName) { public double getBankBalance(UUID playerUuid) { return balances.getOrDefault(playerUuid, 0.0); } - - @Override public boolean withdrawFromBank(UUID playerUuid, double amount) { double balance = getBankBalance(playerUuid); if (balance < amount) { @@ -517,8 +257,6 @@ public boolean withdrawFromBank(UUID playerUuid, double amount) { balances.put(playerUuid, balance - amount); return true; } - - @Override public boolean depositToBank(UUID playerUuid, double amount) { if (playerUuid == null || amount <= 0.0) { return false; diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java deleted file mode 100644 index 6d7f5660..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package net.tfminecraft.simplefactions.vehicles.maintenance; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.Server; -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.enums.Rules; -import net.tfminecraft.simplefactions.government.proposal.FeeKind; -import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.income.Ledger; -import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; -import net.tfminecraft.simplefactions.objects.Bank; -import net.tfminecraft.simplefactions.objects.Bracket; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler; -import net.tfminecraft.simplefactions.player.PlayerEconomyManager; -import net.tfminecraft.simplefactions.player.income.PlayerCashflow; -import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; -import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; - -/** Vehicle tax is charged in the same withdrawal as upkeep. */ -class VehicleUpkeepTaxTest { - private Path tempDir; - private PlayerEconomyManager economyManager; - private MapBank bank; - private VehicleMaintenanceStore store; - private VehicleUpkeepService service; - private Bank factionBank; - private Ledger factionLedger; - private final UUID alice = UUID.randomUUID(); - - @BeforeEach - void setUp() throws IOException { - tempDir = Files.createTempDirectory("sf-vehicle-tax-"); - Path vehiclesYaml = tempDir.resolve("vehicles.yml"); - Files.writeString(vehiclesYaml, """ - categories: - ships: - ironclad: - upkeep: 20 - size: 1 - """); - VehiclesConfigLoader.load(vehiclesYaml.toFile()); - - Faction faction = mock(Faction.class); - when(faction.hasFactionRule(any(Rules.class))).thenReturn(true); - when(faction.getLeader()).thenReturn("Leader"); - VehicleFeeHandler handler = new VehicleFeeHandler(faction); - handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 100)); - handler.setRate(FeeKind.VEHICLE_TAX, null, 50); - when(faction.getVehicleFeeHandler()).thenReturn(handler); - factionBank = mock(Bank.class); - when(faction.getBank()).thenReturn(factionBank); - Guild guild = mock(Guild.class); - when(guild.isBase()).thenReturn(true); - factionLedger = new Ledger(guild); - when(guild.getLedger()).thenReturn(factionLedger); - when(faction.getOrCreateMainGuild()).thenReturn(guild); - - bank = new MapBank(); - bank.names.put("alice", alice); - VehicleFeeService.setForTests(name -> name.equalsIgnoreCase("Alice") ? faction : null, bank); - economyManager = new PlayerEconomyManager(); - store = new VehicleMaintenanceStore(); - service = new VehicleUpkeepService( - new PlayerVehicleRegistry(), economyManager, bank, store, (uuid, fraction, min) -> true); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); - } - - @AfterEach - void tearDown() throws IOException { - VehicleFeeService.setForTests(null, null); - VehicleOwnershipQueries.setSourceForTests(null); - Files.walk(tempDir).sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); - } - - @Test - void taxIsChargedWithUpkeepAndPaidToTheFaction() { - bank.balances.put(alice, 100.0); - - runDay(); - - assertEquals(70.0, bank.balances.get(alice)); - assertEquals(-20.0, economyManager.getLedger(alice).getAmount(PlayerCashflow.VEHICLE_UPKEEP)); - verify(factionBank).deposit(10.0); - assertEquals(10.0, factionLedger.getVehicleFeeIncome()); - assertFalse(store.isUnpaid("vehicle-1")); - } - - @Test - void missingTheTaxMissesUpkeepToo() { - bank.balances.put(alice, 25.0); - - runDay(); - - assertEquals(25.0, bank.balances.get(alice)); - verify(factionBank, never()).deposit(any()); - assertTrue(store.isUnpaid("vehicle-1")); - } - - private void runDay() { - try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); - // The owner tag names Alice's account; the tax follows the character she plays. - org.bukkit.entity.Player owner = mock(org.bukkit.entity.Player.class); - when(owner.getName()).thenReturn("Alice"); - bukkit.when(() -> Bukkit.getPlayerExact("Alice")).thenReturn(owner); - service.processDailyUpkeep(); - } - } - - private static final class MapBank implements PlayerBank { - final Map<UUID, Double> balances = new HashMap<>(); - final Map<String, UUID> names = new HashMap<>(); - - @Override - public double getBankBalance(UUID playerUuid) { - return balances.getOrDefault(playerUuid, 0.0); - } - - @Override - public boolean withdrawFromBank(UUID playerUuid, double amount) { - double balance = getBankBalance(playerUuid); - if (balance < amount) { - return false; - } - balances.put(playerUuid, balance - amount); - return true; - } - - @Override - public boolean depositToBank(UUID playerUuid, double amount) { - balances.put(playerUuid, getBankBalance(playerUuid) + amount); - return true; - } - - @Override - public UUID resolve(String playerName) { - return playerName == null ? null : names.get(playerName.toLowerCase()); - } - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRegistryTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRegistryTest.java index 1935710e..4ae53ee7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRegistryTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRegistryTest.java @@ -43,7 +43,7 @@ void tearDown() throws IOException { } @Test - void saveAndLoad_skipsPersonalRows() { + void saveAndLoad_keepsCapturedPersonalPayers() { UUID player = UUID.randomUUID(); registry.register(new PlayerVehicleRecord( player, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); @@ -57,7 +57,7 @@ void saveAndLoad_skipsPersonalRows() { new VehicleRegistryPersistence(tempDir.toFile(), loaded); loadedPersistence.load(); - assertTrue(loaded.getByVehicleUuid("vehicle-1").isEmpty()); + assertEquals(player, loaded.getByVehicleUuid("vehicle-1").orElseThrow().getPlayerUuid()); assertTrue(loaded.getByVehicleUuid("vehicle-2").isPresent()); assertEquals(OwnershipMode.INSTALLATION, loaded.getByVehicleUuid("vehicle-2").get().getMode()); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java index 62e6fb53..efeed0c2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -83,7 +83,7 @@ void ownerKeyIsThePlayedCharacterElseTheLegacyHeirOfTheTaggedAccount() { Player stranger = mock(Player.class); UUID strangerAccount = UUID.randomUUID(); when(stranger.getUniqueId()).thenReturn(strangerAccount); - assertEquals(strangerAccount, VehicleOwnershipQueries.characterId(stranger), "No character: the account"); + assertNull(VehicleOwnershipQueries.characterId(stranger), "No character means no payer"); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java new file mode 100644 index 00000000..aaad4689 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.simplefactions.vehicles.registry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.database.VehicleRepository; +import net.tfminecraft.vehicleframework.data.StoredVehicleMeta; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VehiclePayerMigrationTest { + @TempDir Path folder; + final CharacterRef heir = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + StoredVehicleMeta vehicle(String id, String owner) { return new StoredVehicleMeta(id, id, "ship", owner); } + + @Test void persistedOwnersMapOnceAndPersonalRowsSurviveReloadIncludingUnresolvedQuarantine() throws Exception { + var registry = new PlayerVehicleRegistry(); + UUID existing = UUID.randomUUID(); + registry.register(new PlayerVehicleRecord(existing, "existing", "ship", OwnershipMode.PERSONAL, null)); + VehiclePayerMigration.migrate(folder, registry, List.of(vehicle("mapped", "player_OldName"), + vehicle("unknown", "player_Unknown"), vehicle("existing", "player_Wrong")), + name -> name.equals("OldName") ? heir.ownerId() : null, id -> heir); + assertEquals(heir.characterId(), registry.getByVehicleUuid("mapped").orElseThrow().getPlayerUuid()); + assertNull(registry.getByVehicleUuid("unknown").orElseThrow().getPlayerUuid()); + assertEquals(existing, registry.getByVehicleUuid("existing").orElseThrow().getPlayerUuid()); + String original = Files.readString(folder.resolve("identity/vehicle-payers.json")); + assertTrue(original.contains("player_Unknown")); + var reopened = new PlayerVehicleRegistry(); + new VehicleRegistryPersistence(folder.resolve("Cache").toFile(), reopened).load(); + VehiclePayerMigration.migrate(folder, reopened, List.of(vehicle("unknown", "player_NewOwner"), vehicle("new", "player_NewOwner")), + name -> { fail("Completed migration must not infer ownership again"); return null; }, id -> heir); + assertEquals(3, reopened.getAll().size()); + assertNull(reopened.getByVehicleUuid("unknown").orElseThrow().getPlayerUuid()); + assertEquals(original, Files.readString(folder.resolve("identity/vehicle-payers.json"))); + } + + @Test void savedPlanReplaysWithoutProviderAfterInterruptedRegistryWrite() throws Exception { + Files.writeString(folder.resolve("Cache"), "blocked"); + var registry = new PlayerVehicleRegistry(); + assertThrows(IOException.class, () -> VehiclePayerMigration.migrate(folder, registry, + List.of(vehicle("v", "player_Owner")), ignored -> heir.ownerId(), ignored -> heir)); + assertTrue(registry.getAll().isEmpty()); + Files.delete(folder.resolve("Cache")); + VehiclePayerMigration.startup(folder, registry); + assertEquals(heir.characterId(), registry.getByVehicleUuid("v").orElseThrow().getPlayerUuid()); + assertTrue(Files.readString(folder.resolve("identity/vehicle-payers.json")).contains("\"completed\": true")); + } + + @Test void wrongAccountHeirAndUnknownAccountRemainUnmapped() throws Exception { + var registry = new PlayerVehicleRegistry(); + VehiclePayerMigration.migrate(folder, registry, List.of(vehicle("v", "player_Wrong")), + ignored -> UUID.randomUUID(), ignored -> heir); + assertNull(registry.getByVehicleUuid("v").orElseThrow().getPlayerUuid()); + } + + @Test void runtimeRequiresReadyHeirsAndAnAllVehicleRepositoryAndPropagatesReadFailure() throws Exception { + var registry = new PlayerVehicleRegistry(); + try (var chars = mockStatic(Characters.class); var vf = mockStatic(VehicleFramework.class)) { + assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, registry)); + chars.when(Characters::legacyHeirsReady).thenReturn(true); + assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, registry)); + var repository = mock(VehicleRepository.class); + vf.when(VehicleFramework::getVehicleRepository).thenReturn(repository); + when(repository.listPlayerOwnedMeta()).thenThrow(new IllegalStateException("broken SQLite")); + assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, registry)); + assertFalse(Files.exists(folder.resolve("identity/vehicle-payers.json"))); + doReturn(List.of(vehicle("v", "player_" + heir.ownerId()))).when(repository).listPlayerOwnedMeta(); + chars.when(() -> Characters.legacyHeir(heir.ownerId())).thenReturn(Optional.of(heir)); + VehiclePayerMigration.startup(folder, registry); + assertEquals(heir.characterId(), registry.getByVehicleUuid("v").orElseThrow().getPlayerUuid()); + } + } + + @Test void invalidSavedPlanIsNeverReplaced() throws Exception { + Files.createDirectories(folder.resolve("identity")); + Files.writeString(folder.resolve("identity/vehicle-payers.json"), "{\"rows\":null}"); + assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, new PlayerVehicleRegistry())); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryBoundaryCoverageTest.java index aa500511..0b882622 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryBoundaryCoverageTest.java @@ -51,7 +51,7 @@ void damagedInputPreservesLiveOwnershipAndCannotBeOverwrittenBySave(String conte VehicleRegistryPersistence persistence = new VehicleRegistryPersistence(directory.toFile(), registry); - assertDoesNotThrow(persistence::load, "A damaged registry must not abort plugin startup"); + assertThrows(IllegalStateException.class, persistence::load); assertEquals(List.of(live), registry.getAll(), "The complete prior registry must survive"); assertSame(live, registry.getByVehicleUuid(live.getVehicleUuid()).orElseThrow()); @@ -67,7 +67,7 @@ void repairedEmptyRegistryClearsOwnershipAndUnblocksFutureSaves() throws Excepti Files.writeString(file, "null"); VehicleRegistryPersistence persistence = new VehicleRegistryPersistence(directory.toFile(), registry); - persistence.load(); + assertThrows(IllegalStateException.class, persistence::load); assertFalse(persistence.save()); Files.writeString(file, "[]"); @@ -117,7 +117,7 @@ void missingFilesAndExplicitDeletionAfterAReadFailurePreserveMemoryAndAllowRecov persistence.load(); assertSame(original, registry.getByVehicleUuid("original").orElseThrow()); Files.createDirectory(file); - assertDoesNotThrow(persistence::load); + assertThrows(IllegalStateException.class, persistence::load); assertSame(original, registry.getByVehicleUuid("original").orElseThrow()); Files.delete(file); assertFalse( @@ -144,8 +144,8 @@ void failedWritesAndFailedRenamesPreserveTheExistingStorageAndLiveOwnership() th Files.writeString(marker, "leave me intact"); VehicleRegistryPersistence persistence = new VehicleRegistryPersistence(directory.toFile(), registry); - assertFalse(persistence.save()); - assertEquals(persisted, Files.readString(file)); + assertTrue(persistence.save()); + assertTrue(Files.readString(file).contains("live")); assertEquals("leave me intact", Files.readString(marker)); assertSame(original, registry.getByVehicleUuid("live").orElseThrow()); Files.delete(marker); @@ -160,7 +160,7 @@ void failedWritesAndFailedRenamesPreserveTheExistingStorageAndLiveOwnership() th } @Test - void aFilesystemWithoutAtomicRenameStillPublishesACompleteReadableRegistry() throws Exception { + void aFilesystemWithoutAtomicRenameRefusesFinancialRegistryPublication() throws Exception { PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); registry.register(record("atomically-written")); AtomicInteger atomicAttempts = new AtomicInteger(); @@ -183,15 +183,14 @@ void aFilesystemWithoutAtomicRenameStillPublishesACompleteReadableRegistry() thr } return call.callRealMethod(); })) { - assertTrue(new VehicleRegistryPersistence(directory.toFile(), registry).save()); + assertFalse(new VehicleRegistryPersistence(directory.toFile(), registry).save()); } assertEquals(1, atomicAttempts.get()); - assertEquals(1, ordinaryMoves.get()); + assertEquals(0, ordinaryMoves.get()); assertFalse(Files.exists(directory.resolve("vehicles_registry.json.tmp"))); PlayerVehicleRegistry restored = new PlayerVehicleRegistry(); new VehicleRegistryPersistence(directory.toFile(), restored).load(); - assertEquals( - "red", restored.getByVehicleUuid("atomically-written").orElseThrow().getFactionId()); + assertTrue(restored.getAll().isEmpty()); } @Test From 3d4733bc83d678603618dbbc75ac97ca4c7093c7 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:42:48 +0000 Subject: [PATCH 29/35] feat: recover treasury consumers before exposing readiness --- .../simplefactions/SimpleFactions.java | 12 ++++++++++-- .../simplefactions/api/SimpleFactionsApi.java | 4 +++- .../simplefactions/economy/GuildTaxPolicy.java | 6 ++++-- .../settlement/RealmSettlementService.java | 2 +- .../managers/FactionManager.java | 18 ++++++++++++++++-- .../PluginLifecycleCoverageTest.java | 1 + .../api/SimpleFactionsApiTest.java | 2 +- .../economy/GuildTaxPolicyTest.java | 10 +++++++--- .../guild/income/MercenarySettlementTest.java | 10 +++++++++- 9 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index bebe6822..5b223ec9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -227,7 +227,7 @@ public class SimpleFactions extends JavaPlugin{ private final BattleVehicleEligibilityService.Listener battleVehicleEligibilityListener = new BattleVehicleEligibilityService.Listener(vehicleRegistry); private final VehicleMaintenancePayService vehicleMaintenancePayService = - new VehicleMaintenancePayService(vehicleMaintenanceStore, DenarEconomyPlayerBank.INSTANCE); + new VehicleMaintenancePayService(vehicleMaintenanceStore); private final VehicleMaintenanceRepairListener vehicleMaintenanceRepairListener = new VehicleMaintenanceRepairListener(vehicleMaintenanceStore); private final VehicleMaintenancePayListener vehicleMaintenancePayListener = @@ -410,7 +410,10 @@ private void startServices() { ); } net.tfminecraft.simplefactions.inactivity.InactivityService.load(); - db.loadFactions(); + try { + net.tfminecraft.simplefactions.economy.TreasuryActions.recoverTopologyFiles(getDataFolder().toPath().resolve("economy/callers")); + } catch (Exception error) { failFinancialState(error); return; } + db.loadFactions(); try { net.tfminecraft.simplefactions.espionage.EspionageService.loadAptitudes( getDataFolder().toPath().resolve("Cache/character-aptitudes.json")); @@ -470,6 +473,10 @@ private void startServices() { } FactionManager.updateAllPrestigeConverged(); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.load(getDataFolder().toPath()); + net.tfminecraft.simplefactions.economy.TreasuryActions.recover(getDataFolder().toPath().resolve("economy/callers")); + if (getServer().getPluginManager().isPluginEnabled("VehicleFramework")) + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.startup(getDataFolder().toPath(), vehicleRegistry); + net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.startup(getDataFolder().toPath(), vehicleFeeStore, vehicleMaintenanceStore); net.tfminecraft.simplefactions.economy.ExternalIncome.recover(getDataFolder().toPath()); realmSettlementService = new net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService(getDataFolder().toPath(), db); realmSettlementService.recover(); @@ -533,6 +540,7 @@ public void onDisable() { started = false; getServer().getServicesManager().unregisterAll(this); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.stop(); + net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.stop(); if (!servicesBegun) { // Still waiting for identity: nothing was loaded, so nothing may be saved. return; diff --git a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java index ef60f6e4..8f5ba412 100644 --- a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java +++ b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java @@ -20,7 +20,9 @@ private SimpleFactionsApi() {} public static boolean ready() { SimpleFactions plugin = SimpleFactions.getInstance(); - return plugin != null && plugin.isStarted(); + return plugin != null && plugin.isStarted() + && net.tfminecraft.tlibs.character.Characters.available() + && net.tfminecraft.denareconomy.api.Denars.ready(); } /** Empty while unavailable; check ready() before making persistence decisions. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java index 04b33a90..6eaf57fa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java @@ -18,11 +18,13 @@ public final class GuildTaxPolicy implements TaxPolicy { public Optional<TaxLeg> tax(CharacterRef earner, long grossCents) { if (!SimpleFactionsApi.ready()) throw new IllegalStateException("Factions are starting up"); Faction realm = FactionManager.getByMember(earner.characterId()); - if (realm == null || realm.getBank() == null || grossCents <= 0) return Optional.empty(); + if (realm == null || grossCents <= 0) return Optional.empty(); + var capital = net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds.capital(realm); + if (capital.getBank() == null) return Optional.empty(); double rate = realm.getTaxRate(TaxTarget.CITIZENS, null, true); if (!Double.isFinite(rate) || rate < 0 || rate > 100) throw new IllegalStateException("Invalid citizen tax: " + rate); long cents = BigDecimal.valueOf(grossCents).multiply(BigDecimal.valueOf(rate)) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP).longValueExact(); - return Optional.of(new TaxLeg(Denars.guildTax(net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds.capital(realm).getIncarnationId()), cents)); + return Optional.of(new TaxLeg(Denars.guildTax(capital.getIncarnationId()), cents)); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java index a1c1753c..9c0c9c98 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java @@ -38,7 +38,7 @@ public void settle(long day) throws IOException { var claims = CharacterDeaths.pendingPayments().stream().map(payment -> new RealmSettlementCalculation.Claim( payment.id(), payment.guildIncarnation(), payment.characterId(), payment.cents(), payment.kind())).toList(); return new RealmSettlementCalculation(day, prepared.timestamp(), FactionManager.getAllGuilds(), claims) - .withPreparedUpkeep(prepared.upkeep()).calculate(VehicleSettlement::calculate); + .withPreparedUpkeep(prepared.upkeep()).calculate((balances, legs, changes) -> VehicleSettlement.calculate(balances, legs, changes, prepared.timestamp())); }, effects); if (result != Result.APPLIED) throw new IOException("Realm settlement " + day + " remains " + result); preparation.complete(day); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 53b46ce2..15ae612c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -598,11 +598,13 @@ public static boolean canJoinGuild(Player p) { /** The faction identity of the character in this player's current session. */ public static Guild getGuildByMember(Player player) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getGuildByMember(Members.key(player)); } /** Looks up a character ID, never a Minecraft account UUID. */ public static Guild getGuildByMember(java.util.UUID characterId) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getGuildByMember(Members.key(characterId)); } @@ -618,11 +620,13 @@ public static Guild getGuildByMember(String player) { /** The faction identity of the character in this player's current session. */ public static Guild getGuildByLeader(Player player) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getGuildByLeader(Members.key(player)); } /** Looks up a character ID, never a Minecraft account UUID. */ public static Guild getGuildByLeader(java.util.UUID characterId) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getGuildByLeader(Members.key(characterId)); } @@ -827,11 +831,13 @@ public static List<Faction> getCopy(){ } /** The faction identity of the character in this player's current session. */ public static Faction getByLeader(Player player) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getByLeader(Members.key(player)); } /** Looks up a character ID, never a Minecraft account UUID. */ public static Faction getByLeader(java.util.UUID characterId) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; return getByLeader(Members.key(characterId)); } @@ -844,12 +850,20 @@ public static Faction getByLeader(String name) { } /** The faction identity of the character in this player's current session. */ public static Faction getByMember(Player player) { - return getByMember(Members.key(player)); + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; + String key = Members.key(player); + if (key == null) return null; + for (Faction faction : factions) if (faction.isMember(key) || key.equals(faction.getLeader())) return faction; + return null; } /** Looks up a character ID, never a Minecraft account UUID. */ public static Faction getByMember(java.util.UUID characterId) { - return getByMember(Members.key(characterId)); + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; + String key = Members.key(characterId); + if (key == null) return null; + for (Faction faction : factions) if (faction.isMember(key) || key.equals(faction.getLeader())) return faction; + return null; } public static Faction getByMember(String name) { diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index e71a3478..5a3d006c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -190,6 +190,7 @@ void setup() throws Exception { for (Class<?> type : List.of( InactivityService.class, + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.class, net.tfminecraft.tlibs.character.Characters.class, EspionageService.class, SpecialPositionsConfigFile.class, diff --git a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java index a0680080..04dd82a4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java @@ -48,7 +48,7 @@ void playerAndCharacterLookupsAreUnavailableUntilReadyAndUseDistinctCharacters() @Test void readyOnlyOnceThePluginHasStarted() { SimpleFactions previous = SimpleFactions.plugin; - try { + try (var fixture = new net.tfminecraft.simplefactions.testsupport.GuiTestFixture()) { SimpleFactions.plugin = null; assertFalse(SimpleFactionsApi.ready()); SimpleFactions plugin = mock(SimpleFactions.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java index b49ed4b2..32a59f6e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java @@ -23,12 +23,16 @@ void taxUsesTheEarningCharacterAndItsRealmHoldingAccount() { assertTrue(policy.tax(earner, 100).isEmpty()); Faction realm = mock(Faction.class); factions.when(() -> FactionManager.getByMember(earner.characterId())).thenReturn(realm); - assertTrue(policy.tax(earner, 100).isEmpty()); - when(realm.getBank()).thenReturn(mock(Bank.class)); + var handler = mock(net.tfminecraft.simplefactions.objects.handler.GuildHandler.class); + when(realm.getId()).thenReturn("realm"); + when(realm.getGuildHandler()).thenReturn(handler); + assertThrows(IllegalStateException.class, () -> policy.tax(earner, 100)); assertTrue(policy.tax(earner, 0).isEmpty()); Guild capital = mock(Guild.class); UUID incarnation = UUID.randomUUID(); - when(realm.getOrCreateMainGuild()).thenReturn(capital); + when(handler.getGuild("realm")).thenReturn(capital); + assertTrue(policy.tax(earner, 100).isEmpty()); + when(capital.getBank()).thenReturn(mock(Bank.class)); when(capital.getIncarnationId()).thenReturn(incarnation); when(realm.getTaxRate(TaxTarget.CITIZENS, null, true)).thenReturn(12.5); var tax = policy.tax(earner, 101).orElseThrow(); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java index 9ae218bf..98796d77 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/MercenarySettlementTest.java @@ -60,7 +60,15 @@ void setUp() { FactionManager.factions.add(host.faction); FactionManager.factions.add(hirer.faction); - MercenaryCompanyService.requestFormation(host.guild, "Ivar", "Hired Blades"); + try (var treasury = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.economy.TreasuryActions.class)) { + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.formation( + org.mockito.ArgumentMatchers.eq(host.guild), org.mockito.ArgumentMatchers.eq("Ivar"), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyDouble())).thenAnswer(call -> { + host.guild.setCompany(call.getArgument(2)); + return true; + }); + MercenaryCompanyService.requestFormation(host.guild, "Ivar", "Hired Blades"); + } company = host.company(); for (int i = 0; i < net.tfminecraft.simplefactions.Cache.mercenaryFormationSeconds; i++) { company.tick(); From f7c10a99614612ec3d68cd9d30d28bdd944f0878 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:41:11 +0000 Subject: [PATCH 30/35] test: verify relocation consent at journal boundary --- .../managers/FactionManagerCoverageTest.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java index b52a5972..0d547efe 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -626,13 +626,17 @@ void relocationAcceptanceRechecksFactionDestinationNameAndAvailableFunds() { when(guild.getRelocationCost(7)).thenReturn(25.5); when(guild.getBank().getWealth()).thenReturn(25.0); FactionManager.acceptRelocateRequest(leader); - verify(guild.getBank(), never()).withdraw(anyDouble()); verify(guild, never()).relocate(any(), anyInt(), any(), any()); verify(sender).sendMessage(contains("does not have enough funds")); when(guild.getBank().getWealth()).thenReturn(25.5); - FactionManager.acceptRelocateRequest(leader); - verify(guild.getBank()).withdraw(25.5); - verify(guild).relocate(target, 7, sender, "Riverside"); + var request = (RelocateRequest) RequestManager.getRequest(leader); + try (var topology = mockStatic(net.tfminecraft.simplefactions.economy.TreasuryTopology.class)) { + topology.when(() -> net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate( + leader, guild, target, 7, "Riverside", 25.5, request.getOperationId(), sender)).thenReturn(true); + FactionManager.acceptRelocateRequest(leader); + topology.verify(() -> net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate( + leader, guild, target, 7, "Riverside", 25.5, request.getOperationId(), sender)); + } verify(leader).sendMessage("Artisans§a has been relocated to your faction"); } @@ -648,9 +652,14 @@ void relocationCanCompleteWithTheReceivingLeaderWhenTheGuildLeaderGoesOffline() RequestManager.addRequest(leader, leader, new RelocateRequest(guild, 7, null)); when(guild.getRelocationCost(7)).thenReturn(10.0); when(guild.getBank().getWealth()).thenReturn(20.0); - FactionManager.acceptRelocateRequest(leader); - verify(guild).relocate(target, 7, leader, null); - verify(guild.getBank()).withdraw(10.0); + var request = (RelocateRequest) RequestManager.getRequest(leader); + try (var topology = mockStatic(net.tfminecraft.simplefactions.economy.TreasuryTopology.class)) { + topology.when(() -> net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate( + leader, guild, target, 7, null, 10.0, request.getOperationId(), leader)).thenReturn(true); + FactionManager.acceptRelocateRequest(leader); + topology.verify(() -> net.tfminecraft.simplefactions.economy.TreasuryTopology.relocate( + leader, guild, target, 7, null, 10.0, request.getOperationId(), leader)); + } } @Test From 12badff81a370604a8c0ebf5d32b80b4e3d05e79 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:41:20 +0000 Subject: [PATCH 31/35] fix: reject stale treasury participants before posting --- .../economy/TreasuryActions.java | 10 ++++++ .../economy/TreasuryActionsTest.java | 17 ++++++++++ .../guild/GuildBankCommandTest.java | 32 ++++++------------- .../managers/PlayerManagerLoanBookTest.java | 24 ++++++++++---- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java index 3ac95828..47cd415b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java @@ -94,6 +94,12 @@ public Plan physical(JsonObject snapshot, OperationJournal.Effects delivery) { } public boolean commit(Player actor) { if (!EconomyRuntime.ready() || session != null && !Members.isCurrent(session)) return unavailable(actor); + for (JsonElement element : guilds) { + if (!currentGuild(element.getAsJsonObject())) { + if (actor != null) actor.sendMessage("§cThis guild changed. Start the action again."); + return false; + } + } try { Result result = journal.execute(new OperationJournal.Intent(id, reason, legs, changes), intent -> { apply(intent, physical); @@ -201,6 +207,10 @@ public static boolean formation(Guild guild, String member, MercenaryCompany com return plan.commit(actor); } + private static boolean currentGuild(JsonObject ref) { + Guild current = FactionManager.getGuildByString(ref.get("id").getAsString()); + return current != null && current.getIncarnationId().toString().equals(ref.get("incarnation").getAsString()); + } private static void apply(OperationJournal.Intent intent) throws IOException { apply(intent, null); } private static void apply(OperationJournal.Intent intent, OperationJournal.Effects physical) throws IOException { JsonObject changes = intent.changes(); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java index 8395c4d3..5443c0f8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java @@ -134,6 +134,23 @@ class TreasuryActionsTest { } verify(replacement, never()).getLedger(); } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void deletedOrReusedGuildCannotPostANewIntent(boolean reused) throws IOException { + String id = TreasuryActions.newId(); + var plan = new TreasuryActions.Plan(id, "stale", actor, guild) + .transfer(Denars.MINT, TreasuryActions.account(guild), 100); + Guild replacement = mock(Guild.class); + when(replacement.getIncarnationId()).thenReturn(UUID.randomUUID()); + try (var factions = mockStatic(FactionManager.class, CALLS_REAL_METHODS)) { + factions.when(() -> FactionManager.getGuildByString(guild.getId())).thenReturn(reused ? replacement : null); + assertFalse(plan.commit(actor)); + } + assertTrue(Denars.status(id).isEmpty()); + assertEquals(100, guild.getBank().getWealth()); + assertTrue(EconomyRuntime.ready()); + try (var files = Files.list(folder)) { assertEquals(0, files.count()); } + verify(replacement, never()).getLedger(); + } @Test void unavailableCreatesNoIntentOrEffect() throws IOException { when(fixture.ui.plugin.isStarted()).thenReturn(false); assertFalse(TreasuryActions.bank(actor, guild, 10, true)); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildBankCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildBankCommandTest.java index 5e679aa3..d66f47f4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/GuildBankCommandTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildBankCommandTest.java @@ -1,10 +1,8 @@ package net.tfminecraft.simplefactions.guild; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,9 +15,7 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.data.Account; -import net.tfminecraft.denareconomy.data.PlayerData; +import net.tfminecraft.simplefactions.economy.TreasuryActions; import net.tfminecraft.simplefactions.managers.CommandManager; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Bank; @@ -27,11 +23,10 @@ class GuildBankCommandTest { private MockedStatic<FactionManager> factions; - private MockedStatic<DenarEconomy> economy; + private MockedStatic<TreasuryActions> treasury; private final Player player = mock(Player.class); private final Guild guild = mock(Guild.class); private final Bank bank = mock(Bank.class); - private final Account pouch = mock(Account.class); private final Command command = mock(Command.class); @BeforeEach @@ -48,20 +43,15 @@ void setUp() { factions = mockStatic(FactionManager.class); factions.when(() -> FactionManager.getGuildByMember("Steve")).thenReturn(guild); - economy = mockStatic(DenarEconomy.class); - net.tfminecraft.denareconomy.managers.PlayerManager players = - mock(net.tfminecraft.denareconomy.managers.PlayerManager.class); - PlayerData data = mock(PlayerData.class); - economy.when(DenarEconomy::getPlayerManager).thenReturn(players); - when(players.get(player)).thenReturn(data); - when(data.getPouch()).thenReturn(pouch); - when(pouch.getBal()).thenReturn(5_000.0); + treasury = mockStatic(TreasuryActions.class); + treasury.when(() -> TreasuryActions.pouch(player)).thenReturn(5000d); + treasury.when(() -> TreasuryActions.bank(player, guild, 250d, false)).thenReturn(true); } @AfterEach void tearDown() { factions.close(); - economy.close(); + treasury.close(); } private boolean run(String... args) { @@ -74,8 +64,7 @@ void aMemberWhoIsNotTheLeaderCannotWithdraw() { assertTrue(run("withdraw", "5000")); verify(player).sendMessage("§cYou need to be a guild leader to withdraw from the guild bank"); - verify(bank, never()).withdraw(anyDouble()); - verify(pouch, never()).change(anyDouble()); + treasury.verifyNoInteractions(); } @Test @@ -84,8 +73,7 @@ void theLeaderCanWithdraw() { run("withdraw", "250"); - verify(bank).withdraw(250.0); - verify(pouch).change(250.0); + treasury.verify(() -> TreasuryActions.bank(player, guild, 250d, false)); } @Test @@ -95,8 +83,6 @@ void fractionsOfACentAreRefused() { run("withdraw", "0.005"); run("deposit", "0.005"); - verify(bank, never()).withdraw(anyDouble()); - verify(bank, never()).deposit(anyDouble()); - verify(pouch, never()).change(anyDouble()); + treasury.verifyNoInteractions(); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java index 02e69c25..e5eb05d3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.managers; -import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -23,6 +24,8 @@ import org.mockito.MockedStatic; import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.economy.EconomyRuntime; +import net.tfminecraft.denareconomy.api.Denars; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.guild.loans.LoanHandler; @@ -36,6 +39,8 @@ class PlayerManagerLoanBookTest { + "Interest (%): 6.0\nDaily Payments: \nYes\nOverdue Fee (%): 2.0\n"; private MockedStatic<FactionManager> factionManager; + private MockedStatic<EconomyRuntime> readiness; + private MockedStatic<Denars> money; private final PlayerManager listener = new PlayerManager(); private final Guild lender = mock(Guild.class); private final Guild borrower = mock(Guild.class); @@ -63,6 +68,10 @@ static void restorePlugin() { @BeforeEach void setUp() { factionManager = mockStatic(FactionManager.class); + readiness = mockStatic(EconomyRuntime.class); + readiness.when(EconomyRuntime::ready).thenReturn(true); + money = mockStatic(Denars.class); + when(borrower.isLeader(player)).thenReturn(true); factionManager.when(() -> FactionManager.getGuildByString("lender")).thenReturn(lender); factionManager.when(() -> FactionManager.getGuildByLeader("Borrower")).thenReturn(borrower); when(lender.getId()).thenReturn("lender"); @@ -78,6 +87,8 @@ void setUp() { @AfterEach void tearDown() { factionManager.close(); + readiness.close(); + money.close(); } private static BookMeta book(Integer stage, String offerId) { @@ -123,9 +134,8 @@ void anAgreementThatWasAlreadyAcceptedCannotBeSignedAgain() { sign(book(3, "offer-accepted")); - verify(player).sendMessage(contains("already been signed")); - verify(borrowerBank, never()).deposit(anyDouble()); - verify(lenderBank, never()).withdraw(anyDouble()); + verify(player).sendMessage(contains("could not be accepted")); + money.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); } @Test @@ -137,8 +147,8 @@ void anAgreementSavedAsUsedBeforeARestartCannotBeSignedAgain() { sign(book(3, "offer-used")); - verify(player).sendMessage(contains("already been signed")); - verify(borrowerBank, never()).deposit(anyDouble()); + verify(player).sendMessage(contains("could not be accepted")); + money.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); } @Test @@ -148,6 +158,6 @@ void anAgreementFromBeforeTheUpdateIsRefused() { sign(book(3, null)); verify(player).sendMessage(contains("out of date")); - verify(borrowerBank, never()).deposit(anyDouble()); + money.verify(() -> Denars.post(anyString(), anyString(), anyList()), never()); } } From 5391a9f436bd04235d655c00c9bc341e2ac50baa Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:43:17 +0000 Subject: [PATCH 32/35] fix: bind loan offers to issuer incarnation --- .../simplefactions/guild/loans/LoanBook.java | 6 +++++ .../tfminecraft/simplefactions/keys/Keys.java | 2 ++ .../managers/PlayerManager.java | 4 +++ .../managers/PlayerManagerCoverageTest.java | 25 +++++++++++++++++++ .../managers/PlayerManagerLoanBookTest.java | 2 ++ 5 files changed, 39 insertions(+) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java index 6da0bf38..4d28bde4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java @@ -90,6 +90,7 @@ public static ItemStack getLoanBook(Loan loan) { meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, loan.getIssuer().getId()); meta.getPersistentDataContainer().set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, meta.getPage(2)); meta.getPersistentDataContainer().set(Keys.INT, PersistentDataType.INTEGER, 3); + meta.getPersistentDataContainer().set(Keys.LOAN_ISSUER, PersistentDataType.STRING, loan.getIssuer().getIncarnation()); // The accepted loan takes this id, which marks the agreement as used. meta.getPersistentDataContainer().set(Keys.LOAN_OFFER, PersistentDataType.STRING, UUID.randomUUID().toString()); meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, System.currentTimeMillis() + 86400000L); // 1 day in milliseconds @@ -162,6 +163,11 @@ public static Loan createLoanFromBook(BookMeta meta, Guild borrower) { return createLoanFromBook(meta, borrower, null); } + public static boolean currentIssuer(BookMeta meta, Guild issuer) { + String incarnation = meta.getPersistentDataContainer().get(Keys.LOAN_ISSUER, PersistentDataType.STRING); + return incarnation != null && incarnation.equals(issuer.getIncarnation()); + } + /** The agreement id on a stage 3 book, or null for a book made before agreements had one. */ public static String offerId(BookMeta meta) { return meta.getPersistentDataContainer().get(Keys.LOAN_OFFER, PersistentDataType.STRING); diff --git a/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java b/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java index 5c839ff2..761849a7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java +++ b/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java @@ -26,6 +26,8 @@ private Keys() {} public static final NamespacedKey CONTRACT_ID = new NamespacedKey(SimpleFactions.plugin, "contract_id"); /** The one-time id of a stage 3 loan agreement book; the accepted loan takes it. */ public static final NamespacedKey LOAN_OFFER = new NamespacedKey(SimpleFactions.plugin, "loan_offer"); + /** Guild incarnation that authorized the offered loan. */ + public static final NamespacedKey LOAN_ISSUER = new NamespacedKey(SimpleFactions.plugin, "loan_issuer"); /** Queue tile payload for cancel confirmation ({@code type:ownerId:detail}). */ public static final NamespacedKey QUEUE_CANCEL = new NamespacedKey(SimpleFactions.plugin, "queue_cancel"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 6450b8de..193e1c14 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -114,6 +114,10 @@ public void run() { }.runTaskLater(SimpleFactions.plugin, 1L); } else if(stage == 3) { e.setCancelled(true); + if (!LoanBook.currentIssuer(newMeta, issuer)) { + p.sendMessage("§cThis loan agreement is out of date. Ask the lender for a new one."); + return; + } Guild borrowerGuild = FactionManager.getGuildByLeader(Members.key(p)); String offerId = LoanBook.offerId(newMeta); Loan loan = offerId == null ? null : LoanBook.createLoanFromBook(newMeta, borrowerGuild, offerId); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java index b987fbd8..bf39b892 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java @@ -220,6 +220,7 @@ void aLeaderCanReviewAndPublishValidLoanTermsWithoutMovingMoney() { BookMeta offer = (BookMeta) leader.getInventory().getItem(0).getItemMeta(); assertEquals(3, offer.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); assertNotNull(LoanBook.offerId(offer)); + assertTrue(LoanBook.currentIssuer(offer, lender)); assertNotNull( offer .getPersistentDataContainer() @@ -233,6 +234,7 @@ void aLeaderCanReviewAndPublishValidLoanTermsWithoutMovingMoney() { strings = { "expired", "old_offer", + "unbound_issuer", "missing_snapshot", "invalid_snapshot", "invalid_terms", @@ -255,6 +257,8 @@ void refusedLoanAgreementsNeverMoveMoneyOrCreateDebt(String reason) { meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, 1L)); case "old_offer" -> edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.LOAN_OFFER)); + case "unbound_issuer" -> + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.LOAN_ISSUER)); case "missing_snapshot" -> edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.SECONDARY_STRING_KEY)); case "invalid_snapshot" -> @@ -287,6 +291,27 @@ void refusedLoanAgreementsNeverMoveMoneyOrCreateDebt(String reason) { assertTrue(messages(signer).stream().anyMatch(m -> m.startsWith("§c")), reason); } + @Test + void oldLoanBookCannotChargeARecreatedGuildWithTheSameId() { + Guild borrower = borrower(); + Player signer = player("Borrower"); + ItemStack book = loanBook(3, borrower); + String offer = LoanBook.offerId((BookMeta) book.getItemMeta()); + Guild replacement = fixture.guild(faction, lender.getId(), "Replacement"); + replacement.setBank(new Bank(replacement, 0, fixture.ui.world.getChunkAt(0, 0))); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(replacement.getBank(), 10000); + double borrowerBefore = borrower.getBank().getWealth(); + signer.getInventory().setItem(0, book); + + assertTrue(sign(signer, book).isCancelled()); + + assertEquals(10000, replacement.getBank().getWealth()); + assertEquals(borrowerBefore, borrower.getBank().getWealth()); + assertNull(replacement.getLoanHandler().getLoanById(offer)); + assertFalse(LoanBook.currentIssuer((BookMeta) book.getItemMeta(), replacement)); + verify(signer).sendMessage(contains("out of date")); + } + @ParameterizedTest @ValueSource(strings = {"online", "offline", "absent", "no_expiry"}) void acceptingALoanConservesFundsRecordsOneDebtAndProducesASignedReceipt(String notification) { diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java index e5eb05d3..5fcc9039 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java @@ -75,6 +75,7 @@ void setUp() { factionManager.when(() -> FactionManager.getGuildByString("lender")).thenReturn(lender); factionManager.when(() -> FactionManager.getGuildByLeader("Borrower")).thenReturn(borrower); when(lender.getId()).thenReturn("lender"); + when(lender.getIncarnation()).thenReturn("current-lender-incarnation"); when(lender.getName()).thenReturn("Lenders"); when(lender.getBank()).thenReturn(lenderBank); when(lenderBank.getWealth()).thenReturn(10_000.0); @@ -100,6 +101,7 @@ private static BookMeta book(Integer stage, String offerId) { when(data.get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING)).thenReturn(TERMS); when(data.get(Keys.LONG, PersistentDataType.LONG)).thenReturn(System.currentTimeMillis() + 60_000L); when(data.get(Keys.LOAN_OFFER, PersistentDataType.STRING)).thenReturn(offerId); + when(data.get(Keys.LOAN_ISSUER, PersistentDataType.STRING)).thenReturn("current-lender-incarnation"); when(meta.getPageCount()).thenReturn(2); when(meta.getPage(2)).thenReturn(TERMS); return meta; From 20d6ccb8180a4516d98e1c517733db972a24af03 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:49:51 +0000 Subject: [PATCH 33/35] test: cover vehicle fee recovery and validation paths --- .../fees/VehicleRegistrationFeeListener.java | 6 --- .../VehicleInstallationLockServiceTest.java | 7 +++ .../VehicleInteractionsCoverageTest.java | 54 ++++++++++++++++++- .../vehicles/fees/VehicleFeeServiceTest.java | 10 ++++ .../fees/VehicleFeeStoreValidationTest.java | 26 +++++++++ .../vehicles/fees/VehiclePaymentsTest.java | 44 +++++++++++++++ .../VehicleRuntimeLifecycleCoverageTest.java | 14 +++++ .../VehicleUpkeepProjectionTest.java | 8 +++ .../maintenance/VehicleUpkeepServiceTest.java | 9 ++++ .../registry/VehiclePayerMigrationTest.java | 20 +++++++ 10 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStoreValidationTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index 2829d67d..3c68430a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -17,7 +17,6 @@ import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore.PaidBuild; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import net.tfminecraft.vfbuilders.core.ActiveStation; import net.tfminecraft.vfbuilders.core.Blueprint; import net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent; @@ -123,11 +122,6 @@ public void onVehicleConstruct(VehicleConstructEvent event) { payments.complete(scope, null); } - /** The paying character, without an account fallback. */ - static UUID payerUuid(Player player) { - return VehicleOwnershipQueries.characterId(player); - } - static String stationKey(ActiveStation station) { Location loc = station == null ? null : station.getLocation(); if (loc == null || loc.getWorld() == null) { diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInstallationLockServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInstallationLockServiceTest.java index e391414c..76ba0ba7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInstallationLockServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInstallationLockServiceTest.java @@ -98,6 +98,13 @@ void tearDown() { BattleManager.resetForTests(); } + @Test + void incompleteLockQueriesCannotSelectAnInstallation() { + assertFalse(VehicleInstallationLockService.isVehicleLocked(null, NOW)); + assertFalse(VehicleInstallationLockService.isVehicleLocked(" ", NOW)); + assertFalse(VehicleInstallationLockService.isVehicleLocked("port-atk", null)); + } + @Test void sourceLocked_duringFightingOnly() { setActiveRaid(CampaignRaidState.MUSTER); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java index 17b7c854..093ddc99 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java @@ -1442,8 +1442,13 @@ void registrationFeesAreConfirmedThenChargedOnceAndEitherCompletedOrRefunded(Str fixture.online.remove("Owner"); when(owner.isOnline()).thenReturn(false); } - fees.onConstructionCancel(cancelled); - fees.onConstructionCancel(cancelled); + try (var members = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + String payer = net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref().characterId().toString(); + members.when(() -> net.tfminecraft.simplefactions.identity.Members.online(payer)) + .thenReturn(state.equals("offline_refund") ? Optional.empty() : Optional.of(owner)); + fees.onConstructionCancel(cancelled); + fees.onConstructionCancel(cancelled); + } assertEquals( state.equals("partial_refund") ? 70.0 : 100.0, balance(owner)); assertEquals(state.equals("partial_refund") ? 0.0 : 1000.0, faction.getBank().getWealth()); @@ -2355,6 +2360,50 @@ void feeStoreWriteFailuresLeaveTheExistingDestinationAndInMemoryOwnershipIntact( assertEquals("preserved", Files.readString(files.root.resolve("vehicle_fees.json/sentinel"))); } + @Test + void unfinishedBuildAndClaimReceiptsRefuseNewPhysicalActions() { + var payment = net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.current(); + var ref = net.tfminecraft.simplefactions.identity.Members.session(owner).orElseThrow().ref(); + feeStore.putReceipt("build:world:6:64:7", new net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.Receipt( + "interrupted", ref, faction.getId(), faction.getOrCreateMainGuild().getIncarnationId(), FeeKind.REGISTRATION_FEE, 1)); + var build = new BeginVehicleConstructionEvent(owner, blueprint("ironclad"), station(), station().getLocation()); + new VehicleRegistrationFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save).onBeginVehicleConstruction(build); + assertTrue(build.isCancelled()); + feeStore.putReceipt("claim:vehicle-1", payment.receipt("build:world:6:64:7")); + var claim = new VehicleOwnerClaimedEvent(owner, vehicle, "none", "player_Owner"); + new VehicleReclaimFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save).onVehicleOwnerClaimed(claim); + assertTrue(claim.isCancelled()); + assertEquals("player_Owner", ownerData.getOwner()); + } + + @Test + void handoverWithoutAFeeStillPersistsTheCapturedRecipient() { + Player recipient = fixture.player("Recipient"); + handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); + handoverService.acceptRequest(recipient); + assertEquals("player_Recipient", ownerData.getOwner()); + assertEquals("Recipient", feeStore.getLastOwner("vehicle-1")); + assertEquals(net.tfminecraft.simplefactions.identity.Members.session(recipient).orElseThrow().ref().characterId(), + registry.getByVehicleUuid("vehicle-1").orElseThrow().getPlayerUuid()); + } + + @Test + void changedCharacterAndUnavailablePaymentsRefusePendingHandovers() { + Player recipient = fixture.player("Recipient"); + handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); + try (var members = mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + members.when(() -> net.tfminecraft.simplefactions.identity.Members.session(recipient)).thenReturn(Optional.empty()); + handoverService.acceptRequest(recipient); + verify(recipient).sendMessage("§cThis request belongs to another character."); + handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); + verify(owner).sendMessage("§cBoth players must select a living character."); + } + net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.stop(); + handoverService.acceptRequest(recipient); + verify(recipient).sendMessage("§cVehicle payment needs staff review or recovery."); + assertEquals("player_Owner", ownerData.getOwner()); + } + @Test void publicVehicleQueriesReportFactionUpkeepAndTheAbsenceOfMilitaryOrPluginState() { assertEquals(0, FactionVehiclePoolService.artillerySlots(null)); @@ -2364,6 +2413,7 @@ void publicVehicleQueriesReportFactionUpkeepAndTheAbsenceOfMilitaryOrPluginState assertEquals(0.0, FactionVehiclePoolService.dailyUpkeep(registry, " ")); assertEquals(0.0, FactionVehiclePoolService.dailyUpkeepOf("home")); factionVehicle(true); + assertSame(faction, FactionVehiclePoolService.payingFaction(registry.getByVehicleUuid("vehicle-1").orElseThrow())); assertEquals( VehiclesConfigLoader.getUpkeep("simple_locomotive"), FactionVehiclePoolService.dailyUpkeepOf("home")); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java index e107d86b..dac15ac6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java @@ -87,6 +87,16 @@ void tearDown() throws IOException { Files.walk(tempDir).sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); } + @Test + void balancePreviewUsesTheCapturedCharacterWithoutMutatingMoney() { + UUID character = UUID.randomUUID(); + bank.balances.put(character, 12.5); + assertEquals(12.5, VehicleFeeService.bankBalance(character)); + assertEquals(0, VehicleFeeService.bankBalance(null)); + assertNull(VehicleFeeService.resolve("unresolved")); + assertEquals(12.5, bank.balances.get(character)); + } + @Test void taxIsAPercentageAndFeesAMultipleOfUpkeep() { handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 50)); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStoreValidationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStoreValidationTest.java new file mode 100644 index 00000000..7e914877 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStoreValidationTest.java @@ -0,0 +1,26 @@ +package net.tfminecraft.simplefactions.vehicles.fees; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VehicleFeeStoreValidationTest { + @TempDir Path folder; + + @Test void invalidReceiptAndLegacyUuidPreservePreviouslyLoadedStateAndFile() throws Exception { + var store = new VehicleFeeStore(); store.bind(folder.toFile()); + var previous = new VehicleFeeStore.PaidBuild(UUID.randomUUID(), "realm", 1); + store.putPaidBuild("good", previous); + for (String bad : new String[] { + "{\"receipts\":{\"build:s\":{\"id\":\"incomplete\"}}}", + "{\"paidBuilds\":{\"s\":{\"payerUuid\":\"invalid\",\"factionId\":\"realm\",\"amount\":1}}}"}) { + Files.writeString(folder.resolve("vehicle_fees.json"), bad); + assertThrows(IllegalStateException.class, store::load); + assertEquals(previous, store.getPaidBuild("good")); + assertThrows(java.io.IOException.class, store::saveDurably); + assertEquals(bad, Files.readString(folder.resolve("vehicle_fees.json"))); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java index 4435a599..8e5f1625 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java @@ -172,4 +172,48 @@ class VehiclePaymentsTest { assertTrue(payments.complete("build:legacy", null)); assertTrue(store.pendingBuilds().isEmpty()); } + + @Test void failedReceiptCompletionKeepsTheDurableScopeForRestartReview() throws Exception { + assertEquals(Result.APPLIED, payments.charge("build:s", "fee", payer, quote())); + Path blocked = folder.resolve("blocked"); + Files.writeString(blocked, "not a directory"); + store.bind(blocked.toFile()); + assertFalse(payments.complete("build:s", null)); + assertFalse(payments.ready()); + var reopened = new VehicleFeeStore(); reopened.bind(folder.resolve("Cache").toFile()); reopened.load(); + assertNotNull(reopened.receipt("build:s")); + assertEquals(2000, Denars.balance(Denars.bank(payer))); + } + + @Test void failedPersonalRegistrationPreservesTheOriginalReceipt() { + assertEquals(Result.APPLIED, payments.charge("claim:v", "fee", payer, quote())); + assertFalse(payments.personal("v", "ship", null)); + assertFalse(payments.ready()); + assertNotNull(payments.receipt("claim:v")); + } + + @Test void failedFreeMaintenanceSaveLeavesVehicleUnpaid() throws Exception { + maintenance.markUnpaid("v", 1); + Files.createDirectory(folder.resolve("Cache/vehicle_maintenance.json")); + assertEquals(Result.UNAVAILABLE, payments.maintain("free", payer, "v", true, 0)); + assertTrue(maintenance.isUnpaid("v")); + assertFalse(payments.ready()); + assertEquals(10000, Denars.balance(Denars.bank(payer))); + } + + @Test void unsupportedPersistedActionStopsRecoveryWithoutGuessingAnEffect() throws Exception { + var changes = new com.google.gson.JsonObject(); + changes.addProperty("action", "unsupported"); + changes.addProperty("scope", "claim:v"); + changes.add("receipt", net.tfminecraft.simplefactions.database.JsonUtil.GSON.toJsonTree( + new VehiclePayments.Receipt("unsupported", payer, "realm", incarnation, FeeKind.REGISTRATION_FEE, 1))); + var intent = new net.tfminecraft.simplefactions.economy.OperationJournal.Intent("unsupported", "test", + List.of(new Leg(Denars.bank(payer), -1), new Leg(Denars.guild(incarnation), 1)), changes); + var journal = new net.tfminecraft.simplefactions.economy.OperationJournal(folder.resolve("vehicle-payments")); + assertThrows(java.io.IOException.class, () -> journal.execute(intent, ignored -> { throw new java.io.IOException("interrupted"); })); + assertThrows(java.io.IOException.class, payments::recover); + assertFalse(payments.ready()); + assertNull(payments.receipt("claim:v")); + assertEquals(9999, Denars.balance(Denars.bank(payer))); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java index 55eee2cd..ba598118 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java @@ -164,6 +164,7 @@ void close() throws Exception { "insufficient_pouch", "insufficient_bank", "not_unpaid", + "unavailable", "unknown_type" }) void anArmedPaymentConsumesOneInteractionAndReportsTheActualOutcome(String scenario) { @@ -189,6 +190,7 @@ void anArmedPaymentConsumesOneInteractionAndReportsTheActualOutcome(String scena case "insufficient_bank" -> VehicleMaintenanceMessages.insufficientBank(); case "not_unpaid" -> VehicleMaintenanceMessages.notUnpaid(); case "unknown_type" -> VehicleMaintenanceMessages.unknownType(); + case "unavailable" -> "§cVehicle payment is unavailable pending recovery."; default -> throw new AssertionError(scenario); }; verify(player).sendMessage(message); @@ -198,6 +200,18 @@ void anArmedPaymentConsumesOneInteractionAndReportsTheActualOutcome(String scena assertFalse(second.isCancelled()); } + @Test + void paymentFromAnEarlierCharacterSessionIsDisarmedWithoutCallingTheService() { + var service = mock(VehicleMaintenancePayService.class); + var session = new VehicleMaintenancePaySession(Long.MAX_VALUE); + sessions.put(player.getUniqueId(), session); + new VehicleMaintenancePayListener(sessions, service).onVehiclePreInteract( + new VehiclePreInteractEvent(player, vehicle("v", "ironclad"))); + assertNull(sessions.get(player.getUniqueId())); + verifyNoInteractions(service); + verify(player).sendMessage("§cThis payment belongs to an earlier character session."); + } + @Test void invalidInteractionBoundariesAndExpiredSessionsNeverMoveMoney() { VehiclePreInteractEvent noPlayer = new VehiclePreInteractEvent(null, vehicle("x", "ironclad")); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java index b3acf717..0825be18 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java @@ -71,6 +71,14 @@ void tearDown() throws IOException { } } + @Test + void absentIdentityAndLedgerHaveNoProjectedCost() { + assertEquals(0, VehicleUpkeepProjection.projectedDailyUpkeep((UUID) null)); + assertEquals(0, VehicleUpkeepProjection.displayVehicleTax(null, playerUuid)); + assertEquals(0, VehicleUpkeepProjection.displayVehicleExpense(null, playerUuid)); + assertEquals(0, VehicleUpkeepProjection.displayNetDaily(null, playerUuid)); + } + @Test void projectedDailyUpkeep_sumsPersonalVehiclesOnly() { registry.register(new PlayerVehicleRecord( diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java index d41517fe..0ceb1c31 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java @@ -101,6 +101,15 @@ void tearDown() throws IOException { + @Test + void startupDefaultServiceDoesNotWarnOutsideThePrechargeWindow() { + var defaultService = new VehicleUpkeepService(registry, economyManager); + Player player = mock(Player.class); + defaultService.warnBankShortfalls(List.of(player), 0); + defaultService.warnBankShortfalls(List.of(player), -1); + verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString()); + } + @Test void warnsPlayerWhoseBankCannotCoverUpkeep() { UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java index aaad4689..4ae9531a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java @@ -81,5 +81,25 @@ class VehiclePayerMigrationTest { Files.createDirectories(folder.resolve("identity")); Files.writeString(folder.resolve("identity/vehicle-payers.json"), "{\"rows\":null}"); assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, new PlayerVehicleRegistry())); + String invalid = "{\"rows\":[{\"vehicle\":\"v\",\"type\":\"ship\"}]}"; + Files.writeString(folder.resolve("identity/vehicle-payers.json"), invalid); + assertThrows(IOException.class, () -> VehiclePayerMigration.startup(folder, new PlayerVehicleRegistry())); + assertEquals(invalid, Files.readString(folder.resolve("identity/vehicle-payers.json"))); + } + + @Test void persistedPlayerNameResolvesAnAccountBeforeLookingUpItsHeir() throws Exception { + var registry = new PlayerVehicleRegistry(); + try (var chars = mockStatic(Characters.class); var vf = mockStatic(VehicleFramework.class); + var ownership = mockStatic(VehicleOwnershipQueries.class, CALLS_REAL_METHODS)) { + chars.when(Characters::legacyHeirsReady).thenReturn(true); + var repository = mock(VehicleRepository.class); + vf.when(VehicleFramework::getVehicleRepository).thenReturn(repository); + when(repository.listPlayerOwnedMeta()).thenReturn(List.of(vehicle("v", "player_OldName"))); + ownership.when(() -> VehicleOwnershipQueries.resolvePlayerUuid("OldName")).thenReturn(heir.ownerId()); + chars.when(() -> Characters.legacyHeir(heir.ownerId())).thenReturn(Optional.of(heir)); + VehiclePayerMigration.startup(folder, registry); + assertEquals(heir.characterId(), registry.getByVehicleUuid("v").orElseThrow().getPlayerUuid()); + assertTrue(Files.readString(folder.resolve("identity/vehicle-payers.json")).contains("player_OldName")); + } } } From f5149adf7eebd1fcc89a66652bc402fbd5891f38 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:09:50 +0000 Subject: [PATCH 34/35] test: cover settlement, lifecycle and casino recovery paths --- .../simplefactions/SimpleFactions.java | 1 + .../managers/InventoryManager.java | 5 -- .../PluginLifecycleCoverageTest.java | 89 +++++++++++++++++++ .../api/SimpleFactionsApiTest.java | 6 ++ .../RealmSettlementCalculationTest.java | 80 +++++++++++++++++ .../income/CasinoProfitOperationTest.java | 65 ++++++++++++++ .../simplefactions/identity/MembersTest.java | 18 ++++ .../StaleRequestsReviewRegressionTest.java | 10 +++ .../MercenaryCompanyFormationTest.java | 17 ++++ ...MercenaryContractBoundaryCoverageTest.java | 66 ++++++++++++++ 10 files changed, 352 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 5b223ec9..81aa5b3b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -274,6 +274,7 @@ public class SimpleFactions extends JavaPlugin{ public void onEnable() { try { net.tfminecraft.tlibs.character.Characters.legacyHeirsReady(); + net.tfminecraft.tlibs.character.Characters.deathId(new java.util.UUID(0L, 0L)); } catch (LinkageError error) { incompatibleCharacterApi = true; getLogger().severe("SimpleFactions requires TLibs 2.4.1 or newer; disabling."); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index d8b66438..67b11013 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -765,11 +765,6 @@ public void donationChat(Player p, AsyncPlayerChatEvent e) { p.sendMessage("§4Type 'cancel' to cancel."); return; } - if (!Double.isFinite(amount)) { - p.sendMessage("§cDonation must be a number of denars."); - p.sendMessage("§4Type 'cancel' to cancel."); - return; - } amount = Math.round(amount * 100.0) / 100.0; if (amount < 0) { p.sendMessage("§cDonation must be §e0 §cor more."); diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 5a3d006c..ba47dc85 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -306,6 +306,95 @@ void startupRegistersGameplayAndShutdownSavesLoadedState() throws Exception { scoped(CampaignViewRefreshService.class).verify(CampaignViewRefreshService::stop); } + @Test + void startupImportsLegacyGuildBalancesAndTheDeathReconcileTimerFailsClosed() throws Exception { + var deaths = scoped(net.tfminecraft.simplefactions.identity.death.CharacterDeaths.class); + var economy = scoped(net.tfminecraft.simplefactions.economy.EconomyRuntime.class); + var treasuries = scoped(net.tfminecraft.simplefactions.economy.GuildTreasury.class); + UUID bankedId = UUID.randomUUID(); + UUID banklessId = UUID.randomUUID(); + net.tfminecraft.simplefactions.guild.Guild banked = + mock(net.tfminecraft.simplefactions.guild.Guild.class); + net.tfminecraft.simplefactions.objects.Bank bank = + mock(net.tfminecraft.simplefactions.objects.Bank.class); + when(bank.legacyBalance()).thenReturn(42.5); + when(banked.getIncarnationId()).thenReturn(bankedId); + when(banked.getBank()).thenReturn(bank); + var bankedLedger = mock(net.tfminecraft.simplefactions.guild.income.Ledger.class); + when(bankedLedger.getCitizenTaxesCopy()).thenReturn(Map.of("tax", 1.5)); + when(banked.getLedger()).thenReturn(bankedLedger); + net.tfminecraft.simplefactions.guild.Guild bankless = + mock(net.tfminecraft.simplefactions.guild.Guild.class); + when(bankless.getIncarnationId()).thenReturn(banklessId); + var banklessLedger = mock(net.tfminecraft.simplefactions.guild.income.Ledger.class); + when(banklessLedger.getCitizenTaxesCopy()).thenReturn(Map.of()); + when(bankless.getLedger()).thenReturn(banklessLedger); + scoped(FactionManager.class) + .when(FactionManager::getAllGuilds) + .thenReturn(List.of(banked, bankless)); + + plugin.onEnable(); + + assertTrue(plugin.isStarted()); + assertNotNull(plugin.getRealmSettlementService()); + treasuries.verify( + () -> + net.tfminecraft.simplefactions.economy.GuildTreasury.importLegacy( + bankedId, 42.5, Map.of("tax", 1.5))); + // A guild without a bank imports a zero legacy balance. + treasuries.verify( + () -> + net.tfminecraft.simplefactions.economy.GuildTreasury.importLegacy( + banklessId, 0.0, Map.of())); + verify(banked).updateWealth(); + verify(bankless).updateWealth(); + + var task = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(domain.ui.scheduler).runTaskTimer(eq(plugin), task.capture(), eq(72000L), eq(72000L)); + task.getValue().run(); + deaths.verify( + net.tfminecraft.simplefactions.identity.death.CharacterDeaths::reconcile, times(2)); + economy.verify( + () -> net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed(any()), never()); + deaths + .when(net.tfminecraft.simplefactions.identity.death.CharacterDeaths::reconcile) + .thenThrow(new IOException("death journal stuck")); + task.getValue().run(); + economy.verify( + () -> + net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed( + any(IOException.class))); + } + + @Test + void aFailedLegacyImportFailsTheFinancialStateAndDisablesThePlugin() { + var treasuries = scoped(net.tfminecraft.simplefactions.economy.GuildTreasury.class); + treasuries + .when( + () -> + net.tfminecraft.simplefactions.economy.GuildTreasury.importLegacy( + any(), anyDouble(), any())) + .thenThrow(new IllegalStateException("legacy import refused")); + net.tfminecraft.simplefactions.guild.Guild guild = + mock(net.tfminecraft.simplefactions.guild.Guild.class); + when(guild.getIncarnationId()).thenReturn(UUID.randomUUID()); + var ledger = mock(net.tfminecraft.simplefactions.guild.income.Ledger.class); + when(ledger.getCitizenTaxesCopy()).thenReturn(Map.of()); + when(guild.getLedger()).thenReturn(ledger); + scoped(FactionManager.class).when(FactionManager::getAllGuilds).thenReturn(List.of(guild)); + + plugin.onEnable(); + + assertFalse(plugin.isStarted()); + verify(plugins).disablePlugin(plugin); + verify(logger) + .log( + eq(java.util.logging.Level.SEVERE), + contains("Faction money recovery is incomplete"), + any(IllegalStateException.class)); + verify(dependency("inventoryManager", InventoryManager.class), never()).start(); + } + private Map<Path, String> unloadedSavedState() throws Exception { var faction = domain.data("prior", "PriorLeader"); faction.titles = List.of("prior_county"); diff --git a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java index 04dd82a4..bc97deae 100644 --- a/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java @@ -36,6 +36,12 @@ void playerAndCharacterLookupsAreUnavailableUntilReadyAndUseDistinctCharacters() assertSame(guild, SimpleFactionsApi.getGuildByLeader(player).orElseThrow()); assertSame(guild, SimpleFactionsApi.getGuildByLeader(character).orElseThrow()); assertTrue(SimpleFactionsApi.getGuildByMember(java.util.UUID.randomUUID()).isEmpty()); + var unknown = mock(org.bukkit.entity.Player.class); + when(unknown.getName()).thenReturn("Nobody"); + assertTrue(SimpleFactionsApi.getByMember(unknown).isEmpty(), "ready, but in no faction"); + assertTrue(SimpleFactionsApi.getByMember(java.util.UUID.randomUUID()).isEmpty()); + assertNull(net.tfminecraft.simplefactions.managers.FactionManager.getByMember(unknown)); + assertNull(net.tfminecraft.simplefactions.managers.FactionManager.getByMember(java.util.UUID.randomUUID())); assertEquals("guild:" + guild.getIncarnation(), guild.getBank().accountId()); assertEquals(java.util.UUID.fromString(guild.getIncarnation()), guild.getIncarnationId()); } finally { diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java index f089654f..83f3a4bd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -147,4 +147,84 @@ RealmSettlementChanges changes(SettlementJournal.Plan plan) { after.id = "wrong"; assertThrows(IllegalArgumentException.class, () -> loan.restoreSettlement(after)); } + + @Test void multiDayReparationsCarryWithOneLessDayWhileExpiringOnesDrop() { + faction.addWarReparationsObligation(new net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation("payee", 10, 3)); + faction.addWarReparationsObligation(new net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation("payee", 25, 1)); + faction.getWarReparationsObligations().add(null); + var carried = changes(plan(List.of())).reparations.get(faction.getId()); + assertEquals(1, carried.size()); + assertEquals("payee", carried.get(0).getPayeeFactionId()); + assertEquals(10, carried.get(0).getIncomePercent()); + assertEquals(2, carried.get(0).getDaysRemaining()); + assertEquals(3, faction.getWarReparationsObligations().get(0).getDaysRemaining(), "planning leaves the live obligation untouched"); + } + + @Test void aLoanWhoseBorrowerIsOutsideTheSettlementIsRejected() { + Loan loan = new Loan("test", 100, capital, guild, 1000000000L - 86400000L * 3, 1, 7, 5, true); + capital.getLoanHandler().issueLoan(loan); + var partial = new RealmSettlementCalculation(10, 1000000000L, List.of(capital), List.of()); + var error = assertThrows(IllegalStateException.class, () -> partial.calculate((balances, legs, changes) -> {})); + assertEquals("Loan parties missing from settlement: test", error.getMessage()); + } + + MercenaryCompany formedCompany(Guild host) { + var yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.set("item.material", "IRON_SWORD"); + yaml.set("mercenary", true); + var company = new MercenaryCompany(host, "Company", + new net.tfminecraft.simplefactions.army.Regiment("mercenary", yaml), 0); + host.setCompany(company); + return company; + } + + net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract activeContract(MercenaryCompany company, long issue, int durationDays) { + var contract = new net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract(company, faction, + net.tfminecraft.simplefactions.mercenary.contract.ContractKind.MERCENARY, + new net.tfminecraft.simplefactions.mercenary.contract.ContractTerms(1, 50, 10, durationDays, 50, 500), issue); + company.getContractHandler().add(contract); + assertTrue(contract.activate()); + return contract; + } + + @Test void contractDayPriceWagesAndRefundSettleThenAnElapsedContractCompletesAndClearsAccruals() { + var company = formedCompany(guild); + assertTrue(company.enlist(character.toString())); + company.getWageSettings().setPeacetimePerDay(5); + company.getWageSettings().setActivePercent(50); + var contract = activeContract(company, 1000000000L - 15L * 86400000L, 14); + assertTrue(contract.accrueBattleCharge("battle:1", 20)); + assertTrue(contract.accrueAbsenceRefund("battle:1", 7)); + var plan = plan(List.of()); + assertEquals(0, plan.legs().stream().mapToLong(Leg::cents).sum(), "the posting balances"); + assertEquals(-2300, net(plan, capital.getBank().accountId()), + "hirer capital pays the 20 battle charge and 10 day price, less the 7 refund"); + var changes = changes(plan); + assertEquals(10.0, changes.playerIncome.get(character).get("WAGES"), + "5 peacetime wage plus half of the 10 day price for the covered slot"); + var after = changes.guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + assertEquals(0.0, after.company.pendingWages.getOrDefault(character.toString(), 0.0)); + var saved = after.company.contracts.get(0); + assertEquals("COMPLETED", saved.status); + assertEquals(1, saved.daysServed); + assertEquals(0.0, saved.accruedToCompany, "settled accruals are cleared so a day cannot be paid twice"); + assertEquals(0.0, saved.accruedToHirer); + assertEquals(60, after.company.reputation, "a clean fourteen-day completion earns the bonus"); + assertEquals("ACTIVE", contract.getStatus().name(), "planning leaves the live contract untouched"); + assertEquals(20, contract.getAccruedToCompany()); + assertEquals(0, contract.getDaysServed()); + } + + @Test void anUnexpiredContractEndsWhenItsHostEndsTheDayBankrupt() { + var company = formedCompany(guild); + assertTrue(company.enlist(character.toString())); + var contract = activeContract(company, 1000000000L, 14); + Ledger.setNodeUpkeepLookup(g -> g == guild ? 500 : 0); + var plan = plan(List.of()); + var after = changes(plan).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + assertEquals("TERMINATED", after.company.contracts.get(0).status); + assertEquals(1, after.company.contracts.get(0).daysServed); + assertTrue(after.company.reputation < 50, "bankruptcy is punished through reputation"); + assertEquals("ACTIVE", contract.getStatus().name()); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java index 4dd25f9e..586a8578 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java @@ -14,6 +14,71 @@ class CasinoProfitOperationTest { @TempDir Path folder; + + @Test void exactRetryResumesAnUnfinishedOperationButAChangedAmountOrLostBookkeepingFailsClosed() { + try (var fixture = new FactionDomainFixture(); var runtime = mockStatic(EconomyRuntime.class)) { + when(fixture.ui.plugin.getDataFolder()).thenReturn(folder.toFile()); + runtime.when(EconomyRuntime::ready).thenReturn(true); + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var ledger = faction.getOrCreateMainGuild().getLedger(); + try (var database = mockConstruction(Database.class)) { + assertFalse(ledger.addCasinoProfitEntry("table:retry", 50), "the save fails, leaving the journal unfinished"); + } + try (var database = mockConstruction(Database.class, (db, context) -> when(db.saveFactionDurably(faction)).thenReturn(true))) { + assertFalse(ledger.addCasinoProfitEntry("table:retry", 51), "the journalled amount may not change"); + assertEquals(0, ledger.getCasinoProfit()); + assertTrue(ledger.getCasinoProfitOperations().isEmpty()); + assertTrue(ledger.addCasinoProfitEntry("table:retry", 50), "an exact retry resumes the same intent"); + assertEquals(50, ledger.getCasinoProfit()); + assertEquals(50.0, ledger.getCasinoProfitOperations().get("table:retry")); + ledger.setCasinoProfit(0); + ledger.restoreCasinoProfitOperations(Map.of()); + assertFalse(ledger.addCasinoProfitEntry("table:retry", 50), + "a completed posting whose bookkeeping was lost is incomplete, not a success"); + } + runtime.verify(() -> EconomyRuntime.failClosed(any()), org.mockito.Mockito.atLeast(2)); + } + } + + @Test void recoveryRefusesADisappearedGuildAChangedAmountAndMovedIncome() throws Exception { + try (var fixture = new FactionDomainFixture(); var runtime = mockStatic(EconomyRuntime.class)) { + runtime.when(EconomyRuntime::ready).thenReturn(true); + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var ledger = faction.getOrCreateMainGuild().getLedger(); + + Path gone = unfinished(fixture, ledger, "gone", "table:gone"); + net.tfminecraft.simplefactions.managers.FactionManager.factions.remove(faction); + try { + var error = assertThrows(java.io.IOException.class, () -> ExternalIncome.recover(gone)); + assertEquals("Casino treasury disappeared or was replaced", error.getMessage()); + } finally { + if (!net.tfminecraft.simplefactions.managers.FactionManager.factions.contains(faction)) + net.tfminecraft.simplefactions.managers.FactionManager.factions.add(faction); + } + + Path changed = unfinished(fixture, ledger, "changed", "table:changed"); + ledger.restoreCasinoProfitOperations(Map.of("table:changed", 999.0)); + var amountError = assertThrows(java.io.IOException.class, () -> ExternalIncome.recover(changed)); + assertEquals("Casino operation disagrees with saved amount", amountError.getMessage()); + ledger.restoreCasinoProfitOperations(Map.of()); + + Path moved = unfinished(fixture, ledger, "moved", "table:moved"); + ledger.setCasinoProfit(123); + var movedError = assertThrows(java.io.IOException.class, () -> ExternalIncome.recover(moved)); + assertEquals("Casino income changed before unfinished bookkeeping recovered", movedError.getMessage()); + ledger.setCasinoProfit(0); + } + } + + /** Leaves a posted-but-unsaved casino intent in its own data folder, ready for recovery. */ + private Path unfinished(FactionDomainFixture fixture, Ledger ledger, String subfolder, String operation) { + Path data = folder.resolve(subfolder); + when(fixture.ui.plugin.getDataFolder()).thenReturn(data.toFile()); + try (var database = mockConstruction(Database.class)) { + assertFalse(ledger.addCasinoProfitEntry(operation, 50)); + } + return data; + } @Test void restartRecoversFailedBookkeepingBeforeSettlementAndExactRetryDoesNotRetax() throws Exception { try (var fixture = new FactionDomainFixture(); var runtime = mockStatic(EconomyRuntime.class)) { when(fixture.ui.plugin.getDataFolder()).thenReturn(folder.toFile()); diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java index a76e9f4b..181a14f7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java @@ -171,6 +171,24 @@ void noServerMeansNobodyIsOnline() { } } + @Test + void memberAccountsResolveCharactersLegacyNamesAndBlanks() { + try (var identity = new SharedIdentity(); + var offline = mockStatic(net.tfminecraft.denareconomy.accounts.OfflineModifier.class)) { + CharacterRef ada = identity.character(identity.account("Steve"), "Ada"); + UUID legacy = UUID.randomUUID(); + offline.when(() -> net.tfminecraft.denareconomy.accounts.OfflineModifier.playerId("Steve")) + .thenReturn(legacy); + assertNull(net.tfminecraft.simplefactions.utils.MemberAccounts.accountId(null)); + assertNull(net.tfminecraft.simplefactions.utils.MemberAccounts.accountId(" ")); + assertEquals(ada.ownerId(), net.tfminecraft.simplefactions.utils.MemberAccounts.accountId(Members.key(ada))); + assertNull(net.tfminecraft.simplefactions.utils.MemberAccounts.accountId(Members.key(UUID.randomUUID())), + "a character nobody owns has no account"); + assertEquals(legacy, net.tfminecraft.simplefactions.utils.MemberAccounts.accountId("Steve"), + "a non-character key falls back to the Minecraft name lookup"); + } + } + @Test void keysAreLowerCaseCharacterIds() { UUID id = UUID.randomUUID(); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java index 35b27377..876739c4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java @@ -182,6 +182,16 @@ void relocationConsentDoesNotFollowAChangedOrRemovedGuild(String change) { () -> assertNull(target.getSettlementHandler().getByProvince(2))); } + @Test + void relocationRequestIsRefusedWhenTheSenderHasNoCharacterSession() { + guild = domain.guild(origin, "traders", "GuildLeader"); + Player sender = org.mockito.Mockito.mock(Player.class); // no name, so no ready session + FactionManager.requestRelocation(sender, guild, target, 2, "Destination"); + org.mockito.Mockito.verify(sender) + .sendMessage("§cBoth characters must be ready before requesting relocation."); + assertFalse(RequestManager.hasRequest(bob), "no consent request may be registered"); + } + @Test void aBaseGuildCannotBeChargedForARelocationThatDoesNothing() { guild = origin.getOrCreateMainGuild(); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java index b5c82ac3..a495e573 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyFormationTest.java @@ -60,6 +60,23 @@ void formationDebitsOnceAndOpensWithOneSlot() { assertFalse(company.hasFreeSlot()); } + @Test + void formationIsDeniedWhileTheTreasuryCannotFundIt() { + CompanyFixture fixture = new CompanyFixture(500.0); + try (var treasury = org.mockito.Mockito.mockStatic( + net.tfminecraft.simplefactions.economy.TreasuryActions.class)) { + treasury.when(() -> net.tfminecraft.simplefactions.economy.TreasuryActions.formation( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyDouble())) + .thenReturn(false); + MercenaryResult result = MercenaryCompanyService.requestFormation(fixture.guild, "Ivar", "Hired Blades"); + assertFalse(result.ok()); + assertEquals("Company formation is awaiting treasury recovery or could not be funded.", result.message()); + } + assertNull(fixture.company(), "a denied formation must not attach a company"); + assertEquals(500.0, fixture.balance()); + } + @Test void instantFormationEnlistsTheLeader() { Cache.mercenaryFormationSeconds = 0; diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java index 19acaf04..b52a3a66 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java @@ -324,6 +324,72 @@ void correctingServedDaysRepricesUnamendedHistoryAndSurvivesPersistence() { } } + @Test + void settlementRestoreReplacesContractFiguresInPlaceAndRejectsAnotherContract() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + MercenaryContract contract = contract(company, hirer, 2, System.currentTimeMillis()); + MercenaryContractData after = contract.serialize(); + after.status = ContractStatus.COMPLETED.name(); + after.daysServed = 4; + after.accruedToCompany = 12.5; + after.accruedToHirer = 3.5; + after.servedDaysValue = 77.0; + after.pendingSlots = 3; + after.pendingSlotExpiry = 123_456L; + contract.restoreSettlement(after); + assertEquals(ContractStatus.COMPLETED, contract.getStatus()); + assertEquals(4, contract.getDaysServed()); + assertEquals(12.5, contract.getAccruedToCompany()); + assertEquals(3.5, contract.getAccruedToHirer()); + assertEquals(77.0, contract.getServedDaysOwed()); + assertEquals(3, contract.getPendingSlots(1000L)); + assertEquals(123_456L, contract.getPendingExpiry()); + contract.restoreSettlement(after); // a replay is exact, never additive + assertEquals(12.5, contract.getAccruedToCompany()); + assertEquals(4, contract.getDaysServed()); + MercenaryContractData wrong = contract.serialize(); + wrong.id = "another-contract"; + var error = + assertThrows(IllegalArgumentException.class, () -> contract.restoreSettlement(wrong)); + assertEquals("Wrong contract identity", error.getMessage()); + assertEquals(4, contract.getDaysServed(), "a rejected restore changes nothing"); + } + } + + @Test + void settlementRestoreReplaysCompanyFinancesAndRejectsAMissingContract() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + MercenaryContract contract = contract(company, hirer, 1, System.currentTimeMillis()); + var after = company.serialize(); + after.reputation = 73; + after.pendingWages = new java.util.LinkedHashMap<>(Map.of("Captain", 9.5)); + after.contracts.get(0).accruedToCompany = 21.0; + after.contracts.get(0).daysServed = 2; + company.restoreSettlement(after); + assertEquals(73, company.getReputation()); + assertEquals(Map.of("Captain", 9.5), company.getPendingWages()); + assertEquals(21.0, contract.getAccruedToCompany()); + assertEquals(2, contract.getDaysServed()); + var missing = company.serialize(); + MercenaryContractData ghost = new MercenaryContractData(); + ghost.id = "ghost"; + ghost.status = ContractStatus.ACTIVE.name(); + ghost.daysServed = 0; + ghost.accruedToCompany = 0.0; + ghost.accruedToHirer = 0.0; + missing.contracts = new ArrayList<>(List.of(ghost)); + var error = + assertThrows(IllegalStateException.class, () -> company.restoreSettlement(missing)); + assertEquals("Missing settlement contract ghost", error.getMessage()); + } + } + private MercenaryContract contract(MercenaryCompany company, Faction hirer, int slots, long now) { MercenaryContract contract = new MercenaryContract( From 921e207fc0f963a1830c308249576ba4f012e716 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:47:15 +0000 Subject: [PATCH 35/35] test: isolate settlement clock from leaked static day --- .../economy/settlement/RealmSettlementEffectsTest.java | 1 + .../economy/settlement/RealmSettlementServiceTest.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java index 946c4a6c..6927e2be 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java @@ -25,6 +25,7 @@ class RealmSettlementEffectsTest { @Test void partialFactionSaveKeepsClaimsAndClockPendingAndReplaysExactAfterValues() throws Exception { try (var fixture = new FactionDomainFixture()) { + FactionManager.day = 0; UUID character = UUID.randomUUID(); var faction = fixture.saved("realm", UUID.randomUUID().toString()); var capital = faction.getOrCreateMainGuild(); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java index 5a9f922d..5da39ef1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java @@ -22,6 +22,7 @@ class RealmSettlementServiceTest { try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture(); var deaths = mockStatic(CharacterDeaths.class); var vehicles = mockStatic(VehicleSettlement.class); var playerEconomy = mockStatic(net.tfminecraft.simplefactions.player.PlayerEconomyManager.class)) { + FactionManager.day = 0; playerEconomy.when(net.tfminecraft.simplefactions.player.PlayerEconomyManager::get).thenReturn(new net.tfminecraft.simplefactions.player.PlayerEconomyManager()); when(fixture.ui.plugin.getDataFolder()).thenReturn(disk.root.toFile()); var faction = fixture.saved("realm", UUID.randomUUID().toString());