From 495a4d11cb93e3e7308452517b198a701780e334 Mon Sep 17 00:00:00 2001 From: ZenithDevHQ Date: Sat, 24 Jan 2026 20:35:36 -0500 Subject: [PATCH 1/2] feat: Add update checker, spawnkill prevention, and per-zone config - Update Checker (2.9): GitHub API integration with 5-min caching, changelog support, and optional auto-download functionality - Spawnkill Prevention (2.10): Configurable spawn protection after respawn with duration, break-on-attack, and break-on-move options - WarZone Per-Zone Config (3.0): Flag-based zone customization with /f admin zoneflag command. Supports PvP, building, containers, item handling, mob spawning, and player effect flags --- gradle.properties | 1 + .../java/com/hyperfactions/HyperFactions.java | 20 ++ .../hyperfactions/command/FactionCommand.java | 78 ++++- .../config/HyperFactionsConfig.java | 30 ++ .../hyperfactions/data/SpawnProtection.java | 73 +++++ .../java/com/hyperfactions/data/Zone.java | 99 +++++- .../com/hyperfactions/data/ZoneFlags.java | 127 ++++++++ .../listener/PlayerListener.java | 44 +++ .../manager/CombatTagManager.java | 105 ++++++- .../hyperfactions/manager/ZoneManager.java | 84 ++++++ .../protection/ProtectionChecker.java | 83 +++-- .../storage/json/JsonZoneStorage.java | 25 +- .../hyperfactions/update/UpdateChecker.java | 283 ++++++++++++++++++ 13 files changed, 1025 insertions(+), 27 deletions(-) create mode 100644 gradle.properties create mode 100644 src/main/java/com/hyperfactions/data/SpawnProtection.java create mode 100644 src/main/java/com/hyperfactions/data/ZoneFlags.java create mode 100644 src/main/java/com/hyperfactions/update/UpdateChecker.java diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4c9a0cb --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +hytaleServerJar=libs/HytaleServer.jar diff --git a/src/main/java/com/hyperfactions/HyperFactions.java b/src/main/java/com/hyperfactions/HyperFactions.java index 8cb3779..3958102 100644 --- a/src/main/java/com/hyperfactions/HyperFactions.java +++ b/src/main/java/com/hyperfactions/HyperFactions.java @@ -11,6 +11,7 @@ import com.hyperfactions.storage.json.JsonFactionStorage; import com.hyperfactions.storage.json.JsonPlayerStorage; import com.hyperfactions.storage.json.JsonZoneStorage; +import com.hyperfactions.update.UpdateChecker; import com.hyperfactions.util.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -54,6 +55,9 @@ public class HyperFactions { // GUI private GuiManager guiManager; + // Update checker + private UpdateChecker updateChecker; + // Task management private final AtomicInteger taskIdCounter = new AtomicInteger(0); private final Map scheduledTasks = new ConcurrentHashMap<>(); @@ -161,6 +165,12 @@ public void enable() { Logger.info("Player %s combat logged - death penalty applied", playerUuid); }); + // Initialize update checker if enabled + if (HyperFactionsConfig.get().isUpdateCheckEnabled()) { + updateChecker = new UpdateChecker(dataDir, VERSION, HyperFactionsConfig.get().getUpdateCheckUrl()); + updateChecker.checkForUpdates(); + } + Logger.info("HyperFactions enabled"); } @@ -314,4 +324,14 @@ public ProtectionChecker getProtectionChecker() { public GuiManager getGuiManager() { return guiManager; } + + /** + * Gets the update checker. + * + * @return the update checker, or null if update checking is disabled + */ + @Nullable + public UpdateChecker getUpdateChecker() { + return updateChecker; + } } diff --git a/src/main/java/com/hyperfactions/command/FactionCommand.java b/src/main/java/com/hyperfactions/command/FactionCommand.java index cef2595..2846825 100644 --- a/src/main/java/com/hyperfactions/command/FactionCommand.java +++ b/src/main/java/com/hyperfactions/command/FactionCommand.java @@ -3,6 +3,7 @@ import com.hyperfactions.HyperFactions; import com.hyperfactions.config.HyperFactionsConfig; import com.hyperfactions.data.*; +import com.hyperfactions.data.ZoneFlags; import com.hyperfactions.integration.HyperPermsIntegration; import com.hyperfactions.manager.*; import com.hyperfactions.platform.HyperFactionsPlugin; @@ -995,9 +996,10 @@ private void handleAdmin(CommandContext ctx, Store store, Ref store, Ref handleZoneFlag(ctx, world.getName(), chunkX, chunkZ, Arrays.copyOfRange(args, 1, args.length)); default -> ctx.sendMessage(prefix().insert(msg("Unknown admin command.", COLOR_RED))); } } + // === Zone Flag Management === + private void handleZoneFlag(CommandContext ctx, String worldName, int chunkX, int chunkZ, String[] args) { + Zone zone = hyperFactions.getZoneManager().getZone(worldName, chunkX, chunkZ); + if (zone == null) { + ctx.sendMessage(prefix().insert(msg("No zone at your location. Stand in a zone to manage flags.", COLOR_RED))); + return; + } + + // No args - show current flags + if (args.length == 0) { + ctx.sendMessage(msg("=== Zone Flags: " + zone.name() + " ===", COLOR_CYAN).bold(true)); + ctx.sendMessage(msg("Zone Type: " + zone.type().getDisplayName(), COLOR_GRAY)); + ctx.sendMessage(msg("", COLOR_GRAY)); + + for (String flag : ZoneFlags.ALL_FLAGS) { + boolean effectiveValue = zone.getEffectiveFlag(flag); + boolean isCustom = zone.hasFlagSet(flag); + String valueStr = effectiveValue ? "true" : "false"; + String customStr = isCustom ? " (custom)" : " (default)"; + String color = effectiveValue ? COLOR_GREEN : COLOR_RED; + ctx.sendMessage(msg(" " + flag + ": ", COLOR_GRAY).insert(msg(valueStr, color)).insert(msg(customStr, COLOR_GRAY))); + } + ctx.sendMessage(msg("", COLOR_GRAY)); + ctx.sendMessage(msg("Usage: /f admin zoneflag ", COLOR_YELLOW)); + return; + } + + // Get flag name + String flagName = args[0].toLowerCase(); + if (!ZoneFlags.isValidFlag(flagName)) { + ctx.sendMessage(prefix().insert(msg("Invalid flag: " + flagName, COLOR_RED))); + ctx.sendMessage(msg("Valid flags: " + String.join(", ", ZoneFlags.ALL_FLAGS), COLOR_GRAY)); + return; + } + + // Show specific flag value + if (args.length == 1) { + boolean effectiveValue = zone.getEffectiveFlag(flagName); + boolean isCustom = zone.hasFlagSet(flagName); + ctx.sendMessage(prefix().insert(msg("Flag '" + flagName + "' = " + effectiveValue, effectiveValue ? COLOR_GREEN : COLOR_RED)) + .insert(msg(isCustom ? " (custom)" : " (default)", COLOR_GRAY))); + return; + } + + // Set or clear flag + String action = args[1].toLowerCase(); + ZoneManager.ZoneResult result; + + if (action.equals("clear") || action.equals("default") || action.equals("reset")) { + result = hyperFactions.getZoneManager().clearZoneFlag(zone.id(), flagName); + if (result == ZoneManager.ZoneResult.SUCCESS) { + boolean defaultValue = zone.isSafeZone() ? ZoneFlags.getSafeZoneDefault(flagName) : ZoneFlags.getWarZoneDefault(flagName); + ctx.sendMessage(prefix().insert(msg("Cleared flag '" + flagName + "' (now using default: " + defaultValue + ")", COLOR_GREEN))); + } else { + ctx.sendMessage(prefix().insert(msg("Failed to clear flag.", COLOR_RED))); + } + } else if (action.equals("true") || action.equals("false")) { + boolean value = action.equals("true"); + result = hyperFactions.getZoneManager().setZoneFlag(zone.id(), flagName, value); + if (result == ZoneManager.ZoneResult.SUCCESS) { + ctx.sendMessage(prefix().insert(msg("Set flag '" + flagName + "' to " + value, COLOR_GREEN))); + } else { + ctx.sendMessage(prefix().insert(msg("Failed to set flag.", COLOR_RED))); + } + } else { + ctx.sendMessage(prefix().insert(msg("Invalid value. Use: true, false, or clear", COLOR_RED))); + } + } + // === Reload === private void handleReload(CommandContext ctx, PlayerRef player) { if (!hasPermission(player, "hyperfactions.admin")) { diff --git a/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java b/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java index 11c8c23..22e8fc9 100644 --- a/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java +++ b/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java @@ -49,6 +49,12 @@ public class HyperFactionsConfig { private boolean factionDamage = false; private boolean taggedLogoutPenalty = true; + // Spawn protection settings + private boolean spawnProtectionEnabled = true; + private int spawnProtectionDurationSeconds = 5; + private boolean spawnProtectionBreakOnAttack = true; + private boolean spawnProtectionBreakOnMove = true; + // Relation settings private int maxAllies = 10; // -1 for unlimited private int maxEnemies = -1; // -1 for unlimited @@ -142,6 +148,15 @@ public void load(@NotNull Path dataDir) { allyDamage = getBool(combat, "allyDamage", allyDamage); factionDamage = getBool(combat, "factionDamage", factionDamage); taggedLogoutPenalty = getBool(combat, "taggedLogoutPenalty", taggedLogoutPenalty); + + // Spawn protection sub-section + if (combat.has("spawnProtection") && combat.get("spawnProtection").isJsonObject()) { + JsonObject spawnProt = combat.getAsJsonObject("spawnProtection"); + spawnProtectionEnabled = getBool(spawnProt, "enabled", spawnProtectionEnabled); + spawnProtectionDurationSeconds = getInt(spawnProt, "durationSeconds", spawnProtectionDurationSeconds); + spawnProtectionBreakOnAttack = getBool(spawnProt, "breakOnAttack", spawnProtectionBreakOnAttack); + spawnProtectionBreakOnMove = getBool(spawnProt, "breakOnMove", spawnProtectionBreakOnMove); + } } // Relation settings @@ -238,6 +253,15 @@ public void save(@NotNull Path dataDir) { combat.addProperty("allyDamage", allyDamage); combat.addProperty("factionDamage", factionDamage); combat.addProperty("taggedLogoutPenalty", taggedLogoutPenalty); + + // Spawn protection sub-section + JsonObject spawnProt = new JsonObject(); + spawnProt.addProperty("enabled", spawnProtectionEnabled); + spawnProt.addProperty("durationSeconds", spawnProtectionDurationSeconds); + spawnProt.addProperty("breakOnAttack", spawnProtectionBreakOnAttack); + spawnProt.addProperty("breakOnMove", spawnProtectionBreakOnMove); + combat.add("spawnProtection", spawnProt); + root.add("combat", combat); // Relation settings @@ -318,6 +342,12 @@ public void reload(@NotNull Path dataDir) { public boolean isFactionDamage() { return factionDamage; } public boolean isTaggedLogoutPenalty() { return taggedLogoutPenalty; } + // === Spawn Protection Getters === + public boolean isSpawnProtectionEnabled() { return spawnProtectionEnabled; } + public int getSpawnProtectionDurationSeconds() { return spawnProtectionDurationSeconds; } + public boolean isSpawnProtectionBreakOnAttack() { return spawnProtectionBreakOnAttack; } + public boolean isSpawnProtectionBreakOnMove() { return spawnProtectionBreakOnMove; } + // === Relation Getters === public int getMaxAllies() { return maxAllies; } public int getMaxEnemies() { return maxEnemies; } diff --git a/src/main/java/com/hyperfactions/data/SpawnProtection.java b/src/main/java/com/hyperfactions/data/SpawnProtection.java new file mode 100644 index 0000000..ba8f1db --- /dev/null +++ b/src/main/java/com/hyperfactions/data/SpawnProtection.java @@ -0,0 +1,73 @@ +package com.hyperfactions.data; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Represents temporary spawn protection for a player after respawning. + * + * @param playerUuid the protected player's UUID + * @param protectedAt when protection was applied (epoch millis) + * @param durationSeconds the protection duration in seconds + * @param world the world where player spawned + * @param chunkX the spawn chunk X coordinate + * @param chunkZ the spawn chunk Z coordinate + */ +public record SpawnProtection( + @NotNull UUID playerUuid, + long protectedAt, + int durationSeconds, + @NotNull String world, + int chunkX, + int chunkZ +) { + /** + * Creates a new spawn protection. + * + * @param playerUuid the player's UUID + * @param durationSeconds the protection duration + * @param world the spawn world + * @param chunkX the spawn chunk X + * @param chunkZ the spawn chunk Z + * @return a new SpawnProtection + */ + public static SpawnProtection create(@NotNull UUID playerUuid, int durationSeconds, + @NotNull String world, int chunkX, int chunkZ) { + return new SpawnProtection(playerUuid, System.currentTimeMillis(), durationSeconds, world, chunkX, chunkZ); + } + + /** + * Checks if this protection has expired. + * + * @return true if expired + */ + public boolean isExpired() { + long elapsed = System.currentTimeMillis() - protectedAt; + return elapsed >= durationSeconds * 1000L; + } + + /** + * Gets the remaining protection time in seconds. + * + * @return remaining seconds, 0 if expired + */ + public int getRemainingSeconds() { + if (isExpired()) return 0; + long elapsed = System.currentTimeMillis() - protectedAt; + long remaining = (durationSeconds * 1000L) - elapsed; + return (int) Math.ceil(remaining / 1000.0); + } + + /** + * Checks if the player has left their spawn chunk. + * + * @param currentWorld the player's current world + * @param currentChunkX the player's current chunk X + * @param currentChunkZ the player's current chunk Z + * @return true if player has left the spawn chunk + */ + public boolean hasLeftSpawnChunk(@NotNull String currentWorld, int currentChunkX, int currentChunkZ) { + return !world.equals(currentWorld) || chunkX != currentChunkX || chunkZ != currentChunkZ; + } +} diff --git a/src/main/java/com/hyperfactions/data/Zone.java b/src/main/java/com/hyperfactions/data/Zone.java index 96fda9e..7d545e2 100644 --- a/src/main/java/com/hyperfactions/data/Zone.java +++ b/src/main/java/com/hyperfactions/data/Zone.java @@ -1,7 +1,11 @@ package com.hyperfactions.data; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; /** @@ -15,6 +19,7 @@ * @param chunkZ the chunk Z coordinate * @param createdAt when the zone was created (epoch millis) * @param createdBy UUID of the admin who created it + * @param flags custom flags for this zone (null = use defaults) */ public record Zone( @NotNull UUID id, @@ -24,10 +29,11 @@ public record Zone( int chunkX, int chunkZ, long createdAt, - @NotNull UUID createdBy + @NotNull UUID createdBy, + @Nullable Map flags ) { /** - * Creates a new zone. + * Creates a new zone with default flags. * * @param name the zone name * @param type the zone type @@ -41,7 +47,7 @@ public static Zone create(@NotNull String name, @NotNull ZoneType type, @NotNull String world, int chunkX, int chunkZ, @NotNull UUID createdBy) { return new Zone(UUID.randomUUID(), name, type, world, chunkX, chunkZ, - System.currentTimeMillis(), createdBy); + System.currentTimeMillis(), createdBy, null); } /** @@ -91,6 +97,91 @@ public boolean isWarZone() { * @return a new Zone with updated name */ public Zone withName(@NotNull String newName) { - return new Zone(id, newName, type, world, chunkX, chunkZ, createdAt, createdBy); + return new Zone(id, newName, type, world, chunkX, chunkZ, createdAt, createdBy, flags); + } + + /** + * Creates a copy with a flag set. + * + * @param flagName the flag name + * @param value the flag value + * @return a new Zone with updated flag + */ + public Zone withFlag(@NotNull String flagName, boolean value) { + Map newFlags = flags != null ? new HashMap<>(flags) : new HashMap<>(); + newFlags.put(flagName, value); + return new Zone(id, name, type, world, chunkX, chunkZ, createdAt, createdBy, newFlags); + } + + /** + * Creates a copy with a flag removed (reverts to default). + * + * @param flagName the flag name to remove + * @return a new Zone with flag removed + */ + public Zone withoutFlag(@NotNull String flagName) { + if (flags == null || !flags.containsKey(flagName)) { + return this; + } + Map newFlags = new HashMap<>(flags); + newFlags.remove(flagName); + return new Zone(id, name, type, world, chunkX, chunkZ, createdAt, createdBy, + newFlags.isEmpty() ? null : newFlags); + } + + /** + * Creates a copy with updated flags. + * + * @param newFlags the new flags map (null = use defaults) + * @return a new Zone with updated flags + */ + public Zone withFlags(@Nullable Map newFlags) { + return new Zone(id, name, type, world, chunkX, chunkZ, createdAt, createdBy, newFlags); + } + + /** + * Gets the value of a specific flag, or null if using default. + * + * @param flagName the flag name + * @return the flag value, or null if not set (use default) + */ + @Nullable + public Boolean getFlag(@NotNull String flagName) { + return flags != null ? flags.get(flagName) : null; + } + + /** + * Checks if a specific flag has been explicitly set (overriding default). + * + * @param flagName the flag name + * @return true if flag has been explicitly set + */ + public boolean hasFlagSet(@NotNull String flagName) { + return flags != null && flags.containsKey(flagName); + } + + /** + * Gets all explicitly set flags (immutable copy). + * + * @return map of flag name to value, never null + */ + @NotNull + public Map getFlags() { + return flags != null ? Collections.unmodifiableMap(flags) : Collections.emptyMap(); + } + + /** + * Gets the effective value for a flag, considering zone type defaults. + * + * @param flagName the flag name + * @return the effective flag value + */ + public boolean getEffectiveFlag(@NotNull String flagName) { + // Check if explicitly set + if (flags != null && flags.containsKey(flagName)) { + return flags.get(flagName); + } + // Return default based on zone type + return isSafeZone() ? ZoneFlags.getSafeZoneDefault(flagName) : ZoneFlags.getWarZoneDefault(flagName); } } diff --git a/src/main/java/com/hyperfactions/data/ZoneFlags.java b/src/main/java/com/hyperfactions/data/ZoneFlags.java new file mode 100644 index 0000000..5aa4b85 --- /dev/null +++ b/src/main/java/com/hyperfactions/data/ZoneFlags.java @@ -0,0 +1,127 @@ +package com.hyperfactions.data; + +/** + * Constants for zone flag names. + * These flags control various behaviors within zones. + */ +public final class ZoneFlags { + + private ZoneFlags() {} // Prevent instantiation + + // === PvP Flags === + /** Whether PvP is enabled in this zone. Default: false for SafeZone, true for WarZone */ + public static final String PVP_ENABLED = "pvp_enabled"; + + /** Whether friendly fire (same faction) is allowed */ + public static final String FRIENDLY_FIRE = "friendly_fire"; + + // === Building Flags === + /** Whether players can place/break blocks */ + public static final String BUILD_ALLOWED = "build_allowed"; + + /** Whether players can access containers (chests, etc.) */ + public static final String CONTAINER_ACCESS = "container_access"; + + /** Whether players can interact with blocks (doors, buttons, etc.) */ + public static final String INTERACT_ALLOWED = "interact_allowed"; + + // === Item Flags === + /** Whether players can drop items */ + public static final String ITEM_DROP = "item_drop"; + + /** Whether players can pick up items */ + public static final String ITEM_PICKUP = "item_pickup"; + + // === Mob Flags === + /** Whether mobs can spawn */ + public static final String MOB_SPAWNING = "mob_spawning"; + + /** Whether mobs can damage players */ + public static final String MOB_DAMAGE = "mob_damage"; + + // === Player Effect Flags === + /** Whether players lose hunger */ + public static final String HUNGER_LOSS = "hunger_loss"; + + /** Whether players take fall damage */ + public static final String FALL_DAMAGE = "fall_damage"; + + /** + * All available flag names for validation. + */ + public static final String[] ALL_FLAGS = { + PVP_ENABLED, + FRIENDLY_FIRE, + BUILD_ALLOWED, + CONTAINER_ACCESS, + INTERACT_ALLOWED, + ITEM_DROP, + ITEM_PICKUP, + MOB_SPAWNING, + MOB_DAMAGE, + HUNGER_LOSS, + FALL_DAMAGE + }; + + /** + * Checks if a flag name is valid. + * + * @param flagName the flag name to check + * @return true if valid + */ + public static boolean isValidFlag(String flagName) { + if (flagName == null) return false; + for (String flag : ALL_FLAGS) { + if (flag.equals(flagName)) { + return true; + } + } + return false; + } + + /** + * Gets the default value for a flag in SafeZones. + * + * @param flagName the flag name + * @return the default value + */ + public static boolean getSafeZoneDefault(String flagName) { + return switch (flagName) { + case PVP_ENABLED -> false; + case FRIENDLY_FIRE -> false; + case BUILD_ALLOWED -> false; + case CONTAINER_ACCESS -> false; + case INTERACT_ALLOWED -> true; + case ITEM_DROP -> true; + case ITEM_PICKUP -> true; + case MOB_SPAWNING -> false; + case MOB_DAMAGE -> false; + case HUNGER_LOSS -> false; + case FALL_DAMAGE -> false; + default -> false; + }; + } + + /** + * Gets the default value for a flag in WarZones. + * + * @param flagName the flag name + * @return the default value + */ + public static boolean getWarZoneDefault(String flagName) { + return switch (flagName) { + case PVP_ENABLED -> true; + case FRIENDLY_FIRE -> false; + case BUILD_ALLOWED -> true; + case CONTAINER_ACCESS -> true; + case INTERACT_ALLOWED -> true; + case ITEM_DROP -> true; + case ITEM_PICKUP -> true; + case MOB_SPAWNING -> true; + case MOB_DAMAGE -> true; + case HUNGER_LOSS -> true; + case FALL_DAMAGE -> true; + default -> true; + }; + } +} diff --git a/src/main/java/com/hyperfactions/listener/PlayerListener.java b/src/main/java/com/hyperfactions/listener/PlayerListener.java index 584d735..9a50038 100644 --- a/src/main/java/com/hyperfactions/listener/PlayerListener.java +++ b/src/main/java/com/hyperfactions/listener/PlayerListener.java @@ -113,11 +113,55 @@ public boolean onCommandPreprocess(@NotNull UUID playerUuid, @NotNull String com /** * Called when a player respawns. + * Clears combat tag and applies spawn protection. + * + * @param playerUuid the player's UUID + * @param world the respawn world + * @param x the respawn X coordinate + * @param z the respawn Z coordinate + */ + public void onPlayerRespawn(@NotNull UUID playerUuid, @NotNull String world, double x, double z) { + // Clear combat tag + hyperFactions.getCombatTagManager().clearTag(playerUuid); + + // Apply spawn protection if enabled + HyperFactionsConfig config = HyperFactionsConfig.get(); + if (config.isSpawnProtectionEnabled()) { + int chunkX = (int) Math.floor(x) >> 4; + int chunkZ = (int) Math.floor(z) >> 4; + int duration = config.getSpawnProtectionDurationSeconds(); + + hyperFactions.getCombatTagManager().applySpawnProtection( + playerUuid, duration, world, chunkX, chunkZ + ); + Logger.debug("Applied %ds spawn protection to %s at chunk %d, %d", + duration, playerUuid, chunkX, chunkZ); + } + } + + /** + * Called when a player respawns (legacy version without location). * Clears combat tag on respawn. * * @param playerUuid the player's UUID + * @deprecated Use {@link #onPlayerRespawn(UUID, String, double, double)} instead */ + @Deprecated public void onPlayerRespawn(@NotNull UUID playerUuid) { hyperFactions.getCombatTagManager().clearTag(playerUuid); } + + /** + * Called when a player moves chunks. + * Checks if spawn protection should be broken. + * + * @param playerUuid the player's UUID + * @param world the world name + * @param chunkX the new chunk X + * @param chunkZ the new chunk Z + * @return true if spawn protection was broken + */ + public boolean onChunkEnter(@NotNull UUID playerUuid, @NotNull String world, int chunkX, int chunkZ) { + return hyperFactions.getCombatTagManager().checkSpawnProtectionMove(playerUuid, world, chunkX, chunkZ); + } } diff --git a/src/main/java/com/hyperfactions/manager/CombatTagManager.java b/src/main/java/com/hyperfactions/manager/CombatTagManager.java index 13d363f..ad02d0c 100644 --- a/src/main/java/com/hyperfactions/manager/CombatTagManager.java +++ b/src/main/java/com/hyperfactions/manager/CombatTagManager.java @@ -2,6 +2,7 @@ import com.hyperfactions.config.HyperFactionsConfig; import com.hyperfactions.data.CombatTag; +import com.hyperfactions.data.SpawnProtection; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,13 +11,16 @@ import java.util.function.Consumer; /** - * Manages combat tagging for PvP logout prevention. + * Manages combat tagging for PvP logout prevention and spawn protection. */ public class CombatTagManager { // Active combat tags: player UUID -> CombatTag private final Map tags = new ConcurrentHashMap<>(); + // Active spawn protections: player UUID -> SpawnProtection + private final Map spawnProtections = new ConcurrentHashMap<>(); + // Callbacks for tag events private Consumer onTagExpired; private Consumer onCombatLogout; @@ -209,4 +213,103 @@ public void tickDecay() { public int getTagCount() { return getTaggedPlayers().size(); } + + // === Spawn Protection === + + /** + * Checks if a player has active spawn protection. + * + * @param playerUuid the player's UUID + * @return true if protected and not expired + */ + public boolean hasSpawnProtection(@NotNull UUID playerUuid) { + SpawnProtection protection = spawnProtections.get(playerUuid); + if (protection == null) { + return false; + } + if (protection.isExpired()) { + spawnProtections.remove(playerUuid); + return false; + } + return true; + } + + /** + * Gets the spawn protection for a player. + * + * @param playerUuid the player's UUID + * @return the protection, or null if not protected + */ + @Nullable + public SpawnProtection getSpawnProtection(@NotNull UUID playerUuid) { + SpawnProtection protection = spawnProtections.get(playerUuid); + if (protection != null && protection.isExpired()) { + spawnProtections.remove(playerUuid); + return null; + } + return protection; + } + + /** + * Applies spawn protection to a player. + * + * @param playerUuid the player's UUID + * @param durationSeconds the protection duration + * @param world the spawn world + * @param chunkX the spawn chunk X + * @param chunkZ the spawn chunk Z + * @return the spawn protection + */ + @NotNull + public SpawnProtection applySpawnProtection(@NotNull UUID playerUuid, int durationSeconds, + @NotNull String world, int chunkX, int chunkZ) { + SpawnProtection protection = SpawnProtection.create(playerUuid, durationSeconds, world, chunkX, chunkZ); + spawnProtections.put(playerUuid, protection); + return protection; + } + + /** + * Clears spawn protection for a player. + * + * @param playerUuid the player's UUID + */ + public void clearSpawnProtection(@NotNull UUID playerUuid) { + spawnProtections.remove(playerUuid); + } + + /** + * Checks if spawn protection should be broken due to chunk movement. + * If the player has left their spawn chunk, protection is cleared. + * + * @param playerUuid the player's UUID + * @param currentWorld the player's current world + * @param currentChunkX the player's current chunk X + * @param currentChunkZ the player's current chunk Z + * @return true if protection was broken due to movement + */ + public boolean checkSpawnProtectionMove(@NotNull UUID playerUuid, @NotNull String currentWorld, + int currentChunkX, int currentChunkZ) { + SpawnProtection protection = getSpawnProtection(playerUuid); + if (protection == null) { + return false; + } + + if (HyperFactionsConfig.get().isSpawnProtectionBreakOnMove() && + protection.hasLeftSpawnChunk(currentWorld, currentChunkX, currentChunkZ)) { + clearSpawnProtection(playerUuid); + return true; + } + return false; + } + + /** + * Gets the remaining spawn protection time in seconds. + * + * @param playerUuid the player's UUID + * @return remaining seconds, 0 if not protected + */ + public int getSpawnProtectionRemainingSeconds(@NotNull UUID playerUuid) { + SpawnProtection protection = getSpawnProtection(playerUuid); + return protection != null ? protection.getRemainingSeconds() : 0; + } } diff --git a/src/main/java/com/hyperfactions/manager/ZoneManager.java b/src/main/java/com/hyperfactions/manager/ZoneManager.java index cf4c28b..88feda9 100644 --- a/src/main/java/com/hyperfactions/manager/ZoneManager.java +++ b/src/main/java/com/hyperfactions/manager/ZoneManager.java @@ -2,6 +2,7 @@ import com.hyperfactions.data.ChunkKey; import com.hyperfactions.data.Zone; +import com.hyperfactions.data.ZoneFlags; import com.hyperfactions.data.ZoneType; import com.hyperfactions.storage.ZoneStorage; import com.hyperfactions.util.Logger; @@ -297,4 +298,87 @@ public ZoneResult renameZone(@NotNull UUID zoneId, @NotNull String newName) { saveAll(); return ZoneResult.SUCCESS; } + + // === Flag Management === + + /** + * Sets a flag on a zone. + * + * @param zoneId the zone ID + * @param flagName the flag name (see ZoneFlags) + * @param value the flag value + * @return the result + */ + public ZoneResult setZoneFlag(@NotNull UUID zoneId, @NotNull String flagName, boolean value) { + if (!ZoneFlags.isValidFlag(flagName)) { + return ZoneResult.NOT_FOUND; // Invalid flag + } + + Zone zone = zonesById.get(zoneId); + if (zone == null) { + return ZoneResult.NOT_FOUND; + } + + Zone updated = zone.withFlag(flagName, value); + zonesById.put(zoneId, updated); + zoneIndex.put(zone.toChunkKey(), updated); + + saveAll(); + Logger.info("Set flag '%s' to %s on zone '%s'", flagName, value, zone.name()); + return ZoneResult.SUCCESS; + } + + /** + * Clears a flag from a zone (reverts to default). + * + * @param zoneId the zone ID + * @param flagName the flag name + * @return the result + */ + public ZoneResult clearZoneFlag(@NotNull UUID zoneId, @NotNull String flagName) { + Zone zone = zonesById.get(zoneId); + if (zone == null) { + return ZoneResult.NOT_FOUND; + } + + Zone updated = zone.withoutFlag(flagName); + zonesById.put(zoneId, updated); + zoneIndex.put(zone.toChunkKey(), updated); + + saveAll(); + Logger.info("Cleared flag '%s' from zone '%s' (using default)", flagName, zone.name()); + return ZoneResult.SUCCESS; + } + + /** + * Gets the effective value of a flag for a zone. + * Returns the zone's custom value if set, otherwise the default for its type. + * + * @param zone the zone + * @param flagName the flag name + * @return the effective flag value + */ + public boolean getEffectiveFlag(@NotNull Zone zone, @NotNull String flagName) { + return zone.getEffectiveFlag(flagName); + } + + /** + * Gets the effective value of a flag at a location. + * Returns the zone's flag value if in a zone, otherwise returns the default. + * + * @param world the world name + * @param chunkX the chunk X + * @param chunkZ the chunk Z + * @param flagName the flag name + * @param defaultValue the default value if not in a zone + * @return the effective flag value + */ + public boolean getEffectiveFlagAt(@NotNull String world, int chunkX, int chunkZ, + @NotNull String flagName, boolean defaultValue) { + Zone zone = getZone(world, chunkX, chunkZ); + if (zone == null) { + return defaultValue; + } + return zone.getEffectiveFlag(flagName); + } } diff --git a/src/main/java/com/hyperfactions/protection/ProtectionChecker.java b/src/main/java/com/hyperfactions/protection/ProtectionChecker.java index 4ad4f6a..0a68f53 100644 --- a/src/main/java/com/hyperfactions/protection/ProtectionChecker.java +++ b/src/main/java/com/hyperfactions/protection/ProtectionChecker.java @@ -3,6 +3,8 @@ import com.hyperfactions.config.HyperFactionsConfig; import com.hyperfactions.data.Faction; import com.hyperfactions.data.RelationType; +import com.hyperfactions.data.Zone; +import com.hyperfactions.data.ZoneFlags; import com.hyperfactions.integration.HyperPermsIntegration; import com.hyperfactions.manager.*; import org.jetbrains.annotations.NotNull; @@ -61,7 +63,8 @@ public enum PvPResult { DENIED_SAME_FACTION, DENIED_ALLY, DENIED_ATTACKER_SAFEZONE, - DENIED_DEFENDER_SAFEZONE + DENIED_DEFENDER_SAFEZONE, + DENIED_SPAWN_PROTECTED } /** @@ -123,14 +126,26 @@ public ProtectionResult canInteractChunk(@NotNull UUID playerUuid, @NotNull Stri } // 2. Check zone - if (zoneManager.isInSafeZone(world, chunkX, chunkZ)) { - // SafeZone: only admins can build - return ProtectionResult.DENIED_SAFEZONE; - } - - if (zoneManager.isInWarZone(world, chunkX, chunkZ)) { - // WarZone: anyone can interact - return ProtectionResult.ALLOWED_WARZONE; + Zone zone = zoneManager.getZone(world, chunkX, chunkZ); + if (zone != null) { + // Get the appropriate flag for the interaction type + String flagName = switch (type) { + case BUILD -> ZoneFlags.BUILD_ALLOWED; + case INTERACT -> ZoneFlags.INTERACT_ALLOWED; + case CONTAINER -> ZoneFlags.CONTAINER_ACCESS; + case DAMAGE -> ZoneFlags.PVP_ENABLED; // For entity damage + case USE -> ZoneFlags.INTERACT_ALLOWED; + }; + + boolean allowed = zone.getEffectiveFlag(flagName); + if (!allowed) { + return zone.isSafeZone() ? ProtectionResult.DENIED_SAFEZONE : ProtectionResult.DENIED_NO_PERMISSION; + } + // If zone allows this interaction, still need to check claim ownership below + // For WarZones with build allowed, anyone can interact + if (zone.isWarZone() && allowed) { + return ProtectionResult.ALLOWED_WARZONE; + } } // 3. Check claim owner @@ -204,24 +219,55 @@ public PvPResult canDamagePlayerChunk(@NotNull UUID attackerUuid, @NotNull UUID @NotNull String world, int chunkX, int chunkZ) { HyperFactionsConfig config = HyperFactionsConfig.get(); - // 1. Check SafeZone - no PvP - if (zoneManager.isInSafeZone(world, chunkX, chunkZ)) { - return PvPResult.DENIED_SAFEZONE; + // 0. Check defender's spawn protection + if (combatTagManager.hasSpawnProtection(defenderUuid)) { + return PvPResult.DENIED_SPAWN_PROTECTED; } - // 2. Check WarZone - always PvP - if (zoneManager.isInWarZone(world, chunkX, chunkZ)) { - return PvPResult.ALLOWED_WARZONE; + // 0b. Break attacker's spawn protection if they attack (if configured) + if (config.isSpawnProtectionBreakOnAttack() && combatTagManager.hasSpawnProtection(attackerUuid)) { + combatTagManager.clearSpawnProtection(attackerUuid); } - // 3. Check same faction + // 1. Check zone for PvP flag + Zone zone = zoneManager.getZone(world, chunkX, chunkZ); + if (zone != null) { + boolean pvpEnabled = zone.getEffectiveFlag(ZoneFlags.PVP_ENABLED); + if (!pvpEnabled) { + return PvPResult.DENIED_SAFEZONE; + } + // Zone has PvP enabled - check friendly fire if in zone + boolean friendlyFireAllowed = zone.getEffectiveFlag(ZoneFlags.FRIENDLY_FIRE); + + // Check same faction + if (factionManager.areInSameFaction(attackerUuid, defenderUuid)) { + if (!friendlyFireAllowed && !config.isFactionDamage()) { + return PvPResult.DENIED_SAME_FACTION; + } + } + + // Check ally + RelationType relation = relationManager.getPlayerRelation(attackerUuid, defenderUuid); + if (relation == RelationType.ALLY) { + if (!friendlyFireAllowed && !config.isAllyDamage()) { + return PvPResult.DENIED_ALLY; + } + } + + // PvP is enabled in this zone + return zone.isWarZone() ? PvPResult.ALLOWED_WARZONE : PvPResult.ALLOWED; + } + + // Not in a zone - use standard checks + + // 2. Check same faction if (factionManager.areInSameFaction(attackerUuid, defenderUuid)) { if (!config.isFactionDamage()) { return PvPResult.DENIED_SAME_FACTION; } } - // 4. Check ally + // 3. Check ally RelationType relation = relationManager.getPlayerRelation(attackerUuid, defenderUuid); if (relation == RelationType.ALLY) { if (!config.isAllyDamage()) { @@ -229,7 +275,7 @@ public PvPResult canDamagePlayerChunk(@NotNull UUID attackerUuid, @NotNull UUID } } - // 5. Default: allow PvP + // 4. Default: allow PvP return PvPResult.ALLOWED; } @@ -320,6 +366,7 @@ public String getDenialMessage(@NotNull PvPResult result) { case DENIED_SAME_FACTION -> "\u00A7cYou cannot attack faction members."; case DENIED_ALLY -> "\u00A7cYou cannot attack allies."; case DENIED_ATTACKER_SAFEZONE, DENIED_DEFENDER_SAFEZONE -> "\u00A7cPvP is disabled in SafeZones."; + case DENIED_SPAWN_PROTECTED -> "\u00A7cThat player has spawn protection."; default -> "\u00A7cYou cannot attack this player."; }; } diff --git a/src/main/java/com/hyperfactions/storage/json/JsonZoneStorage.java b/src/main/java/com/hyperfactions/storage/json/JsonZoneStorage.java index 37da6e9..a8e37f9 100644 --- a/src/main/java/com/hyperfactions/storage/json/JsonZoneStorage.java +++ b/src/main/java/com/hyperfactions/storage/json/JsonZoneStorage.java @@ -17,6 +17,8 @@ import java.nio.file.Path; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.Map; +import java.util.HashMap; /** * JSON file-based implementation of ZoneStorage. @@ -108,10 +110,30 @@ private JsonObject serializeZone(Zone zone) { obj.addProperty("chunkZ", zone.chunkZ()); obj.addProperty("createdAt", zone.createdAt()); obj.addProperty("createdBy", zone.createdBy().toString()); + + // Serialize flags if present + if (zone.flags() != null && !zone.flags().isEmpty()) { + JsonObject flagsObj = new JsonObject(); + for (Map.Entry entry : zone.flags().entrySet()) { + flagsObj.addProperty(entry.getKey(), entry.getValue()); + } + obj.add("flags", flagsObj); + } + return obj; } private Zone deserializeZone(JsonObject obj) { + // Deserialize flags if present + Map flags = null; + if (obj.has("flags") && obj.get("flags").isJsonObject()) { + flags = new HashMap<>(); + JsonObject flagsObj = obj.getAsJsonObject("flags"); + for (String key : flagsObj.keySet()) { + flags.put(key, flagsObj.get(key).getAsBoolean()); + } + } + return new Zone( UUID.fromString(obj.get("id").getAsString()), obj.get("name").getAsString(), @@ -120,7 +142,8 @@ private Zone deserializeZone(JsonObject obj) { obj.get("chunkX").getAsInt(), obj.get("chunkZ").getAsInt(), obj.get("createdAt").getAsLong(), - UUID.fromString(obj.get("createdBy").getAsString()) + UUID.fromString(obj.get("createdBy").getAsString()), + flags ); } } diff --git a/src/main/java/com/hyperfactions/update/UpdateChecker.java b/src/main/java/com/hyperfactions/update/UpdateChecker.java new file mode 100644 index 0000000..2e4eccc --- /dev/null +++ b/src/main/java/com/hyperfactions/update/UpdateChecker.java @@ -0,0 +1,283 @@ +package com.hyperfactions.update; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hyperfactions.util.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Checks for plugin updates from the GitHub Releases API. + *

+ * Uses the GitHub API endpoint for the latest release, parsing + * {@code tag_name} for the version, {@code body} for the changelog, + * and the first JAR asset's {@code browser_download_url} for the download. + */ +public final class UpdateChecker { + + private static final String USER_AGENT = "HyperFactions-UpdateChecker"; + private static final int TIMEOUT_MS = 10000; + private static final Gson GSON = new Gson(); + + private final Path dataDirectory; + private final String currentVersion; + private final String checkUrl; + + private final AtomicReference cachedUpdate = new AtomicReference<>(); + private volatile long lastCheckTime = 0; + private static final long CACHE_DURATION_MS = 300000; // 5 minutes + + public UpdateChecker(@NotNull Path dataDirectory, @NotNull String currentVersion, @NotNull String checkUrl) { + this.dataDirectory = dataDirectory; + this.currentVersion = currentVersion; + this.checkUrl = checkUrl; + } + + /** + * Gets the current plugin version. + */ + @NotNull + public String getCurrentVersion() { + return currentVersion; + } + + /** + * Checks for updates asynchronously. + * + * @return a future containing update info, or null if up-to-date or error + */ + public CompletableFuture checkForUpdates() { + return checkForUpdates(false); + } + + /** + * Checks for updates asynchronously. + * + * @param forceRefresh if true, ignores cache + * @return a future containing update info, or null if up-to-date or error + */ + public CompletableFuture checkForUpdates(boolean forceRefresh) { + // Check cache first + if (!forceRefresh && System.currentTimeMillis() - lastCheckTime < CACHE_DURATION_MS) { + return CompletableFuture.completedFuture(cachedUpdate.get()); + } + + return CompletableFuture.supplyAsync(() -> { + try { + Logger.info("[Update] Checking for updates from %s", checkUrl); + + URL url = URI.create(checkUrl).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", USER_AGENT); + conn.setRequestProperty("Accept", "application/vnd.github+json"); + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(TIMEOUT_MS); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + Logger.warn("[Update] Failed to check for updates: HTTP %d", responseCode); + return null; + } + + // Read response + String json; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + json = sb.toString(); + } + + // Parse GitHub release JSON + JsonObject obj = GSON.fromJson(json, JsonObject.class); + String tagName = obj.get("tag_name").getAsString(); + String latestVersion = tagName.startsWith("v") ? tagName.substring(1) : tagName; + String changelog = obj.has("body") && !obj.get("body").isJsonNull() + ? obj.get("body").getAsString() : null; + + // Find the first .jar asset download URL + String downloadUrl = null; + if (obj.has("assets") && obj.get("assets").isJsonArray()) { + JsonArray assets = obj.getAsJsonArray("assets"); + for (int i = 0; i < assets.size(); i++) { + JsonObject asset = assets.get(i).getAsJsonObject(); + String assetName = asset.get("name").getAsString(); + if (assetName.endsWith(".jar")) { + downloadUrl = asset.get("browser_download_url").getAsString(); + break; + } + } + } + + lastCheckTime = System.currentTimeMillis(); + + // Compare versions + if (isNewerVersion(latestVersion, currentVersion)) { + UpdateInfo info = new UpdateInfo(latestVersion, downloadUrl, changelog); + cachedUpdate.set(info); + Logger.info("[Update] New version available: %s (current: %s)", latestVersion, currentVersion); + return info; + } else { + cachedUpdate.set(null); + Logger.info("[Update] Plugin is up-to-date (v%s)", currentVersion); + return null; + } + + } catch (Exception e) { + Logger.warn("[Update] Failed to check for updates: %s", e.getMessage()); + return null; + } + }); + } + + /** + * Downloads the update to the mods folder. + * + * @param info the update info + * @return a future containing the downloaded file path, or null on failure + */ + public CompletableFuture downloadUpdate(@NotNull UpdateInfo info) { + if (info.downloadUrl() == null || info.downloadUrl().isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + return CompletableFuture.supplyAsync(() -> { + try { + Logger.info("[Update] Downloading update v%s from %s", info.version(), info.downloadUrl()); + + URL url = URI.create(info.downloadUrl()).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", USER_AGENT); + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(60000); // 60 second read timeout for downloads + + // Follow redirects + conn.setInstanceFollowRedirects(true); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + Logger.warn("[Update] Failed to download update: HTTP %d", responseCode); + return null; + } + + // Determine output path + Path modsFolder = dataDirectory.getParent(); + Path updateFile = modsFolder.resolve("HyperFactions-" + info.version() + ".jar"); + Path currentJar = modsFolder.resolve("HyperFactions-" + currentVersion + ".jar"); + + // Download to temp file first + Path tempFile = modsFolder.resolve("HyperFactions-" + info.version() + ".jar.tmp"); + + try (InputStream in = conn.getInputStream()) { + Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + + // Verify download (basic check - file size > 0) + if (Files.size(tempFile) < 1000) { + Logger.warn("[Update] Downloaded file seems too small, aborting"); + Files.deleteIfExists(tempFile); + return null; + } + + // Rename temp to final + Files.move(tempFile, updateFile, StandardCopyOption.REPLACE_EXISTING); + + // Backup current JAR if it exists + // On Windows, the JAR may be locked by the JVM - handle gracefully + if (Files.exists(currentJar)) { + Path backupFile = modsFolder.resolve("HyperFactions-" + currentVersion + ".jar.backup"); + try { + Files.move(currentJar, backupFile, StandardCopyOption.REPLACE_EXISTING); + Logger.info("[Update] Backed up current JAR to %s", backupFile.getFileName()); + } catch (java.nio.file.FileSystemException e) { + // Windows locks loaded JARs - can't backup while running + Logger.warn("[Update] Could not backup old JAR (file in use). Please delete %s manually after restart.", currentJar.getFileName()); + } + } + + Logger.info("[Update] Successfully downloaded update to %s", updateFile.getFileName()); + return updateFile; + + } catch (Exception e) { + Logger.warn("[Update] Failed to download update: %s", e.getMessage()); + e.printStackTrace(); + return null; + } + }); + } + + /** + * Gets the cached update info, if any. + */ + @Nullable + public UpdateInfo getCachedUpdate() { + return cachedUpdate.get(); + } + + /** + * Checks if there's a cached update available. + */ + public boolean hasUpdateAvailable() { + return cachedUpdate.get() != null; + } + + /** + * Compares two version strings. + * + * @return true if newVersion is newer than oldVersion + */ + private boolean isNewerVersion(@NotNull String newVersion, @NotNull String oldVersion) { + try { + // Remove 'v' prefix if present + String v1 = newVersion.toLowerCase().startsWith("v") ? newVersion.substring(1) : newVersion; + String v2 = oldVersion.toLowerCase().startsWith("v") ? oldVersion.substring(1) : oldVersion; + + String[] parts1 = v1.split("\\."); + String[] parts2 = v2.split("\\."); + + int maxLen = Math.max(parts1.length, parts2.length); + for (int i = 0; i < maxLen; i++) { + int num1 = i < parts1.length ? parseVersionPart(parts1[i]) : 0; + int num2 = i < parts2.length ? parseVersionPart(parts2[i]) : 0; + + if (num1 > num2) return true; + if (num1 < num2) return false; + } + + return false; // Equal versions + } catch (Exception e) { + // Fallback to string comparison + return newVersion.compareTo(oldVersion) > 0; + } + } + + private int parseVersionPart(@NotNull String part) { + // Handle versions like "1.0.0-beta1" + String numPart = part.split("-")[0]; + return Integer.parseInt(numPart); + } + + /** + * Record containing update information. + */ + public record UpdateInfo( + @NotNull String version, + @Nullable String downloadUrl, + @Nullable String changelog + ) {} +} From 436e61ed03bf22e339f8d979eb70a14fb9583eb5 Mon Sep 17 00:00:00 2001 From: ZenithDevHQ Date: Sat, 24 Jan 2026 20:56:56 -0500 Subject: [PATCH 2/2] feat: Add technical debt fixes, public API expansion, and economy foundation Technical Debt Resolution: - Add auto-save periodic task with configurable interval (default 5 min) - Create TeleportContext record with builder pattern for cleaner teleport API - Add factionClaimsIndex reverse lookup map for O(1) faction claims query - Add invite cleanup integration in periodic tasks Public API Expansion: - Add manager getters to HyperFactionsAPI (claim, relation, combat, power, zone, teleport) - Add EventBus integration methods for cross-mod event subscriptions - Create EconomyAPI interface for faction treasury operations Economy Foundation: - Create FactionEconomy record for balance and transaction tracking - Create EconomyManager implementing EconomyAPI with deposit/withdraw/transfer - Add economy config fields (currency name, symbol, starting balance) - Add ECONOMY log type to FactionLog for transaction logging --- DEVELOPMENT_ROADMAP.md | 63 ++- .../java/com/hyperfactions/HyperFactions.java | 125 +++++- .../com/hyperfactions/api/EconomyAPI.java | 180 ++++++++ .../hyperfactions/api/HyperFactionsAPI.java | 128 ++++++ .../config/HyperFactionsConfig.java | 54 +++ .../hyperfactions/data/FactionEconomy.java | 121 ++++++ .../com/hyperfactions/data/FactionLog.java | 3 +- .../hyperfactions/data/TeleportContext.java | 98 +++++ .../hyperfactions/manager/ClaimManager.java | 57 ++- .../hyperfactions/manager/EconomyManager.java | 386 ++++++++++++++++++ .../manager/TeleportManager.java | 20 + .../platform/HyperFactionsPlugin.java | 23 +- 12 files changed, 1219 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/hyperfactions/api/EconomyAPI.java create mode 100644 src/main/java/com/hyperfactions/data/FactionEconomy.java create mode 100644 src/main/java/com/hyperfactions/data/TeleportContext.java create mode 100644 src/main/java/com/hyperfactions/manager/EconomyManager.java diff --git a/DEVELOPMENT_ROADMAP.md b/DEVELOPMENT_ROADMAP.md index 01c58e5..79fdd4a 100644 --- a/DEVELOPMENT_ROADMAP.md +++ b/DEVELOPMENT_ROADMAP.md @@ -1,7 +1,7 @@ # HyperFactions Development Roadmap -> Last Updated: January 24, 2026 (Added comprehensive GUI overhaul (Phase 2.11), command restructure (/f opens GUI), economy system with taxes/upkeep/war costs, and additional features) -> Version: 0.2.0 +> Last Updated: January 24, 2026 (Completed 2.9 Update Checker, 2.10 Spawnkilling Prevention, 3.0 WarZone Per-Zone Configuration) +> Version: 0.3.0 > Repository: https://github.com/HyperSystemsDev/HyperFactions --- @@ -477,7 +477,7 @@ Teleport warmups (e.g., `/f home`, `/f unstuck`) currently only cancel on moveme ### 2.9 Update Checker (GitHub Releases) - **Priority:** P1 - **Effort:** 0.5 day -- **Status:** :red_circle: Not Started +- **Status:** :white_check_mark: **COMPLETE** **Description:** Implement GitHub release checking similar to HyperPerms to notify server admins when new versions are available. @@ -527,12 +527,20 @@ Implement GitHub release checking similar to HyperPerms to notify server admins - Downloads to temp file (.jar.tmp) then atomic rename - All operations fully async +**Implementation Notes:** +- Created `update/UpdateChecker.java` with GitHub Releases API integration +- 5-minute response caching to prevent API spam +- Changelog support and optional auto-download +- Graceful 404 handling (no releases exist yet) +- Integrated into `HyperFactions.java` enable() method +- Config options: `updates.enabled`, `updates.checkUrl` + --- ### 2.10 Spawnkilling Prevention - **Priority:** P1 - **Effort:** 1 day -- **Status:** :red_circle: Not Started +- **Status:** :white_check_mark: **COMPLETE** **Problem:** During raids or wars, players who die and respawn at their faction home can be repeatedly killed by enemies camping the spawn point. This creates unfair "spawn camping" scenarios. @@ -583,6 +591,13 @@ Grant temporary invulnerability after respawn when player died in PvP and respaw **Future Enhancement:** When raid/war system (3.4, 3.7) is implemented, make `onlyDuringRaid` default to true for more realistic combat. +**Implementation Notes:** +- Created `data/SpawnProtection.java` record to track protection state +- Added spawn protection tracking to `CombatTagManager.java` +- Added `DENIED_SPAWN_PROTECTED` to `ProtectionChecker.PvPResult` enum +- Config options added: `combat.spawnProtection.{enabled, durationSeconds, breakOnAttack, breakOnMove}` +- Protection automatically removed when player attacks or leaves own territory + --- ### 2.11 GUI System Overhaul & Command Restructure @@ -1104,7 +1119,7 @@ When raid/war system (3.4, 3.7) is implemented, make `onlyDuringRaid` default to ### 3.0 WarZone Per-Zone Configuration - **Priority:** P1 - **Effort:** 1 day -- **Status:** :red_circle: Not Started +- **Status:** :white_check_mark: **COMPLETE** **Current Problem:** All WarZones behave identically with global settings. Server admins cannot customize individual zones for different purposes (tournaments vs training grounds vs battlefields). @@ -1186,6 +1201,14 @@ Per-zone overrides stored in zone data (zones.json): **Priority:** P1 - High value for server event customization +**Implementation Notes:** +- Created `data/ZoneFlags.java` with 11 flag constants (ALLOW_PVP, ALLOW_ITEM_DROP, ALLOW_BLOCK_BREAK, ALLOW_BLOCK_PLACE, CONSUME_POWER, etc.) +- Added flags support to `Zone.java` record with builder pattern +- Added `setZoneFlag()`, `clearZoneFlag()`, `getEffectiveFlag()` to `ZoneManager.java` +- Updated `ProtectionChecker.java` to check zone flags for protection decisions +- Added `/f admin zoneflag ` command to `FactionCommand.java` +- Updated `JsonZoneStorage.java` for flags persistence with JSON serialization + --- ### 3.1 Public API for Cross-Mod Integration (NEW) @@ -1971,12 +1994,18 @@ Faction B surrenders on day 5: ### Performance Issues -| Issue | Location | Impact | Fix | -|-------|----------|--------|-----| -| O(n) getFactionClaims() | ClaimManager.java | High with many claims | Add reverse index Map> | -| No auto-save | HyperFactions.java | Data loss on crash | Add periodic save task | -| 7 callback params | TeleportManager.java | Hard to use | Create TeleportContext object | -| No invite cleanup | InviteManager.java | Memory leak | Add scheduled cleanup task | +| Issue | Location | Impact | Fix | Status | +|-------|----------|--------|-----|--------| +| O(n) getFactionClaims() | ClaimManager.java | High with many claims | Add reverse index Map> | ✅ Complete | +| No auto-save | HyperFactions.java | Data loss on crash | Add periodic save task | ✅ Complete | +| 7 callback params | TeleportManager.java | Hard to use | Create TeleportContext object | ✅ Complete | +| No invite cleanup | InviteManager.java | Memory leak | Add scheduled cleanup task | ✅ Complete | + +**Technical Debt Resolution Notes (v0.3.0):** +- **Claim Reverse Index:** Added `factionClaimsIndex` Map in ClaimManager for O(1) lookups of faction claims +- **Auto-save:** Added configurable periodic save task in HyperFactions.java (default: 5 minutes) +- **TeleportContext:** Created `data/TeleportContext.java` record with builder pattern to replace callback parameters +- **Invite Cleanup:** Integrated invite cleanup into periodic tasks in HyperFactions.java ### Code Quality @@ -2343,8 +2372,13 @@ Be explicit about units: - Combat tagging - Safe/War zones -### Version 0.3.0 (Planned) -**Critical Pre-Release Features:** +### Version 0.3.0 (Current) - January 24, 2026 +**Completed Features:** +- **Update Checker (2.9)** - GitHub Releases API integration with 5-minute caching, changelog support, and auto-download capability +- **Spawnkilling Prevention (2.10)** - Temporary invulnerability after PvP death respawn in own territory, configurable duration and break conditions +- **WarZone Per-Zone Configuration (3.0)** - 11 configurable flags per zone (PvP, item drop, block edit, power loss, etc.) with `/f admin zoneflag` command + +**Still Planned:** - **GUI System Overhaul (2-3 weeks):** - Complete GUI implementation for all features - `/f` command opens main menu (sub-commands still work) @@ -2352,9 +2386,6 @@ Be explicit about units: - Polished, fully functional interfaces - Visual permission matrix, economy management, war/raid tracking - Warmup damage monitoring (fix teleport exploit) -- Update checker (GitHub releases integration) -- Spawnkilling prevention (spawn protection system) -- WarZone per-zone configuration (tournament/training ground support) **Major Features:** - Public API for cross-mod integration diff --git a/src/main/java/com/hyperfactions/HyperFactions.java b/src/main/java/com/hyperfactions/HyperFactions.java index 3958102..5c6d6be 100644 --- a/src/main/java/com/hyperfactions/HyperFactions.java +++ b/src/main/java/com/hyperfactions/HyperFactions.java @@ -61,11 +61,14 @@ public class HyperFactions { // Task management private final AtomicInteger taskIdCounter = new AtomicInteger(0); private final Map scheduledTasks = new ConcurrentHashMap<>(); + private int autoSaveTaskId = -1; + private int inviteCleanupTaskId = -1; // Platform callbacks (set by plugin) private Consumer asyncExecutor; private TaskSchedulerCallback taskScheduler; private TaskCancelCallback taskCanceller; + private RepeatingTaskSchedulerCallback repeatingTaskScheduler; /** * Functional interface for scheduling delayed tasks. @@ -83,6 +86,14 @@ public interface TaskCancelCallback { void cancel(int taskId); } + /** + * Functional interface for scheduling repeating tasks. + */ + @FunctionalInterface + public interface RepeatingTaskSchedulerCallback { + int schedule(int delayTicks, int periodTicks, Runnable task); + } + /** * Represents a scheduled task. */ @@ -171,26 +182,41 @@ public void enable() { updateChecker.checkForUpdates(); } + // Start periodic tasks (auto-save, invite cleanup) + // Note: These are started after platform sets callbacks via setRepeatingTaskScheduler() + // The platform should call startPeriodicTasks() after setting up callbacks + Logger.info("HyperFactions enabled"); } + /** + * Starts periodic tasks (auto-save, invite cleanup). + * Should be called by the platform after setting up task scheduler callbacks. + */ + public void startPeriodicTasks() { + startAutoSaveTask(); + startInviteCleanupTask(); + } + /** * Disables HyperFactions. */ public void disable() { Logger.info("HyperFactions disabling..."); - // Save all data - if (factionManager != null) { - factionManager.saveAll().join(); - } - if (powerManager != null) { - powerManager.saveAll().join(); + // Cancel periodic tasks first + if (autoSaveTaskId > 0) { + cancelTask(autoSaveTaskId); + autoSaveTaskId = -1; } - if (zoneManager != null) { - zoneManager.saveAll().join(); + if (inviteCleanupTaskId > 0) { + cancelTask(inviteCleanupTaskId); + inviteCleanupTaskId = -1; } + // Save all data + saveAllData(); + // Shutdown storage if (factionStorage != null) { factionStorage.shutdown().join(); @@ -202,7 +228,7 @@ public void disable() { zoneStorage.shutdown().join(); } - // Cancel all scheduled tasks + // Cancel remaining scheduled tasks for (int taskId : scheduledTasks.keySet()) { cancelTask(taskId); } @@ -232,6 +258,10 @@ public void setTaskCanceller(@NotNull TaskCancelCallback canceller) { this.taskCanceller = canceller; } + public void setRepeatingTaskScheduler(@NotNull RepeatingTaskSchedulerCallback scheduler) { + this.repeatingTaskScheduler = scheduler; + } + // === Task scheduling === /** @@ -268,6 +298,83 @@ public void cancelTask(int taskId) { } } + /** + * Schedules a repeating task. + * + * @param delayTicks initial delay in ticks + * @param periodTicks period in ticks + * @param task the task + * @return the task ID + */ + public int scheduleRepeatingTask(int delayTicks, int periodTicks, @NotNull Runnable task) { + if (repeatingTaskScheduler != null) { + int id = taskIdCounter.incrementAndGet(); + int platformId = repeatingTaskScheduler.schedule(delayTicks, periodTicks, task); + scheduledTasks.put(id, new ScheduledTask(platformId, task)); + return id; + } + return -1; + } + + /** + * Performs a save of all data. + * Called periodically by auto-save and on shutdown. + */ + public void saveAllData() { + Logger.info("Auto-saving data..."); + if (factionManager != null) { + factionManager.saveAll().join(); + } + if (powerManager != null) { + powerManager.saveAll().join(); + } + if (zoneManager != null) { + zoneManager.saveAll().join(); + } + Logger.info("Auto-save complete"); + } + + /** + * Starts the auto-save periodic task if enabled. + */ + private void startAutoSaveTask() { + HyperFactionsConfig config = HyperFactionsConfig.get(); + if (!config.isAutoSaveEnabled()) { + Logger.info("Auto-save is disabled in config"); + return; + } + + int intervalMinutes = config.getAutoSaveIntervalMinutes(); + if (intervalMinutes <= 0) { + Logger.warn("Invalid auto-save interval: %d minutes, using default 5 minutes", intervalMinutes); + intervalMinutes = 5; + } + + int periodTicks = intervalMinutes * 60 * 20; // Convert minutes to ticks (20 ticks per second) + autoSaveTaskId = scheduleRepeatingTask(periodTicks, periodTicks, this::saveAllData); + + if (autoSaveTaskId > 0) { + Logger.info("Auto-save scheduled every %d minutes", intervalMinutes); + } + } + + /** + * Starts the invite cleanup periodic task. + */ + private void startInviteCleanupTask() { + // Run every 5 minutes (6000 ticks) + int periodTicks = 5 * 60 * 20; + inviteCleanupTaskId = scheduleRepeatingTask(periodTicks, periodTicks, () -> { + if (inviteManager != null) { + inviteManager.cleanupExpired(); + } + }); + + if (inviteCleanupTaskId > 0) { + Logger.info("Invite cleanup task scheduled every 5 minutes"); + } + } + // === Getters === @NotNull diff --git a/src/main/java/com/hyperfactions/api/EconomyAPI.java b/src/main/java/com/hyperfactions/api/EconomyAPI.java new file mode 100644 index 0000000..6f9d575 --- /dev/null +++ b/src/main/java/com/hyperfactions/api/EconomyAPI.java @@ -0,0 +1,180 @@ +package com.hyperfactions.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Public API for HyperFactions economy system. + * Provides access to faction treasury operations. + */ +public interface EconomyAPI { + + /** + * Result of an economy transaction. + */ + enum TransactionResult { + SUCCESS, + INSUFFICIENT_FUNDS, + INVALID_AMOUNT, + FACTION_NOT_FOUND, + PLAYER_NOT_FOUND, + NOT_IN_FACTION, + NO_PERMISSION, + ERROR + } + + /** + * Represents a transaction in faction treasury. + */ + record Transaction( + @NotNull UUID factionId, + @Nullable UUID actorId, + @NotNull TransactionType type, + double amount, + double balanceAfter, + long timestamp, + @NotNull String description + ) {} + + /** + * Types of transactions. + */ + enum TransactionType { + DEPOSIT, + WITHDRAW, + TRANSFER_IN, + TRANSFER_OUT, + UPKEEP, + TAX_COLLECTION, + WAR_COST, + RAID_COST, + SPOILS, + ADMIN_ADJUSTMENT + } + + // === Balance Queries === + + /** + * Gets a faction's treasury balance. + * + * @param factionId the faction ID + * @return the balance, or 0.0 if faction not found + */ + double getFactionBalance(@NotNull UUID factionId); + + /** + * Checks if a faction has sufficient funds. + * + * @param factionId the faction ID + * @param amount the amount to check + * @return true if faction has at least this amount + */ + boolean hasFunds(@NotNull UUID factionId, double amount); + + // === Transactions === + + /** + * Deposits money into a faction's treasury. + * + * @param factionId the faction ID + * @param amount the amount to deposit + * @param actorId the player making the deposit (null for system) + * @param description description for transaction log + * @return the transaction result + */ + @NotNull + CompletableFuture deposit( + @NotNull UUID factionId, + double amount, + @Nullable UUID actorId, + @NotNull String description + ); + + /** + * Withdraws money from a faction's treasury. + * + * @param factionId the faction ID + * @param amount the amount to withdraw + * @param actorId the player making the withdrawal + * @param description description for transaction log + * @return the transaction result + */ + @NotNull + CompletableFuture withdraw( + @NotNull UUID factionId, + double amount, + @NotNull UUID actorId, + @NotNull String description + ); + + /** + * Transfers money between two factions. + * + * @param fromFactionId source faction ID + * @param toFactionId target faction ID + * @param amount the amount to transfer + * @param actorId the player initiating the transfer + * @param description description for transaction log + * @return the transaction result + */ + @NotNull + CompletableFuture transfer( + @NotNull UUID fromFactionId, + @NotNull UUID toFactionId, + double amount, + @Nullable UUID actorId, + @NotNull String description + ); + + // === Transaction History === + + /** + * Gets recent transactions for a faction. + * + * @param factionId the faction ID + * @param limit maximum number of transactions to return + * @return list of transactions, most recent first + */ + @NotNull + List getTransactionHistory(@NotNull UUID factionId, int limit); + + // === Currency Formatting === + + /** + * Gets the currency name (singular). + * + * @return the currency name (e.g., "dollar", "coin") + */ + @NotNull + String getCurrencyName(); + + /** + * Gets the currency name (plural). + * + * @return the plural currency name (e.g., "dollars", "coins") + */ + @NotNull + String getCurrencyNamePlural(); + + /** + * Formats an amount as a currency string. + * + * @param amount the amount + * @return formatted string (e.g., "$1,234.56") + */ + @NotNull + String formatCurrency(double amount); + + // === Status === + + /** + * Checks if the economy system is enabled. + * + * @return true if economy features are available + */ + boolean isEnabled(); +} diff --git a/src/main/java/com/hyperfactions/api/HyperFactionsAPI.java b/src/main/java/com/hyperfactions/api/HyperFactionsAPI.java index 3d1dd08..e598493 100644 --- a/src/main/java/com/hyperfactions/api/HyperFactionsAPI.java +++ b/src/main/java/com/hyperfactions/api/HyperFactionsAPI.java @@ -1,6 +1,7 @@ package com.hyperfactions.api; import com.hyperfactions.HyperFactions; +import com.hyperfactions.api.events.EventBus; import com.hyperfactions.data.Faction; import com.hyperfactions.data.PlayerPower; import com.hyperfactions.data.RelationType; @@ -258,4 +259,131 @@ public static ProtectionChecker getProtectionChecker() { public static boolean canBuild(@NotNull UUID playerUuid, @NotNull String world, double x, double z) { return getInstance().getProtectionChecker().canBuild(playerUuid, world, x, z); } + + // === Manager Access === + + /** + * Gets the FactionManager for advanced operations. + * + * @return the faction manager + */ + @NotNull + public static FactionManager getFactionManager() { + return getInstance().getFactionManager(); + } + + /** + * Gets the ClaimManager for territory operations. + * + * @return the claim manager + */ + @NotNull + public static ClaimManager getClaimManager() { + return getInstance().getClaimManager(); + } + + /** + * Gets the PowerManager for power operations. + * + * @return the power manager + */ + @NotNull + public static PowerManager getPowerManager() { + return getInstance().getPowerManager(); + } + + /** + * Gets the RelationManager for alliance/enemy operations. + * + * @return the relation manager + */ + @NotNull + public static RelationManager getRelationManager() { + return getInstance().getRelationManager(); + } + + /** + * Gets the ZoneManager for SafeZone/WarZone operations. + * + * @return the zone manager + */ + @NotNull + public static ZoneManager getZoneManager() { + return getInstance().getZoneManager(); + } + + /** + * Gets the CombatTagManager for combat tag operations. + * + * @return the combat tag manager + */ + @NotNull + public static CombatTagManager getCombatTagManager() { + return getInstance().getCombatTagManager(); + } + + /** + * Gets the TeleportManager for teleport operations. + * + * @return the teleport manager + */ + @NotNull + public static TeleportManager getTeleportManager() { + return getInstance().getTeleportManager(); + } + + /** + * Gets the InviteManager for invite operations. + * + * @return the invite manager + */ + @NotNull + public static InviteManager getInviteManager() { + return getInstance().getInviteManager(); + } + + // === Event System === + + /** + * Gets the EventBus for subscribing to faction events. + * + *

Example usage:

+ *
{@code
+     * HyperFactionsAPI.getEventBus().register(FactionCreateEvent.class, event -> {
+     *     System.out.println("Faction created: " + event.faction().name());
+     * });
+     * }
+ * + * @return the EventBus class for static access + */ + @NotNull + public static Class getEventBus() { + return EventBus.class; + } + + /** + * Registers an event listener. + * Convenience method that delegates to EventBus. + * + * @param eventClass the event class + * @param listener the listener + * @param the event type + */ + public static void registerEventListener(@NotNull Class eventClass, + @NotNull java.util.function.Consumer listener) { + EventBus.register(eventClass, listener); + } + + /** + * Unregisters an event listener. + * Convenience method that delegates to EventBus. + * + * @param eventClass the event class + * @param listener the listener + * @param the event type + */ + public static void unregisterEventListener(@NotNull Class eventClass, + @NotNull java.util.function.Consumer listener) { + EventBus.unregister(eventClass, listener); + } } diff --git a/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java b/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java index 22e8fc9..7ac0292 100644 --- a/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java +++ b/src/main/java/com/hyperfactions/config/HyperFactionsConfig.java @@ -73,6 +73,17 @@ public class HyperFactionsConfig { private boolean updateCheckEnabled = true; private String updateCheckUrl = "https://api.github.com/repos/ZenithDevHQ/HyperFactions/releases/latest"; + // Auto-save settings + private boolean autoSaveEnabled = true; + private int autoSaveIntervalMinutes = 5; + + // Economy settings + private boolean economyEnabled = true; + private String economyCurrencyName = "dollar"; + private String economyCurrencyNamePlural = "dollars"; + private String economyCurrencySymbol = "$"; + private double economyStartingBalance = 0.0; + // Message settings private String prefix = "\u00A7b[HyperFactions]\u00A7r "; private String primaryColor = "#00FFFF"; @@ -189,6 +200,23 @@ public void load(@NotNull Path dataDir) { updateCheckUrl = getString(updates, "url", updateCheckUrl); } + // Auto-save settings + if (root.has("autoSave") && root.get("autoSave").isJsonObject()) { + JsonObject autoSave = root.getAsJsonObject("autoSave"); + autoSaveEnabled = getBool(autoSave, "enabled", autoSaveEnabled); + autoSaveIntervalMinutes = getInt(autoSave, "intervalMinutes", autoSaveIntervalMinutes); + } + + // Economy settings + if (root.has("economy") && root.get("economy").isJsonObject()) { + JsonObject economy = root.getAsJsonObject("economy"); + economyEnabled = getBool(economy, "enabled", economyEnabled); + economyCurrencyName = getString(economy, "currencyName", economyCurrencyName); + economyCurrencyNamePlural = getString(economy, "currencyNamePlural", economyCurrencyNamePlural); + economyCurrencySymbol = getString(economy, "currencySymbol", economyCurrencySymbol); + economyStartingBalance = getDouble(economy, "startingBalance", economyStartingBalance); + } + // Message settings if (root.has("messages") && root.get("messages").isJsonObject()) { JsonObject messages = root.getAsJsonObject("messages"); @@ -290,6 +318,21 @@ public void save(@NotNull Path dataDir) { updates.addProperty("url", updateCheckUrl); root.add("updates", updates); + // Auto-save settings + JsonObject autoSave = new JsonObject(); + autoSave.addProperty("enabled", autoSaveEnabled); + autoSave.addProperty("intervalMinutes", autoSaveIntervalMinutes); + root.add("autoSave", autoSave); + + // Economy settings + JsonObject economy = new JsonObject(); + economy.addProperty("enabled", economyEnabled); + economy.addProperty("currencyName", economyCurrencyName); + economy.addProperty("currencyNamePlural", economyCurrencyNamePlural); + economy.addProperty("currencySymbol", economyCurrencySymbol); + economy.addProperty("startingBalance", economyStartingBalance); + root.add("economy", economy); + // Message settings JsonObject messages = new JsonObject(); messages.addProperty("prefix", prefix); @@ -366,6 +409,17 @@ public void reload(@NotNull Path dataDir) { public boolean isUpdateCheckEnabled() { return updateCheckEnabled; } public String getUpdateCheckUrl() { return updateCheckUrl; } + // === Auto-save Getters === + public boolean isAutoSaveEnabled() { return autoSaveEnabled; } + public int getAutoSaveIntervalMinutes() { return autoSaveIntervalMinutes; } + + // === Economy Getters === + public boolean isEconomyEnabled() { return economyEnabled; } + public String getEconomyCurrencyName() { return economyCurrencyName; } + public String getEconomyCurrencyNamePlural() { return economyCurrencyNamePlural; } + public String getEconomyCurrencySymbol() { return economyCurrencySymbol; } + public double getEconomyStartingBalance() { return economyStartingBalance; } + // === Message Getters === public String getPrefix() { return prefix; } public String getPrimaryColor() { return primaryColor; } diff --git a/src/main/java/com/hyperfactions/data/FactionEconomy.java b/src/main/java/com/hyperfactions/data/FactionEconomy.java new file mode 100644 index 0000000..7004ace --- /dev/null +++ b/src/main/java/com/hyperfactions/data/FactionEconomy.java @@ -0,0 +1,121 @@ +package com.hyperfactions.data; + +import com.hyperfactions.api.EconomyAPI; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Represents a faction's economy data. + */ +public record FactionEconomy( + double balance, + @NotNull List transactionHistory +) { + /** + * Maximum number of transactions to keep in history. + */ + public static final int MAX_HISTORY = 50; + + /** + * Creates an empty economy with zero balance. + * + * @return a new empty FactionEconomy + */ + public static FactionEconomy empty() { + return new FactionEconomy(0.0, new ArrayList<>()); + } + + /** + * Creates an economy with a starting balance. + * + * @param startingBalance the starting balance + * @return a new FactionEconomy + */ + public static FactionEconomy withStartingBalance(double startingBalance) { + return new FactionEconomy(startingBalance, new ArrayList<>()); + } + + /** + * Defensive copy constructor. + */ + public FactionEconomy { + // Defensive copy + transactionHistory = new ArrayList<>(transactionHistory); + } + + /** + * Returns a copy with updated balance. + * + * @param newBalance the new balance + * @return a new FactionEconomy with the updated balance + */ + public FactionEconomy withBalance(double newBalance) { + return new FactionEconomy(newBalance, transactionHistory); + } + + /** + * Returns a copy with added transaction. + * + * @param transaction the transaction to add + * @return a new FactionEconomy with the transaction added + */ + public FactionEconomy withTransaction(@NotNull EconomyAPI.Transaction transaction) { + List newHistory = new ArrayList<>(transactionHistory); + newHistory.add(0, transaction); // Add to front (most recent first) + + // Trim if exceeds max history + while (newHistory.size() > MAX_HISTORY) { + newHistory.remove(newHistory.size() - 1); + } + + return new FactionEconomy(balance, newHistory); + } + + /** + * Returns a copy with updated balance and added transaction. + * + * @param newBalance the new balance + * @param transaction the transaction to add + * @return a new FactionEconomy + */ + public FactionEconomy withBalanceAndTransaction(double newBalance, + @NotNull EconomyAPI.Transaction transaction) { + return withBalance(newBalance).withTransaction(transaction); + } + + /** + * Checks if there are sufficient funds for a withdrawal. + * + * @param amount the amount to check + * @return true if balance >= amount + */ + public boolean hasFunds(double amount) { + return balance >= amount; + } + + /** + * Gets the recent transaction history. + * + * @param limit maximum number of transactions to return + * @return list of transactions (most recent first) + */ + @NotNull + public List getRecentTransactions(int limit) { + if (limit <= 0) return Collections.emptyList(); + if (limit >= transactionHistory.size()) { + return Collections.unmodifiableList(transactionHistory); + } + return Collections.unmodifiableList(transactionHistory.subList(0, limit)); + } + + /** + * Gets an unmodifiable view of the transaction history. + * + * @return unmodifiable list of transactions + */ + @NotNull + public List transactionHistory() { + return Collections.unmodifiableList(transactionHistory); + } +} diff --git a/src/main/java/com/hyperfactions/data/FactionLog.java b/src/main/java/com/hyperfactions/data/FactionLog.java index fba7841..ac3f866 100644 --- a/src/main/java/com/hyperfactions/data/FactionLog.java +++ b/src/main/java/com/hyperfactions/data/FactionLog.java @@ -37,7 +37,8 @@ public enum LogType { RELATION_NEUTRAL("Neutral"), LEADER_TRANSFER("Transfer"), SETTINGS_CHANGE("Settings"), - POWER_CHANGE("Power"); + POWER_CHANGE("Power"), + ECONOMY("Economy"); private final String displayName; diff --git a/src/main/java/com/hyperfactions/data/TeleportContext.java b/src/main/java/com/hyperfactions/data/TeleportContext.java new file mode 100644 index 0000000..dc36b24 --- /dev/null +++ b/src/main/java/com/hyperfactions/data/TeleportContext.java @@ -0,0 +1,98 @@ +package com.hyperfactions.data; + +import com.hyperfactions.data.Faction; +import com.hyperfactions.manager.TeleportManager.StartLocation; +import com.hyperfactions.manager.TeleportManager.TaskScheduler; +import com.hyperfactions.manager.TeleportManager.TeleportExecutor; +import com.hyperfactions.manager.TeleportManager.TeleportResult; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Context object for teleportation operations. + * Encapsulates all the callbacks and data needed for a teleport request. + * + *

This replaces the previous 7-parameter method signature with a cleaner builder pattern.

+ */ +public record TeleportContext( + @NotNull UUID playerUuid, + @NotNull StartLocation startLocation, + @NotNull TaskScheduler scheduleTask, + @NotNull Consumer cancelTask, + @NotNull TeleportExecutor doTeleport, + @NotNull Consumer sendMessage, + @NotNull Supplier isTagged +) { + /** + * Builder for creating TeleportContext instances. + */ + public static class Builder { + private UUID playerUuid; + private StartLocation startLocation; + private TaskScheduler scheduleTask; + private Consumer cancelTask; + private TeleportExecutor doTeleport; + private Consumer sendMessage; + private Supplier isTagged; + + public Builder playerUuid(@NotNull UUID playerUuid) { + this.playerUuid = playerUuid; + return this; + } + + public Builder startLocation(@NotNull StartLocation startLocation) { + this.startLocation = startLocation; + return this; + } + + public Builder scheduleTask(@NotNull TaskScheduler scheduleTask) { + this.scheduleTask = scheduleTask; + return this; + } + + public Builder cancelTask(@NotNull Consumer cancelTask) { + this.cancelTask = cancelTask; + return this; + } + + public Builder doTeleport(@NotNull TeleportExecutor doTeleport) { + this.doTeleport = doTeleport; + return this; + } + + public Builder sendMessage(@NotNull Consumer sendMessage) { + this.sendMessage = sendMessage; + return this; + } + + public Builder isTagged(@NotNull Supplier isTagged) { + this.isTagged = isTagged; + return this; + } + + public TeleportContext build() { + if (playerUuid == null) throw new IllegalStateException("playerUuid is required"); + if (startLocation == null) throw new IllegalStateException("startLocation is required"); + if (scheduleTask == null) throw new IllegalStateException("scheduleTask is required"); + if (cancelTask == null) throw new IllegalStateException("cancelTask is required"); + if (doTeleport == null) throw new IllegalStateException("doTeleport is required"); + if (sendMessage == null) throw new IllegalStateException("sendMessage is required"); + if (isTagged == null) throw new IllegalStateException("isTagged is required"); + + return new TeleportContext( + playerUuid, startLocation, scheduleTask, cancelTask, + doTeleport, sendMessage, isTagged + ); + } + } + + /** + * Creates a new builder for TeleportContext. + */ + public static Builder builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/hyperfactions/manager/ClaimManager.java b/src/main/java/com/hyperfactions/manager/ClaimManager.java index f0a6c86..c1943dc 100644 --- a/src/main/java/com/hyperfactions/manager/ClaimManager.java +++ b/src/main/java/com/hyperfactions/manager/ClaimManager.java @@ -23,6 +23,9 @@ public class ClaimManager { // Index: ChunkKey -> faction ID for fast lookups private final Map claimIndex = new ConcurrentHashMap<>(); + // Reverse index: faction ID -> Set for O(1) getFactionClaims() + private final Map> factionClaimsIndex = new ConcurrentHashMap<>(); + public ClaimManager(@NotNull FactionManager factionManager, @NotNull PowerManager powerManager) { this.factionManager = factionManager; this.powerManager = powerManager; @@ -34,14 +37,21 @@ public ClaimManager(@NotNull FactionManager factionManager, @NotNull PowerManage */ public void buildIndex() { claimIndex.clear(); + factionClaimsIndex.clear(); for (Faction faction : factionManager.getAllFactions()) { + Set factionClaims = ConcurrentHashMap.newKeySet(); for (FactionClaim claim : faction.claims()) { - claimIndex.put(claim.toChunkKey(), faction.id()); + ChunkKey key = claim.toChunkKey(); + claimIndex.put(key, faction.id()); + factionClaims.add(key); + } + if (!factionClaims.isEmpty()) { + factionClaimsIndex.put(faction.id(), factionClaims); } } - Logger.info("Built claim index with %d claims", claimIndex.size()); + Logger.info("Built claim index with %d claims for %d factions", claimIndex.size(), factionClaimsIndex.size()); } /** @@ -193,8 +203,9 @@ public ClaimResult claim(@NotNull UUID playerUuid, @NotNull String world, int ch .withLog(FactionLog.create(FactionLog.LogType.CLAIM, String.format("Claimed chunk at %d, %d in %s", chunkX, chunkZ, world), playerUuid)); - // Update index and faction + // Update indices and faction claimIndex.put(key, faction.id()); + factionClaimsIndex.computeIfAbsent(faction.id(), k -> ConcurrentHashMap.newKeySet()).add(key); factionManager.updateFaction(updated); return ClaimResult.SUCCESS; @@ -247,6 +258,13 @@ public ClaimResult unclaim(@NotNull UUID playerUuid, @NotNull String world, int String.format("Unclaimed chunk at %d, %d in %s", chunkX, chunkZ, world), playerUuid)); claimIndex.remove(key); + Set factionClaims = factionClaimsIndex.get(faction.id()); + if (factionClaims != null) { + factionClaims.remove(key); + if (factionClaims.isEmpty()) { + factionClaimsIndex.remove(faction.id()); + } + } factionManager.updateFaction(updated); return ClaimResult.SUCCESS; @@ -320,8 +338,20 @@ public ClaimResult overclaim(@NotNull UUID playerUuid, @NotNull String world, in .withLog(FactionLog.create(FactionLog.LogType.OVERCLAIM, String.format("Overclaimed chunk at %d, %d from %s", chunkX, chunkZ, defenderFaction.name()), playerUuid)); - // Update index and factions + // Update indices - remove from defender + Set defenderClaims = factionClaimsIndex.get(defenderId); + if (defenderClaims != null) { + defenderClaims.remove(key); + if (defenderClaims.isEmpty()) { + factionClaimsIndex.remove(defenderId); + } + } + + // Update indices - add to attacker claimIndex.put(key, attackerFaction.id()); + factionClaimsIndex.computeIfAbsent(attackerFaction.id(), k -> ConcurrentHashMap.newKeySet()).add(key); + + // Update factions factionManager.updateFaction(updatedDefender); factionManager.updateFaction(updatedAttacker); @@ -335,24 +365,27 @@ public ClaimResult overclaim(@NotNull UUID playerUuid, @NotNull String world, in * @param factionId the faction ID */ public void unclaimAll(@NotNull UUID factionId) { + // Remove from main index claimIndex.entrySet().removeIf(entry -> entry.getValue().equals(factionId)); + // Remove from reverse index + factionClaimsIndex.remove(factionId); } /** * Gets all claims for a faction. + * O(1) lookup using the reverse index. * * @param factionId the faction ID - * @return set of chunk keys + * @return set of chunk keys (unmodifiable view) */ @NotNull public Set getFactionClaims(@NotNull UUID factionId) { - Set claims = new HashSet<>(); - for (var entry : claimIndex.entrySet()) { - if (entry.getValue().equals(factionId)) { - claims.add(entry.getKey()); - } + Set claims = factionClaimsIndex.get(factionId); + if (claims == null) { + return Collections.emptySet(); } - return claims; + // Return unmodifiable view to prevent external modification + return Collections.unmodifiableSet(claims); } private ClaimResult forceClaimChunk(Faction faction, UUID playerUuid, String world, int chunkX, int chunkZ) { @@ -363,7 +396,9 @@ private ClaimResult forceClaimChunk(Faction faction, UUID playerUuid, String wor .withLog(FactionLog.create(FactionLog.LogType.CLAIM, String.format("Claimed chunk at %d, %d in %s", chunkX, chunkZ, world), playerUuid)); + // Update both indices claimIndex.put(key, faction.id()); + factionClaimsIndex.computeIfAbsent(faction.id(), k -> ConcurrentHashMap.newKeySet()).add(key); factionManager.updateFaction(updated); return ClaimResult.SUCCESS; diff --git a/src/main/java/com/hyperfactions/manager/EconomyManager.java b/src/main/java/com/hyperfactions/manager/EconomyManager.java new file mode 100644 index 0000000..3487df3 --- /dev/null +++ b/src/main/java/com/hyperfactions/manager/EconomyManager.java @@ -0,0 +1,386 @@ +package com.hyperfactions.manager; + +import com.hyperfactions.api.EconomyAPI; +import com.hyperfactions.config.HyperFactionsConfig; +import com.hyperfactions.data.Faction; +import com.hyperfactions.data.FactionEconomy; +import com.hyperfactions.data.FactionLog; +import com.hyperfactions.util.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.text.NumberFormat; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** + * Manages faction treasury and economy operations. + */ +public class EconomyManager implements EconomyAPI { + + private final FactionManager factionManager; + + // Cache for economy data (stored on Faction record) + private final Map economyCache = new HashMap<>(); + + // Currency formatting + private final NumberFormat currencyFormat; + + public EconomyManager(@NotNull FactionManager factionManager) { + this.factionManager = factionManager; + this.currencyFormat = NumberFormat.getCurrencyInstance(Locale.US); + } + + /** + * Initializes economy data for a faction. + * + * @param factionId the faction ID + */ + public void initializeFaction(@NotNull UUID factionId) { + if (!economyCache.containsKey(factionId)) { + economyCache.put(factionId, FactionEconomy.empty()); + } + } + + /** + * Cleans up economy data when a faction is disbanded. + * + * @param factionId the faction ID + */ + public void removeFaction(@NotNull UUID factionId) { + economyCache.remove(factionId); + } + + /** + * Gets the economy data for a faction. + * + * @param factionId the faction ID + * @return the economy data, or null if faction not found + */ + @Nullable + public FactionEconomy getEconomy(@NotNull UUID factionId) { + return economyCache.get(factionId); + } + + /** + * Loads economy data from all factions. + * Should be called after FactionManager loads. + */ + public void loadAll() { + for (Faction faction : factionManager.getAllFactions()) { + // Initialize with empty economy if not present + economyCache.put(faction.id(), FactionEconomy.empty()); + } + Logger.info("Loaded economy data for %d factions", economyCache.size()); + } + + // === EconomyAPI Implementation === + + @Override + public double getFactionBalance(@NotNull UUID factionId) { + FactionEconomy economy = economyCache.get(factionId); + return economy != null ? economy.balance() : 0.0; + } + + @Override + public boolean hasFunds(@NotNull UUID factionId, double amount) { + if (amount <= 0) return true; + FactionEconomy economy = economyCache.get(factionId); + return economy != null && economy.hasFunds(amount); + } + + @Override + @NotNull + public CompletableFuture deposit( + @NotNull UUID factionId, + double amount, + @Nullable UUID actorId, + @NotNull String description + ) { + return CompletableFuture.supplyAsync(() -> { + if (amount <= 0) { + return TransactionResult.INVALID_AMOUNT; + } + + Faction faction = factionManager.getFaction(factionId); + if (faction == null) { + return TransactionResult.FACTION_NOT_FOUND; + } + + FactionEconomy economy = economyCache.get(factionId); + if (economy == null) { + economy = FactionEconomy.empty(); + } + + double newBalance = economy.balance() + amount; + Transaction transaction = new Transaction( + factionId, + actorId, + TransactionType.DEPOSIT, + amount, + newBalance, + System.currentTimeMillis(), + description + ); + + FactionEconomy updated = economy.withBalanceAndTransaction(newBalance, transaction); + economyCache.put(factionId, updated); + + // Log to faction + String logMessage = String.format("Deposit: %s (+%s)", + formatCurrency(newBalance), formatCurrency(amount)); + Faction updatedFaction = faction.withLog( + FactionLog.create(FactionLog.LogType.ECONOMY, logMessage, actorId) + ); + factionManager.updateFaction(updatedFaction); + + Logger.debug("Deposit to %s: %s (new balance: %s)", + faction.name(), formatCurrency(amount), formatCurrency(newBalance)); + + return TransactionResult.SUCCESS; + }); + } + + @Override + @NotNull + public CompletableFuture withdraw( + @NotNull UUID factionId, + double amount, + @NotNull UUID actorId, + @NotNull String description + ) { + return CompletableFuture.supplyAsync(() -> { + if (amount <= 0) { + return TransactionResult.INVALID_AMOUNT; + } + + Faction faction = factionManager.getFaction(factionId); + if (faction == null) { + return TransactionResult.FACTION_NOT_FOUND; + } + + // Check permission (officer+) + var member = faction.getMember(actorId); + if (member == null) { + return TransactionResult.NOT_IN_FACTION; + } + if (!member.isOfficerOrHigher()) { + return TransactionResult.NO_PERMISSION; + } + + FactionEconomy economy = economyCache.get(factionId); + if (economy == null || !economy.hasFunds(amount)) { + return TransactionResult.INSUFFICIENT_FUNDS; + } + + double newBalance = economy.balance() - amount; + Transaction transaction = new Transaction( + factionId, + actorId, + TransactionType.WITHDRAW, + amount, + newBalance, + System.currentTimeMillis(), + description + ); + + FactionEconomy updated = economy.withBalanceAndTransaction(newBalance, transaction); + economyCache.put(factionId, updated); + + // Log to faction + String logMessage = String.format("Withdrawal: %s (-%s)", + formatCurrency(newBalance), formatCurrency(amount)); + Faction updatedFaction = faction.withLog( + FactionLog.create(FactionLog.LogType.ECONOMY, logMessage, actorId) + ); + factionManager.updateFaction(updatedFaction); + + Logger.debug("Withdrawal from %s: %s (new balance: %s)", + faction.name(), formatCurrency(amount), formatCurrency(newBalance)); + + return TransactionResult.SUCCESS; + }); + } + + @Override + @NotNull + public CompletableFuture transfer( + @NotNull UUID fromFactionId, + @NotNull UUID toFactionId, + double amount, + @Nullable UUID actorId, + @NotNull String description + ) { + return CompletableFuture.supplyAsync(() -> { + if (amount <= 0) { + return TransactionResult.INVALID_AMOUNT; + } + + Faction fromFaction = factionManager.getFaction(fromFactionId); + Faction toFaction = factionManager.getFaction(toFactionId); + + if (fromFaction == null || toFaction == null) { + return TransactionResult.FACTION_NOT_FOUND; + } + + FactionEconomy fromEconomy = economyCache.get(fromFactionId); + if (fromEconomy == null || !fromEconomy.hasFunds(amount)) { + return TransactionResult.INSUFFICIENT_FUNDS; + } + + FactionEconomy toEconomy = economyCache.get(toFactionId); + if (toEconomy == null) { + toEconomy = FactionEconomy.empty(); + } + + // Perform transfer + double fromNewBalance = fromEconomy.balance() - amount; + double toNewBalance = toEconomy.balance() + amount; + + Transaction fromTransaction = new Transaction( + fromFactionId, + actorId, + TransactionType.TRANSFER_OUT, + amount, + fromNewBalance, + System.currentTimeMillis(), + "Transfer to " + toFaction.name() + ": " + description + ); + + Transaction toTransaction = new Transaction( + toFactionId, + actorId, + TransactionType.TRANSFER_IN, + amount, + toNewBalance, + System.currentTimeMillis(), + "Transfer from " + fromFaction.name() + ": " + description + ); + + // Update both economies + economyCache.put(fromFactionId, fromEconomy.withBalanceAndTransaction(fromNewBalance, fromTransaction)); + economyCache.put(toFactionId, toEconomy.withBalanceAndTransaction(toNewBalance, toTransaction)); + + Logger.debug("Transfer from %s to %s: %s", + fromFaction.name(), toFaction.name(), formatCurrency(amount)); + + return TransactionResult.SUCCESS; + }); + } + + @Override + @NotNull + public List getTransactionHistory(@NotNull UUID factionId, int limit) { + FactionEconomy economy = economyCache.get(factionId); + if (economy == null) { + return Collections.emptyList(); + } + return economy.getRecentTransactions(limit); + } + + @Override + @NotNull + public String getCurrencyName() { + return HyperFactionsConfig.get().getEconomyCurrencyName(); + } + + @Override + @NotNull + public String getCurrencyNamePlural() { + return HyperFactionsConfig.get().getEconomyCurrencyNamePlural(); + } + + @Override + @NotNull + public String formatCurrency(double amount) { + String symbol = HyperFactionsConfig.get().getEconomyCurrencySymbol(); + return String.format("%s%.2f", symbol, amount); + } + + @Override + public boolean isEnabled() { + return HyperFactionsConfig.get().isEconomyEnabled(); + } + + /** + * Performs a system deposit (no actor, for rewards/adjustments). + * + * @param factionId the faction ID + * @param amount the amount + * @param type the transaction type + * @param description description + * @return the result + */ + @NotNull + public TransactionResult systemDeposit( + @NotNull UUID factionId, + double amount, + @NotNull TransactionType type, + @NotNull String description + ) { + if (amount <= 0) { + return TransactionResult.INVALID_AMOUNT; + } + + Faction faction = factionManager.getFaction(factionId); + if (faction == null) { + return TransactionResult.FACTION_NOT_FOUND; + } + + FactionEconomy economy = economyCache.getOrDefault(factionId, FactionEconomy.empty()); + double newBalance = economy.balance() + amount; + + Transaction transaction = new Transaction( + factionId, + null, // System + type, + amount, + newBalance, + System.currentTimeMillis(), + description + ); + + economyCache.put(factionId, economy.withBalanceAndTransaction(newBalance, transaction)); + return TransactionResult.SUCCESS; + } + + /** + * Performs a system withdrawal (no actor, for upkeep/costs). + * + * @param factionId the faction ID + * @param amount the amount + * @param type the transaction type + * @param description description + * @return the result + */ + @NotNull + public TransactionResult systemWithdraw( + @NotNull UUID factionId, + double amount, + @NotNull TransactionType type, + @NotNull String description + ) { + if (amount <= 0) { + return TransactionResult.INVALID_AMOUNT; + } + + FactionEconomy economy = economyCache.get(factionId); + if (economy == null || !economy.hasFunds(amount)) { + return TransactionResult.INSUFFICIENT_FUNDS; + } + + double newBalance = economy.balance() - amount; + Transaction transaction = new Transaction( + factionId, + null, // System + type, + amount, + newBalance, + System.currentTimeMillis(), + description + ); + + economyCache.put(factionId, economy.withBalanceAndTransaction(newBalance, transaction)); + return TransactionResult.SUCCESS; + } +} diff --git a/src/main/java/com/hyperfactions/manager/TeleportManager.java b/src/main/java/com/hyperfactions/manager/TeleportManager.java index cd3d0ab..9b3521c 100644 --- a/src/main/java/com/hyperfactions/manager/TeleportManager.java +++ b/src/main/java/com/hyperfactions/manager/TeleportManager.java @@ -2,6 +2,7 @@ import com.hyperfactions.config.HyperFactionsConfig; import com.hyperfactions.data.Faction; +import com.hyperfactions.data.TeleportContext; import com.hyperfactions.integration.HyperPermsIntegration; import com.hyperfactions.util.Logger; import com.hyperfactions.util.TimeUtil; @@ -219,6 +220,25 @@ public TeleportResult teleportToHome( return TeleportResult.SUCCESS; } + /** + * Initiates a teleport to faction home using a TeleportContext. + * This is the recommended method signature for cleaner code. + * + * @param context the teleport context containing all required parameters + * @return the initial result + */ + public TeleportResult teleportToHome(@NotNull TeleportContext context) { + return teleportToHome( + context.playerUuid(), + context.startLocation(), + context.scheduleTask(), + context.cancelTask(), + context.doTeleport(), + context.sendMessage(), + context.isTagged() + ); + } + /** * Checks if a player moved and should cancel teleport. * diff --git a/src/main/java/com/hyperfactions/platform/HyperFactionsPlugin.java b/src/main/java/com/hyperfactions/platform/HyperFactionsPlugin.java index 42622ec..41c8d64 100644 --- a/src/main/java/com/hyperfactions/platform/HyperFactionsPlugin.java +++ b/src/main/java/com/hyperfactions/platform/HyperFactionsPlugin.java @@ -141,7 +141,7 @@ private void configurePlatformCallbacks() { java.util.concurrent.CompletableFuture.runAsync(task); }); - // Task scheduler + // Task scheduler (for one-shot delayed tasks) hyperFactions.setTaskScheduler((delayTicks, task) -> { int id = taskIdCounter.incrementAndGet(); java.util.Timer timer = new java.util.Timer(); @@ -157,6 +157,22 @@ public void run() { return id; }); + // Repeating task scheduler (for periodic tasks like auto-save) + hyperFactions.setRepeatingTaskScheduler((delayTicks, periodTicks, task) -> { + int id = taskIdCounter.incrementAndGet(); + java.util.Timer timer = new java.util.Timer(); + long delayMs = delayTicks * 50L; + long periodMs = periodTicks * 50L; + timer.scheduleAtFixedRate(new java.util.TimerTask() { + @Override + public void run() { + task.run(); + } + }, delayMs, periodMs); + scheduledTasks.put(id, timer); + return id; + }); + // Task canceller hyperFactions.setTaskCanceller(taskId -> { Object task = scheduledTasks.remove(taskId); @@ -219,7 +235,7 @@ private void registerBlockProtectionSystems() { } /** - * Starts periodic tasks (power regen, combat tag decay). + * Starts periodic tasks (power regen, combat tag decay, auto-save, invite cleanup). */ private void startPeriodicTasks() { tickExecutor = Executors.newSingleThreadScheduledExecutor(r -> { @@ -252,6 +268,9 @@ private void startPeriodicTasks() { 1, 1, TimeUnit.SECONDS ); + // Start core periodic tasks (auto-save, invite cleanup) + hyperFactions.startPeriodicTasks(); + getLogger().at(Level.INFO).log("Started periodic tasks"); }