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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ESSENTIALS_BACK` zone flag — controls whether /back teleportation works in zones (defaults to allowed)
- `FactionHomeTeleportEvent` and `FactionHomeTeleportPreEvent` events for home teleport tracking

**Per-World Max Claims**
- New `maxClaims` per-world setting in `worlds.json` — limits how many claims a single faction can hold in a specific world
- `null` or `0` = use global limit, `>0` = per-faction per-world hard cap
- Enforced in both `/f claim` and `/f overclaim` flows
- Admin commands: `/f admin world set <world> maxclaims <value>`, supports `default`/`0` to clear
- New `WORLD_MAX_CLAIMS_REACHED` claim result handled in all consumer sites (commands, GUI map, dashboard)
- Localized error messages in all 10 locales

**World Settings API**
- `HyperFactionsAPI.registerWorldSettings(worldKey, settings)` — upsert with persistence, thread-safe
- `HyperFactionsAPI.getWorldSettings(worldName)` — resolved through wildcard pattern matching
- `HyperFactionsAPI.getConfiguredWorldSettings(worldKey)` — exact key match, no pattern resolution
- `HyperFactionsAPI.removeWorldSettings(worldKey)` — removes and persists
- `WorldSettingsResolver` made thread-safe with volatile fields and copy-on-write rebuild

### Changed

**Consolidate Duplicate Message Keys**
Expand Down
55 changes: 55 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This document is for third-party mod developers who want to hook into HyperFacti
- [Protection](#protection)
- [Language / i18n](#language--i18n)
- [Chat Color Customization](#chat-color-customization)
- [World Settings](#world-settings)
- [Configuration](#configuration)
- [Manager Access](#manager-access)
- [Economy API](#economy-api)
Expand Down Expand Up @@ -349,6 +350,60 @@ HyperFactionsAPI.setChatColors(originalColors);

---

## World Settings

Manage per-world behavior overrides at runtime. Other plugins can register, query, and remove world settings programmatically. Changes are persisted to `worlds.json` immediately.

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `registerWorldSettings(String worldKey, WorldsConfig.WorldSettings settings)` | `void` | Upsert world settings — creates or replaces the entry for `worldKey`, persists to disk. Thread-safe. |
| `getWorldSettings(String worldName)` | `@Nullable WorldsConfig.WorldSettings` | Resolve settings for a world name, including wildcard pattern matching (exact match > wildcards > null). |
| `getConfiguredWorldSettings(String worldKey)` | `@Nullable WorldsConfig.WorldSettings` | Get settings for an exact key only (no pattern matching). Returns null if the key is not configured. |
| `removeWorldSettings(String worldKey)` | `void` | Remove the entry for `worldKey` and persist the change. No-op if key does not exist. |

### WorldSettings Record

`WorldsConfig.WorldSettings` is a record with 5 fields. Any field set to `null` inherits from global config:

```java
record WorldSettings(
@Nullable Boolean claiming, // Allow claiming in this world
@Nullable Boolean powerLoss, // Apply power loss in this world
@Nullable Boolean friendlyFireFaction, // Same-faction PvP override
@Nullable Boolean friendlyFireAlly, // Ally PvP override
@Nullable Integer maxClaims // Per-faction claim cap (null/0 = use global)
)
```

### Example

```java
// Register world settings from another mod
WorldsConfig.WorldSettings eventSettings = new WorldsConfig.WorldSettings(
true, // claiming allowed
false, // no power loss
null, // faction FF: use global
null, // ally FF: use global
5 // max 5 claims per faction
);
HyperFactionsAPI.registerWorldSettings("events", eventSettings);

// Query resolved settings (includes pattern matching)
WorldsConfig.WorldSettings resolved = HyperFactionsAPI.getWorldSettings("events");

// Query exact key only (no pattern matching)
WorldsConfig.WorldSettings exact = HyperFactionsAPI.getConfiguredWorldSettings("events");

// Remove settings
HyperFactionsAPI.removeWorldSettings("events");
```

> **Note:** `registerWorldSettings()` uses upsert semantics — if the key already exists, the entry is replaced. All mutations are thread-safe and persisted to `worlds.json` immediately.

---

## Configuration

| Method | Description |
Expand Down
4 changes: 3 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,9 @@ Admin commands use nested subcommand structure:
├── world # Per-world settings management
│ ├── list # List all world overrides
│ ├── info <world> # Show settings for a world
│ ├── set <world> <key> <value> # Set a per-world setting
│ ├── set <world> <key> <value> # Set a per-world setting (keys: claiming, powerLoss, friendlyFireFaction, friendlyFireAlly, maxClaims)
│ │ # maxClaims takes an integer (e.g., /f admin world set events maxClaims 5)
│ │ # Use "maxClaims default" or "maxClaims 0" to clear per-world limit (inherit global)
│ └── reset <world> # Reset world to defaults
├── economy # Economy management
│ └── upkeep # Upkeep system control
Expand Down
8 changes: 5 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ Territory settings:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `maxClaims` | int | 100 | Hard limit per faction |
| `maxClaims` | int | 100 | Global hard limit per faction (can be overridden per-world via `worlds.json`) |
| `onlyAdjacent` | bool | false | Require adjacent claims |
| `decayEnabled` | bool | true | Enable claim decay |
| `decayDaysInactive` | int | 30 | Days before decay starts |
Expand Down Expand Up @@ -564,14 +564,15 @@ Per-world behavior overrides in `config/worlds.json`:
| `claimBlacklist` | array | [] | Worlds where claiming is unconditionally blocked |
| `worlds` | object | `{}` | Per-world setting overrides (keyed by world name or wildcard pattern) |

Per-world settings (4 per entry):
Per-world settings (5 per entry):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `claiming` | bool | true | Whether claiming is allowed in this world |
| `powerLoss` | bool | true | Whether power loss applies in this world |
| `friendlyFireFaction` | bool | *(from global config)* | Same-faction PvP override |
| `friendlyFireAlly` | bool | *(from global config)* | Ally PvP override |
| `maxClaims` | Integer | null | Maximum claims a faction can hold in this world. `null` or `0` = use global limit, `>0` = per-faction per-world hard cap |

**Wildcard support**: Use `%` as a wildcard in world names (e.g., `arena_%` matches `arena_1`, `arena_pvp`). Priority resolution: exact name match > wildcard patterns (fewer wildcards = higher priority) > default policy.

Expand All @@ -584,7 +585,8 @@ Per-world settings (4 per entry):
"claimBlacklist": ["lobby"],
"worlds": {
"arena_%": { "claiming": false, "powerLoss": false },
"instance-%": { "claiming": false }
"instance-%": { "claiming": false },
"events": { "claiming": true, "powerLoss": false, "maxClaims": 5 }
}
}
```
Expand Down
4 changes: 4 additions & 0 deletions docs/managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ Territory claiming and chunk ownership tracking.
| `overclaim(playerUuid, world, chunkX, chunkZ)` | `territory.overclaim` | `ClaimResult` |
| `getClaimOwner(world, chunkX, chunkZ)` | - | `UUID` (factionId) |
| `getClaimCount(factionId)` | - | `int` |
| `countFactionClaimsInWorld(factionId, world)` | - | `int` |
| `getFactionClaims(factionId)` | - | `List<FactionClaim>` |

### Result Enum
Expand All @@ -212,6 +213,7 @@ public enum ClaimResult {
ALREADY_YOURS,
INSUFFICIENT_POWER,
MAX_CLAIMS_REACHED,
WORLD_MAX_CLAIMS_REACHED,
ADJACENT_REQUIRED,
WORLD_BLACKLISTED,
NOT_IN_WHITELIST,
Expand All @@ -220,6 +222,8 @@ public enum ClaimResult {
}
```

`WORLD_MAX_CLAIMS_REACHED` is returned when the faction has hit the per-world claim cap configured in `worlds.json` (the `maxClaims` setting). This is checked in both the `claim()` and `overclaim()` flows using the `countFactionClaimsInWorld()` helper, which counts existing claims for a faction in a specific world.

### Debounce

Claim and unclaim operations have a 500ms per-player debounce to prevent double-execution from rapid command dispatch or key-down/key-up events.
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/com/hyperfactions/api/HyperFactionsAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,59 @@ public static Set<ChunkKey> getFactionClaims(@NotNull UUID factionId) {
return getInstance().getClaimManager().getFactionClaims(factionId);
}

// === World Settings ===

/**
* Registers or updates per-world settings for the given world key.
* Upsert semantics: skips save if settings are identical to existing.
* Settings are persisted to worlds.json immediately.
* Thread-safe — can be called from any thread.
*
* @param worldKey the world name or wildcard pattern (e.g., "events", "instance_%")
* @param settings the settings to apply (null fields = inherit from global config)
*/
public static void registerWorldSettings(@NotNull String worldKey,
@NotNull com.hyperfactions.config.modules.WorldsConfig.WorldSettings settings) {
ConfigManager.get().registerWorldSettings(worldKey, settings);
}

/**
* Gets the resolved settings for a world (through pattern matching).
* Returns null if no specific settings exist for this world.
*
* @param worldName the world name
* @return resolved settings, or null for default policy
*/
@Nullable
public static com.hyperfactions.config.modules.WorldsConfig.WorldSettings getWorldSettings(
@NotNull String worldName) {
return ConfigManager.get().getWorldSettingsResolver().resolve(worldName);
}

/**
* Gets the raw configured settings for an exact world key.
* Does NOT do pattern matching — returns settings only if the exact key exists.
*
* @param worldKey the exact world key
* @return the settings, or null if not configured
*/
@Nullable
public static com.hyperfactions.config.modules.WorldsConfig.WorldSettings getConfiguredWorldSettings(
@NotNull String worldKey) {
return ConfigManager.get().worlds().getWorldSettings(worldKey);
}

/**
* Removes per-world settings for the given key and persists.
* Thread-safe.
*
* @param worldKey the world key to remove
* @return true if settings were removed
*/
public static boolean removeWorldSettings(@NotNull String worldKey) {
return ConfigManager.get().removeExternalWorldSettings(worldKey);
}

// === Configuration ===

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ private void handleList(CommandContext ctx, @Nullable PlayerRef player) {
if (settings.friendlyFireAlly() != null) {
parts.add("ffAlly=" + boolStr(settings.friendlyFireAlly()));
}
if (settings.maxClaims() != null && settings.maxClaims() > 0) {
parts.add("maxClaims=" + settings.maxClaims());
}

if (parts.isEmpty()) {
line = line.insert(msg("(no overrides)", COLOR_GRAY));
Expand Down Expand Up @@ -167,6 +170,8 @@ private void handleInfo(CommandContext ctx, String[] args) {
ctx.sendMessage(msg(" Power loss: " + boolStr(powerLoss), COLOR_WHITE));
ctx.sendMessage(msg(" Faction FF: " + (ffFaction != null ? boolStr(ffFaction) : "global (" + boolStr(config.isFactionDamage()) + ")"), COLOR_WHITE));
ctx.sendMessage(msg(" Ally FF: " + (ffAlly != null ? boolStr(ffAlly) : "global (" + boolStr(config.isAllyDamage()) + ")"), COLOR_WHITE));
Integer maxClaims = resolved != null ? resolved.maxClaims() : null;
ctx.sendMessage(msg(" Max claims: " + (maxClaims != null && maxClaims > 0 ? maxClaims : "unlimited (global)"), COLOR_WHITE));

if (resolved != null) {
ctx.sendMessage(msg(" Source: per-world override", COLOR_GRAY));
Expand All @@ -181,15 +186,50 @@ private void handleInfo(CommandContext ctx, String[] args) {
*/
private void handleSet(CommandContext ctx, @Nullable PlayerRef player, String[] args) {
if (args.length < 3) {
ctx.sendMessage(prefix().insert(msg("Usage: /f admin world set <world> <setting> <true|false>", COLOR_RED)));
ctx.sendMessage(msg(" Settings: claiming, powerLoss, friendlyFireFaction, friendlyFireAlly", COLOR_GRAY));
ctx.sendMessage(prefix().insert(msg("Usage: /f admin world set <world> <setting> <value>", COLOR_RED)));
ctx.sendMessage(msg(" Settings: claiming, powerLoss, friendlyFireFaction, friendlyFireAlly, maxClaims", COLOR_GRAY));
return;
}

String worldKey = args[0];
String setting = args[1].toLowerCase();
String valueStr = args[2].toLowerCase();

// Handle integer settings
if (setting.equals("maxclaims")) {
WorldsConfig config = ConfigManager.get().worlds();
WorldSettings current = config.getWorldSettings(worldKey);
if (current == null) {
current = WorldSettings.DEFAULTS;
}

Integer maxClaimsVal;
if (valueStr.equals("default") || valueStr.equals("null") || valueStr.equals("0")) {
maxClaimsVal = null;
} else {
try {
maxClaimsVal = Integer.parseInt(valueStr);
} catch (NumberFormatException e) {
ctx.sendMessage(prefix().insert(msg("maxClaims must be a number, 'default', or '0'.", COLOR_RED)));
return;
}
if (maxClaimsVal < 0) {
ctx.sendMessage(prefix().insert(msg("maxClaims cannot be negative.", COLOR_RED)));
return;
}
}

WorldSettings updated = new WorldSettings(current.claiming(), current.powerLoss(),
current.friendlyFireFaction(), current.friendlyFireAlly(), maxClaimsVal);
config.setWorldSettings(worldKey, updated);
config.save();
ConfigManager.get().getWorldSettingsResolver().rebuild(config);

String displayVal = maxClaimsVal != null ? String.valueOf(maxClaimsVal) : "unlimited";
ctx.sendMessage(prefix().insert(msg(HFMessages.get(player, AdminKeys.AdminCmd.WORLD_SET, "maxClaims", displayVal, worldKey), COLOR_GREEN)));
return;
}

if (!valueStr.equals("true") && !valueStr.equals("false")) {
ctx.sendMessage(prefix().insert(msg("Value must be 'true' or 'false'.", COLOR_RED)));
return;
Expand All @@ -203,13 +243,13 @@ private void handleSet(CommandContext ctx, @Nullable PlayerRef player, String[]
}

WorldSettings updated = switch (setting) {
case "claiming" -> new WorldSettings(value, current.powerLoss(), current.friendlyFireFaction(), current.friendlyFireAlly());
case "powerloss" -> new WorldSettings(current.claiming(), value, current.friendlyFireFaction(), current.friendlyFireAlly());
case "friendlyfirefaction", "fffaction" -> new WorldSettings(current.claiming(), current.powerLoss(), value, current.friendlyFireAlly());
case "friendlyfireally", "ffally" -> new WorldSettings(current.claiming(), current.powerLoss(), current.friendlyFireFaction(), value);
case "claiming" -> new WorldSettings(value, current.powerLoss(), current.friendlyFireFaction(), current.friendlyFireAlly(), current.maxClaims());
case "powerloss" -> new WorldSettings(current.claiming(), value, current.friendlyFireFaction(), current.friendlyFireAlly(), current.maxClaims());
case "friendlyfirefaction", "fffaction" -> new WorldSettings(current.claiming(), current.powerLoss(), value, current.friendlyFireAlly(), current.maxClaims());
case "friendlyfireally", "ffally" -> new WorldSettings(current.claiming(), current.powerLoss(), current.friendlyFireFaction(), value, current.maxClaims());
default -> {
ctx.sendMessage(prefix().insert(msg(HFMessages.get(player, AdminKeys.AdminCmd.WORLD_UNKNOWN_SETTING, setting), COLOR_RED)));
ctx.sendMessage(msg(" Settings: claiming, powerLoss, friendlyFireFaction, friendlyFireAlly", COLOR_GRAY));
ctx.sendMessage(msg(" Settings: claiming, powerLoss, friendlyFireFaction, friendlyFireAlly, maxClaims", COLOR_GRAY));
yield null;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.hyperfactions.HyperFactions;
import com.hyperfactions.Permissions;
import com.hyperfactions.config.ConfigManager;
import com.hyperfactions.command.FactionCommandContext;
import com.hyperfactions.command.FactionSubCommand;
import com.hyperfactions.command.util.CommandUtil;
Expand Down Expand Up @@ -115,6 +116,10 @@ protected void execute(@NotNull CommandContext ctx,
case ALREADY_CLAIMED_SELF -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.ALREADY_YOURS));
case ALREADY_CLAIMED_OTHER -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.ALREADY_CLAIMED));
case MAX_CLAIMS_REACHED -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.MAX_CLAIMS));
case WORLD_MAX_CLAIMS_REACHED -> {
Integer wmc = ConfigManager.get().getWorldMaxClaims(currentWorld.getName());
ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.WORLD_MAX_CLAIMS, wmc != null ? wmc : "?"));
}
case NOT_ADJACENT -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.NOT_CONNECTED));
case WORLD_NOT_ALLOWED -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.WORLD_NOT_ALLOWED));
case ORBISGUARD_PROTECTED -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.ORBISGUARD));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.hyperfactions.HyperFactions;
import com.hyperfactions.Permissions;
import com.hyperfactions.config.ConfigManager;
import com.hyperfactions.command.FactionCommandContext;
import com.hyperfactions.command.FactionSubCommand;
import com.hyperfactions.command.util.CommandUtil;
Expand Down Expand Up @@ -86,6 +87,10 @@ protected void execute(@NotNull CommandContext ctx,
case ALREADY_CLAIMED_ALLY -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.OVERCLAIM_ALLY));
case TARGET_HAS_POWER -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.TARGET_HAS_POWER));
case MAX_CLAIMS_REACHED -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.MAX_CLAIMS));
case WORLD_MAX_CLAIMS_REACHED -> {
Integer wmc = ConfigManager.get().getWorldMaxClaims(currentWorld.getName());
ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.WORLD_MAX_CLAIMS, wmc != null ? wmc : "?"));
}
default -> ctx.sendMessage(MessageUtil.error(player, CommandKeys.Claim.OVERCLAIM_FAILED));
}
}
Expand Down
Loading
Loading