diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256
index 69fe82d9..06ffd345 100644
--- a/.github/dependencies.sha256
+++ b/.github/dependencies.sha256
@@ -3,3 +3,4 @@
a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems-6.10.1-SNAPSHOT.jar
225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar
81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar
+5a01b37bd7442fb0f2c809cadac82ba70f75173956e04efac3336595cc070368 libs/ItemsAdder-4.0.18.jar
diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh
index ccfc5faa..3a0a1b7a 100644
--- a/.github/scripts/install-local-dependencies.sh
+++ b/.github/scripts/install-local-dependencies.sh
@@ -19,3 +19,6 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.
mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \
-Dfile="libs/MMOCore-1.13.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOCore" \
-Dversion="1.13.1-SNAPSHOT-tfmc-81d511d08309" -Dpackaging=jar -DgeneratePom=true "$@"
+mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \
+ -Dfile="libs/ItemsAdder-4.0.18.jar" -DgroupId="local" -DartifactId="ItemsAdder" \
+ -Dversion="4.0.18-tfmc-5a01b37bd744" -Dpackaging=jar -DgeneratePom=true "$@"
diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh
index 7229af17..d0e533e6 100644
--- a/.github/scripts/prepare-release.sh
+++ b/.github/scripts/prepare-release.sh
@@ -8,4 +8,5 @@ curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/a37f7789fcdc/MMOItems-6.10.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOItems-6.10.1-SNAPSHOT.jar"
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar"
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/81d511d08309/MMOCore-1.13.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOCore-1.13.1-SNAPSHOT.jar"
+curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/5a01b37bd744/ItemsAdder-4.0.18.jar?ref=$ref" > "libs/ItemsAdder-4.0.18.jar"
bash .github/scripts/install-local-dependencies.sh "$@"
diff --git a/pom.xml b/pom.xml
index cf4b160b..0e55c4cc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -55,6 +55,12 @@
1.13.1-SNAPSHOT-tfmc-81d511d08309
provided
+
+ local
+ ItemsAdder
+ 4.0.18-tfmc-5a01b37bd744
+ provided
+
io.papermc.paper
paper-api
diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
index f6b43c83..c73efe3c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
+++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
@@ -410,6 +410,10 @@ public void registerListeners() {
getServer().getPluginManager().registerEvents(displayNameGate, this);
getServer().getPluginManager().registerEvents(declareCodePrompt, this);
getServer().getPluginManager().registerEvents(factionManager, this);
+ if (getServer().getPluginManager().getPlugin("ItemsAdder") != null) {
+ getServer().getPluginManager().registerEvents(
+ new net.tfminecraft.simplefactions.managers.VotingBoothListener(factionManager), this);
+ }
getServer().getPluginManager().registerEvents(provincePresenceListener, this);
getServer().getPluginManager().registerEvents(battleManager, this);
getServer().getPluginManager().registerEvents(battleItemDurabilityListener, this);
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java
index c277300b..5c076303 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java
@@ -290,6 +290,9 @@ public void tick() {
}
election.tick();
if(!hasElections()) lastElectionDate = new Date(0);
+ // Elections otherwise start only when factions load. The day tick is what
+ // catches a Monday that begins while the server is already running.
+ ping();
validateFavoursAndRepressions();
replace();
}
@@ -617,20 +620,22 @@ public String getTimeUntilNextElection() {
LocalDate nextDate = getNextElectionStartDate();
+ ZoneId zone = ZoneId.systemDefault();
Instant now = Instant.now();
- Instant next = nextDate
- .atStartOfDay(ZoneId.systemDefault())
- .toInstant();
+ Instant next = nextDate.atStartOfDay(zone).toInstant();
+ if (!next.isAfter(now) && nextDate.equals(LocalDate.ofInstant(now, zone))) {
+ return "Today";
+ }
+ return formatTimeUntil(now, next);
+ }
- if (next.isBefore(now)) {
+ public static String formatTimeUntil(Instant now, Instant next) {
+ if (now == null || next == null || !next.isAfter(now)) {
return "0d 0h";
}
-
long seconds = ChronoUnit.SECONDS.between(now, next);
-
long days = seconds / 86400;
long hours = (seconds % 86400) / 3600;
-
return days + "d " + hours + "h";
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/VotingBlock.java b/src/main/java/net/tfminecraft/simplefactions/government/VotingBlock.java
new file mode 100644
index 00000000..78f177d8
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/government/VotingBlock.java
@@ -0,0 +1,45 @@
+package net.tfminecraft.simplefactions.government;
+
+public final class VotingBlock {
+ private VotingBlock() {
+ }
+
+ public static String furnitureId(String configPath) {
+ String path = typedPath(configPath, "iaf");
+ return path == null || path.isBlank() ? null : path;
+ }
+
+ public static boolean matches(String configPath, String namespacedId) {
+ if (namespacedId == null || namespacedId.isBlank()) {
+ return false;
+ }
+ String configured = furnitureId(configPath);
+ return configured != null && configured.equalsIgnoreCase(namespacedId);
+ }
+
+ private static String typedPath(String configPath, String expectedType) {
+ if (configPath == null || configPath.isBlank()) {
+ return null;
+ }
+ String trimmed = configPath.trim();
+ int openParen = trimmed.indexOf('(');
+ int closeParen = trimmed.indexOf(')', openParen + 1);
+ String type;
+ String path;
+ if (openParen >= 0 && closeParen > openParen) {
+ type = trimmed.substring(0, openParen);
+ path = trimmed.substring(openParen + 1, closeParen);
+ } else {
+ int dot = trimmed.indexOf('.');
+ if (dot <= 0) {
+ return null;
+ }
+ type = trimmed.substring(0, dot);
+ path = trimmed.substring(dot + 1);
+ }
+ if (!type.equalsIgnoreCase(expectedType)) {
+ return null;
+ }
+ return path.trim();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java
index 2501f845..5cb27b68 100644
--- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java
+++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java
@@ -106,7 +106,7 @@ public void loadConfig(File configFile) {
Cache.branchUpgradeCost = config.getDouble("branch-upgrade-cost", 100.0);
Cache.branchUpgradeExponent = config.getDouble("branch-upgrade-exponent", 1.1);
- Cache.votingBlock = config.getString("voting-block", "v(chiseled_bookshelf)");
+ Cache.votingBlock = config.getString("voting-block", "iaf(tfmc:voting_booth)");
Cache.baseYear = config.getString("starting-year", "372 AE");
if(config.contains("terrain-modifiers")) {
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
index 900e021a..336a686a 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
@@ -52,6 +52,7 @@
import net.tfminecraft.simplefactions.player.PlayerEconomyManager;
import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank;
import net.tfminecraft.simplefactions.government.Government;
+import net.tfminecraft.simplefactions.government.VotingBlock;
import net.tfminecraft.simplefactions.government.movement.Movement;
import net.tfminecraft.simplefactions.government.movement.cause.Cause;
import net.tfminecraft.tlibs.TLibs;
@@ -1154,7 +1155,10 @@ public static void acceptMovementLeaderTargetRequest(Player p) {
//Elections and stuff
+ private final Map boothOpenedAt = new HashMap<>();
+
public Faction getByVotingBooth(Block b) {
+ if (b == null) return null;
for(Faction f : factions) {
Government gov = f.getGovernment();
if(gov.isVotingBooth(b.getLocation())) return f;
@@ -1165,30 +1169,42 @@ public Faction getByVotingBooth(Block b) {
@EventHandler
public void openBooth(PlayerInteractEvent e) {
if(!e.getAction().equals(Action.RIGHT_CLICK_BLOCK)) return;
- Block b = e.getClickedBlock();
- Player p = e.getPlayer();
+ if (presentBooth(e.getPlayer(), e.getClickedBlock())) {
+ e.setCancelled(true);
+ }
+ }
+
+ boolean presentBooth(Player p, Block b) {
Faction f = getByVotingBooth(b);
- if(f == null) return;
- e.setCancelled(true);
+ if(f == null) return false;
+ long now = System.currentTimeMillis();
+ Long openedAt = boothOpenedAt.get(p.getUniqueId());
+ if (openedAt != null && now - openedAt < 400) return true;
+ boothOpenedAt.put(p.getUniqueId(), now);
if(!f.getGovernment().hasElections()) {
p.sendMessage("§cThis faction has no elections");
p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f);
- return;
+ return true;
}
if(!f.canVote(p.getName())) {
p.sendMessage("§cYou have no voting rights in this faction");
p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f);
- return;
+ return true;
}
InventoryManager inv = new InventoryManager();
inv.electionView(p, f);
+ return true;
}
@EventHandler
public void placeVotingBooth(BlockPlaceEvent e) {
+ if (VotingBlock.furnitureId(Cache.votingBlock) != null) return;
Block b = e.getBlock();
- Player p = e.getPlayer();
if(!TLibs.getBlockAPI().getChecker().checkBlock(b, Cache.votingBlock)) return;
+ registerVotingBooth(e.getPlayer(), b);
+ }
+
+ void registerVotingBooth(Player p, Block b) {
Faction f = FactionManager.getByMember(p.getName());
if(f == null) return;
Government gov = f.getGovernment();
@@ -1200,9 +1216,13 @@ public void placeVotingBooth(BlockPlaceEvent e) {
@EventHandler
public void breakVotingBooth(BlockBreakEvent e) {
+ if (VotingBlock.furnitureId(Cache.votingBlock) != null) return;
Block b = e.getBlock();
- Player p = e.getPlayer();
if(!TLibs.getBlockAPI().getChecker().checkBlock(b, Cache.votingBlock)) return;
+ unregisterVotingBooth(e.getPlayer(), b);
+ }
+
+ void unregisterVotingBooth(Player p, Block b) {
Faction f = getByVotingBooth(b);
if(f == null) return;
Government gov = f.getGovernment();
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/VotingBoothListener.java b/src/main/java/net/tfminecraft/simplefactions/managers/VotingBoothListener.java
new file mode 100644
index 00000000..5a9c7449
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/VotingBoothListener.java
@@ -0,0 +1,51 @@
+package net.tfminecraft.simplefactions.managers;
+
+import org.bukkit.block.Block;
+import org.bukkit.entity.Entity;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.block.Action;
+
+import dev.lone.itemsadder.api.Events.FurnitureBreakEvent;
+import dev.lone.itemsadder.api.Events.FurnitureInteractEvent;
+import dev.lone.itemsadder.api.Events.FurniturePlaceSuccessEvent;
+import net.tfminecraft.simplefactions.Cache;
+import net.tfminecraft.simplefactions.government.VotingBlock;
+
+public class VotingBoothListener implements Listener {
+ private final FactionManager factions;
+
+ public VotingBoothListener(FactionManager factions) {
+ this.factions = factions;
+ }
+
+ @EventHandler
+ public void openVotingFurniture(FurnitureInteractEvent event) {
+ Action action = event.getAction();
+ if (action != null && action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
+ if (!VotingBlock.matches(Cache.votingBlock, event.getNamespacedID())) return;
+ Entity entity = event.getBukkitEntity();
+ if (entity == null) return;
+ if (factions.presentBooth(event.getPlayer(), entity.getLocation().getBlock())) {
+ event.setCancelled(true);
+ }
+ }
+
+ @EventHandler
+ public void placeVotingFurniture(FurniturePlaceSuccessEvent event) {
+ if (!VotingBlock.matches(Cache.votingBlock, event.getNamespacedID())) return;
+ Entity entity = event.getBukkitEntity();
+ if (entity == null) return;
+ factions.registerVotingBooth(event.getPlayer(), entity.getLocation().getBlock());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void breakVotingFurniture(FurnitureBreakEvent event) {
+ if (!VotingBlock.matches(Cache.votingBlock, event.getNamespacedID())) return;
+ Entity entity = event.getBukkitEntity();
+ if (entity == null) return;
+ Block block = entity.getLocation().getBlock();
+ factions.unregisterVotingBooth(event.getPlayer(), block);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
index 8f871d5e..c5919583 100644
--- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
+++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
@@ -165,9 +165,9 @@ public Faction(String id, String leader) {
this.settlementHandler = new SettlementHandler(this);
this.installationHandler = new InstallationHandler(this);
this.taxHandler = new TaxHandler(this, 5, 5, 5, 5, 5);
- lawHandler.apply();
this.guildHandler = new GuildHandler(this);
guildHandler.addGuild(new Guild(this));
+ lawHandler.apply();
createBanner(bannerPatterns);
// Keyed per object: FactionCreateEvent can cancel this one, and the retry needs its own fetch.
List placeholder = this.bannerPatterns;
@@ -1074,6 +1074,9 @@ public void applyLaw(Law law, LawGroup group) {
// --- 🔴 ELECTION CANCELLATION LOGIC ---
cancelInvalidElections();
+ if (enablesElections(effect)) {
+ government.ping();
+ }
// --- vassal logic ---
if (effect.prohibitsVassals() && hasVassals()) {
@@ -1133,6 +1136,15 @@ public void applyPoliticalAction(Cause cause, Proposal proposal) {
}
}
+ static boolean enablesElections(LawEffect effect) {
+ if (effect == null || !effect.hasRules()) {
+ return false;
+ }
+ Map rules = effect.getRules();
+ return Boolean.TRUE.equals(rules.get(Rules.LEADER_ELECTIONS))
+ || Boolean.TRUE.equals(rules.get(Rules.ELECTED_COUNCIL));
+ }
+
private void cancelInvalidElections() {
Government gov = getGovernment();
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index 42c26a0d..138e6312 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -56,7 +56,7 @@ mercenary-default-breach-refund: 500.0
# smaller refund would make no-showing more profitable per head than fighting.
bank-block: iaf(tfmc:bank)
-voting-block: v(chiseled_bookshelf)
+voting-block: iaf(tfmc:voting_booth)
starting-year: "372 AE"
max-extra-node-capacity: 0
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 3c4133b8..b8395129 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -5,7 +5,7 @@ version: ${project.version}
author: Drefvelin
depend: [DenarEconomy, TLibs]
-softdepend: [MMOItems, MythicLib, MMOCore, TFMCWeb, VehicleFramework, VFBuilders, ConditionalEvents, RPCharacters]
+softdepend: [MMOItems, MythicLib, MMOCore, TFMCWeb, VehicleFramework, VFBuilders, ConditionalEvents, RPCharacters, ItemsAdder]
commands:
faction:
diff --git a/src/test/java/net/tfminecraft/simplefactions/government/ElectionCountdownTest.java b/src/test/java/net/tfminecraft/simplefactions/government/ElectionCountdownTest.java
new file mode 100644
index 00000000..9191d54b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/government/ElectionCountdownTest.java
@@ -0,0 +1,25 @@
+package net.tfminecraft.simplefactions.government;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+class ElectionCountdownTest {
+
+ @Test
+ void formatTimeUntil_countsWholeDaysAndHours() {
+ Instant now = Instant.parse("2026-09-21T08:15:00Z");
+ Instant next = Instant.parse("2026-09-28T00:00:00Z");
+ assertEquals("6d 15h", Government.formatTimeUntil(now, next));
+ }
+
+ @Test
+ void formatTimeUntil_pastTargetIsZero() {
+ Instant now = Instant.parse("2026-09-28T08:15:00Z");
+ Instant next = Instant.parse("2026-09-28T00:00:00Z");
+ assertEquals("0d 0h", Government.formatTimeUntil(now, next));
+ assertEquals("0d 0h", Government.formatTimeUntil(now, null));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/government/VotingBlockTest.java b/src/test/java/net/tfminecraft/simplefactions/government/VotingBlockTest.java
new file mode 100644
index 00000000..6ae22434
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/government/VotingBlockTest.java
@@ -0,0 +1,27 @@
+package net.tfminecraft.simplefactions.government;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class VotingBlockTest {
+
+ @Test
+ void furnitureId_readsItemsAdderFurniturePath() {
+ assertEquals("tfmc:voting_booth", VotingBlock.furnitureId("iaf(tfmc:voting_booth)"));
+ assertEquals("tfmc:voting_booth", VotingBlock.furnitureId("iaf.tfmc:voting_booth"));
+ assertNull(VotingBlock.furnitureId("v(chiseled_bookshelf)"));
+ assertNull(VotingBlock.furnitureId(null));
+ }
+
+ @Test
+ void matches_comparesNamespacedId() {
+ assertTrue(VotingBlock.matches("iaf(tfmc:voting_booth)", "tfmc:voting_booth"));
+ assertTrue(VotingBlock.matches("iaf(tfmc:voting_booth)", "TFMC:Voting_Booth"));
+ assertFalse(VotingBlock.matches("iaf(tfmc:voting_booth)", "tfmc:bank"));
+ assertFalse(VotingBlock.matches("v(chiseled_bookshelf)", "tfmc:voting_booth"));
+ }
+}