diff --git a/pom.xml b/pom.xml index 6905dd15..abdc8720 100644 --- a/pom.xml +++ b/pom.xml @@ -6,16 +6,16 @@ net.tfminecraft simplefactions - main-SNAPSHOT + 4.0.0 21 UTF-8 full - 0.2.4 + 0.3.0 2.16.0 - 2.3.0 + 2.4.1 2.10.0 2.1.0 @@ -158,6 +158,12 @@ 5.2.0 test + + org.xerial + sqlite-jdbc + 3.49.1.0 + test + diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 62c639a5..81aa5b3b 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; @@ -139,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(); @@ -224,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 = @@ -255,18 +258,33 @@ 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(); + net.tfminecraft.tlibs.character.Characters.deathId(new java.util.UUID(0L, 0L)); } catch (LinkageError error) { incompatibleCharacterApi = true; - getLogger().severe("SimpleFactions requires TLibs 2.3.0 or newer; disabling."); + getLogger().severe("SimpleFactions requires TLibs 2.4.1 or newer; disabling."); getServer().getPluginManager().disablePlugin(this); return; } startupComplete = false; + started = false; + startFailed = false; + servicesBegun = false; config = getConfig(); plugin = this; FactionManager.inv = inventoryManager; @@ -279,11 +297,102 @@ 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()) || "DenarEconomy".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.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; + } + + 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; + } + try { startServices(); } + catch (Exception error) { failFinancialState(error); } + 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(); - registerListeners(); - registerRpCharactersChatHooks(); + vehicleRegistryPersistence = new VehicleRegistryPersistence( new File(getDataFolder(), "Cache"), vehicleRegistry); @@ -302,7 +411,10 @@ public void onEnable() { ); } 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")); @@ -351,7 +463,33 @@ public void onEnable() { 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(); + } + 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(); + 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(); } @@ -372,8 +510,6 @@ public void onEnable() { 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(); @@ -388,11 +524,28 @@ public void onEnable() { inventoryManager.start(); vehicleMaintenanceDecayTask.start(); startupComplete = 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() { if (incompatibleCharacterApi) return; + if (identityPoll != null) identityPoll.cancel(); + identityPoll = null; + 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; + } if (trackRefreshTask != null) trackRefreshTask.cancel(); net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.unregister(); MercenaryStatService.clearAll(); @@ -686,37 +839,23 @@ 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(); } - /** - * 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)) { - String owner = 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/api/SimpleFactionsApi.java b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java new file mode 100644 index 00000000..8f5ba412 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java @@ -0,0 +1,67 @@ +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 + * 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() + && net.tfminecraft.tlibs.character.Characters.available() + && net.tfminecraft.denareconomy.api.Denars.ready(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getGuildByMember(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByMember(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getGuildByMember(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByMember(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getGuildByLeader(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getGuildByLeader(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getByMember(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getByMember(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getByMember(UUID characterId) { + return ready() ? Optional.ofNullable(FactionManager.getByMember(characterId)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional getByLeader(Player player) { + return ready() ? Optional.ofNullable(FactionManager.getByLeader(player)) : Optional.empty(); + } + + /** Empty while unavailable; check ready() before making persistence decisions. */ + public static Optional 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 participants = new java.util.LinkedHashSet<>(); public String displayName; public String battleType; public Integer warId; diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 07144755..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"); @@ -411,6 +420,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(); @@ -432,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 citizenTaxes = g.getLedger().getCitizenTaxesCopy(); gd.citizenTaxes = citizenTaxes.isEmpty() ? null : citizenTaxes; @@ -531,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..82ec7009 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/database/DurableJson.java @@ -0,0 +1,48 @@ +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); + // 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); + } + } + + public static T read(Path file, Class 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 37655c61..55a0305b 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; @@ -63,6 +65,7 @@ public class GuildData { @SerializedName("casino profit") public Double casinoProfit; + public Map 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 memberCharacters = new java.util.LinkedHashMap<>(); public String name; public String leaderId; public List memberIds = new ArrayList<>(); 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/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 originalOperations = ledger.getCasinoProfitOperations(); + Map 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 new file mode 100644 index 00000000..6eaf57fa --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicy.java @@ -0,0 +1,30 @@ +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 tax(CharacterRef earner, long grossCents) { + if (!SimpleFactionsApi.ready()) throw new IllegalStateException("Factions are starting up"); + Faction realm = FactionManager.getByMember(earner.characterId()); + 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(capital.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 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/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 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 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/TreasuryActions.java b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java new file mode 100644 index 00000000..47cd415b --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/TreasuryActions.java @@ -0,0 +1,291 @@ +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 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); + 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); + }); + 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 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(); + if (changes.get("schema").getAsInt() != 1) throw new IOException("Unsupported treasury schema"); + List 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 dirty = new LinkedHashSet<>(); + LinkedHashSet 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 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 items) { + var session = Members.session(player).orElse(null); + if (!EconomyRuntime.ready() || session == null) return false; + String before = inventory(player); + List 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/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 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 integers(JsonElement values) { + List 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 capitals = new LinkedHashMap<>(); + for (JsonElement value : state.getAsJsonArray("guilds")) { + JsonObject g = value.getAsJsonObject(); capitals.put(g.get("id").getAsString(), g.get("capital").getAsInt()); + } + List 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 provinces = new ArrayList<>(); List 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/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..9005e724 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java @@ -0,0 +1,339 @@ +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); + 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()) { + 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, !java.util.Objects.equals(beforeIncome.get(account(guild)), balances.get(account(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) { + 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) + : 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, 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 (!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++) { + 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 = 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); + 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..009132dd --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffects.java @@ -0,0 +1,121 @@ +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<>(); + 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()); + if (current == null) throw new IOException("Settlement regiment missing: " + regiment.getKey()); + 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); + } + 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; + notices.forEach(Runnable::run); + } +} 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..9c0c9c98 --- /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((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); + 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/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<? extends org.bukkit.entity.Player> players, - java.util.function.Function<org.bukkit.entity.Player, String> 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<String> inviteCompletions(java.util.Collection<String> 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/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..78234a01 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -1,9 +1,10 @@ package net.tfminecraft.simplefactions.espionage; +import net.tfminecraft.simplefactions.identity.Members; + import java.util.ArrayList; import java.util.List; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -40,7 +41,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; @@ -66,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") @@ -86,17 +87,24 @@ private static void usage(Player player) { player.sendMessage("§7/faction spymaster share <overlord|vassals|allies> <none|rumours|broad|reliable|detailed>"); } + /** Online members who could hold the office, completed by character name. */ + private static List<String> candidates(net.tfminecraft.simplefactions.objects.Faction faction, String[] args) { + List<String> 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<String> complete(Player player, String[] args) { List<String> 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); + choices.addAll(candidates(faction, args)); } if (isHolder) choices.add("sabotage"); if (isHolder && EspionageConfig.sharingAllowed()) choices.add("share"); @@ -106,6 +114,9 @@ public static List<String> 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 4bf5ce9d..7b09e831 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; @@ -42,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.isMemberIgnoreCase(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. */ @@ -59,7 +61,7 @@ public static void initializeFounder(Faction faction) { private static void initializeFounder(Faction faction, java.util.Set<Faction> 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()) { @@ -69,7 +71,7 @@ private static void initializeFounder(Faction faction, java.util.Set<Faction> di continue; } var assignment = new SpecialPositionAssignment(); - assignment.playerName = faction.getLeader(); + assignment.playerName = founder.getName(); assignment.playerId = session.ref().ownerId(); int aptitude = 0; try { @@ -130,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); @@ -138,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); } @@ -164,7 +167,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) { @@ -203,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; @@ -236,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(); @@ -270,7 +271,7 @@ public static java.util.List<net.tfminecraft.simplefactions.government.Stability } public static boolean appoint(Player actor, Faction faction, Player candidate) { - if (!isOwn(actor, faction) || !faction.isLeader(actor.getName())) { + if (!isOwn(actor, faction) || !faction.isLeader(Members.key(actor))) { actor.sendMessage("§cOnly your faction leader can appoint a Spymaster."); return false; } @@ -348,7 +349,7 @@ private static int characterAptitude(Player player, } public static boolean remove(Player actor, Faction faction) { - if (!isOwn(actor, faction) || !faction.isLeader(actor.getName())) { + if (!isOwn(actor, faction) || !faction.isLeader(Members.key(actor))) { actor.sendMessage("§cOnly your faction leader can remove the Spymaster."); return false; } @@ -467,7 +468,7 @@ static int vassalageBonus(Faction observer, Faction target) { /** Reading or clicking menus never generates intelligence. */ public static IntelligenceReport report(Player viewer, Faction target) { if (viewer == null || canViewCovert(viewer, target)) return null; - Faction observer = FactionManager.getByMember(viewer.getName()); + Faction observer = FactionManager.getByMember(Members.key(viewer)); if (!hasSpymaster(observer)) return null; return observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day()); } @@ -477,7 +478,7 @@ public static IntelligenceReport report(Player viewer, Faction target) { /** GUI-opening commands request one faction-wide report per foreign target each UTC day. */ public static void refreshReports(Player viewer) { if (viewer == null || bypasses(viewer)) return; - Faction observer = FactionManager.getByMember(viewer.getName()); + Faction observer = FactionManager.getByMember(Members.key(viewer)); if (observer == null) return; boolean updated = false; java.util.Set<Faction> dirty = new java.util.LinkedHashSet<>(); @@ -558,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()) { @@ -570,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())); } } @@ -619,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/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 0b293e28..49e78308 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -99,6 +99,22 @@ void rememberDeath(java.util.UUID deathId) { } } + /** The death journal owns completion; an appointment made after this death is preserved. */ + 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)) + && 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 false; + removeSpymaster(); + waiveAppointmentWait(SpecialPosition.SPYMASTER); + rememberDeath(deathId); + return true; + } + 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/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<String> build(Player viewer, Faction faction, Guild only) { var report = exact ? null : EspionageService.report(viewer, faction); List<String> 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<String> 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<Guild> 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<String> build(Player viewer, Faction faction, Guild only) { for (Guild guild : guilds) { List<IntelligenceReport.RosterMember> 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/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/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 574e1fba..d092f270 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; @@ -177,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; } } @@ -337,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() @@ -358,17 +361,17 @@ 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. */ - 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); } @@ -376,7 +379,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); @@ -391,8 +394,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; } @@ -411,11 +414,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); @@ -430,7 +433,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(name).canBeElevated(null)) return false; if(!PoliticalActionLoader.getByAction(action).allowGuilds()) return false; break; case VASSAL_LEADER: @@ -442,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/election/Election.java b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java index 4346e629..1a99b50e 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; @@ -148,7 +150,7 @@ public void clearCandidates() { public int getVotes(Candidate c, String player) { int count = 0; for(Map.Entry<String, String> entry : votes.get(c).entrySet()) { - if(entry.getValue().equalsIgnoreCase(player)) count++; + if(entry.getValue().equals(player)) count++; } return count; } @@ -180,7 +182,8 @@ public List<String> 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; @@ -199,27 +202,28 @@ public boolean isCandiate(Candidate c, String player) { } public void applyReasons(List<String> 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(p.getName())) { + 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(p.getName()); - if (g != null && !g.isBase() && g.isLeader(p.getName())) { + 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")); } } @@ -262,7 +266,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:"); @@ -299,7 +303,7 @@ public void removeCandidate(Candidate c, String candidate) { Map<String, String> voteMap = votes.get(c); voteMap.entrySet().removeIf(entry -> - entry.getValue().equalsIgnoreCase(candidate) + entry.getValue().equals(candidate) ); } @@ -308,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 10317d80..6e96e02f 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; @@ -230,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; @@ -379,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; @@ -398,12 +404,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; } } @@ -420,7 +426,7 @@ public List<Cause> 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; } } @@ -586,20 +592,22 @@ private double getTotalTradePower() { } public Object getJoiningAs(Player p) { - Member relation = f.getRelationToFaction(p.getName()); + String key = Members.key(p); + Member relation = f.getRelationToFaction(key); if (relation == Member.GUILD_LEADER || relation == Member.GUILD_MEMBER) { - return FactionManager.getGuildByMember(p.getName()); + return FactionManager.getGuildByMember(key); } else if (relation == Member.VASSAL_LEADER) { - return FactionManager.getByMember(p.getName()); + return FactionManager.getByMember(key); } else if (relation == Member.MEMBER) { - return p.getName(); + return key; } return null; } public boolean quickJoinCheck(Player p) { - if(isMember(p.getName())) return false; - Member relation = f.getRelationToFaction(p.getName()); + 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<String> getAllMembers() { public List<String> getFormattedList() { List<String> 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<String> citizenCandidates(Movement movement, Cause cause) { + Pool pool = cause == null ? movement.getSupporters() : cause.getPool(); + List<String> 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<String> 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<String> 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<String> hostGuildIds(Movement movement) { @@ -407,7 +424,7 @@ public static List<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> names(String[] args, int from, List<String> 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<String> prefix(String typed, String... candidates) { List<String> 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<String> 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..2cfc37b4 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; @@ -22,6 +22,7 @@ public class Session { private Player leader; + private final String leaderCharacter; private Block lantern; private Faction f; private Council c; @@ -32,13 +33,14 @@ public class Session { private Map<Proposal, VoteResult> proposals = new LinkedHashMap<>(); private final Map<Proposal, int[]> decidedVoteCounts = new HashMap<>(); private SessionHologram hologram; - private Map<String, Vote> currentVotes = new HashMap<>(); // Player name -> Vote + private Map<String, Vote> currentVotes = new HashMap<>(); // member key -> Vote private Set<String> eligibleVoters = new HashSet<>(); // Council members + leader // Keep the existing legacy text representation, formatting, and exact-string comparisons. @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); @@ -80,7 +82,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 +91,33 @@ 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 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; } - currentVotes.put(playerName, vote); + currentVotes.put(voter, vote); updateHologram(); // Check if all votes are cast @@ -213,7 +230,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 +257,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 aa2a3819..78039508 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<String> 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; @@ -117,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()) { @@ -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,11 +183,12 @@ 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; 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()); @@ -225,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); } @@ -385,7 +391,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); @@ -411,22 +417,33 @@ 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 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; } + + /** 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) { @@ -447,8 +464,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<String> 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,9 +475,18 @@ 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); } + /** 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. */ @@ -489,9 +515,15 @@ 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)); } + + /** + * 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<Integer, Branch> getBranches() { return branches; } public Branch getBranch(int i) { return branches.getOrDefault(i, null); @@ -1074,6 +1106,10 @@ public List<String> getDividendEligibleMembers() { return eligible; } + public void restoreDividendEligibility(List<String> members) { + dividendEligible = new ArrayList<>(members); + } + public void refreshDividendEligibility() { dividendEligible = new ArrayList<>(members); } @@ -1089,14 +1125,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); @@ -1121,10 +1149,11 @@ private void clearFavoursAndRepressions() { repressed = false; } - public static String findIgnoreCase(List<String> 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<String> 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/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/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/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..c495b71c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -38,7 +38,9 @@ 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<>(); private final Map<String, Double> loanPayments = new HashMap<>(); @@ -51,6 +53,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; @@ -78,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)) { @@ -149,6 +191,23 @@ 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; + return net.tfminecraft.simplefactions.economy.ExternalIncome.casino(guild, operationId, amount); + } + + 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; } @@ -184,13 +243,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: @@ -213,7 +273,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: @@ -252,8 +312,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(); } @@ -267,8 +327,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(); } @@ -367,17 +427,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; @@ -429,7 +489,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; } @@ -649,7 +709,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; } @@ -691,7 +751,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); } } @@ -726,7 +786,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; } @@ -799,7 +859,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; @@ -810,10 +870,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; @@ -823,7 +888,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))); } @@ -832,12 +897,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))); } @@ -845,7 +910,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()); } } @@ -862,21 +927,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); } @@ -907,8 +974,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); @@ -928,13 +995,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(); } @@ -956,7 +1024,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()) { @@ -985,15 +1053,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) { @@ -1007,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: @@ -1025,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(); @@ -1039,7 +1110,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; @@ -1049,7 +1120,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; @@ -1067,7 +1138,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; @@ -1087,7 +1158,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; @@ -1104,7 +1175,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); @@ -1114,8 +1185,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; @@ -1127,8 +1198,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(); @@ -1169,7 +1240,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); } @@ -1178,11 +1249,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; @@ -1190,7 +1261,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; @@ -1199,7 +1270,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/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<String, Double> 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/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/LoanBook.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java index fb75d588..4d28bde4 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,17 +79,18 @@ 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()); 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 @@ -160,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/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/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 new file mode 100644 index 00000000..d9825ee6 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/Members.java @@ -0,0 +1,320 @@ +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<CharacterSession> session(Player player); + + boolean isCurrent(CharacterSession session); + + String key(Player player); + + 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(); + 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 : resolver.key(characterId); + } + + public static String key(CharacterRef ref) { + return ref == null ? null : key(ref.characterId()); + } + + public static Optional<CharacterSession> session(Player player) { + return player == null ? Optional.empty() : resolver.session(player); + } + + 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"; + 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<UUID> 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<String> display(Collection<String> keys) { + List<String> 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<Player> 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<CharacterRef> ref(String key) { + if (key == null || characterId(key) == null) return Optional.empty(); + return resolver.info(key).map(CharacterInfo::ref); + } + + public static Optional<CharacterInfo> 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<String> 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; + 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. 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; + 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<Player> 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<String> completions(Collection<String> 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<? extends Player> 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<CharacterSession> 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<CharacterInfo> 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<Player> 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/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..8f7edef1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -0,0 +1,324 @@ +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); + 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) { + var session = plugin.getSessionManager().getSession(faction.getGovernment().getCouncil()); + 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; + } + + 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/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<String> 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<String, DecayClock> clocks, Map<String, SavedC } } - private static boolean isOnline(String name) { - try { - if (Bukkit.getServer() == 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 void refreshEconomy() { diff --git a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java index 9439ca89..ee74aa2a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationProtectionListener.java @@ -63,8 +63,7 @@ static boolean isOwnerMember(Player player, Installation installation) { if (owner == null) { return false; } - String name = player.getName(); - return owner.isMemberIgnoreCase(name) || name.equalsIgnoreCase(owner.getLeader()); + return owner.isMember(player) || owner.isLeader(player); } private static String blockedMessage(Installation installation) { 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 dae77c0e..732bc55e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/handler/InstallationHandler.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.database.InstallationConstructionData; @@ -23,6 +22,7 @@ import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationConstruction; import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; import net.tfminecraft.tlibs.utils.TimeFormatter; @@ -77,6 +77,24 @@ 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) { + 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)); + enqueueMapUpdate(); + } + public InstallationConstruction getPendingConstruction() { return pendingConstruction; } @@ -222,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 = Bukkit.getPlayerExact(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(); @@ -391,7 +371,7 @@ void register(Installation installation) { void dissolve(Installation installation) { removeInstallation(installation); - Player leader = Bukkit.getPlayerExact(faction.getLeader()); + Player leader = Members.player(faction.getLeader()); if (leader != null) { leader.sendMessage( "§c" @@ -402,20 +382,6 @@ void dissolve(Installation installation) { } } - private void dissolveForNonPayment(Installation installation) { - removeInstallation(installation); - - Player leader = Bukkit.getPlayerExact(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()); @@ -505,7 +471,7 @@ private void completeConstruction(InstallationConstruction construction) { if (installation != null) { installation.setLevel(installation.getLevel() + 1); enqueueMapUpdate(); - Player leader = Bukkit.getPlayerExact(faction.getLeader()); + Player leader = Members.player(faction.getLeader()); if (leader != null) { leader.sendMessage( "§a" + installation.getKind().getDisplayName() @@ -527,7 +493,7 @@ private void completeConstruction(InstallationConstruction construction) { System.currentTimeMillis()); clearPendingConstruction(); - Player leader = Bukkit.getPlayerExact(faction.getLeader()); + Player leader = Members.player(faction.getLeader()); if (leader != null) { leader.sendMessage( "§a" 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<Player> 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<Player> 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/integration/rpcharacters/chat/OrgChatMemberCollector.java b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollector.java index c87d7dbc..d5936fff 100644 --- a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollector.java +++ b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/OrgChatMemberCollector.java @@ -4,22 +4,24 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.function.Function; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.identity.Members; + final class OrgChatMemberCollector { private OrgChatMemberCollector() {} - static List<Player> onlinePlayersNamed(Collection<String> memberNames) { + /** Players currently playing one of the member characters. */ + static List<Player> onlinePlayersNamed(Collection<String> members) { if (Bukkit.getServer() == null) { return List.of(); } - return onlinePlayersNamed(memberNames, Bukkit::getPlayerExact); + return onlinePlayersNamed(members, Members::player); } static List<Player> onlinePlayersNamed(Collection<String> memberNames, Function<String, Player> lookup) { @@ -28,17 +30,13 @@ static List<Player> onlinePlayersNamed(Collection<String> memberNames, Function< } List<Player> online = new ArrayList<>(); - Set<String> seen = new HashSet<>(); + Set<Player> 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/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/CapitalMovePrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/CapitalMovePrompt.java index 177f8474..1247bd68 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; @@ -24,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) { @@ -55,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); @@ -64,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); } @@ -92,8 +104,14 @@ 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(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; @@ -109,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" @@ -143,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/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 44749ab2..de743c1c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -1,5 +1,8 @@ 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; import net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages; @@ -54,11 +57,12 @@ 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{ 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"; @@ -69,11 +73,19 @@ 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); 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); } @@ -94,17 +106,22 @@ 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(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; } - if(f.isInGuild(p.getName())) { + if(f.isInGuild(Members.key(p))) { 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 +145,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; } @@ -154,8 +171,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()); } @@ -164,7 +181,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 +193,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 +205,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 +218,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; @@ -215,13 +232,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; @@ -245,33 +266,33 @@ 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; } 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) { - 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()); + } 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"); return true; @@ -280,25 +301,22 @@ 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(), rest(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(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 +336,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"); @@ -335,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(p.getName(), 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 { @@ -356,8 +373,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"); @@ -373,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 { @@ -394,7 +411,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 +443,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 +463,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; @@ -471,32 +488,33 @@ 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) { - Guild g = FactionManager.getGuildByMember(p.getName()); + } 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"); 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; } - if(!g.isMember(args[1])) { + String heir = Members.find(g.getMembers(), rest(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(pl.getName())) { - pl.sendMessage("§a"+args[1]+ " is the new guild leader!"); + if(g.isMember(Members.key(pl))) { + pl.sendMessage("§a"+Members.display(heir)+ " is the new guild leader!"); } } 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; @@ -510,7 +528,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(), rest(args, 2)); if(member == null) { p.sendMessage("§cPlayer is not in the guild"); return true; @@ -520,15 +538,15 @@ 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(pl.getName())) { - pl.sendMessage("§a"+member+ " is the new guild leader!"); + if(!pl.equals(p) && g.isMember(Members.key(pl))) { + pl.sendMessage("§a"+Members.display(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 +561,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; } @@ -551,7 +569,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()) { @@ -566,8 +588,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 <name> §cto claim your first province and found your capital city."); return true; @@ -586,7 +608,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 +651,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 +681,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 +739,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 +765,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 +805,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 +834,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 +846,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 +873,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; @@ -859,43 +881,40 @@ 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) { - Faction f = FactionManager.getByMember(p.getName()); + } 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"); return true; } - if(!p.getName().equalsIgnoreCase(f.getLeader())) { + if(!Members.is(p, f.getLeader())) { p.sendMessage("§cOnly the leader can dismiss members!"); return true; } - if(args[1].equalsIgnoreCase(f.getLeader())) { + 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; } - 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(p.getName()); + } 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"); 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; } @@ -903,42 +922,47 @@ 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(), rest(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(pl.getName())) { - pl.sendMessage("§a"+args[1]+ " is the new faction leader!"); + if(f.getMembers().contains(Members.key(pl))) { + pl.sendMessage("§a"+Members.display(heir)+ " 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; } - 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(); - if(f.isMemberIgnoreCase(name)) { + 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; } @@ -951,31 +975,31 @@ 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) { - 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()); - if(f.isInGuild(p.getName())) { + Faction f = FactionManager.getByMember(Members.key(p)); + if(f.isInGuild(Members.key(p))) { 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 +1014,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 +1027,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 +1081,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 +1091,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 +1101,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 +1111,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 +1126,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 +1147,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"); @@ -1140,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(p.getName(), 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 { @@ -1159,8 +1182,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"); @@ -1176,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 { @@ -1394,7 +1417,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; @@ -1404,22 +1427,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(), rest(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(pl.getName())) { - pl.sendMessage("§a"+args[2]+ " is the new faction leader!"); + if(f.getMembers().contains(Members.key(pl))) { + pl.sendMessage("§a"+Members.display(heir)+ " is the new faction leader!"); } } return true; @@ -1433,18 +1457,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(pl.getName())) { - pl.sendMessage("§a"+args[2]+ " joined the faction!"); + if(f.getMembers().contains(Members.key(pl))) { + pl.sendMessage("§a"+Members.display(joiner)+ " joined the faction!"); } } return true; @@ -1474,16 +1503,11 @@ 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 = 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"); } @@ -1535,7 +1559,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 +1582,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..15ae612c 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; @@ -58,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{ @@ -527,7 +527,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!"); @@ -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(), - OfflineModifier::playerId); - 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) { @@ -685,23 +588,50 @@ 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(); } + /** 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)); + } + 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; } + /** 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)); + } + 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; @@ -728,22 +658,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; } @@ -914,17 +829,48 @@ 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) { + 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)); + } + 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; } + /** The faction identity of the character in this player's current session. */ + public static Faction getByMember(Player 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) { + 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) { 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; } @@ -1000,7 +946,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; @@ -1009,12 +955,19 @@ 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) { 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; @@ -1028,14 +981,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)"); @@ -1052,14 +1005,14 @@ 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"); } 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; @@ -1073,7 +1026,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; @@ -1100,22 +1053,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; @@ -1125,7 +1078,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; @@ -1153,7 +1106,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)) { @@ -1170,33 +1123,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)); @@ -1204,7 +1158,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; } @@ -1219,7 +1173,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; } @@ -1231,9 +1185,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."); } } @@ -1270,7 +1224,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 +1243,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 +1277,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..67b11013 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; @@ -8,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; @@ -16,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; @@ -95,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{ @@ -110,7 +118,11 @@ public class InventoryManager implements Listener{ public HashMap<Player, DividendChange> dividendChange = new HashMap<>(); public HashMap<Player, DonationChange> donationChange = new HashMap<>(); public HashMap<Player, SlotChangePrompt> slotChanges = new HashMap<>(); + /** The character session each open menu belongs to, by account. */ + private final Map<UUID, Optional<CharacterSession>> menuSessions = new HashMap<>(); + private static final String CONFIRM_TITLE = "§7Confirm Action"; + InventoryUpdater updater = new InventoryUpdater(this); public InventoryUpdater getUpdater() { @@ -431,9 +443,9 @@ 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)); + 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" + @@ -527,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); @@ -540,7 +552,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,20 +568,20 @@ 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); + 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!"); @@ -618,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); @@ -628,12 +640,12 @@ 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; } - if(!g.getId().equalsIgnoreCase(compare.getId())) { + if (!g.getIncarnationId().equals(compare.getIncarnationId()) || !loanPayments.get(p).isCurrent()) { loanPayments.remove(p); //player changed guild return; } @@ -650,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."); @@ -657,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; @@ -673,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); @@ -746,16 +759,12 @@ 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."); 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."); @@ -815,7 +824,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; @@ -829,7 +838,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); @@ -902,7 +911,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 +920,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()); } }; @@ -1057,6 +1066,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. @@ -1072,6 +1085,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<CharacterSession> current = Members.session(p); + Optional<CharacterSession> 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) { @@ -1094,6 +1139,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) { @@ -1159,7 +1208,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 +1539,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 +1552,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 +1578,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 +1606,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 +1631,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; @@ -1689,7 +1738,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/InviteCommands.java b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java index 7b08b6c6..546767b3 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; @@ -36,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]); @@ -43,11 +49,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<Guild> invites = guildInvites(p.getName()); + List<Guild> invites = guildInvites(Members.key(p)); joinOnlyInvite(p, GUILD, invites.stream().map(Guild::getId).toList(), invites.stream().map(Guild::getName).toList()); } else { - List<Faction> invites = factionInvites(p.getName()); + List<Faction> invites = factionInvites(Members.key(p)); joinOnlyInvite(p, FACTION, invites.stream().map(Faction::getId).toList(), invites.stream().map(Faction::getName).toList()); } @@ -119,7 +125,7 @@ static List<Faction> 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 +138,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!"); + tellOthers(g.getFaction(), p, "§a" + Members.name(p) + " 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,47 +162,45 @@ 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!"); + tellOthers(f, p, "§a" + Members.name(p) + " joined the faction!"); } 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; } 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) { 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; } 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) { 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); } } } 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/MercenaryCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java index ee7d2885..912097eb 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; @@ -50,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 <player>"); 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 <player>"); 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) { @@ -92,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; @@ -101,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")) { @@ -136,7 +150,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 +167,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 +278,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..193e1c14 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; @@ -22,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; @@ -30,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 @@ -46,6 +42,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) { @@ -112,7 +114,11 @@ public void run() { }.runTaskLater(SimpleFactions.plugin, 1L); } else if(stage == 3) { e.setCancelled(true); - Guild borrowerGuild = FactionManager.getGuildByLeader(p.getName()); + 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); String validate = newMeta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); @@ -162,16 +168,13 @@ 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 = 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); @@ -188,7 +191,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); } @@ -221,7 +224,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 +238,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; } @@ -265,12 +268,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))); } } @@ -310,35 +313,11 @@ 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; } - @EventHandler - 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); - 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); - } - } - } - 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(); @@ -347,9 +326,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 @@ -360,8 +341,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 +358,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..0e009a39 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; @@ -280,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())); } @@ -620,21 +622,21 @@ 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; } 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; @@ -644,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) { @@ -658,21 +660,21 @@ 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; } 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; @@ -682,28 +684,28 @@ 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; } 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; @@ -713,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/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/managers/SessionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java index 7723a06f..b27f89be 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(); @@ -140,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(); @@ -160,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/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..9db1673e 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; @@ -332,7 +334,7 @@ private static void notifyWarDeclared(Iterable<String> 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); @@ -442,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); } @@ -487,24 +489,24 @@ 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; } 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; @@ -526,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()); @@ -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", @@ -554,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/managers/inventory/CampaignInstallationPickView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignInstallationPickView.java index 980fcadb..b31e7e0a 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; } @@ -175,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)); @@ -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..185b38e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CampaignRaidLaunchView.java @@ -1,11 +1,12 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.identity.Members; + import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidResults; 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; @@ -196,7 +197,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; } @@ -267,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); } @@ -287,11 +288,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/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<String> buildFormingLore(MercenaryCompany company) { List<String> 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<String> buildFoundConfirmLore(Guild guild) { public List<String> buildCompanyLore(Guild guild, MercenaryCompany company) { List<String> 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<String> 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<String> 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 e1113459..8b95dc9d 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)); } @@ -211,19 +213,19 @@ 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; } 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); } @@ -233,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); } @@ -241,14 +243,14 @@ 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; } 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 9085b86b..e28e2991 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,9 +177,9 @@ 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)); + report(p, company.getContractHandler().withdrawSlots(contractId, Members.key(p), now)); detailView(p, guild, inventory, contractId); } else { inv.beginSlotChange(p, guild, contract); @@ -187,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/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..36ddfb0e 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; @@ -40,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(p.getName())){ - if(!e.hasVoted(c, p.getName())) { + if(gov.hasElection() && e.canVote(Members.key(p))){ + 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")); @@ -73,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<String> 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 078bdad7..ffb33ced 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,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(p.getName()) && !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); @@ -129,12 +131,12 @@ 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.hasVoted(type, p.getName()) && + if(election.canVote(Members.key(p)) && election.isActive()) { + 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 8640afa5..72f8aaca 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -1,7 +1,8 @@ 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; import net.tfminecraft.simplefactions.espionage.SpecialPosition; import net.tfminecraft.simplefactions.espionage.EspionageConfig; @@ -70,11 +71,11 @@ 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." - : "\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<String> 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)); @@ -161,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; } @@ -219,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) { @@ -251,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"), @@ -269,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." @@ -288,7 +289,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,19 +375,20 @@ 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<String> 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); @@ -460,13 +462,13 @@ 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()) { 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 4cb45c40..e9bea4e7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -1,14 +1,14 @@ 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; 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; @@ -93,7 +93,7 @@ public List<String> 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())); @@ -112,7 +112,7 @@ public List<String> 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()); @@ -292,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<String> lore = new ArrayList<String>(); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); m.setLore(lore); @@ -303,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<String> lore = new ArrayList<String>(); 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; @@ -406,7 +406,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); List<String> 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<String> lore = net.tfminecraft.simplefactions.espionage.RosterLore.faction(p, f).stream().map(StringFormatter::formatHex).toList(); @@ -497,7 +497,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<String> lore = new ArrayList<String>(); lore.add("§7Click to edit Tier"); m.setLore(lore); @@ -509,10 +509,10 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#8acc6eTitles")); List<String> lore = new ArrayList<String>(); - 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..465617cb 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; @@ -67,7 +69,7 @@ public ItemStack createGovernmentItem(Player p, Faction f) { m.setDisplayName(StringFormatter.formatHex("#93c9a7Government:")); List<String> lore = new ArrayList<String>(); 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; @@ -96,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); @@ -144,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)); } } @@ -173,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)); } } } @@ -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")); @@ -454,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)")); @@ -512,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.")); @@ -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) || @@ -617,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); } @@ -636,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<String> lore = new ArrayList<>(); // Display wealth and ranking @@ -683,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<String> lore = new ArrayList<>(); // Display wealth and ranking @@ -737,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<String> lore = new ArrayList<String>(); - 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); @@ -914,14 +912,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 +937,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 +981,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 +1006,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..f484f7b9 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; @@ -75,12 +77,12 @@ 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() && 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; } @@ -369,8 +371,8 @@ 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().toggleRefuse(p.getName()); + || !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); } 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; } @@ -516,9 +518,9 @@ 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(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,24 +548,24 @@ 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); 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); @@ -593,29 +595,29 @@ 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); 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); 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); @@ -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; } @@ -862,11 +864,11 @@ 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); - 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,24 +886,24 @@ 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); 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<String> 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/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 38535d89..c6b7c188 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; @@ -66,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; } @@ -194,7 +214,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)); } @@ -213,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) { @@ -408,7 +428,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 +449,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; @@ -484,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/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index 9f2d70e1..0965f1f6 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(); @@ -309,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/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/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/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/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<m.getRegiments().size(); x++) { int slot = x+12; Regiment r = m.getRegiments().get(x); @@ -100,7 +102,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if (queuePayload != null) { if (!(inventory.getHolder() instanceof SFInventoryHolder holder)) return; Faction f = FactionManager.getByString(holder.getId()); - if (f == null || !f.getLeader().equalsIgnoreCase(p.getName())) return; + if (f == null || !Members.is(p, f.getLeader())) return; inv.openQueueCancelConfirm(p, f, queuePayload, "§eCancel queued regiment expansion?"); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return; @@ -121,7 +123,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { key = new org.bukkit.NamespacedKey(SimpleFactions.plugin, "type"); String type = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if(type == null) return; - if(!f.getLeader().equalsIgnoreCase(p.getName())) return; + if(!Members.is(p, f.getLeader())) return; if(type.contentEquals("increase")) { if(f.getMilitary().getQueue().size() == 3) { p.sendMessage("§cQueue is full"); 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 434ba350..349e9872 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.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; @@ -32,7 +34,7 @@ public class MovementCreator { @SuppressWarnings({"deprecation"}) public ItemStack createMovementLeaderItem(Player p, Movement movement) { ItemStack item = new ItemStack(Material.PLAYER_HEAD); - if(!movement.hasLeader() && movement.canBeLeader(p.getName())) { + if(!movement.hasLeader() && movement.canBeLeader(Members.key(p))) { item = new ItemStack(Material.GREEN_CONCRETE); } else if(!movement.hasLeader()) { item = new ItemStack(Material.RED_CONCRETE); @@ -43,13 +45,13 @@ public ItemStack createMovementLeaderItem(Player p, Movement movement) { } m.setDisplayName(StringFormatter.formatHex("#c5e0e3Movement Leader")); List<String> 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.")); if(!movement.hasLeader()) { lore.add(" "); - if(movement.canBeLeader(p.getName())) { + if(movement.canBeLeader(Members.key(p))) { lore.add(StringFormatter.formatHex("#45afc4Click to become the leader")); } else { lore.add(StringFormatter.formatHex("#c74d32You need to lead a cause to be the movement leader")); @@ -83,7 +85,7 @@ public ItemStack createOrganizationItem(Movement movement) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createJoinAsSupporterButton(Player player, Movement movement) { - boolean isSupporter = movement.getSupporters().getAllMembers().contains(player.getName()); + boolean isSupporter = movement.getSupporters().getAllMembers().contains(Members.key(player)); ItemStack item = new ItemStack(isSupporter ? Material.RED_BANNER : Material.GREEN_BANNER); ItemMeta m = item.getItemMeta(); m.setDisplayName(StringFormatter.formatHex(isSupporter ? "#c74d32Leave as Supporter" : "#45afc4Join as Supporter")); @@ -110,7 +112,7 @@ public ItemStack createJoinAsSupporterButton(Player player, Movement movement) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createJoinAsForeignBackerButton(Player player, Movement movement) { - Faction playerFaction = FactionManager.getByMember(player.getName()); + Faction playerFaction = FactionManager.getByMember(Members.key(player)); boolean isBacker = playerFaction != null && movement.getForeignBackers().contains(playerFaction); ItemStack item = new ItemStack(isBacker ? Material.RED_BANNER : Material.YELLOW_BANNER); ItemMeta m = item.getItemMeta(); @@ -157,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<String> 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")); } @@ -186,7 +188,7 @@ public ItemStack createCauseItem(Cause cause, Player p, Faction f) { @SuppressWarnings({"deprecation"}) public ItemStack createCauseLeaderItem(Player p, Cause cause) { ItemStack item = new ItemStack(Material.PLAYER_HEAD); - if(!cause.hasLeader() && cause.canBeLeader(p.getName())) { + if(!cause.hasLeader() && cause.canBeLeader(Members.key(p))) { item = new ItemStack(Material.GREEN_CONCRETE); } else if(!cause.hasLeader()) { item = new ItemStack(Material.RED_CONCRETE); @@ -198,13 +200,13 @@ public ItemStack createCauseLeaderItem(Player p, Cause cause) { m.setDisplayName(StringFormatter.formatHex("#c5e0e3Cause Leader")); List<String> 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")); } if(!cause.hasLeader()) { lore.add(" "); - if(cause.canBeLeader(p.getName())) { + if(cause.canBeLeader(Members.key(p))) { lore.add(StringFormatter.formatHex("#45afc4Click to become the leader")); } else { lore.add(StringFormatter.formatHex("#c74d32You are not able to become leader")); @@ -245,7 +247,7 @@ public ItemStack createCauseProposalItem(Cause cause, Player p, Faction f) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createJoinCauseButton(Player player, Cause cause) { - boolean isMember = cause.getFullMemberList().contains(player.getName()); + boolean isMember = cause.getFullMemberList().contains(Members.key(player)); ItemStack item = new ItemStack(isMember ? Material.RED_WOOL : Material.GREEN_WOOL); ItemMeta m = item.getItemMeta(); m.setDisplayName(StringFormatter.formatHex(isMember ? "#c74d32Leave Cause" : "#45afc4Join Cause")); @@ -304,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<String> lore = new ArrayList<>(); appendMovementSummaryLore(lore, movement); lore.add(" "); @@ -316,7 +318,7 @@ public ItemStack createMovementListItem(Movement movement) { } private void appendMovementSummaryLore(List<String> 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())); @@ -333,9 +335,9 @@ public ItemStack createPhaseItem(Player p, Phase phase, Movement movement) { ItemStack item = new ItemStack(Material.RED_CONCRETE); if(phase == movement.getPhase()) { item = new ItemStack(Material.GREEN_CONCRETE); - } else if(phase.getIndex() == movement.getPhase().getIndex()-1 && movement.isLeader(p.getName())) { + } else if(phase.getIndex() == movement.getPhase().getIndex()-1 && movement.isLeader(Members.key(p))) { item = new ItemStack(Material.YELLOW_CONCRETE); - } else if(phase.getIndex() < 4 && phase.getIndex() == movement.getPhase().getIndex()+1 && movement.getOrganization() >= 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) { @@ -507,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<String> lore = new ArrayList<>(); lore.add(StringFormatter.formatHex("#9c9775Action: #c2bea7" + cause.getAction().getDisplay())); lore.add(" "); @@ -620,7 +622,7 @@ public ItemStack createTargetButton(Player p, Cause cause) { List<String> 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")); @@ -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 { @@ -655,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<String> 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 05e28d9b..b3f5746a 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; @@ -78,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(); @@ -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()) { @@ -449,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", @@ -458,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); @@ -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)) { @@ -479,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); @@ -492,12 +494,12 @@ 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) { 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( @@ -652,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!")); } } @@ -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(), @@ -682,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!")); } } @@ -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) { @@ -707,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(); @@ -728,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(); @@ -750,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; } @@ -768,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; } @@ -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<DiplomacyListEntry> 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<RelationLoader.getAttitudes().size(); x++) { int slot = x+10; i.setItem(slot, creator.createAttitudeItem(RelationLoader.getAttitudes().get(x), origin, f, true)); @@ -196,7 +198,7 @@ public void relationView(Inventory i, Player player, Faction f, boolean open) { if(!t.isSettable()) continue; if(t.isTradeAgreement()) continue; if(t.isTreaty()) continue; - i.setItem(slot, creator.createRelationTypeItem(RelationLoader.getTypes().get(x), f, FactionManager.getByMember(player.getName()), true)); + i.setItem(slot, creator.createRelationTypeItem(RelationLoader.getTypes().get(x), f, FactionManager.getByMember(Members.key(player)), true)); slot++; } i.setItem(26, inv.createBackButton(SFGUI.RELATION_VIEW)); @@ -214,7 +216,7 @@ public void tradeAgreementView(Inventory i, Player player, Faction f, boolean op RelationType t = RelationLoader.getTypes().get(x); if(!t.isSettable()) continue; if(!t.isTradeAgreement()) continue; - i.setItem(slot, creator.createTradeAgreementTypeItem(player, RelationLoader.getTypes().get(x), f, FactionManager.getByMember(player.getName()), true)); + i.setItem(slot, creator.createTradeAgreementTypeItem(player, RelationLoader.getTypes().get(x), f, FactionManager.getByMember(Members.key(player)), true)); slot++; } i.setItem(26, inv.createBackButton(SFGUI.TRADE_AGREEMENT_VIEW)); @@ -232,7 +234,7 @@ public void treatyView(Inventory i, Player player, Faction f, boolean open) { RelationType t = RelationLoader.getTypes().get(x); if(!t.isSettable()) continue; if(!t.isTreaty()) continue; - i.setItem(slot, creator.createTreatyTypeItem(player, t, f, FactionManager.getByMember(player.getName()), true)); + i.setItem(slot, creator.createTreatyTypeItem(player, t, f, FactionManager.getByMember(Members.key(player)), true)); slot++; } i.setItem(26, inv.createBackButton(SFGUI.TREATY_VIEW)); @@ -264,7 +266,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); if(!h.getType().equals(SFGUI.DIPLOMACY_VIEW)) return; - if(f == null || FactionManager.getByMember(p.getName()) == null) return; + if(f == null || FactionManager.getByMember(Members.key(p)) == null) return; if(e.getSlot() == 30) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); attitudeView(null, p, f, true); @@ -278,7 +280,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); treatyView(null, p, f, true); } else if(e.getSlot() == 24) { - Faction attacker = FactionManager.getByLeader(p.getName()); + Faction attacker = FactionManager.getByLeader(Members.key(p)); if (attacker == null) return; if (WarManager.exists(attacker, f)) { p.sendMessage("§cYour faction is already in a war with that faction."); @@ -309,7 +311,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); if(!h.getType().equals(SFGUI.ATTITUDE_VIEW)) return; - Faction origin = FactionManager.getByMember(p.getName()); + Faction origin = FactionManager.getByMember(Members.key(p)); if(f == null || origin == null) return; String attitudeId = meta.getPersistentDataContainer().get(new NamespacedKey(SimpleFactions.plugin, "id"), PersistentDataType.STRING); Attitude a = RelationLoader.getAttitude(attitudeId); @@ -330,7 +332,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "id"); String rid = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - Faction origin = FactionManager.getByMember(p.getName()); + Faction origin = FactionManager.getByMember(Members.key(p)); RelationType r = RelationLoader.getType(rid); if(f == null || origin == null || r == null) return; @@ -383,7 +385,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "id"); String rid = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - Faction origin = FactionManager.getByMember(p.getName()); + Faction origin = FactionManager.getByMember(Members.key(p)); RelationType r = RelationLoader.getType(rid); if(f == null || origin == null || r == null) return; @@ -412,7 +414,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "id"); String rid = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - Faction origin = FactionManager.getByMember(p.getName()); + Faction origin = FactionManager.getByMember(Members.key(p)); RelationType r = RelationLoader.getType(rid); if(f == null || origin == null || r == null) return; 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<String> 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.<String>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/TierTitleView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java index 72dcc32c..0cb30305 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.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; @@ -39,7 +41,7 @@ public void tierView(Inventory i, Player player, Faction f, boolean open) { if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TIER_VIEW), 54, "§7Tier View"); } - if(f.getLeader().equalsIgnoreCase(player.getName())) { + if(Members.is(player, f.getLeader())) { int slot = 0; for(Tier t : TierLoader.get()) { i.setItem(slot, creator.createTierItem(f, t, -1)); @@ -60,9 +62,9 @@ public void titleView(Inventory i, Player player, Faction f, boolean open) { if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TITLE_VIEW), 54, "§7Title View"); } - Faction pf = FactionManager.getByLeader(player.getName()); + Faction pf = FactionManager.getByLeader(Members.key(player)); String overlord = RelationManager.getOverlord(f); - if(f.getLeader().equalsIgnoreCase(player.getName())) { + if(Members.is(player, f.getLeader())) { int slot = 10; for(Tier t : TierLoader.get()) { if(TitleLoader.getByTier(t).size() > 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<ItemStack> 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/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/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/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/MapSystem.java b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java index 94918d67..4aa51b88 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; @@ -275,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); @@ -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/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/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index 45ae493b..3b9c936e 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<String> 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; } /* ===================================================== @@ -497,6 +488,26 @@ 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); + } + + /** 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(); } @@ -556,12 +567,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 +598,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 +607,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<String> playersInCompany() { List<String> players = new ArrayList<>(); addPlayer(players, getLeader()); @@ -618,17 +629,10 @@ private List<String> playersInCompany() { private static void addPlayer(List<String> 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<String> 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 +652,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..cdd9dbb9 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; @@ -70,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) + "."); @@ -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. * * <p>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<CharacterInfo> 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 6df0e846..ec4567bb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.mercenary.company; -import org.bukkit.Bukkit; +import net.tfminecraft.simplefactions.identity.Members; + import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.guild.Guild; @@ -16,16 +17,21 @@ private MercenaryInvites() { public static void invite(Player leader, String targetName) { Guild guild = net.tfminecraft.simplefactions.managers.FactionManager - .getGuildByLeader(leader.getName()); + .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(); @@ -34,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."); } @@ -52,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) { @@ -73,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. * - * <p>Each kind has a company-wide base and an optional per-player override, so a + * <p>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<String, Double> 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<String, Double> 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 27a08629..f0a0c0ef 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; @@ -149,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) { @@ -392,14 +394,13 @@ public List<MercenaryContract> 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) { 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/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/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<Engagement> 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<Guild> 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/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<UUID> account(String name); + + Optional<CharacterRef> 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<UUID> 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<CharacterRef> 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<String, Optional<String>> names = new HashMap<>(); + Map<UUID, Optional<String>> 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<String> stores() throws IOException { + List<String> stores = new ArrayList<>(); + for (String folder : List.of("Data", "Wars", "Warbands", "Battles")) { + Path directory = root.resolve(folder); + if (!Files.isDirectory(directory)) continue; + try (Stream<Path> 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<String, Optional<String>> names, Map<UUID, Optional<String>> 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<String> 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<String, JsonElement> rewritten = new LinkedHashMap<>(); + for (Map.Entry<String, JsonElement> 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<String> LISTS_OF_MEMBERS = Set.of( + "members", "dividendEligible", "enlisted", "council members", "eligible voters", "citizens"); + private static final Set<String> 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<String, Entry> 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<Occurrence> 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<String, Record> 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<Record> loaded = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), + new TypeToken<List<Record>>() {}.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<Record> 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<Record> 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 <recordId> <characterId>}. */ +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 <recordId> <characterId>"); + } + 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..bd3e21ad --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/migration/StoreMapper.java @@ -0,0 +1,423 @@ +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<String, Optional<String>> names; + private final Map<UUID, Optional<String>> accounts; + private final Map<String, Quarantine.Record> records = new LinkedHashMap<>(); + private final List<String> warnings = new ArrayList<>(); + private boolean changed; + + StoreMapper(String store, CharacterMigration.Heirs heirs, + Map<String, Optional<String>> names, Map<UUID, Optional<String>> accounts) { + this.store = store; + this.heirs = heirs; + this.names = names; + this.accounts = accounts; + } + + Collection<Quarantine.Record> records() { + return records.values(); + } + + List<String> warnings() { + return warnings; + } + + boolean changed() { + return changed; + } + + /* ---------- stores ---------- */ + + /** {@code Data/<faction>.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_<id>.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<String, JsonElement> 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<String> 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<String, JsonElement> rewritten = new LinkedHashMap<>(); + for (Map.Entry<String, JsonElement> 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<JsonObject> guilds = objects(root, "guilds"); + JsonObject base = base(guilds, factionId); + List<JsonObject> leadersFirst = new ArrayList<>(); + if (base != null) leadersFirst.add(base); + for (JsonObject guild : guilds) if (guild != base) leadersFirst.add(guild); + Map<String, JsonObject> 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<JsonObject> 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) { + changed |= !Members.key(asKey).equals(raw); + return Members.key(asKey); + } + Optional<String> 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<String> 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<String> 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<JsonObject> objects(JsonObject parent, String field) { + List<JsonObject> 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<String> strings(JsonObject parent, String field) { + List<String> values = new ArrayList<>(); + if (parent.get(field) instanceof JsonArray array) { + Consumer<JsonElement> add = element -> { + String value = string(element); + if (value != null) values.add(value); + }; + array.forEach(add); + } + return values; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java index e1705a1f..4951bd3f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Bank.java @@ -1,58 +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 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 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; - } - - 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/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 5ac3e5e8..67f39d21 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; @@ -519,21 +520,18 @@ public void setInvited(List<String> 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<Modifier> getWealthModifiers() { List<Modifier> list = new ArrayList<>(getOrCreateMainGuild().getWealthModifiers()); for(Guild guild : guildHandler.getGuilds()) { @@ -694,6 +692,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(Members.key(player)); + } + /** Whether the player's current character belongs to this realm. */ + public boolean isMember(org.bukkit.entity.Player player) { + return isMember(Members.key(player)); + } public boolean canBecomeLeader(String member) { if(isLeader(member)) return false; if(!isMember(member)) return false; @@ -708,7 +714,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() { @@ -768,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; @@ -1008,7 +1021,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); @@ -1371,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(); @@ -1420,8 +1392,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/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/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index 638eb326..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; @@ -14,9 +17,15 @@ 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; + 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( @@ -32,8 +41,12 @@ public VehicleHandoverRequest( this.ownerUuid = ownerUuid; this.ownerName = ownerName; this.recipientUuid = recipientUuid; - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerName, vehicleTypeId); + 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; @@ -55,12 +68,21 @@ public String getOwnerName() { return ownerName; } + public String getOwnerKey() { + return ownerKey; + } + 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/player/PlayerEconomyManager.java b/src/main/java/net/tfminecraft/simplefactions/player/PlayerEconomyManager.java index 3ec4b415..619db654 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.identity.Members; public final class PlayerEconomyManager { private final Map<UUID, PlayerLedger> 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 character a member key names. */ + public PlayerLedger getLedger(String member) { + UUID uuid = Members.characterId(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<Integer> ownedProvinces, List<Title> 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/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/FactionCleanup.java b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java index 3a81f757..8c6c5e09 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,36 @@ public static void advanceOfflineDays(List<Faction> factions) { } } - public static int daysOffline(String username) { - if (username == null) return 0; + /** 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; - 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 +121,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/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/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index 6ee514ec..1716cc9d 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<String> completeCompany(Player p, String[] args) { List<String> 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()) { @@ -80,11 +82,11 @@ private List<String> 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()) { @@ -118,6 +120,11 @@ private List<String> 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<String> filtered(List<String> completions, String prefix) { String lower = prefix == null ? "" : prefix.toLowerCase(); completions.removeIf(s -> !s.toLowerCase().startsWith(lower)); @@ -150,7 +157,7 @@ private List<String> completeTransferVehicleIds(Player p, String[] args) { private List<String> completeInstallationIds(Player p, String prefix, boolean includePendingConstruction) { List<String> completions = new ArrayList<>(); - Faction f = FactionManager.getByLeader(p.getName()); + Faction f = FactionManager.getByLeader(Members.key(p)); if (f == null) { return completions; } @@ -211,7 +218,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 +243,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 +255,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 +327,8 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le Player p = (Player) sender; List<String> 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 +336,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 +409,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 +428,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<String> completions = new ArrayList<String>(); if(f != null) { completions.add(f.getId()); @@ -431,7 +438,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<String> completions = new ArrayList<String>(); if(g != null && !g.isBase()) { completions.add(g.getId()); @@ -452,44 +459,45 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le List<String> completions = new ArrayList<String>(); 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(p.getName()); + Guild g = FactionManager.getGuildByLeader(Members.key(p)); List<String> completions = new ArrayList<String>(); 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(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); List<String> completions = new ArrayList<String>(); 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(p.getName()); + Faction f = FactionManager.getByMember(Members.key(p)); List<String> completions = new ArrayList<String>(); 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){ @@ -553,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){ @@ -587,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<String> completions = new ArrayList<String>(); 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<String> completions = new ArrayList<String>(); 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 @@ -703,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/main/java/net/tfminecraft/simplefactions/utils/Wealth.java b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java index 5544bf06..19a7a4d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/Wealth.java @@ -4,21 +4,23 @@ 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; import net.tfminecraft.denareconomy.enums.Accounts; import net.tfminecraft.simplefactions.managers.RelationManager; public class Wealth { + /** Pouch, bank and a share of the character's guild. */ 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 +29,13 @@ public static double wealth(String player) { return wealth; } + private static double balance(String member, Accounts 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) { 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 518dbdbb..ff731433 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; @@ -14,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; @@ -32,7 +33,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 +69,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 +86,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; @@ -94,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(); @@ -115,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. */ @@ -124,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(); @@ -143,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) { @@ -167,7 +176,9 @@ public static void armMaintenancePay(Player player) { } public static void armMaintenancePay(Player player, PaymentSource source) { - if (source == PaymentSource.POUCH && FactionManager.getByLeader(player.getName()) == null) { + 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; } @@ -178,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)); } @@ -265,7 +276,7 @@ public static String handoverTarget(String[] args) { || !args[1].equalsIgnoreCase("handover")) { return null; } - return args.length >= 3 ? args[2] : ""; + return target(args); } /** @@ -279,7 +290,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 8b1930c1..07d92901 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; @@ -25,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 { @@ -106,9 +109,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; @@ -135,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 2a13563e..a86b0754 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()); @@ -59,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) { @@ -90,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..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; @@ -66,16 +70,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 +109,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()) { @@ -141,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/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 5747fc66..a5d4bb36 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; @@ -60,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; } @@ -78,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; } @@ -136,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); @@ -166,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); @@ -268,7 +271,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..e0b63003 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()); @@ -95,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; @@ -175,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..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,14 +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.vehicles.maintenance.DenarEconomyPlayerBank; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; @@ -34,13 +30,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,74 +47,10 @@ 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); } - /** - * 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(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 4febe614..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,24 +15,37 @@ * 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); } - 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) { @@ -65,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 820031ed..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,11 +1,14 @@ 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; 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; @@ -37,8 +40,14 @@ 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 || 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()); @@ -46,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, lastOwner)); + 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; @@ -66,8 +75,14 @@ public void onClaimed(VehicleOwnerClaimedEvent event) { if (event.getPlayer() == null || event.getVehicle() == null) { return; } - store.setLastOwner(event.getVehicle().getUUID(), event.getPlayer().getName()); - 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 b2b8864e..3c68430a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -11,11 +11,12 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; +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; 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; @@ -46,14 +47,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); - Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, player.getName(), vehicleTypeId); + 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); @@ -61,33 +73,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(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)); @@ -100,24 +112,14 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVehicleConstruct(VehicleConstructEvent event) { - boolean changed = store.takePaidBuild(stationKey(event.getStation())) != null; - if (event.getVehicle() != null && event.getConstructorUuid() != null) { - String owner = event.getConstructor() != null - ? event.getConstructor().getName() - : VehicleOwnershipQueries.resolvePlayerName(event.getConstructorUuid()); - if (owner != null) { - store.setLastOwner(event.getVehicle().getUUID(), owner); - changed = true; - } - } - if (changed) { - saver.run(); - } - } - - static UUID payerUuid(Player player) { - UUID resolved = VehicleFeeService.resolve(player.getName()); - return resolved != null ? resolved : player.getUniqueId(); + 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); } 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 a1e43349..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,5 +1,9 @@ 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; import java.util.function.BiPredicate; @@ -61,7 +65,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) { @@ -70,7 +74,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); @@ -94,7 +98,10 @@ private String personalVehicleType(String ownerName, String vehicleUuid) { } public void offer(Player owner, Player recipient, String vehicleUuid, String vehicleTypeId) { - Guild guild = FactionManager.getGuildByMember(owner.getName()); + 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, vehicleUuid, @@ -107,8 +114,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) { @@ -123,43 +130,46 @@ 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, 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 = 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()); 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(), recipient.getName()); - saver.run(); - recipient.sendMessage(VehicleHandoverMessages.successRecipient(req.getOwnerName())); + 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()) { 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..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,63 +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; - +/** Read-only projections. Every payment is posted through a durable vehicle intent. */ public final class DenarEconomyPlayerBank { public interface PlayerBank { - double getBankBalance(UUID playerUuid); - - boolean withdrawFromBank(UUID playerUuid, double amount); - - boolean depositToBank(UUID playerUuid, double amount); - - /** Account id for this name, whether or not they are logged in. */ - UUID resolve(String playerName); + 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 playerName) { - return OfflineModifier.playerId(playerName); + @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(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(playerUuid, Accounts.BANK, -amount); - } - - @Override - public boolean depositToBank(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerUuid, Accounts.BANK, amount); - } - - @Override - public double getPouchBalance(UUID playerUuid) { - return OfflineModifier.balance(playerUuid, Accounts.POUCH); - } - - @Override - public boolean withdrawFromPouch(UUID playerUuid, double amount) { - return amount > 0.0 && OfflineModifier.apply(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 new file mode 100644 index 00000000..38a4cc98 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java @@ -0,0 +1,160 @@ +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.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; +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, 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, + 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 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); + 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 && 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 = 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); + 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(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; + } + 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/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java index f55b9c56..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,35 +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 player's faction will charge with tomorrow's upkeep. */ public static double projectedDailyTax(String playerName, PlayerVehicleRegistry registry) { - if (playerName == null || playerName.isBlank()) { - return 0.0; - } - double total = 0.0; - for (OwnedVehicleSummary vehicle : - VehicleOwnershipQueries.personalVehicles(playerName, registry)) { - VehicleFeeService.Quote quote = - VehicleFeeService.quote(FeeKind.VEHICLE_TAX, playerName, 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); } @@ -63,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 a8752e7f..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,33 +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.vehicleframework.data.OwnedVehicleSummary; +import net.tfminecraft.simplefactions.identity.Members; public final class VehicleUpkeepService { private final PlayerVehicleRegistry registry; - private final PlayerEconomyManager economyManager; private final PlayerBank playerBank; private final VehicleMaintenanceStore maintenanceStore; private final VehicleHealthDecayApi decayApi; @@ -51,91 +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, 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) { @@ -152,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 = Bukkit.getPlayerExact(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 1fcf82a1..93b4d609 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,46 @@ public static UUID resolvePlayerUuid(String playerName) { return OfflineModifier.playerId(playerName); } + /** + * 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() || Bukkit.getServer() == null) { + return null; + } + Player online = Bukkit.getPlayerExact(accountName); + if (online != null) { + return Members.key(online); + } + if (!Bukkit.isPrimaryThread() || !Characters.available()) { + return null; + } + UUID account = OfflineModifier.playerId(accountName); + if (account == null) { + return null; + } + return Characters.legacyHeir(account) + .filter(heir -> account.equals(heir.ownerId())) + .map(Members::key) + .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(null); + } + + /** 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/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/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/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/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/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/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/ui/BattleCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java index 02bafc4c..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 @@ -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; @@ -155,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; @@ -166,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); @@ -183,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; @@ -196,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; @@ -210,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; @@ -221,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) { @@ -245,7 +239,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 +280,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); @@ -520,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..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 @@ -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; @@ -24,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<>(); @@ -55,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; } @@ -121,9 +124,27 @@ 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); } public void addMember(UUID memberId) { @@ -132,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); } @@ -223,7 +245,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/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/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 093fa035..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 @@ -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; @@ -14,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; @@ -32,21 +36,49 @@ 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)); + // Only the character that left may take the place back. + 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)); + } 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; @@ -55,11 +87,12 @@ 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); + if (onlinePlayer != null) warband.addPlayer(onlinePlayer); + else warband.addMember(playerId); if (onlinePlayer != null) { restoreBattleState(onlinePlayer); } @@ -74,7 +107,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; @@ -89,7 +123,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( @@ -104,17 +138,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; @@ -122,19 +145,7 @@ 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) { - return null; - } - return FactionManager.getByMember(player.getName()); - } - - 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..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 @@ -1,21 +1,27 @@ 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. */ + 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/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<String> 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 8bd1df46..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 @@ -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; @@ -8,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; @@ -46,9 +49,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); @@ -114,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); @@ -123,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); @@ -136,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); } @@ -152,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); } } @@ -167,9 +170,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; @@ -189,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; } } @@ -237,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); } } @@ -269,8 +272,9 @@ private static void deleteIfPresent(Warband warband) { public static final class Listener implements org.bukkit.event.Listener { private final Map<UUID, String> 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 cb2753d0..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 @@ -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); @@ -45,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/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<String> 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/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 5f087419..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 @@ -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,14 +79,14 @@ 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; } 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; } @@ -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; @@ -133,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"); } @@ -329,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 e20675a6..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 @@ -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<UUID, Faction> uuidToFaction() { if (online == null) { return null; } - return FactionManager.getByMember(online.getName()); + return FactionManager.getByMember(Members.key(online)); }; } @@ -45,17 +47,16 @@ public static Function<UUID, Faction> 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<String, UUID> 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..5dd0d850 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 account's legacy heir. */ + static String constructorKey(UUID constructorUuid) { + Player online = Bukkit.getPlayer(constructorUuid); + if (online != null) { + return Members.key(online); + } + return Characters.legacyHeir(constructorUuid).map(Members::key).orElse(null); + } } 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/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index 366e2514..ba47dc85 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, @@ -210,18 +211,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.1")); verify(plugin, never()).getConfig(); scoped(BattlePersistenceService.class).verifyNoInteractions(); scoped(InactivityService.class).verifyNoInteractions(); @@ -264,7 +273,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(); @@ -297,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"); @@ -360,7 +458,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) @@ -392,7 +490,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); @@ -637,7 +735,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); @@ -741,7 +839,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( @@ -902,7 +1000,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"); @@ -955,8 +1053,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()); } } @@ -1040,26 +1140,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)); - 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); @@ -1138,6 +1228,147 @@ 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 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); + Map<Path, String> 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()).prepare(); + 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()).prepare(); + + 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)).prepare(); + verify(plugins).callEvent(any(net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent.class)); + poll.run(); + enableEvent("RPCharacters"); + verify(dependency("factionManager", FactionManager.class)).prepare(); + + 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()).prepare(); + enableEvent("RPCharacters"); + verify(dependency("factionManager", FactionManager.class)).prepare(); + 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()).prepare(); + 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<UUID> account(String name) { + return name.equals("Caesar") ? Optional.of(account) : Optional.empty(); + } + + @Override + public Optional<net.tfminecraft.tlibs.character.CharacterRef> 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 { @@ -1207,7 +1438,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/api/SimpleFactionsApiTest.java b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java new file mode 100644 index 00000000..bc97deae --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/api/SimpleFactionsApiTest.java @@ -0,0 +1,76 @@ +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 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()); + 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 { + 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; + try (var fixture = new net.tfminecraft.simplefactions.testsupport.GuiTestFixture()) { + 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/database/DatabaseCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java index ebde031e..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()); @@ -140,7 +146,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..32a59f6e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/GuildTaxPolicyTest.java @@ -0,0 +1,47 @@ +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); + 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(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(); + 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/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/TreasuryActionsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java new file mode 100644 index 00000000..5443c0f8 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/TreasuryActionsTest.java @@ -0,0 +1,306 @@ +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(); + } + @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)); + 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/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/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 new file mode 100644 index 00000000..83f3a4bd --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -0,0 +1,230 @@ +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 debtIsPreservedAndDailyShortfallsLiquidateWithoutTouchingLiveBranches() { + guild.getBranch(0).levelUp(); + guild.getBranch(0).levelUp(); + Ledger.setNodeUpkeepLookup(g -> g == guild ? 200 : 0); + var plan = plan(List.of()); + 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()); + 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() { + 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)); + } + + @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/economy/settlement/RealmSettlementEffectsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java new file mode 100644 index 00000000..6927e2be --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementEffectsTest.java @@ -0,0 +1,74 @@ +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()) { + FactionManager.day = 0; + 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..5da39ef1 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementServiceTest.java @@ -0,0 +1,53 @@ +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)) { + 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()); + 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/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/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 4ed6657a..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.isMemberIgnoreCase("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 bdc1be96..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.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(true); } @Test @@ -35,7 +34,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/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java index c7168ba7..f321ac6b 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))); @@ -130,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( @@ -175,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)); @@ -413,15 +414,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..927cc15f 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)); @@ -304,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); @@ -337,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")); @@ -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 29aef22f..dfc903e3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -64,11 +64,10 @@ 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.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 +97,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 +115,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 +131,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,14 +163,13 @@ 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")); 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.isMemberIgnoreCase("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 9a576e59..7a761339 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java @@ -1,17 +1,41 @@ 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 its key, + * which is the name itself under the default name-keyed test resolver. + */ + static UUID characterOf(String name) { + return net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(name); + } + + /** 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.isMemberIgnoreCase("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 fe77ab73..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.isMemberIgnoreCase("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()); @@ -53,7 +52,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 +97,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..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.isMemberIgnoreCase("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 402f3feb..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.isMemberIgnoreCase("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 30924aee..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.isMemberIgnoreCase("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.isMemberIgnoreCase("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 e2779e80..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; @@ -29,6 +30,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 +45,8 @@ class OfficeVacancyTest { assertEquals(-10, penalties.getFirst().getModifier()); assertEquals("Vacant Spymaster", penalties.getFirst().getName()); } - var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; - when(faction.isMemberIgnoreCase("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 +64,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.isMemberIgnoreCase("FormerSpy")).thenReturn(true); + when(faction.isMember(EspionageTestFixtures.key("FormerSpy"))).thenReturn(true); assertTrue(EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).isEmpty()); } @@ -121,7 +123,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); @@ -327,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); } } @@ -374,7 +384,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 +395,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 +414,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 50a8fa16..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.isMemberIgnoreCase(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.isMemberIgnoreCase("Spy")).thenReturn(false); + characters.when(() -> OfficeCharacters.characterUuid(any())).thenCallRealMethod(); + when(target.isMember(EspionageTestFixtures.key("Spy"))).thenReturn(false); assertTrue(EspionageService.canViewExact(viewer, target)); - when(target.isMemberIgnoreCase("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.isMemberIgnoreCase("ObserverSpy")).thenReturn(false); + when(observer.isMember(EspionageTestFixtures.key("ObserverSpy"))).thenReturn(false); assertNull(EspionageService.report(viewer, target), "Ineligible observers cannot read cached findings"); - when(observer.isMemberIgnoreCase("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 c53658f7..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.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)); + 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,12 +192,12 @@ 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"); 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"); @@ -233,12 +234,12 @@ 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); 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)) { @@ -252,12 +253,12 @@ 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"); 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/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<String> 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..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) @@ -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())); @@ -707,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 = @@ -720,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/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<FactionManager> 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/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/guild/GuildCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java index 60672c41..04af78d3 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"))); } @@ -100,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() { @@ -177,28 +154,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 @@ -250,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)); @@ -278,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(); @@ -298,7 +279,6 @@ void expansionAndRelocationCostsFollowLevelAndProvinceOwnership() { when(province.getOwner()).thenReturn(other); assertEquals(945, guild.getRelocationCost(7)); guild.setBank(null); - guild.liquidateRandom(); assertEquals(6, guild.getSize()); } @@ -495,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/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<String>()); 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<String> 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<String> 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/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/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()); + } +} 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<String, CharacterInfo> characters = new HashMap<>(); + private final Map<Player, String> 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<CharacterSession> 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<CharacterInfo> info(String key) { + return Optional.ofNullable(characters.get(key)); + } + + @Override + public Optional<Player> 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/guild/income/CasinoProfitOperationTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java new file mode 100644 index 00000000..586a8578 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/CasinoProfitOperationTest.java @@ -0,0 +1,120 @@ +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.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 { + @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()); + 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.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()); + } + } +} 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/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/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(); 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..181a14f7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/MembersTest.java @@ -0,0 +1,231 @@ +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<String>) 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()))); + 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); + } + } + + @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<String> 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 noServerMeansNobodyIsOnline() { + try (var bukkit = mockStatic(Bukkit.class)) { + assertNull(Members.findOnline("Steve")); + assertNull(Members.accountName(Members.key(UUID.randomUUID()))); + bukkit.when(Bukkit::getServer).thenThrow(new IllegalStateException("no server")); + assertNull(Members.findOnline("Steve")); + } + } + + @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(); + 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> 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<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; - - 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/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..d49a6eef --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java @@ -0,0 +1,418 @@ +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 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(); + 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/inactivity/InactivityCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java index 96e3645b..44e3d6af 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")); } @@ -209,9 +209,9 @@ 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); - history(Map.of("alice", 21, "bob", 22)); + 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); 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/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/integration/IntegrationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java index 3ae0db5a..23dafdb4 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; @@ -134,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(); @@ -168,25 +166,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/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/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index 296b332b..21cc3fa0 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; @@ -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()))); } } @@ -1124,6 +1122,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 +1133,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")); @@ -1619,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 @@ -1784,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<DisplayNameGate> names = mockStatic(DisplayNameGate.class); + MockedConstruction<Faction> 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<Members> 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<net.tfminecraft.simplefactions.migration.QuarantineCommands> 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); @@ -1800,15 +1865,15 @@ void factionInvitationsRequireLeadershipCapacityAndAnUnalignedInvitee() { verify(player).sendMessage(contains("Only the leader")); when(faction.getLeader()).thenReturn("Leader"); int oldMaximum = Cache.maxMembers; - try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { + try (MockedStatic<Members> 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); - when(faction.isMemberIgnoreCase("Alice")).thenReturn(true); + 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")); - 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")); @@ -1841,14 +1906,14 @@ void guildInvitationsRejectSelfDuplicatesAndProtectedMemberships() { assertTrue(run("guild", "invite", "Lady", "Alice")); verify(player).sendMessage(contains("use /faction invite")); when(guild.isBase()).thenReturn(false); - try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { + try (MockedStatic<Members> 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 +1953,8 @@ void closedBordersBlockOutsideGuildInvitesButAllowTheFactionLeader() { guildLeader(); when(faction.hasFactionRule(Rules.CLOSED_BORDERS)).thenReturn(true); Player alice = gui.player("Alice"); - try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { - characters.when(() -> CharacterNames.resolveOnline(player, "Alice")).thenReturn(alice); + try (MockedStatic<Members> 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 e38c0dde..0d547efe 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -127,8 +127,8 @@ private Faction faction(String id, String leader) { when(faction.getRGB()).thenReturn(id + "-rgb"); List<String> members = new ArrayList<>(List.of(leader)); when(faction.getMembers()).thenReturn(members); - when(faction.isMemberIgnoreCase(anyString())) - .thenAnswer(call -> members.stream().anyMatch(name -> name.equalsIgnoreCase(call.getArgument(0)))); + when(faction.isMember(anyString())) + .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,30 +252,36 @@ 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((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((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(); @@ -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"))); @@ -618,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"); } @@ -640,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 @@ -937,7 +954,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); @@ -959,7 +975,6 @@ public void close() { cleanup.close(); routes.close(); reparations.close(); - payouts.close(); termination.close(); accrual.close(); inactivity.close(); @@ -967,108 +982,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); } } @@ -1102,34 +1031,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/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/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<FactionManager> 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/MercenaryCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java index 0829fdab..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"); @@ -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/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/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/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java index 7e150b1e..bf39b892 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); @@ -219,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() @@ -232,6 +234,7 @@ void aLeaderCanReviewAndPublishValidLoanTermsWithoutMovingMoney() { strings = { "expired", "old_offer", + "unbound_issuer", "missing_snapshot", "invalid_snapshot", "invalid_terms", @@ -254,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" -> @@ -267,7 +272,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() @@ -286,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) { @@ -294,6 +320,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")) @@ -340,7 +368,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()); @@ -348,24 +376,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); - 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 @@ -379,25 +394,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)); + 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 @@ -513,12 +519,16 @@ 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")) { 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()); @@ -650,10 +660,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/PlayerManagerLoanBookTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java index 02e69c25..5fcc9039 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,9 +68,14 @@ 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"); + when(lender.getIncarnation()).thenReturn("current-lender-incarnation"); when(lender.getName()).thenReturn("Lenders"); when(lender.getBank()).thenReturn(lenderBank); when(lenderBank.getWealth()).thenReturn(10_000.0); @@ -78,6 +88,8 @@ void setUp() { @AfterEach void tearDown() { factionManager.close(); + readiness.close(); + money.close(); } private static BookMeta book(Integer stage, String offerId) { @@ -89,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; @@ -123,9 +136,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 +149,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 +160,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()); } } 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/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"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java index bf5231eb..876739c4 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()); @@ -181,10 +182,20 @@ 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(); - 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 +288,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()); + } + } 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<CharacterNames> 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<String> 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<UUID, CharacterSession> sessions = new HashMap<>(); + private final Map<String, CharacterInfo> infos = new HashMap<>(); + private final Map<String, Player> online = new HashMap<>(); + private final Set<CharacterSession> 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<CharacterSession> 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<CharacterInfo> info(String key) { + return Optional.ofNullable(infos.get(key)); + } + + @Override + public Optional<Player> 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/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/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> 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")); + } + } +} 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..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"); @@ -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/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/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/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/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 d3b2fa04..a495e573 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()); @@ -61,12 +60,29 @@ 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; 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 +93,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 +135,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 +147,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 +162,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 +174,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 +186,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 +198,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,11 +208,12 @@ 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()); - 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/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/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/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/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/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/MercenaryContractBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java index c4ed380a..b52a3a66 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,14 @@ class MercenaryContractBoundaryCoverageTest { @Test - void wageOverridesFollowThePlayerAcrossNameCasingAndLocale() { + 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(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); @@ -47,11 +54,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")); @@ -316,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( 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/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<String> 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/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/migration/CharacterMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java new file mode 100644 index 00000000..e61c39d4 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/migration/CharacterMigrationTest.java @@ -0,0 +1,552 @@ +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<String, UUID> accounts = new HashMap<>(); + private final Map<UUID, CharacterRef> heirs = new HashMap<>(); + private int heirLookups; + + private final CharacterMigration.Heirs fakeHeirs = new CharacterMigration.Heirs() { + @Override + public Optional<UUID> account(String name) { + return Optional.ofNullable(accounts.get(name.toLowerCase(java.util.Locale.ROOT))); + } + + @Override + public Optional<CharacterRef> 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<String> 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 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(); + 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 <recordId> <characterId>"); + } + } + + @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/objects/DomainConfigurationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java index 1d779a5f..7f5c0e3b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java @@ -469,16 +469,20 @@ 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.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..c87cf8d9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java @@ -131,22 +131,25 @@ 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.isMemberIgnoreCase("bob")); - assertFalse(faction.isMemberIgnoreCase("missing")); + assertTrue(faction.isMember("Bob")); + assertFalse(faction.isMember("bob")); + assertFalse(faction.isMember("missing")); } @Test @@ -420,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() @@ -434,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 @@ -642,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()); } @@ -668,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()); @@ -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); @@ -1006,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")) @@ -1077,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); @@ -1118,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); @@ -1146,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/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<String>()); 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()); } 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/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/testsupport/GuiTestFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java index f9262f1d..7e0ef766 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java @@ -81,6 +81,8 @@ 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; + private java.nio.file.Path callerJournal; public GuiTestFixture() { when(world.getName()).thenReturn("world"); @@ -103,6 +105,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); @@ -131,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) { @@ -313,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 -> { @@ -688,6 +709,10 @@ ItemState copy() { 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/testsupport/NameKeyedMembers.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java new file mode 100644 index 00000000..50e0007d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/NameKeyedMembers.java @@ -0,0 +1,109 @@ +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(); + /** 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); + } + + @Override + public void beforeEach(ExtensionContext context) { + install(); + } + + @Override + public void afterEach(ExtensionContext context) { + install(); + } + + @Override + 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, 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 + public boolean isCurrent(CharacterSession session) { + return true; + } + + @Override + public String key(Player player) { + return player.getName(); + } + + @Override + public Optional<CharacterInfo> info(String key) { + return Optional.empty(); + } + + @Override + public Optional<Player> 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. + } + } + 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); + } + + private static UUID id(String name) { + return UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)); + } +} 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> 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<UUID, CharacterSession> sessions = new HashMap<>(); + public final Map<UUID, CharacterInfo> infos = new HashMap<>(); + public final Map<UUID, CharacterRef> heirs = new HashMap<>(); + public final Map<String, Player> online = new HashMap<>(); + public final Map<UUID, Player> connections = new HashMap<>(); + public final Map<String, OfflinePlayer> 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<String, OfflinePlayer> 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<CharacterInfo> 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<Player> players() { + return connections.values(); + } + + @Override + public void close() { + try { + unregister(); + NameKeyedMembers.install(); + } finally { + bukkit.close(); + } + } +} 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/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); } + } +} 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..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,79 +598,40 @@ 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; } 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/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 184a7550..67fe9ed9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java @@ -23,33 +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 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/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/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/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 b2920822..093ddc99 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,22 +1434,27 @@ 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); } - 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, 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")); @@ -1477,12 +1492,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.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( new VehicleConstructEvent( @@ -1492,10 +1510,9 @@ 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); + verifyNoInteractions(save); } @ParameterizedTest @@ -1608,7 +1625,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); @@ -1637,13 +1654,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 @@ -2143,7 +2160,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, @@ -2223,7 +2240,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()); } @@ -2239,16 +2256,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", """ @@ -2261,10 +2280,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(); @@ -2290,7 +2310,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"); @@ -2311,14 +2331,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 @@ -2327,17 +2347,63 @@ 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"))); } + @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)); @@ -2347,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")); @@ -2436,7 +2503,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/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/fees/VehicleFeeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java index 55089c4d..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)); @@ -114,33 +124,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 +135,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 +143,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/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 new file mode 100644 index 00000000..8e5f1625 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java @@ -0,0 +1,219 @@ +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()); + } + + @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/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..ba598118 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( @@ -183,17 +164,18 @@ void disablingUpkeepClearsOldPersonalDebtAndStopsDecayAndRepairBlocking() throws "insufficient_pouch", "insufficient_bank", "not_unpaid", + "unavailable", "unknown_type" }) 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 +183,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(); @@ -215,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); @@ -224,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")); @@ -260,14 +248,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 +411,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 +427,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 +485,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 +569,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 +668,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 new file mode 100644 index 00000000..55428069 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java @@ -0,0 +1,236 @@ +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); + 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(); + 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 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); + 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")); + } + } +} 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..0825be18 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")); } @@ -70,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( @@ -78,7 +87,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..0ceb1c31 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,34 @@ 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")); + 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 = 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 +128,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 +143,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 +170,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 +258,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 +266,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 b0d33fcd..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java +++ /dev/null @@ -1,162 +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)); - 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 new file mode 100644 index 00000000..efeed0c2 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -0,0 +1,139 @@ +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, CharacterRef> heirs = 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.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(); + } + + @AfterEach + void close() { + accounts.close(); + bukkit.close(); + } + + @Test + void ownerKeyIsThePlayedCharacterElseTheLegacyHeirOfTheTaggedAccount() { + assertNull(VehicleOwnershipQueries.ownerKey(null)); + assertNull(VehicleOwnershipQueries.ownerKey(" ")); + 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"); + + 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); + assertNull(VehicleOwnershipQueries.characterId(stranger), "No character means no payer"); + } + + @Test + void withoutAServerOrProviderNoCharacterIsKnown() { + heirs.put(account, new CharacterRef(account, characterId)); + registered = null; + assertNull(VehicleOwnershipQueries.ownerKey("Owner")); + registered = provider; + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertNull(VehicleOwnershipQueries.ownerKey("Owner")); + bukkit.when(Bukkit::getServer).thenReturn(null); + assertNull(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); + } +} 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..4ae9531a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java @@ -0,0 +1,105 @@ +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())); + 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")); + } + } +} 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 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<FactionManager> 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/engine/core/CapturedBattleCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java new file mode 100644 index 00000000..e2a09fce --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CapturedBattleCharactersTest.java @@ -0,0 +1,90 @@ +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 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); + 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()); + } + } +} 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..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 @@ -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,54 @@ 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 quittingAfterSessionEndsRetainsTheCapturedCharacter() { Warband band = factionBand("offline_quit"); + var captured = session(rig.alice); rig.domain.online.remove("Alice"); membership.handleQuit(rig.alice.getUniqueId()); - assertFalse(membership.getPendingRejoin(rig.alice.getUniqueId()).hasFaction()); + 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())); 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 +378,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/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/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/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/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<net.tfminecraft.tlibs.character.CharacterSession> 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<net.tfminecraft.tlibs.character.CharacterInfo> 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<org.bukkit.entity.Player> 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/campaign/vote/BattleQuorumServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleQuorumServiceTest.java index 10e3834d..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 @@ -85,6 +85,28 @@ 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 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(); 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); 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..82525dbb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java @@ -0,0 +1,75 @@ +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> 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 anOfflineBuilderIsJudgedByTheAccountsLegacyHeir() { + UUID characterId = UUID.randomUUID(); + CharacterIdentity identity = mock(CharacterIdentity.class); + when(services.load(CharacterIdentity.class)).thenReturn(identity); + 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())); + } + + @Test + void anOfflineBuilderWithoutTheCharacterProviderHasNoCharacter() { + assertNull(VfBuildersConstructionFreeze.constructorKey(account)); + } +} 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()); + } + } +} 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