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 provinces, List titles, Set 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 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 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 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 upkeep) {}
+ private static final class Saved {
+ long day;
+ long timestamp;
+ List removals;
+ List upkeep;
+ Map> organization;
+ Map 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 unfinishedDays() throws IOException {
+ if (!Files.exists(folder)) return List.of();
+ List 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 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 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 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 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 regimentSlots,
+ List installations, InstallationConstructionData construction,
+ long charge, double armyLedgerCost, double installationLedgerCost) {
+
+ public static FactionUpkeepPlan calculate(Faction faction) {
+ List 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 slots = new LinkedHashMap<>();
+ for (Regiment regiment : regiments) slots.put(regiment.getId(), regiment.getCurrentSlots());
+ List 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 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 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 balances, Map legs, RealmSettlementChanges changes);
+ }
+
+ private final long day;
+ private final long now;
+ private final List guilds;
+ private final List claims;
+ private List preparedUpkeep;
+ private final Map ledgers = new LinkedHashMap<>();
+ private final Map views = new LinkedHashMap<>();
+ private final Map companies = new LinkedHashMap<>();
+ private final Map loans = new IdentityHashMap<>();
+ private final Map credits = new LinkedHashMap<>();
+ private final Map initial = new LinkedHashMap<>();
+ private final Map balances = new LinkedHashMap<>();
+ private final Map legs = new LinkedHashMap<>();
+ private final RealmSettlementChanges changes = new RealmSettlementChanges();
+
+ public RealmSettlementCalculation(long day, long now, Collection guilds, List 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 upkeep) {
+ this.preparedUpkeep = List.copyOf(upkeep);
+ return this;
+ }
+
+ public SettlementJournal.Plan calculate(Vehicles vehicles) {
+ capture();
+ accrue();
+ var history = Ledger.collectHistoryDay(guilds, ledgers::get);
+ Map 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 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 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 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 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 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 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 wages = buffer.getPlayerPayouts().getOrDefault(guild, Map.of());
+ long wageTotal = wages.values().stream().mapToLong(Money::cents).sum();
+ List 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 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 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> 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> 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 upkeep = new ArrayList<>();
+ public final List guilds = new ArrayList<>();
+ public final Map> reparations = new LinkedHashMap<>();
+ public final Map deathPayments = new LinkedHashMap<>();
+ public final Map> 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 citizenTaxes;
+ public double casinoProfit;
+ public double vehicleFeeIncome;
+ public int creditScore;
+ public List loans = new ArrayList<>();
+ public MercenaryCompanyData company;
+ public Map branches = new LinkedHashMap<>();
+ public List dividendEligible;
+ public Map> ledgerLastDay;
+ public Map> 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 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 save = new LinkedHashSet<>();
+ java.util.List 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 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 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 names) {
- for (var player : players) if (player.getName().equalsIgnoreCase(query)) return new Resolution(player, false);
- var matches = players.stream().filter(player -> org.bukkit.ChatColor.stripColor(names.apply(player))
- .equalsIgnoreCase(query)).toList();
- return new Resolution(matches.size() == 1 ? matches.getFirst() : null, matches.size() > 1);
- }
-
- record Resolution(org.bukkit.entity.Player player, boolean ambiguous) {}
-
- /** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */
- public static java.util.List inviteCompletions(java.util.Collection names, String[] args) {
- if (args.length < 2) return java.util.List.of();
- int skip = args.length - 2;
- String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length));
- return names.stream().filter(java.util.Objects::nonNull).map(org.bukkit.ChatColor::stripColor).distinct()
- .filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length()))
- .map(name -> name.split(" "))
- .filter(words -> words.length > skip)
- .map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length)))
- .toList();
- }
-
- public static String of(String playerName) {
- String name = activeName(playerName);
- return name == null ? playerName : name;
- }
-
- private static String activeName(String playerName) {
- if (playerName == null) return "Unknown";
- if (!Characters.available()) return playerName;
- var player = Bukkit.getPlayerExact(playerName);
- if (player != null) {
- var session = Characters.currentSession(player).orElse(null);
- if (session == null || !session.ref().ownerId().equals(player.getUniqueId())) return null;
- // Plain text, like offline names: callers colour names themselves and reports persist them.
- String name = org.bukkit.ChatColor.stripColor(Characters.displayName(player));
- return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player
- && Characters.isCurrent(session) ? name : null;
- }
- var cached = Bukkit.getOfflinePlayerIfCached(playerName);
- if (cached == null) return null;
- return Characters.lastPlayed(cached.getUniqueId())
- .filter(info -> info.ref().ownerId().equals(cached.getUniqueId()))
- .map(net.tfminecraft.tlibs.character.CharacterInfo::name).orElse(null);
- }
-}
diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/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 ");
}
+ /** Online members who could hold the office, completed by character name. */
+ private static List candidates(net.tfminecraft.simplefactions.objects.Faction faction, String[] args) {
+ List names = new ArrayList<>();
+ for (String member : faction.getMembers())
+ if (Members.isOnline(member) && EspionageService.eligible(faction, member)) names.add(Members.display(member));
+ return Members.completions(names, args);
+ }
+
public static List complete(Player player, String[] args) {
List choices = new ArrayList<>();
- var faction = FactionManager.getByMember(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 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 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 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 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 build(Player viewer, Faction faction, Guild only) {
var report = exact ? null : EspionageService.report(viewer, faction);
List lore = new ArrayList<>();
String title = faction.getRulerTitle();
- lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + CharacterNames.display(viewer, faction.getLeader(), faction));
+ lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + Members.display(viewer, faction.getLeader()));
List leaderOffices = exact ? java.util.Arrays.stream(SpecialPosition.values()).filter(office -> {
var holder = faction.getEspionage().holder(office);
- return holder != null && holder.playerName.equalsIgnoreCase(faction.getLeader());
+ return holder != null && faction.isLeader(holder.memberKey());
}).map(SpecialPosition::label).toList() : report == null ? List.of() : report.details("office-holder", "leader-offices");
- for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader(), faction));
+ for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + Members.display(viewer, faction.getLeader()));
List guilds = new ArrayList<>(only == null ? faction.getGuildHandler().getGuilds() : List.of(only));
guilds.sort(Comparator.comparing((Guild guild) -> !guild.isBase()).thenComparing(Comparator.comparing((Guild guild) -> {
var wealth = exact ? guild.getWealth() : EspionageService.visibleValue(viewer, faction,
@@ -34,10 +35,10 @@ private static List build(Player viewer, Faction faction, Guild only) {
for (Guild guild : guilds) {
List entries;
if (exact) entries = guild.getMembers().stream().filter(name -> !faction.isLeader(name)).map(name ->
- new IntelligenceReport.RosterMember(CharacterNames.display(viewer, name), guild.getId(), guild.getName(), true, guild.isLeader(name),
+ new IntelligenceReport.RosterMember(Members.display(viewer, name), guild.getId(), guild.getName(), true, guild.isLeader(name),
java.util.Arrays.stream(SpecialPosition.values()).filter(office -> {
var holder = faction.getEspionage().holder(office);
- return holder != null && holder.playerName.equalsIgnoreCase(name);
+ return holder != null && name.equals(holder.memberKey());
}).toList())).toList();
else entries = report == null || report.roster == null ? List.of() : report.roster.stream()
.filter(entry -> entry.guildId().equals(guild.getId()))
diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java
index 29ebf425..a351f689 100644
--- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java
+++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java
@@ -2,8 +2,11 @@
import java.util.UUID;
+import net.tfminecraft.simplefactions.identity.Members;
+
public class SpecialPositionAssignment {
public UUID playerId;
+ /** Account name at binding, kept for older saves; membership uses {@link #memberKey()}. */
public String playerName;
public String characterId;
// Character binding time, independent of an automatic founder's immediate full aptitude.
@@ -19,4 +22,9 @@ public class SpecialPositionAssignment {
public boolean isHolder(UUID id) {
return id != null && id.equals(playerId);
}
+
+ /** Member key of the bound character; null while no character is bound. */
+ public String memberKey() {
+ return Members.key(OfficeCharacters.characterUuid(characterId));
+ }
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/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 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 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 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 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 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 getAllMembers() {
public List getFormattedList() {
List formattedList = new ArrayList<>();
for (String citizen : citizens) {
- formattedList.add(StringFormatter.formatHex(citizen + " #77d1a3(Citizen)"));
+ formattedList.add(StringFormatter.formatHex(Members.display(citizen) + " #77d1a3(Citizen)"));
}
for (Guild guild : guilds) {
formattedList.add(StringFormatter.formatHex(guild.getName() + " #d1b83b(Guild)"));
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java
index ffe15a6d..5e06068c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java
@@ -5,6 +5,7 @@
import net.tfminecraft.simplefactions.database.Database;
import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.identity.Members;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.managers.LogManager;
import net.tfminecraft.simplefactions.objects.Faction;
@@ -140,12 +141,14 @@ public static Result target(String movementId, String causeIndex, String player)
return new Result(false, TARGET_USAGE);
}
Faction host = movement.getFaction();
- if (host == null || !host.canBecomeLeader(player)) {
+ String key = host == null ? null : memberKey(host.getMembers(), player);
+ if (key == null || !host.canBecomeLeader(key)) {
return new Result(false, "§c" + player + " cannot be the wanted leader.");
}
- cause.getProposal().setTarget(player);
+ cause.getProposal().setTarget(key);
persist(movement);
- return new Result(true, "§aSet wanted leader of " + movement.getId() + " cause " + causeIndex + " to " + player + ".");
+ return new Result(true, "§aSet wanted leader of " + movement.getId() + " cause " + causeIndex + " to "
+ + Members.display(key) + ".");
}
private static String missingTargetReason(Movement movement) {
@@ -178,7 +181,7 @@ private static Result mutateMember(
return new Result(false, "§cUnknown cause index: " + causeIndex);
}
}
- Object obj = resolveMember(memberType, targetId);
+ Object obj = resolveMember(movement, cause, memberType, targetId);
if (obj == null) {
return new Result(false, "§cUnknown " + memberType + ": " + targetId);
}
@@ -272,18 +275,32 @@ private static Cause resolveCause(Movement movement, String indexArg) {
}
}
- private static Object resolveMember(String type, String target) {
+ private static Object resolveMember(Movement movement, Cause cause, String type, String target) {
if (type == null || target == null || target.isBlank()) {
return null;
}
return switch (type.toLowerCase(java.util.Locale.ROOT)) {
- case "citizen" -> target;
+ case "citizen" -> memberKey(citizenCandidates(movement, cause), target);
case "guild" -> FactionManager.getGuildByString(target);
case "vassal" -> FactionManager.getByString(target);
default -> null;
};
}
+ /** Current pool citizens plus the host's members, so staff can remove a citizen who has since left. */
+ private static List citizenCandidates(Movement movement, Cause cause) {
+ Pool pool = cause == null ? movement.getSupporters() : cause.getPool();
+ List keys = new ArrayList<>(pool.getCitizens());
+ if (movement.getFaction() != null) keys.addAll(movement.getFaction().getCompleteMemberList());
+ return keys;
+ }
+
+ /** A member key typed as-is, or found by Minecraft or character name. */
+ static String memberKey(List keys, String input) {
+ if (keys.contains(input)) return input;
+ return Members.find(keys, input);
+ }
+
private static boolean isPresent(Movement movement, Cause cause, Object obj) {
Pool pool = cause == null ? movement.getSupporters() : cause.getPool();
if (obj instanceof String citizen) {
@@ -296,7 +313,7 @@ private static boolean isPresent(Movement movement, Cause cause, Object obj) {
}
private static String formatListLine(Faction host, Movement movement) {
- String leader = movement.getLeader() == null ? "None" : movement.getLeader();
+ String leader = movement.getLeader() == null ? "None" : Members.display(movement.getLeader());
String phase = movement.getPhase() == null ? "-" : movement.getPhase().name();
int supporters = movement.getSupporters() == null ? 0 : movement.getSupporters().getAllMembers().size();
int backers = movement.getForeignBackers() == null ? 0 : movement.getForeignBackers().size();
@@ -348,7 +365,7 @@ public static List hostCitizenNames(Movement movement) {
if (host == null || host.getOrCreateMainGuild() == null || host.getOrCreateMainGuild().getMembers() == null) {
return List.of();
}
- return new ArrayList<>(host.getOrCreateMainGuild().getMembers());
+ return Members.display(host.getOrCreateMainGuild().getMembers());
}
public static List hostGuildIds(Movement movement) {
@@ -407,7 +424,7 @@ public static List wantedLeaderNames(Movement movement) {
}
for (String member : host.getMembers()) {
if (host.canBecomeLeader(member)) {
- names.add(member);
+ names.add(Members.display(member));
}
}
return names;
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java
index 918684d2..0cc8ffc5 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java
@@ -66,7 +66,7 @@ private boolean handleTarget(Player player, String[] args) {
player.sendMessage(MovementAdminService.TARGET_USAGE);
return true;
}
- MovementAdminService.Result result = MovementAdminService.target(args[2], args[3], args[4]);
+ MovementAdminService.Result result = MovementAdminService.target(args[2], args[3], tail(args, 4));
player.sendMessage(result.message());
return true;
}
@@ -94,16 +94,16 @@ private boolean handleMutate(Player player, boolean joining, String[] args) {
return true;
}
result = joining
- ? MovementAdminService.joinSupporter(movementId, args[4], args[5])
- : MovementAdminService.leaveSupporter(movementId, args[4], args[5]);
+ ? MovementAdminService.joinSupporter(movementId, args[4], tail(args, 5))
+ : MovementAdminService.leaveSupporter(movementId, args[4], tail(args, 5));
} else if (slot.equals("cause")) {
if (args.length < 7) {
player.sendMessage(usage);
return true;
}
result = joining
- ? MovementAdminService.joinCause(movementId, args[4], args[5], args[6])
- : MovementAdminService.leaveCause(movementId, args[4], args[5], args[6]);
+ ? MovementAdminService.joinCause(movementId, args[4], args[5], tail(args, 6))
+ : MovementAdminService.leaveCause(movementId, args[4], args[5], tail(args, 6));
} else {
player.sendMessage(usage);
return true;
@@ -111,4 +111,9 @@ private boolean handleMutate(Player player, boolean joining, String[] args) {
player.sendMessage(result.message());
return true;
}
+
+ /** The rest of the arguments, so character names may contain spaces. */
+ private static String tail(String[] args, int from) {
+ return String.join(" ", java.util.Arrays.copyOfRange(args, from, args.length));
+ }
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java
index a1ba2ac0..28beb23c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java
@@ -7,6 +7,7 @@
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
+import net.tfminecraft.simplefactions.identity.Members;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.utils.Permissions;
import net.tfminecraft.simplefactions.government.movement.Movement;
@@ -42,10 +43,7 @@ public List onTabComplete(CommandSender sender, Command command, String
if (args.length == 4) {
return prefixList(args[3], MovementAdminService.causeIndices(movement));
}
- if (args.length == 5) {
- return prefixList(args[4], MovementAdminService.wantedLeaderNames(movement));
- }
- return List.of();
+ return names(args, 4, MovementAdminService.wantedLeaderNames(movement));
}
if (action.equals("demands")) {
if (args.length == 3) {
@@ -86,10 +84,7 @@ public List onTabComplete(CommandSender sender, Command command, String
if (args.length == 5) {
return prefix(args[4], "citizen", "guild", "vassal");
}
- if (args.length == 6) {
- return prefixList(args[5], targets(movement, args[4]));
- }
- return List.of();
+ return targets(movement, args, 5);
}
if (slot.equals("cause")) {
if (args.length == 5) {
@@ -98,22 +93,35 @@ public List onTabComplete(CommandSender sender, Command command, String
if (args.length == 6) {
return prefix(args[5], "citizen", "guild", "vassal");
}
- if (args.length == 7) {
- return prefixList(args[6], targets(movement, args[5]));
- }
+ return targets(movement, args, 6);
}
return List.of();
}
- private static List targets(Movement movement, String memberType) {
- return switch (memberType.toLowerCase(java.util.Locale.ROOT)) {
- case "citizen" -> MovementAdminService.hostCitizenNames(movement);
- case "guild" -> MovementAdminService.hostGuildIds(movement);
- case "vassal" -> MovementAdminService.hostSubjectIds(movement);
+ /** Completes the target at {@code from}; citizen names may span several words. */
+ private static List targets(Movement movement, String[] args, int from) {
+ String memberType = args[from - 1].toLowerCase(java.util.Locale.ROOT);
+ if (memberType.equals("citizen")) {
+ return names(args, from, MovementAdminService.hostCitizenNames(movement));
+ }
+ if (args.length != from + 1) {
+ return List.of();
+ }
+ return switch (memberType) {
+ case "guild" -> prefixList(args[from], MovementAdminService.hostGuildIds(movement));
+ case "vassal" -> prefixList(args[from], MovementAdminService.hostSubjectIds(movement));
default -> List.of();
};
}
+ /** Completes a character name that starts at {@code from} and may contain spaces. */
+ private static List names(String[] args, int from, List names) {
+ String[] tail = new String[args.length - from + 1];
+ tail[0] = "";
+ System.arraycopy(args, from, tail, 1, args.length - from);
+ return Members.completions(names, tail);
+ }
+
private static List prefix(String typed, String... candidates) {
List completions = new ArrayList<>();
String lower = typed == null ? "" : typed.toLowerCase(java.util.Locale.ROOT);
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java
index 6fc239d1..00c16149 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java
@@ -129,20 +129,20 @@ public void checkMembers(Pool pool) {
}
}
- public boolean canMemberJoin(String playerName, boolean checkExisting, boolean feedback) {
- return memberBlockReason(playerName, checkExisting, false) == null;
+ public boolean canMemberJoin(String member, boolean checkExisting, boolean feedback) {
+ return memberBlockReason(member, checkExisting, false) == null;
}
- public String memberBlockReason(String playerName, boolean checkExisting, boolean staff) {
- Member relation = movement.getFaction().getRelationToFaction(playerName);
+ public String memberBlockReason(String member, boolean checkExisting, boolean staff) {
+ Member relation = movement.getFaction().getRelationToFaction(member);
if (relation != Member.MEMBER) {
- return MovementJoinCopy.causeCitizenMustBeMember(staff, playerName, movement.getFaction());
+ return MovementJoinCopy.causeCitizenMustBeMember(staff, member, movement.getFaction());
}
if (!proposal.getPoliticalAction().allowCitizens()) {
- return MovementJoinCopy.causeCitizensNotAllowed(staff, playerName);
+ return MovementJoinCopy.causeCitizensNotAllowed(staff, member);
}
- if (checkExisting && movement.isMember(playerName)) {
- return MovementJoinCopy.alreadyCauseMember(staff, playerName);
+ if (checkExisting && movement.isMember(member)) {
+ return MovementJoinCopy.alreadyCauseMember(staff, member);
}
return null;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
index 60c31fac..ebb41a6c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
@@ -4,6 +4,8 @@
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
import org.bukkit.entity.Player;
+
+import net.tfminecraft.simplefactions.identity.Members;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.Material;
import java.util.ArrayList;
@@ -171,7 +173,7 @@ public ItemStack getAsBook(Player p) {
: isFeeProposal() ? "Fee Proposal" : "Political Action Proposal";
if (title.length() > 32) title = title.substring(0, 32);
meta.setTitle(title);
- meta.setAuthor(proposer != null ? proposer : "Unknown");
+ meta.setAuthor(Members.display(proposer));
Faction f = gov != null ? gov.getFaction() : null;
@@ -179,7 +181,7 @@ public ItemStack getAsBook(Player p) {
List first = new ArrayList<>();
first.add(StringFormatter.formatHex("#93c9a7" + title));
first.add("");
- first.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7" + proposer));
+ first.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7" + Members.display(proposer)));
if (isLawProposal() && law != null) {
LawGroup group = f != null ? f.getLawHandler().getGroup(law.getGroup()) : null;
@@ -228,7 +230,7 @@ public ItemStack getAsBook(Player p) {
first.add(StringFormatter.formatHex("#b8ae61Action: #c2bea7"+action.getDisplay()));
switch(action) {
case CHANGE_LEADER:
- first.add(StringFormatter.formatHex("#d4c9aeTarget: "+ (hasTarget() ? "#51e0a2"+ target : "#a3462cNo target")));
+ first.add(StringFormatter.formatHex("#d4c9aeTarget: "+ (hasTarget() ? "#51e0a2"+ Members.display(target) : "#a3462cNo target")));
break;
case WHITE_PEACE:
case SURRENDER:
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java
index 37011281..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 proposals = new LinkedHashMap<>();
private final Map decidedVoteCounts = new HashMap<>();
private SessionHologram hologram;
- private Map currentVotes = new HashMap<>(); // Player name -> Vote
+ private Map currentVotes = new HashMap<>(); // member key -> Vote
private Set eligibleVoters = new HashSet<>(); // Council members + leader
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@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 votesSnapshot() {
+ return new HashMap<>(currentVotes);
+ }
+
+ public Set 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 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 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 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 getBranches() { return branches; }
public Branch getBranch(int i) {
return branches.getOrDefault(i, null);
@@ -1074,6 +1106,10 @@ public List getDividendEligibleMembers() {
return eligible;
}
+ public void restoreDividendEligibility(List 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 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 names, String name) {
- if(name == null || names == null) return null;
- for(String stored : names) {
- if(stored != null && stored.equalsIgnoreCase(name)) return stored;
+ /** The stored member key equal to {@code key}, or null; keys match exactly. */
+ public static String find(List keys, String key) {
+ if(key == null || keys == null) return null;
+ for(String stored : keys) {
+ if(key.equals(stored)) return stored;
}
return null;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/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 citizenTaxes = new HashMap<>();
private final Map 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 casinoProfitOperations = new HashMap<>();
/** Today's funded loan payments, set only while the settlement is being built. */
private Map 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 loansTaken() {
+ return settlement == null ? guild.getLoanHandler().getLoansTaken() : settlement.taken;
+ }
+
+ private List 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 getCasinoProfitOperations() {
+ return new HashMap<>(casinoProfitOperations);
+ }
+
+ public void restoreCasinoProfitOperations(Map 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>> collectHistoryDay(Iterable guilds) {
+ return collectHistoryDay(guilds, Guild::getLedger);
+ }
+
+ public static Map>> collectHistoryDay(
+ Iterable guilds, java.util.function.Function ledgers) {
Map>> 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>> 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>> 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>> collect
if (trade != null && trade.getTariffsByFactionMap() != null) {
for (Map.Entry 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 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 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 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 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 realm;
+ final List given;
+ final List taken;
+ final MercenaryCompany company;
+ final Map income;
+ final double wealth;
+ double donation;
+
+ public SettlementLedgerView(Map realm, List given, List taken,
+ MercenaryCompany company, Map 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 session(Player player);
+
+ boolean isCurrent(CharacterSession session);
+
+ String key(Player player);
+
+ Optional info(String key);
+
+ Optional 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 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 accountId(String key) {
+ return ref(key).map(CharacterRef::ownerId);
+ }
+
+ /** The Minecraft name of the account that plays the character, or null. */
+ public static String accountName(String key) {
+ UUID owner = accountId(key).orElse(null);
+ if (owner == null) return null;
+ try {
+ return Bukkit.getServer() == null ? null : Bukkit.getOfflinePlayer(owner).getName();
+ } catch (RuntimeException ignored) {
+ return null;
+ }
+ }
+
+ /** Display names for several keys, in order. */
+ public static List display(Collection keys) {
+ List names = new ArrayList<>();
+ if (keys != null) for (String key : keys) names.add(display(key));
+ return names;
+ }
+
+ /** The online player currently playing that character. */
+ public static Optional online(String key) {
+ if (key == null || key.isBlank()) return Optional.empty();
+ return resolver.online(key);
+ }
+
+ /** Nullable form of {@link #online(String)} for older call sites. */
+ public static Player player(String key) {
+ return online(key).orElse(null);
+ }
+
+ public static boolean isOnline(String key) {
+ return online(key).isPresent();
+ }
+
+ public static Optional ref(String key) {
+ if (key == null || characterId(key) == null) return Optional.empty();
+ return resolver.info(key).map(CharacterInfo::ref);
+ }
+
+ public static Optional info(String key) {
+ if (key == null || characterId(key) == null) return Optional.empty();
+ return resolver.info(key);
+ }
+
+ /** Whether the player is currently playing the character with that key. */
+ public static boolean is(Player player, String key) {
+ String own = key(player);
+ return own != null && own.equals(key);
+ }
+
+ public static boolean isQuarantined(String key) {
+ return key != null && key.startsWith(QUARANTINED);
+ }
+
+ /**
+ * Finds a member among {@code keys} by what a player typed: an online
+ * player's Minecraft name or a character name. Null when nothing or more
+ * than one member matches.
+ */
+ public static String find(Collection keys, String input) {
+ if (keys == null || input == null) return null;
+ String query = unquote(input);
+ 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 matches = new ArrayList<>();
+ for (Player player : onlinePlayers()) {
+ if (player != null && name(player).equalsIgnoreCase(query)) matches.add(player);
+ }
+ if (matches.size() == 1) return matches.getFirst();
+ if (sender != null) {
+ sender.sendMessage(matches.isEmpty()
+ ? "\u00a7cNo online player found by that character or Minecraft name."
+ : "\u00a7cSeveral characters share that name. Use their Minecraft name.");
+ }
+ return null;
+ }
+
+ /** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */
+ public static List completions(Collection names, String[] args) {
+ if (args.length < 2) return List.of();
+ int skip = args.length - 2;
+ String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length));
+ return names.stream().filter(java.util.Objects::nonNull).map(ChatColor::stripColor).distinct()
+ .filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length()))
+ .map(name -> name.split(" "))
+ .filter(words -> words.length > skip)
+ .map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length)))
+ .toList();
+ }
+
+ /** The parsed character ID of a key, or null if it is not one. */
+ public static UUID characterId(String key) {
+ if (key == null) return null;
+ try {
+ UUID id = UUID.fromString(key);
+ return id.toString().equalsIgnoreCase(key) ? id : null;
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+
+ private static Player onlineByName(String name) {
+ try {
+ return Bukkit.getServer() == null ? null : Bukkit.getPlayerExact(name);
+ } catch (RuntimeException ignored) {
+ return null;
+ }
+ }
+
+ private static Collection 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 session(Player player) {
+ if (!Characters.available()) return Optional.empty();
+ return Characters.currentSession(player)
+ .filter(session -> session.ref().ownerId().equals(player.getUniqueId()))
+ .filter(Characters::isCurrent);
+ }
+
+ @Override
+ public boolean isCurrent(CharacterSession session) {
+ return Characters.available() && Characters.isCurrent(session);
+ }
+
+ @Override
+ public String key(Player player) {
+ return session(player).map(session -> Members.key(session.ref())).orElse(null);
+ }
+
+ @Override
+ public Optional info(String key) {
+ UUID id = characterId(key);
+ if (id == null || !Characters.available()) return Optional.empty();
+ return Characters.info(id).filter(info -> id.equals(info.ref().characterId()));
+ }
+
+ @Override
+ public Optional online(String key) {
+ return info(key).map(info -> Bukkit.getPlayer(info.ref().ownerId()))
+ .filter(player -> key.equals(key(player)));
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/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 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 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 steps = new ArrayList<>();
+ List claims = new ArrayList<>();
+ Map> 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 pendingPayments() throws IOException {
+ List 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 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 journals = all();
+ Map known = new HashMap<>();
+ Map 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 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 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 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 paidCents) {}
+
+ private static boolean cleaned(DeathJournal journal, String id) {
+ return journal.steps.stream().anyMatch(step -> step.id.equals(id) && step.done);
+ }
+
+ private List all() throws IOException {
+ if (!Files.exists(folder)) return List.of();
+ List 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();
+ 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();
+ 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 referencedCharacters() {
+ Set 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 successions = new ArrayList<>();
+ for (Faction faction : FactionManager.getCopy()) {
+ JsonObject before = snapshot(faction);
+ Set 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.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 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 members) {
public static void onLogin(Player player) {
if (player == null) return;
ensureLoaded();
- String name = player.getName();
+ String name = Members.key(player);
+ if (name == null) return;
boolean wasInactive = InactivityRules.isInactive(FactionCleanup.daysOffline(name));
FactionCleanup.ping(name);
if (!wasInactive) return;
@@ -343,16 +345,8 @@ private static void writeFrom(Map clocks, Map installations, InstallationConstructionData construction) {
+ java.util.Set 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 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 resolve(Player sender, ChatChannel channel) {
return Collections.emptySet();
}
- Faction faction = FactionManager.getByMember(sender.getName());
+ Faction faction = FactionManager.getByMember(Members.key(sender));
if (faction == null) {
return Collections.emptySet();
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java
index 7ad6b868..3e72fbe5 100644
--- a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java
+++ b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/chat/GuildChatRecipientResolver.java
@@ -1,5 +1,7 @@
package net.tfminecraft.simplefactions.integration.rpcharacters.chat;
+import net.tfminecraft.simplefactions.identity.Members;
+
import java.util.Collections;
import java.util.Set;
@@ -19,7 +21,7 @@ public Set resolve(Player sender, ChatChannel channel) {
return Collections.emptySet();
}
- Guild guild = FactionManager.getGuildByMember(sender.getName());
+ Guild guild = FactionManager.getGuildByMember(Members.key(sender));
if (guild == null) {
return Collections.emptySet();
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/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 onlinePlayersNamed(Collection memberNames) {
+ /** Players currently playing one of the member characters. */
+ static List onlinePlayersNamed(Collection members) {
if (Bukkit.getServer() == null) {
return List.of();
}
- return onlinePlayersNamed(memberNames, Bukkit::getPlayerExact);
+ return onlinePlayersNamed(members, Members::player);
}
static List onlinePlayersNamed(Collection memberNames, Function lookup) {
@@ -28,17 +30,13 @@ static List onlinePlayersNamed(Collection memberNames, Function<
}
List