Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/simplefactions/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ private Cache() {}
public static double maxTradeUpkeep = 0.75;
public static double maxPlaytimePrestigeExponent = 5;
public static int memberShareWindowDays = 30;
/** Hours after a held realm's death before the will heir takes it on (HeldRealms). */
public static double heldRealmHeirWaitHours = 24;
public static String baseYear;
public static int baseIrlYear = java.time.Year.now().getValue();
public static String bankBlock;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ public static boolean vehiclesAwaitEstate(UUID characterId) {
}
}

/**
* Whether a will heir may take on what a dead character left: its latest death's estate is OPEN
* or DECIDED, so staff have not held or reverted it. False when that cannot be told.
*/
public static boolean heirMayInherit(UUID characterId) {
if (characterId == null || !available()) return false;
try {
return EstateCalls.heirMayInherit(characterId);
} catch (RuntimeException | LinkageError error) {
return false;
}
}

/** Offers the death-guild answers once factions have started. */
public static void registerFallback(Plugin plugin) {
if (!available()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ static boolean vehiclesAwaitEstate(UUID characterId) {
.orElse(false);
}

static boolean heirMayInherit(UUID characterId) {
return Estates.stateOf(characterId)
.map(state -> state == EstateState.OPEN || state == EstateState.DECIDED)
.orElse(false);
}

static void registerFallback(Plugin plugin) {
plugin.getServer().getServicesManager().register(EstateFallback.class, new GuildEstateFallback(), plugin,
ServicePriority.Normal);
Expand Down
97 changes: 91 additions & 6 deletions src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,37 @@
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.logging.Logger;
import org.bukkit.entity.Player;
import net.tfminecraft.simplefactions.Cache;
import net.tfminecraft.simplefactions.database.Database;
import net.tfminecraft.simplefactions.estate.EstateBridge;
import net.tfminecraft.simplefactions.identity.LeaderCharacterListener;
import net.tfminecraft.simplefactions.identity.Members;
import net.tfminecraft.simplefactions.integration.rpcharacters.WillHeirs;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.managers.WarManager;
import net.tfminecraft.simplefactions.objects.Faction;
import net.tfminecraft.tlibs.character.CharacterInfo;
import net.tfminecraft.tlibs.character.CharacterStatus;

/**
* A realm whose last member died is held: its main guild keeps the dead leader, upkeep and taxes
* pause, and nobody can join it or declare war on it. A revive puts the leader back; staff can seat someone with
* {@code /faction forcejoin} and {@code /faction forceleader}. A realm left with no members for
* {@link #HOLD} closes once it is at peace.
* pause, and nobody can join it or declare war on it. A revive puts the leader back; after
* {@link Cache#heldRealmHeirWaitHours} the heir named in the dead leader's will takes it on instead.
* Staff can seat someone with {@code /faction forcejoin} and {@code /faction forceleader}. A realm
* left with no members for {@link #HOLD} closes once it is at peace.
*/
public final class HeldRealms {
public static final Duration HOLD = Duration.ofDays(7);
/** Realms whose closure failed part way, by id, with their main guild's incarnation; retried hourly. */
private static final Map<String, UUID> unclosed = new ConcurrentHashMap<>();
/** The member key of the heir a dead character's will names, or null; replaced in tests. */
static Function<String, String> heirs = WillHeirs::heir;
/** Whether a dead character's estate lets its heir act (staff have not held or reverted it); replaced in tests. */
static Predicate<String> estates = key -> EstateBridge.heirMayInherit(Members.characterId(key));

private HeldRealms() {}

Expand Down Expand Up @@ -81,9 +93,19 @@ public static void revived(String key) {
}
}

/** The character started playing: give it any held realm its will heir wait is over for. */
public static void heirStarted(String key, long now) {
if (key == null) return;
for (Faction faction : FactionManager.getCopy()) {
Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId());
if (main != null && main.isHeld()) inherit(faction, main, now, key);
}
}

/**
* Hourly: gives realms back to revived leaders and closes those empty for {@link #HOLD} and at
* peace. A realm whose revived leader could not be saved back in is kept for the next hour.
* Hourly: gives realms back to revived leaders or to the will heirs of dead ones, and closes those
* empty for {@link #HOLD} and at peace. A realm whose new leader could not be saved in is kept for
* the next hour.
*/
public static void closeExpired(long now, Path dataFolder) {
for (var entry : new ArrayList<>(unclosed.entrySet())) {
Expand All @@ -97,7 +119,8 @@ public static void closeExpired(long now, Path dataFolder) {
close(faction, faction.getId(), main.getIncarnationId(), now, dataFolder);
continue;
}
if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE) continue;
if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE
|| inherit(faction, main, now, null) != Restore.NOT_ELIGIBLE) continue;
if (main.trackEmpty(now, faction.getMembers().isEmpty())) save(faction);
if (weekOver(faction, main, now) && !WarManager.isAtWar(faction)) {
close(faction, faction.getId(), main.getIncarnationId(), now, dataFolder);
Expand Down Expand Up @@ -145,6 +168,8 @@ private static void close(Faction faction, String id, UUID incarnation, long now

public static void resetForTests() {
unclosed.clear();
heirs = WillHeirs::heir;
estates = key -> EstateBridge.heirMayInherit(Members.characterId(key));
}

private static boolean weekOver(Faction faction, Guild main, long now) {
Expand Down Expand Up @@ -177,6 +202,66 @@ private static Restore restore(Faction faction, Guild main) {
return Restore.NOT_SAVED;
}

/**
* Seats the heir named in the dead leader's will once {@link Cache#heldRealmHeirWaitHours} have passed
* since the death and before its held week runs out, even while a war keeps it open. The heir must be
* alive and in no faction, and the estate neither held nor reverted by staff; an heir in another faction
* is told to leave it first when they start playing. {@code only} limits it to that heir, who has just
* started playing. A failed save puts the hold back for the next try.
*/
private static Restore inherit(Faction faction, Guild main, long now, String only) {
String dead = main.getDeadLeader();
if (dead == null || main.getClosingSince() > 0 || !faction.getMembers().isEmpty()
|| now - main.getLeaderlessSince() < heirWait() || weekOver(faction, main, now)
|| !Members.info(dead).map(info -> info.status() == CharacterStatus.DEAD).orElse(false)) {
return Restore.NOT_ELIGIBLE;
}
String heir = heirs.apply(dead);
if (heir == null || heir.equals(dead) || only != null && !only.equals(heir)
|| !Members.info(heir).map(CharacterInfo::alive).orElse(false) || !estates.test(dead)) {
return Restore.NOT_ELIGIBLE;
}
Faction current = FactionManager.getByMember(heir);
if (current != null) {
// Told when they start playing, not every hour.
if (only != null) Members.online(heir).ifPresent(p -> p.sendMessage("搂e" + Members.display(dead) + " left you "
+ faction.getName() + " 搂ein their will. Leave " + current.getName() + " 搂e" + deadline(main, now)
+ " to take it on."));
return Restore.NOT_ELIGIBLE;
}
long since = main.getLeaderlessSince();
long empty = main.getEmptySince();
faction.addMember(heir);
faction.setLeader(heir);
if (save(faction)) {
Logger.getLogger("SimpleFactions").info("Gave " + faction.getId() + " to " + heir
+ ", the will heir of its dead leader " + dead);
LeaderCharacterListener.refresh(heir);
faction.updatePrestige();
Members.online(heir).ifPresent(p -> p.sendMessage("搂a" + Members.display(dead) + " left you "
+ faction.getName() + " 搂ain their will. You now lead it."));
return Restore.RESTORED;
}
faction.succeedDeadLeader(heir, "");
main.getMembers().remove(heir);
main.restoreLeaderless(dead, since, empty, null);
if (!save(faction)) Logger.getLogger("SimpleFactions").severe("Could not save " + faction.getId()
+ " after undoing its passing to a will heir; its file may name them. Check it before restarting.");
return Restore.NOT_SAVED;
}

private static long heirWait() {
return (long) (Cache.heldRealmHeirWaitHours * 3_600_000L);
}

/** "within N days" before the held week runs out, or "soon" when the clock has not started. */
private static String deadline(Guild main, long now) {
if (main.getEmptySince() <= 0) return "soon";
long left = main.getEmptySince() + HOLD.toMillis() - now;
long days = Math.max(1, (left + Duration.ofDays(1).toMillis() - 1) / Duration.ofDays(1).toMillis());
return "within " + days + (days == 1 ? " day" : " days");
}

private static boolean save(Faction faction) {
if (new Database().saveFactionDurably(faction)) return true;
Logger.getLogger("SimpleFactions").warning("Could not save held realm " + faction.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.objects.Faction;

/** Refreshes map leader names when a shared character session is ready, and returns held realms on revive. */
/**
* Refreshes map leader names when a shared character session is ready, returns held realms on revive,
* and gives a will heir the held realm left to them when they start playing.
*/
public final class LeaderCharacterListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void onCharacterStart(CharacterSessionStartEvent event) {
Expand All @@ -22,6 +25,8 @@ public void onCharacterStart(CharacterSessionStartEvent event) {
|| !Characters.isCurrent(event.getSession())) return;
refresh(Members.key(event.getSession().ref()));
net.tfminecraft.simplefactions.guild.LeaderlessGuilds.remind(player);
net.tfminecraft.simplefactions.guild.HeldRealms.heirStarted(Members.key(event.getSession().ref()),
System.currentTimeMillis());
}

/** A revived leader takes back the realm held since their death. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package net.tfminecraft.simplefactions.integration.rpcharacters;

import java.util.UUID;

import org.bukkit.Bukkit;

import com.google.gson.JsonElement;
import com.google.gson.JsonParser;

import net.tfminecraft.simplefactions.identity.Members;

/**
* The heir a dead character's will names, read from its saved RPCharacters file ({@code will-heir}).
* A will cannot change after its testator dies, so the file holds the will made while alive.
*/
public final class WillHeirs {
private WillHeirs() {}

/** The heir's member key; null without an heir, without RPCharacters, or when the file cannot be read. */
public static String heir(String deadKey) {
UUID character = Members.characterId(deadKey);
UUID owner = Members.accountId(deadKey).orElse(null);
if (character == null || owner == null) return null;
try {
if (!Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) return null;
return Saved.heir(owner, character);
} catch (RuntimeException | LinkageError unreadable) {
return null;
}
}

/** The heir named in a character file's text, as a member key, or null. */
static String parse(String json) {
if (json == null) return null;
try {
JsonElement root = JsonParser.parseString(json);
if (!root.isJsonObject()) return null;
JsonElement heir = root.getAsJsonObject().get("will-heir");
if (heir == null || !heir.isJsonPrimitive() || !heir.getAsJsonPrimitive().isString()) return null;
return Members.key(UUID.fromString(heir.getAsString()));
} catch (RuntimeException unreadable) {
return null;
}
}

/** Touches RPCharacters' types; loaded only once the plugin is known to be enabled. */
private static final class Saved {
private Saved() {}

static String heir(UUID owner, UUID character) {
var account = net.tfminecraft.rpcharacters.database.Database.readSavedAccount(owner);
if (account == null) return null;
String file = character + ".json";
for (var saved : account.characters()) {
if (saved.file().getName().equalsIgnoreCase(file)) return parse(saved.json());
}
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public void loadConfig(File configFile) {
}
Cache.maxPlaytimePrestigeExponent = config.getDouble("max-prestige-playtime-exponent", 5);
Cache.memberShareWindowDays = Math.max(1, config.getInt("member-share-window-days", 30));
double heirWait = config.getDouble("held-realm-heir-wait-hours", 24);
Cache.heldRealmHeirWaitHours = Double.isFinite(heirWait) ? Math.max(0, heirWait) : 24;
Cache.bankBlock = config.getString("bank-block", "v.lodestone");
Cache.maxExtraNodeCapacity = config.getInt("max-extra-node-capacity", 0);

Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ max-prestige-playtime-exponent: 5
# characters by how long each was played over this many days.
member-share-window-days: 30
max-members: 64
# Hours after a realm's last member dies before the heir named in their will
# takes it on; a revive inside the window gives it back to them instead. The
# heir must be alive and in no faction before the held week runs out. Match it
# to RPCharacters' estates.wait-hours. Fractions are allowed for rehearsals.
held-realm-heir-wait-hours: 24
max-untitled-provinces: 5
max-free-titles: 3
province-cost: 50
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,24 @@ class EstateBridgeTest {
assertFalse(EstateBridge.vehiclesAwaitEstate(null));
}

@Test void heirsMayInheritWhileTheLatestDeathsEstateIsOpenOrDecided() {
for (EstateState state : EstateState.values()) {
estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.of(state));
boolean may = state == EstateState.OPEN || state == EstateState.DECIDED;
assertEquals(may, EstateBridge.heirMayInherit(character), state.name());
}
estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.empty());
assertFalse(EstateBridge.heirMayInherit(character), "no estate service or no estate");
assertFalse(EstateBridge.heirMayInherit(null));
}

@Test void estateFailuresNeverEscapeTheBridge() {
estates.when(() -> Estates.stateOf(character)).thenThrow(new IllegalStateException("off the main thread"));
assertFalse(EstateBridge.vehiclesAwaitEstate(character));
assertFalse(EstateBridge.heirMayInherit(character));
estates.when(() -> Estates.stateOf(character)).thenThrow(new NoSuchMethodError("older TLibs"));
assertFalse(EstateBridge.vehiclesAwaitEstate(character));
assertFalse(EstateBridge.heirMayInherit(character));
when(server.getServicesManager()).thenThrow(new IllegalStateException("disabled"));
EstateBridge.registerFallback(plugin);
EstateBridge.registerVehicles(plugin, Path.of("data"), new PlayerVehicleRegistry(), new VehicleFeeStore());
Expand All @@ -94,6 +107,7 @@ class EstateBridgeTest {
EstateBridge.useProbe(false);
estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.of(EstateState.OPEN));
assertFalse(EstateBridge.vehiclesAwaitEstate(character));
assertFalse(EstateBridge.heirMayInherit(character));
EstateBridge.registerFallback(plugin);
EstateBridge.registerVehicles(plugin, Path.of("data"), new PlayerVehicleRegistry(), new VehicleFeeStore());
assertNull(services.load(EstateFallback.class));
Expand Down
Loading
Loading