Add Coal Cents economy

This commit is contained in:
CoalGov Deploy
2026-05-04 17:00:05 +00:00
commit c8cc200004
73 changed files with 6873 additions and 0 deletions

197
CoalGov/README.md Normal file
View File

@@ -0,0 +1,197 @@
# CoalGov
CoalGov is a server-side Paper/Bukkit plugin for vanilla Minecraft clients. It adds a small coal-backed economy, land classes, claims, mining permits, protected government land, and coal ore mining restrictions.
The ledger uses Coal Cents for sub-coal transactions: `100cc` equals `1 coal`. Commands that accept money still treat plain numbers as coal, so `2.5`, `2.50`, and `250cc` all mean `2 coal 50cc`.
## Build
```bash
./gradlew build
```
The jar is written to `build/libs/CoalGov-*.jar`.
## VPS Install
1. Build with `./gradlew build`.
2. Copy `build/libs/CoalGov-*.jar` to `/path/to/server/plugins/`.
3. Restart the Paper server.
4. Edit `plugins/CoalGov/config.yml`.
5. Run `/coalgov admin reload`.
This is a Paper/Bukkit plugin only. It does not require Forge, Fabric, resource packs, client mods, or custom launchers.
## Commands
- `/coal balance`
- `/coal deposit`
- `/coal withdraw <amount>`; withdrawals must be whole coal because they create coal items
- `/coal pay <player> <amount>`; accepts coal decimals or `cc`
- `/coal fines [list|pay <id|all>]`
- `/market`
- `/claim wand`
- `/claim buy <homestead|industrial> <radius>`
- `/claim buy <homestead|industrial>` after selecting a polygon with `/claim wand`
- `/claim info`
- `/claim list`
- `/claim show <id>`
- `/claim appraise [id]`
- `/claim sell [id]`
- `/claim transfer <id> <player>`
- `/claim abandon <id>`
- `/permit buy mining <regionName>`
- `/permit list`
- `/diviningrod`
- `/land info`
- `/land create <name> <landClass> <radius>`
- `/land delete <name>`
- `/land list`
- `/coalgov admin balance <player>`
- `/coalgov admin grant <player> <amount>`
- `/coalgov admin take <player> <amount>`
- `/coalgov admin reload`
- `/coalgov admin bypass <on|off|status>`
- `/coalgov admin rod`
- `/coalgov admin treasury <balance|grant <player> <amount>>`
- `/coalgov admin tax exempt <player> <on|off|status>`
- `/police fine <player> <amount> <reason>`
- `/cgnpc zone create <name>`
- `/cgnpc create <trader|worker> <name> <zone>`
- `/cgnpc stock <id> <material> <amount>`
- `/cgnpc funds <id> <amount>`
- `/cgnpc haggle <id> <buy|sell> <material> <amount> <offer>`
## Super Furnace
Craft a CoalGov Super Furnace with a furnace in the center and stone in the eight surrounding slots. Placed super furnaces are persisted in SQLite, smelt 5x as fast, and burn fuel 4x faster.
## Permissions
- `coalgov.admin`: admin commands, default `op`. Gameplay bypass is available with `/coalgov admin bypass on`.
- `coalgov.coal`: coal economy commands, default `true`
- `coalgov.claim`: claim commands, default `true`
- `coalgov.permit`: permit commands, default `true`
- `coalgov.land.info`: land info commands, default `true`
- `coalgov.market`: market trading, default `true`
- `coalgov.diviningrod`: divining rod command, default `true`
- `coalgov.police`: police fine commands, default `op`
## Claim Wand
Run `/claim wand` to receive a CoalGov claim wand. Right-click blocks to add polygon points. Once at least three points are selected, `/claim buy homestead` or `/claim buy industrial` buys the polygon instead of a radius claim. Sneak right-click with the wand to clear the current polygon. The plugin shows temporary particles between points and client-side gold marker block previews while selecting, and permanent protected marker blocks are placed at claim vertices after purchase.
## Market
`/market` opens a vanilla inventory menu. Players can sell configured resources to the Ministry or buy from Ministry stock. A normal click buys 1 item, and shift-click buys 64. Prices are dynamic: each material has a base price, but the live price rises when market stock is low and falls when stock is high. Buying and selling use Coal Cents internally, so cheap blocks like cobblestone can transact below 1 coal without forcing bulk purchases. Selling resources increases stock; buying resources decreases stock. Bulk sales are priced against simulated rising stock so one large sale cannot receive the first scarce-stock quote for every item. Coal-equivalent materials keep a floor based on direct coal value, charcoal conversion, or fuel value; arbitrary/generated resources like cobblestone have no forced 1-coal floor. The stock is persisted in SQLite.
## Example Config
```yaml
economy:
starting_balance: 0
deposit_coal: true
deposit_coal_blocks: true
claims:
homestead:
base_cost: 32
cost_per_block: 0.05
max_radius: 45
first_claim:
free: true
max_radius: 12
industrial:
base_cost: 128
cost_per_block: 0.15
max_radius: 96
permits:
mining:
cost: 64
duration_hours: 72
market:
dynamic:
initial_stock: 1024
target_stock: 1024
min_multiplier: 0.35
max_multiplier: 3.0
sell_multiplier: 0.85
buy_multiplier: 1.15
prices:
DIRT: 0.01
COBBLESTONE: 0.02
SAND: 0.05
GRAVEL: 0.04
OAK_LOG: 0.25
SPRUCE_LOG: 0.25
BIRCH_LOG: 0.25
TORCH: 0.10
COAL: 1.0
COAL_BLOCK: 9.0
RAW_COPPER: 0.5
COPPER_INGOT: 1.0
RAW_IRON: 1.0
IRON_INGOT: 2.0
RAW_GOLD: 2.0
GOLD_INGOT: 4.0
DIAMOND: 32.0
EMERALD: 12.0
land:
default_land_class: FREEHOLD
deny_mining_in_government: true
deny_mining_in_protected_preserve: true
deny_mining_in_border_zone: true
spawn:
protection_radius: 32
guide:
create_government_region: true
lines:
- "COALGOV BASIN"
- "Free first"
- "homestead:"
- "/claim buy"
- "Use /claim wand"
- "for polygons"
- "Coal seams:"
- "redwood ridges"
- "Permits:"
- "/permit buy"
- "mining <region>"
- "/market trades"
messages:
prefix: "&8[&6CoalGov&8]&r "
```
## Manual Test Checklist
- Server starts without errors.
- Database file is created at `plugins/CoalGov/coalgov.db`.
- Player joining creates a `players` row.
- `/coal balance` works.
- `/coal deposit` removes coal from inventory and increases balance by `100cc` per coal item.
- `/coal withdraw` decreases balance and gives coal items for whole-coal amounts.
- `/coal pay` transfers whole coal, decimal coal, and `cc` amounts.
- `/market` opens a vanilla inventory trading menu.
- Selling resources through `/market` increases market stock.
- Buying resources through `/market` decreases market stock and changes future prices.
- `/land create` creates a region.
- `/land info` reports current land class.
- `/claim buy` creates a claim and charges coal.
- `/claim appraise` appraises the claim you are standing in.
- `/claim sell` sells the claim you are standing in back to the government at appraised value.
- `/claim show <id>` displays a temporary particle outline.
- `/claim wand` draws a temporary polygon with particles and marker block previews.
- `/claim buy homestead` after wand selection creates a polygon claim.
- Claim corners are marked with protected gold blocks.
- Unclaimed land is protected from normal block placement and breaking.
- Overlapping claims are rejected.
- Mining coal in `PROTECTED_PRESERVE` is denied.
- Mining coal in `MINING_CONCESSION` without permit is denied.
- Buying a mining permit allows mining coal in that concession.
- Admin permission alone does not bypass gameplay restrictions.
- `/coalgov admin bypass on` bypasses build, mining, and marker restrictions for that admin session.

32
CoalGov/build.gradle Normal file
View File

@@ -0,0 +1,32 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version '8.3.6'
}
group = 'com.librewiki'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT'
compileOnly 'net.citizensnpcs:citizens-main:2.0.42-SNAPSHOT'
implementation 'org.xerial:sqlite-jdbc:3.49.1.0'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 21
}
tasks.shadowJar {
archiveClassifier.set('')
}
tasks.build {
dependsOn tasks.shadowJar
}

27
CoalGov/settings.gradle Normal file
View File

@@ -0,0 +1,27 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven {
name = "papermc"
url = uri("https://repo.papermc.io/repository/maven-public/")
}
maven {
name = "citizens"
url = uri("https://maven.citizensnpcs.co/repo")
}
maven {
name = "alessiodp"
url = uri("https://repo.alessiodp.com/releases/")
}
}
}
rootProject.name = "CoalGov"

View File

@@ -0,0 +1,251 @@
package com.librewiki.coalgov;
import com.librewiki.coalgov.command.AdminCommand;
import com.librewiki.coalgov.command.ClaimCommand;
import com.librewiki.coalgov.command.CoalCommand;
import com.librewiki.coalgov.command.DiviningRodCommand;
import com.librewiki.coalgov.command.LandCommand;
import com.librewiki.coalgov.command.MarketCommand;
import com.librewiki.coalgov.command.NpcCommand;
import com.librewiki.coalgov.command.PermitCommand;
import com.librewiki.coalgov.command.PoliceCommand;
import com.librewiki.coalgov.listener.BlockBreakListener;
import com.librewiki.coalgov.listener.BlockPlaceListener;
import com.librewiki.coalgov.listener.ClaimWandListener;
import com.librewiki.coalgov.listener.DiviningRodListener;
import com.librewiki.coalgov.listener.MarketListener;
import com.librewiki.coalgov.listener.NpcListener;
import com.librewiki.coalgov.listener.PlayerJoinListener;
import com.librewiki.coalgov.listener.SuperFurnaceListener;
import com.librewiki.coalgov.service.BuildProtectionService;
import com.librewiki.coalgov.service.ClaimMarkerService;
import com.librewiki.coalgov.service.ClaimSelectionService;
import com.librewiki.coalgov.service.ClaimService;
import com.librewiki.coalgov.service.ClaimVisualService;
import com.librewiki.coalgov.service.DiviningRodService;
import com.librewiki.coalgov.service.EconomyService;
import com.librewiki.coalgov.service.GovernmentService;
import com.librewiki.coalgov.service.LandService;
import com.librewiki.coalgov.service.LandAppraisalService;
import com.librewiki.coalgov.service.MarketService;
import com.librewiki.coalgov.service.MiningService;
import com.librewiki.coalgov.service.NpcService;
import com.librewiki.coalgov.service.OpenRouterService;
import com.librewiki.coalgov.service.PermitService;
import com.librewiki.coalgov.service.SpawnGuideService;
import com.librewiki.coalgov.service.SuperFurnaceService;
import com.librewiki.coalgov.service.TaxService;
import com.librewiki.coalgov.storage.ClaimRepository;
import com.librewiki.coalgov.storage.Database;
import com.librewiki.coalgov.storage.EconomyRepository;
import com.librewiki.coalgov.storage.GovernmentRepository;
import com.librewiki.coalgov.storage.LandRepository;
import com.librewiki.coalgov.storage.MarketRepository;
import com.librewiki.coalgov.storage.NpcRepository;
import com.librewiki.coalgov.storage.PermitRepository;
import com.librewiki.coalgov.storage.SuperFurnaceRepository;
import com.librewiki.coalgov.util.CoalMoney;
import com.librewiki.coalgov.util.Messages;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
public final class CoalGovPlugin extends JavaPlugin {
private Database database;
private EconomyService economyService;
private GovernmentService governmentService;
private ClaimService claimService;
private ClaimMarkerService claimMarkerService;
private ClaimSelectionService claimSelectionService;
private ClaimVisualService claimVisualService;
private BuildProtectionService buildProtectionService;
private LandAppraisalService landAppraisalService;
private MarketService marketService;
private NpcService npcService;
private OpenRouterService openRouterService;
private LandService landService;
private PermitService permitService;
private MiningService miningService;
private DiviningRodService diviningRodService;
private SuperFurnaceService superFurnaceService;
private SpawnGuideService spawnGuideService;
private TaxService taxService;
private Messages messages;
private final Set<UUID> adminBypassPlayers = new HashSet<>();
@Override
public void onEnable() {
saveDefaultConfig();
messages = new Messages(getConfig());
database = new Database(getDataFolder());
try {
database.open();
} catch (SQLException exception) {
getLogger().severe("Failed to initialize SQLite database: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
return;
}
EconomyRepository economyRepository = new EconomyRepository(database);
LandRepository landRepository = new LandRepository(database);
ClaimRepository claimRepository = new ClaimRepository(database);
PermitRepository permitRepository = new PermitRepository(database);
MarketRepository marketRepository = new MarketRepository(database);
GovernmentRepository governmentRepository = new GovernmentRepository(database);
SuperFurnaceRepository superFurnaceRepository = new SuperFurnaceRepository(database);
NpcRepository npcRepository = new NpcRepository(database);
economyService = new EconomyService(economyRepository, CoalMoney.fromCoalConfig(getConfig().getDouble("economy.starting_balance", 0.0D)));
governmentService = new GovernmentService(governmentRepository, economyService, getConfig());
landService = new LandService(landRepository, getConfig());
claimService = new ClaimService(claimRepository, landService, economyService, getConfig());
claimMarkerService = new ClaimMarkerService(this, claimService);
claimSelectionService = new ClaimSelectionService(this);
claimVisualService = new ClaimVisualService(this);
buildProtectionService = new BuildProtectionService(this, claimService);
landAppraisalService = new LandAppraisalService(this, claimService);
marketService = new MarketService(this, marketRepository);
npcService = new NpcService(this, npcRepository);
openRouterService = new OpenRouterService(this);
permitService = new PermitService(permitRepository, landService, economyService, getConfig());
miningService = new MiningService(this, landService, claimService, permitService);
diviningRodService = new DiviningRodService(this);
superFurnaceService = new SuperFurnaceService(this, superFurnaceRepository);
spawnGuideService = new SpawnGuideService(this);
taxService = new TaxService();
Objects.requireNonNull(getCommand("coal")).setExecutor(new CoalCommand(this));
Objects.requireNonNull(getCommand("claim")).setExecutor(new ClaimCommand(this));
Objects.requireNonNull(getCommand("permit")).setExecutor(new PermitCommand(this));
Objects.requireNonNull(getCommand("land")).setExecutor(new LandCommand(this));
Objects.requireNonNull(getCommand("market")).setExecutor(new MarketCommand(this));
Objects.requireNonNull(getCommand("diviningrod")).setExecutor(new DiviningRodCommand(this));
Objects.requireNonNull(getCommand("police")).setExecutor(new PoliceCommand(this));
Objects.requireNonNull(getCommand("cgnpc")).setExecutor(new NpcCommand(this));
Objects.requireNonNull(getCommand("coalgov")).setExecutor(new AdminCommand(this));
getServer().getPluginManager().registerEvents(new BlockBreakListener(this), this);
getServer().getPluginManager().registerEvents(new BlockPlaceListener(this), this);
getServer().getPluginManager().registerEvents(new ClaimWandListener(this), this);
getServer().getPluginManager().registerEvents(new DiviningRodListener(this), this);
getServer().getPluginManager().registerEvents(new MarketListener(this), this);
getServer().getPluginManager().registerEvents(new NpcListener(this), this);
getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this);
getServer().getPluginManager().registerEvents(new SuperFurnaceListener(this), this);
superFurnaceService.registerRecipe();
getServer().getScheduler().runTaskLater(this, () -> claimMarkerService.placeAllLoadedMarkers(), 40L);
long npcTicks = Math.max(20L, getConfig().getLong("npc.worker.tick_interval_seconds", 20L) * 20L);
getServer().getScheduler().runTaskTimer(this, () -> npcService.tickWorkers(), 80L, npcTicks);
getLogger().info("CoalGov enabled.");
}
@Override
public void onDisable() {
if (database != null) {
database.close();
}
}
public void reloadCoalGovConfig() {
reloadConfig();
messages = new Messages(getConfig());
governmentService.setConfig(getConfig());
landService.setConfig(getConfig());
claimService.setConfig(getConfig());
permitService.setConfig(getConfig());
superFurnaceService.registerRecipe();
}
public boolean hasAdminBypass(Player player) {
return player.hasPermission("coalgov.admin") && adminBypassPlayers.contains(player.getUniqueId());
}
public void setAdminBypass(Player player, boolean enabled) {
if (enabled) {
adminBypassPlayers.add(player.getUniqueId());
} else {
adminBypassPlayers.remove(player.getUniqueId());
}
}
public EconomyService economyService() {
return economyService;
}
public GovernmentService governmentService() {
return governmentService;
}
public ClaimService claimService() {
return claimService;
}
public ClaimVisualService claimVisualService() {
return claimVisualService;
}
public ClaimMarkerService claimMarkerService() {
return claimMarkerService;
}
public ClaimSelectionService claimSelectionService() {
return claimSelectionService;
}
public BuildProtectionService buildProtectionService() {
return buildProtectionService;
}
public LandAppraisalService landAppraisalService() {
return landAppraisalService;
}
public MarketService marketService() {
return marketService;
}
public NpcService npcService() {
return npcService;
}
public OpenRouterService openRouterService() {
return openRouterService;
}
public LandService landService() {
return landService;
}
public PermitService permitService() {
return permitService;
}
public MiningService miningService() {
return miningService;
}
public DiviningRodService diviningRodService() {
return diviningRodService;
}
public SuperFurnaceService superFurnaceService() {
return superFurnaceService;
}
public SpawnGuideService spawnGuideService() {
return spawnGuideService;
}
public TaxService taxService() {
return taxService;
}
public Messages messages() {
return messages;
}
}

View File

@@ -0,0 +1,213 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
public final class AdminCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public AdminCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0 || !args[0].equalsIgnoreCase("admin")) {
usage(sender);
return true;
}
if (!sender.hasPermission("coalgov.admin")) {
sender.sendMessage(plugin.messages().text("&cYou lack permission."));
return true;
}
try {
if (args.length >= 2 && args[1].equalsIgnoreCase("reload")) {
plugin.reloadCoalGovConfig();
sender.sendMessage(plugin.messages().text("&aConfig reloaded."));
return true;
}
if (args.length >= 2 && args[1].equalsIgnoreCase("bypass")) {
bypass(sender, args);
return true;
}
if (args.length >= 2 && args[1].equalsIgnoreCase("spawn")) {
spawn(sender, args);
return true;
}
if (args.length >= 2 && args[1].equalsIgnoreCase("rod")) {
rod(sender, args);
return true;
}
if (args.length >= 2 && args[1].equalsIgnoreCase("treasury")) {
treasury(sender, args);
return true;
}
if (args.length >= 2 && args[1].equalsIgnoreCase("tax")) {
tax(sender, args);
return true;
}
if (args.length < 3) {
usage(sender);
return true;
}
OfflinePlayer target = Bukkit.getOfflinePlayer(args[2]);
plugin.economyService().ensurePlayer(target);
switch (args[1].toLowerCase()) {
case "balance" -> sender.sendMessage(plugin.messages().text("&e" + args[2] + ": &f"
+ CoalMoney.format(plugin.economyService().balance(target.getUniqueId()))));
case "grant" -> grant(sender, target, args);
case "take" -> take(sender, target, args);
default -> usage(sender);
}
} catch (SQLException exception) {
plugin.getLogger().warning("Admin command failed: " + exception.getMessage());
sender.sendMessage(plugin.messages().text("&cCoalGov database unavailable."));
}
return true;
}
private void usage(CommandSender sender) {
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin <balance|grant|take|reload|spawn|bypass|rod|treasury|tax>"));
}
private void bypass(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length == 2 || (args.length == 3 && args[2].equalsIgnoreCase("status"))) {
String status = plugin.hasAdminBypass(player) ? "&aON" : "&cOFF";
player.sendMessage(plugin.messages().text("&eAdmin bypass is " + status + "&e."));
return;
}
if (args.length != 3) {
player.sendMessage(plugin.messages().text("&eUsage: /coalgov admin bypass <on|off|status>"));
return;
}
if (args[2].equalsIgnoreCase("on")) {
plugin.setAdminBypass(player, true);
player.sendMessage(plugin.messages().text("&aAdmin bypass enabled. Build, mining, and marker restrictions are bypassed."));
return;
}
if (args[2].equalsIgnoreCase("off")) {
plugin.setAdminBypass(player, false);
player.sendMessage(plugin.messages().text("&aAdmin bypass disabled. You now play under normal CoalGov rules."));
return;
}
player.sendMessage(plugin.messages().text("&eUsage: /coalgov admin bypass <on|off|status>"));
}
private void spawn(CommandSender sender, String[] args) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length != 3 || !args[2].equalsIgnoreCase("setup")) {
player.sendMessage(plugin.messages().text("&eUsage: /coalgov admin spawn setup"));
return;
}
plugin.spawnGuideService().setup(player);
player.sendMessage(plugin.messages().text("&aSpawn set, protected, and guide signs placed."));
}
private void rod(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length != 2) {
player.sendMessage(plugin.messages().text("&eUsage: /coalgov admin rod"));
return;
}
player.getInventory().addItem(plugin.diviningRodService().rod());
player.sendMessage(plugin.messages().text("&aDivining rod granted."));
}
private void treasury(CommandSender sender, String[] args) throws SQLException {
if (args.length == 3 && args[2].equalsIgnoreCase("balance")) {
sender.sendMessage(plugin.messages().text("&eTreasury: &f" + CoalMoney.format(plugin.governmentService().treasuryBalance())));
return;
}
if (args.length == 5 && args[2].equalsIgnoreCase("grant")) {
OfflinePlayer target = Bukkit.getOfflinePlayer(args[3]);
plugin.economyService().ensurePlayer(target);
long amount = CoalMoney.parsePositive(args[4]);
if (amount <= 0L) {
sender.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
if (!plugin.governmentService().grantFromTreasury(target.getUniqueId(), amount)) {
sender.sendMessage(plugin.messages().text("&cTreasury grant failed. Check treasury balance."));
return;
}
sender.sendMessage(plugin.messages().text("&aGranted &f" + CoalMoney.format(amount) + " &afrom the treasury."));
return;
}
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin treasury <balance|grant <player> <amount>>"));
}
private void tax(CommandSender sender, String[] args) throws SQLException {
if (args.length != 5 || !args[2].equalsIgnoreCase("exempt")) {
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin tax exempt <player> <on|off|status>"));
return;
}
OfflinePlayer target = Bukkit.getOfflinePlayer(args[3]);
plugin.economyService().ensurePlayer(target);
if (args[4].equalsIgnoreCase("status")) {
boolean exempt = plugin.governmentService().taxExempt(target.getUniqueId());
sender.sendMessage(plugin.messages().text("&eTax exemption for &f" + args[3] + "&e: " + (exempt ? "&aON" : "&cOFF")));
return;
}
if (args[4].equalsIgnoreCase("on")) {
plugin.governmentService().setTaxExempt(target.getUniqueId(), true);
sender.sendMessage(plugin.messages().text("&aTax exemption enabled for &f" + args[3] + "&a."));
return;
}
if (args[4].equalsIgnoreCase("off")) {
plugin.governmentService().setTaxExempt(target.getUniqueId(), false);
sender.sendMessage(plugin.messages().text("&aTax exemption disabled for &f" + args[3] + "&a."));
return;
}
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin tax exempt <player> <on|off|status>"));
}
private void grant(CommandSender sender, OfflinePlayer target, String[] args) throws SQLException {
if (args.length != 4) {
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin grant <player> <amount>"));
return;
}
long amount = CoalMoney.parsePositive(args[3]);
if (amount <= 0) {
sender.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
plugin.economyService().grant(target.getUniqueId(), amount);
sender.sendMessage(plugin.messages().text("&aGranted &f" + CoalMoney.format(amount) + "&a."));
}
private void take(CommandSender sender, OfflinePlayer target, String[] args) throws SQLException {
if (args.length != 4) {
sender.sendMessage(plugin.messages().text("&eUsage: /coalgov admin take <player> <amount>"));
return;
}
long amount = CoalMoney.parsePositive(args[3]);
if (amount <= 0) {
sender.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
if (plugin.economyService().take(target.getUniqueId(), amount)) {
sender.sendMessage(plugin.messages().text("&aTook &f" + CoalMoney.format(amount) + "&a."));
} else {
sender.sendMessage(plugin.messages().text("&cBalance too low."));
}
}
}

View File

@@ -0,0 +1,298 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.model.LandRegion;
import com.librewiki.coalgov.service.ClaimService;
import com.librewiki.coalgov.service.LandAppraisalService;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public final class ClaimCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public ClaimCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return true;
}
if (args.length == 0) {
player.sendMessage(plugin.messages().text("&eUsage: /claim <wand|buy|info|list|show|appraise|sell|transfer|abandon>"));
return true;
}
try {
plugin.economyService().ensurePlayer(player);
switch (args[0].toLowerCase()) {
case "wand" -> wand(player);
case "buy" -> buy(player, args);
case "info" -> info(player);
case "list" -> list(player);
case "show" -> show(player, args);
case "appraise" -> appraise(player, args);
case "sell" -> sell(player, args);
case "transfer" -> transfer(player, args);
case "abandon" -> abandon(player, args);
default -> player.sendMessage(plugin.messages().text("&eUsage: /claim <wand|buy|info|list|show|appraise|sell|transfer|abandon>"));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Claim command failed: " + exception.getMessage());
player.sendMessage(plugin.messages().text("&cClaim registry unavailable."));
}
return true;
}
private void buy(Player player, String[] args) throws SQLException {
if (args.length != 2 && args.length != 3) {
player.sendMessage(plugin.messages().text("&eUsage: /claim buy <homestead|industrial> [radius]"));
return;
}
ClaimService.BuyResult result;
if (args.length == 3) {
int radius = parseInt(args[2]);
result = plugin.claimService().buy(player.getUniqueId(), player.getLocation(), args[1], radius);
} else {
List<ClaimPoint> points = plugin.claimSelectionService().claimPoints(player);
result = plugin.claimService().buyPolygon(player.getUniqueId(), player.getWorld().getName(), args[1], points);
}
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
player.sendMessage(plugin.messages().text("&aClaim #" + result.id() + " created for &f" + CoalMoney.format(result.cost()) + "&a."));
plugin.claimSelectionService().consume(player);
plugin.claimService().findOwnedById(player.getUniqueId(), result.id()).ifPresent(claim -> {
plugin.claimMarkerService().placeMarkers(claim);
plugin.claimVisualService().show(player, claim);
});
}
private void wand(Player player) {
player.getInventory().addItem(plugin.claimSelectionService().wand());
player.sendMessage(plugin.messages().text("&aClaim wand issued. Right-click blocks to draw a polygon with marker previews; sneak right-click to clear."));
}
private void info(Player player) throws SQLException {
Optional<Claim> claim = plugin.claimService().claimAt(player.getLocation());
Optional<LandRegion> region = plugin.landService().regionAt(player.getLocation());
player.sendMessage(plugin.messages().text("&eLand: &f" + region.map(r -> r.name() + " " + r.landClass()).orElse(plugin.landService().classAt(player.getLocation()).name())));
if (claim.isPresent()) {
Claim c = claim.get();
player.sendMessage(plugin.messages().text("&eClaim: &f#" + c.id() + " " + c.claimType() + " owner " + c.ownerUuid()));
if (c.isPolygon()) {
player.sendMessage(plugin.messages().text("&eShape: &fpolygon with " + c.vertices().size() + " points"));
}
plugin.claimVisualService().show(player, c);
} else {
player.sendMessage(plugin.messages().text("&eClaim: &fnone"));
}
}
private void list(Player player) throws SQLException {
List<Claim> claims = plugin.claimService().list(player.getUniqueId());
if (claims.isEmpty()) {
player.sendMessage(plugin.messages().text("&eYou have no claims."));
return;
}
player.sendMessage(plugin.messages().text("&eYour claims:"));
for (Claim claim : claims) {
String shape = claim.isPolygon() ? " polygon" : " rectangle";
player.sendMessage(plugin.messages().raw("&7#" + claim.id() + " &f" + claim.claimType() + shape + " purchase " + CoalMoney.format(plugin.claimService().claimPurchaseCost(claim)) + " " + claim.world()
+ " [" + claim.x1() + "," + claim.z1() + "] to [" + claim.x2() + "," + claim.z2() + "]"));
}
player.sendMessage(plugin.messages().text("&7Use &f/claim appraise [id] &7for sale value, or &f/claim show <id>&7."));
}
private void show(Player player, String[] args) throws SQLException {
if (args.length != 2) {
player.sendMessage(plugin.messages().text("&eUsage: /claim show <id>"));
return;
}
long id = parseLong(args[1]);
if (id <= 0) {
player.sendMessage(plugin.messages().text("&cInvalid claim id."));
return;
}
Optional<Claim> claim = plugin.claimService().findOwnedById(player.getUniqueId(), id);
if (claim.isEmpty()) {
player.sendMessage(plugin.messages().text("&cNo owned claim with that id."));
return;
}
plugin.claimVisualService().show(player, claim.get());
player.sendMessage(plugin.messages().text("&aShowing claim #" + id + " boundary."));
}
private void appraise(Player player, String[] args) throws SQLException {
Optional<Claim> claim = resolveOwnedClaim(player, args, "appraise");
if (claim.isEmpty()) {
return;
}
LandAppraisalService.Appraisal appraisal = plugin.landAppraisalService().appraise(claim.get());
if (!appraisal.worldLoaded()) {
player.sendMessage(plugin.messages().text("&cThat claim's world is not loaded."));
return;
}
sendAppraisal(player, appraisal);
}
private void sell(Player player, String[] args) throws SQLException {
Optional<Claim> claim = resolveOwnedClaim(player, args, "sell");
if (claim.isEmpty()) {
return;
}
LandAppraisalService.Appraisal appraisal = plugin.landAppraisalService().appraise(claim.get());
if (!appraisal.worldLoaded()) {
player.sendMessage(plugin.messages().text("&cThat claim's world is not loaded."));
return;
}
ClaimService.SellResult result = plugin.claimService().sell(player.getUniqueId(), claim.get().id(), appraisal.totalValue());
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
plugin.claimMarkerService().removeMarkers(result.claim());
if (result.refund() >= 0L) {
player.sendMessage(plugin.messages().text("&aSold claim #" + result.claim().id() + " back to the Ministry for &f" + CoalMoney.format(result.refund()) + "&a."));
} else {
player.sendMessage(plugin.messages().text("&eSurrendered claim #" + result.claim().id() + " and paid &f" + CoalMoney.format(Math.abs(result.refund())) + " &efor extracted land value."));
}
sendAppraisal(player, appraisal);
}
private void transfer(Player player, String[] args) throws SQLException {
if (args.length != 3) {
player.sendMessage(plugin.messages().text("&eUsage: /claim transfer <id> <player>"));
return;
}
long id = parseLong(args[1]);
if (id <= 0L) {
player.sendMessage(plugin.messages().text("&cInvalid claim id."));
return;
}
OfflinePlayer recipient = Bukkit.getOfflinePlayer(args[2]);
plugin.economyService().ensurePlayer(recipient);
ClaimService.TransferResult result = plugin.claimService().transfer(player.getUniqueId(), id, recipient.getUniqueId());
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
String recipientName = recipient.getName() == null ? recipient.getUniqueId().toString() : recipient.getName();
player.sendMessage(plugin.messages().text("&aTransferred claim #" + id + " to &f" + recipientName + "&a."));
if (recipient.isOnline() && recipient.getPlayer() != null) {
recipient.getPlayer().sendMessage(plugin.messages().text("&a" + player.getName() + " transferred claim #" + id + " to you."));
}
}
private Optional<Claim> resolveOwnedClaim(Player player, String[] args, String action) throws SQLException {
if (args.length > 2) {
player.sendMessage(plugin.messages().text("&eUsage: /claim " + action + " [id]"));
return Optional.empty();
}
Optional<Claim> claim;
if (args.length == 2) {
long id = parseLong(args[1]);
if (id <= 0) {
player.sendMessage(plugin.messages().text("&cInvalid claim id."));
return Optional.empty();
}
claim = plugin.claimService().findOwnedById(player.getUniqueId(), id);
} else {
claim = plugin.claimService().claimAt(player.getLocation())
.filter(c -> c.ownerUuid().equals(player.getUniqueId()));
}
if (claim.isEmpty()) {
player.sendMessage(plugin.messages().text("&cStand inside one of your claims, or use /claim " + action + " <id>."));
}
return claim;
}
private void sendAppraisal(Player player, LandAppraisalService.Appraisal appraisal) {
player.sendMessage(plugin.messages().text("&eAppraisal: &f" + CoalMoney.format(appraisal.totalValue())));
player.sendMessage(plugin.messages().raw("&7Land condition: &f" + appraisal.conditionPercent()
+ "% &7base value: &f" + CoalMoney.format(Math.round(appraisal.conditionValue()))
+ " &7resources: &f" + CoalMoney.format(appraisal.resourceValue())
+ " &7depletion charge: &f" + CoalMoney.format(appraisal.depletionChargeRounded())));
if (appraisal.originalCost() == 0L) {
player.sendMessage(plugin.messages().raw("&7Starter homestead land value is &f0&7; only resources can offset depletion."));
}
String resourceSummary = resourceSummary(appraisal);
if (!resourceSummary.isEmpty()) {
player.sendMessage(plugin.messages().raw("&7Counted resources: &f" + resourceSummary));
}
}
private String resourceSummary(LandAppraisalService.Appraisal appraisal) {
StringBuilder builder = new StringBuilder();
int shown = 0;
for (var entry : appraisal.resources().entrySet()) {
if (entry.getValue() <= 0L) {
continue;
}
if (shown++ > 0) {
builder.append(", ");
}
builder.append(readableMaterial(entry.getKey())).append(" ").append(entry.getValue());
if (shown >= 5) {
break;
}
}
return builder.toString();
}
private String readableMaterial(Material material) {
return material.name().toLowerCase().replace('_', ' ');
}
private void abandon(Player player, String[] args) throws SQLException {
if (args.length != 2) {
player.sendMessage(plugin.messages().text("&eUsage: /claim abandon <id>"));
return;
}
long id = parseLong(args[1]);
if (id <= 0) {
player.sendMessage(plugin.messages().text("&cInvalid claim id."));
return;
}
Optional<Claim> claim = plugin.claimService().findOwnedById(player.getUniqueId(), id);
if (plugin.claimService().abandon(player.getUniqueId(), id)) {
claim.ifPresent(c -> plugin.claimMarkerService().removeMarkers(c));
player.sendMessage(plugin.messages().text("&aClaim abandoned."));
} else {
player.sendMessage(plugin.messages().text("&cNo owned claim with that id."));
}
}
private int parseInt(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException exception) {
return -1;
}
}
private long parseLong(String raw) {
try {
return Long.parseLong(raw);
} catch (NumberFormatException exception) {
return -1L;
}
}
}

View File

@@ -0,0 +1,205 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Fine;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.sql.SQLException;
import java.util.List;
public final class CoalCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public CoalCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return true;
}
if (args.length == 0) {
player.sendMessage(plugin.messages().text("&eUsage: /coal <balance|deposit|withdraw|pay|fines>"));
return true;
}
try {
plugin.economyService().ensurePlayer(player);
switch (args[0].toLowerCase()) {
case "balance" -> balance(player);
case "deposit" -> deposit(player);
case "withdraw" -> withdraw(player, args);
case "pay" -> pay(player, args);
case "fines" -> fines(player, args);
default -> player.sendMessage(plugin.messages().text("&eUsage: /coal <balance|deposit|withdraw|pay|fines>"));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Coal command failed: " + exception.getMessage());
player.sendMessage(plugin.messages().text("&cCoal ledger unavailable."));
}
return true;
}
private void balance(Player player) throws SQLException {
player.sendMessage(plugin.messages().text("&eBalance: &f" + CoalMoney.format(plugin.economyService().balance(player.getUniqueId()))));
}
private void deposit(Player player) throws SQLException {
boolean coalEnabled = plugin.getConfig().getBoolean("economy.deposit_coal", true);
boolean blocksEnabled = plugin.getConfig().getBoolean("economy.deposit_coal_blocks", true);
long credit = 0;
for (ItemStack item : player.getInventory().getContents()) {
if (item == null) {
continue;
}
if (coalEnabled && item.getType() == Material.COAL) {
credit += item.getAmount() * CoalMoney.CENTS_PER_COAL;
item.setAmount(0);
} else if (blocksEnabled && item.getType() == Material.COAL_BLOCK) {
credit += item.getAmount() * 9L * CoalMoney.CENTS_PER_COAL;
item.setAmount(0);
}
}
if (credit <= 0) {
player.sendMessage(plugin.messages().text("&cNo depositable coal found."));
return;
}
plugin.economyService().deposit(player.getUniqueId(), credit);
player.sendMessage(plugin.messages().text("&aDeposited &f" + CoalMoney.format(credit) + "&a."));
}
private void withdraw(Player player, String[] args) throws SQLException {
if (args.length != 2) {
player.sendMessage(plugin.messages().text("&eUsage: /coal withdraw <amount>"));
return;
}
long amount = CoalMoney.parsePositive(args[1]);
if (amount <= 0) {
player.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
if (amount % CoalMoney.CENTS_PER_COAL != 0L) {
player.sendMessage(plugin.messages().text("&cWithdrawals must be whole coal because they create coal items."));
return;
}
long coalItems = amount / CoalMoney.CENTS_PER_COAL;
if (plugin.economyService().balance(player.getUniqueId()) < amount) {
player.sendMessage(plugin.messages().text("&cBalance too low."));
return;
}
if (!canFitCoal(player, coalItems)) {
player.sendMessage(plugin.messages().text("&cYou need more inventory space."));
return;
}
if (!plugin.economyService().withdraw(player.getUniqueId(), amount)) {
player.sendMessage(plugin.messages().text("&cBalance too low."));
return;
}
long remaining = coalItems;
while (remaining > 0) {
int stack = (int) Math.min(Material.COAL.getMaxStackSize(), remaining);
player.getInventory().addItem(new ItemStack(Material.COAL, stack));
remaining -= stack;
}
player.sendMessage(plugin.messages().text("&aWithdrew &f" + CoalMoney.format(amount) + "&a."));
}
private void pay(Player player, String[] args) throws SQLException {
if (args.length != 3) {
player.sendMessage(plugin.messages().text("&eUsage: /coal pay <player> <amount>"));
return;
}
Player target = Bukkit.getPlayerExact(args[1]);
if (target == null) {
player.sendMessage(plugin.messages().text("&cThat player must be online."));
return;
}
long amount = CoalMoney.parsePositive(args[2]);
if (amount <= 0) {
player.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
plugin.economyService().ensurePlayer(target);
if (!plugin.economyService().pay(player.getUniqueId(), target.getUniqueId(), amount)) {
player.sendMessage(plugin.messages().text("&cPayment failed. Check your balance."));
return;
}
player.sendMessage(plugin.messages().text("&aPaid &f" + CoalMoney.format(amount) + " &ato " + target.getName() + "."));
target.sendMessage(plugin.messages().text("&aReceived &f" + CoalMoney.format(amount) + " &afrom " + player.getName() + "."));
}
private void fines(Player player, String[] args) throws SQLException {
if (args.length == 1 || (args.length == 2 && args[1].equalsIgnoreCase("list"))) {
List<Fine> fines = plugin.governmentService().unpaidFines(player.getUniqueId());
if (fines.isEmpty()) {
player.sendMessage(plugin.messages().text("&aYou have no unpaid fines."));
return;
}
long total = fines.stream().mapToLong(Fine::amount).sum();
player.sendMessage(plugin.messages().text("&eUnpaid fines: &f" + CoalMoney.format(total) + " &etotal"));
for (Fine fine : fines) {
player.sendMessage(plugin.messages().raw("&7#" + fine.id() + " &f" + CoalMoney.format(fine.amount()) + " &7" + fine.reason()));
}
player.sendMessage(plugin.messages().text("&7Use &f/coal fines pay <id|all>&7."));
return;
}
if (args.length == 3 && args[1].equalsIgnoreCase("pay")) {
if (args[2].equalsIgnoreCase("all")) {
var result = plugin.governmentService().payAllFines(player.getUniqueId());
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
player.sendMessage(plugin.messages().text("&aPaid &f" + CoalMoney.format(result.amount()) + " &ain fines."));
return;
}
long id = parsePositive(args[2]);
if (id <= 0L) {
player.sendMessage(plugin.messages().text("&cInvalid fine id."));
return;
}
var result = plugin.governmentService().payFine(player.getUniqueId(), id);
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
player.sendMessage(plugin.messages().text("&aPaid fine #" + id + " for &f" + CoalMoney.format(result.amount()) + "&a."));
return;
}
player.sendMessage(plugin.messages().text("&eUsage: /coal fines [list|pay <id|all>]"));
}
private boolean canFitCoal(Player player, long amount) {
if (amount > Integer.MAX_VALUE) {
return false;
}
long capacity = 0;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item == null || item.getType().isAir()) {
capacity += Material.COAL.getMaxStackSize();
} else if (item.getType() == Material.COAL) {
capacity += Material.COAL.getMaxStackSize() - item.getAmount();
}
if (capacity >= amount) {
return true;
}
}
return false;
}
private long parsePositive(String raw) {
try {
return Long.parseLong(raw);
} catch (NumberFormatException exception) {
return -1L;
}
}
}

View File

@@ -0,0 +1,30 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class DiviningRodCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public DiviningRodCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return true;
}
if (args.length != 0) {
player.sendMessage(plugin.messages().text("&eUsage: /diviningrod"));
return true;
}
player.getInventory().addItem(plugin.diviningRodService().rod());
player.sendMessage(plugin.messages().text("&aDivining rod granted."));
return true;
}
}

View File

@@ -0,0 +1,138 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.model.LandRegion;
import com.librewiki.coalgov.util.Cuboid2D;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public final class LandCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public LandCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sender.sendMessage(plugin.messages().text("&eUsage: /land <info|create|delete|list>"));
return true;
}
try {
switch (args[0].toLowerCase()) {
case "info" -> info(sender);
case "create" -> create(sender, args);
case "delete" -> delete(sender, args);
case "list" -> list(sender);
default -> sender.sendMessage(plugin.messages().text("&eUsage: /land <info|create|delete|list>"));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Land command failed: " + exception.getMessage());
sender.sendMessage(plugin.messages().text("&cLand registry unavailable."));
}
return true;
}
private void info(CommandSender sender) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (!player.hasPermission("coalgov.land.info") && !player.hasPermission("coalgov.admin")) {
player.sendMessage(plugin.messages().text("&cYou lack permission."));
return;
}
Optional<LandRegion> region = plugin.landService().regionAt(player.getLocation());
if (region.isPresent()) {
LandRegion r = region.get();
player.sendMessage(plugin.messages().text("&eLand: &f" + r.name() + " " + r.landClass()
+ " [" + r.x1() + "," + r.z1() + "] to [" + r.x2() + "," + r.z2() + "]"));
} else {
player.sendMessage(plugin.messages().text("&eLand: &f" + plugin.landService().classAt(player.getLocation()) + " default"));
}
}
private void create(CommandSender sender, String[] args) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (!player.hasPermission("coalgov.admin")) {
player.sendMessage(plugin.messages().text("&cYou lack permission."));
return;
}
if (args.length != 4) {
player.sendMessage(plugin.messages().text("&eUsage: /land create <name> <landClass> <radius>"));
return;
}
LandClass landClass;
try {
landClass = LandClass.valueOf(args[2].toUpperCase());
} catch (IllegalArgumentException exception) {
player.sendMessage(plugin.messages().text("&cInvalid land class."));
return;
}
int radius = parseInt(args[3]);
if (radius <= 0) {
player.sendMessage(plugin.messages().text("&cRadius must be greater than zero."));
return;
}
Cuboid2D area = new Cuboid2D(
player.getWorld().getName(),
player.getLocation().getBlockX() - radius,
player.getLocation().getBlockZ() - radius,
player.getLocation().getBlockX() + radius,
player.getLocation().getBlockZ() + radius
);
if (plugin.landService().create(args[1], area, landClass)) {
player.sendMessage(plugin.messages().text("&aLand region created."));
} else {
player.sendMessage(plugin.messages().text("&cCould not create land region."));
}
}
private void delete(CommandSender sender, String[] args) throws SQLException {
if (!sender.hasPermission("coalgov.admin")) {
sender.sendMessage(plugin.messages().text("&cYou lack permission."));
return;
}
if (args.length != 2) {
sender.sendMessage(plugin.messages().text("&eUsage: /land delete <name>"));
return;
}
if (plugin.landService().delete(args[1])) {
sender.sendMessage(plugin.messages().text("&aLand region deleted."));
} else {
sender.sendMessage(plugin.messages().text("&cNo land region with that name."));
}
}
private void list(CommandSender sender) throws SQLException {
List<LandRegion> regions = plugin.landService().listAll();
if (regions.isEmpty()) {
sender.sendMessage(plugin.messages().text("&eNo land regions."));
return;
}
sender.sendMessage(plugin.messages().text("&eLand regions:"));
for (LandRegion r : regions) {
sender.sendMessage(plugin.messages().raw("&7" + r.name() + " &f" + r.landClass() + " " + r.world()
+ " [" + r.x1() + "," + r.z1() + "] to [" + r.x2() + "," + r.z2() + "]"));
}
}
private int parseInt(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException exception) {
return -1;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
public final class MarketCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public MarketCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return true;
}
try {
plugin.economyService().ensurePlayer(player);
plugin.marketService().open(player);
} catch (SQLException exception) {
plugin.getLogger().warning("Market command failed: " + exception.getMessage());
player.sendMessage(plugin.messages().text("&cMarket ledger unavailable."));
}
return true;
}
}

View File

@@ -0,0 +1,216 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.model.CoalNpc;
import com.librewiki.coalgov.model.NpcZone;
import com.librewiki.coalgov.service.NpcService;
import com.librewiki.coalgov.service.OpenRouterService;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public final class NpcCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public NpcCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("coalgov.admin")) {
sender.sendMessage(plugin.messages().text("&cYou lack permission."));
return true;
}
try {
if (args.length >= 1 && args[0].equalsIgnoreCase("zone")) {
zone(sender, args);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("create")) {
create(sender, args);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("remove")) {
remove(sender, args);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("list")) {
list(sender);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("stock")) {
stock(sender, args);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("funds")) {
funds(sender, args);
} else if (args.length >= 1 && args[0].equalsIgnoreCase("haggle")) {
haggle(sender, args);
} else {
usage(sender);
}
} catch (SQLException exception) {
plugin.getLogger().warning("NPC command failed: " + exception.getMessage());
sender.sendMessage(plugin.messages().text("&cNPC registry unavailable."));
}
return true;
}
private void zone(CommandSender sender, String[] args) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length != 3 || !args[1].equalsIgnoreCase("create")) {
player.sendMessage(plugin.messages().text("&eUsage: /cgnpc zone create <name>"));
return;
}
List<ClaimPoint> points = plugin.claimSelectionService().claimPoints(player);
if (points.size() < 3) {
player.sendMessage(plugin.messages().text("&cUse /claim wand to select at least 3 polygon points."));
return;
}
long id = plugin.npcService().createZone(args[2], player.getWorld().getName(), points);
plugin.claimSelectionService().consume(player);
player.sendMessage(plugin.messages().text("&aNPC zone #" + id + " created."));
}
private void create(CommandSender sender, String[] args) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length != 4) {
player.sendMessage(plugin.messages().text("&eUsage: /cgnpc create <trader|worker> <name> <zone>"));
return;
}
String type = args[1].toUpperCase();
if (!type.equals("TRADER") && !type.equals("WORKER")) {
player.sendMessage(plugin.messages().text("&cNPC type must be trader or worker."));
return;
}
Optional<NpcZone> zone = plugin.npcService().findZone(args[3]);
if (zone.isEmpty()) {
player.sendMessage(plugin.messages().text("&cNo NPC zone named " + args[3] + "."));
return;
}
long id = plugin.npcService().createNpc(type, args[2], zone.get(), player.getLocation());
player.sendMessage(plugin.messages().text("&aNPC #" + id + " created."));
}
private void remove(CommandSender sender, String[] args) throws SQLException {
if (args.length != 2) {
sender.sendMessage(plugin.messages().text("&eUsage: /cgnpc remove <id>"));
return;
}
long id = parseLong(args[1]);
if (plugin.npcService().removeNpc(id)) {
sender.sendMessage(plugin.messages().text("&aNPC removed."));
} else {
sender.sendMessage(plugin.messages().text("&cNo NPC with that id."));
}
}
private void list(CommandSender sender) throws SQLException {
sender.sendMessage(plugin.messages().text("&eNPC zones:"));
for (NpcZone zone : plugin.npcService().listZones()) {
sender.sendMessage(plugin.messages().raw("&7#" + zone.id() + " &f" + zone.name() + " " + zone.world()));
}
sender.sendMessage(plugin.messages().text("&eNPCs:"));
for (CoalNpc npc : plugin.npcService().listNpcs()) {
sender.sendMessage(plugin.messages().raw("&7#" + npc.id() + " &f" + npc.name() + " " + npc.npcType() + " citizens " + npc.citizensId()));
}
}
private void stock(CommandSender sender, String[] args) throws SQLException {
if (args.length != 4) {
sender.sendMessage(plugin.messages().text("&eUsage: /cgnpc stock <id> <material> <amount>"));
return;
}
Optional<CoalNpc> npc = plugin.npcService().findNpc(parseLong(args[1]));
Material material = Material.matchMaterial(args[2]);
long amount = parseLong(args[3]);
if (npc.isEmpty() || material == null || amount <= 0L) {
sender.sendMessage(plugin.messages().text("&cInvalid NPC, material, or amount."));
return;
}
plugin.npcService().addInventory(npc.get().id(), material, amount);
sender.sendMessage(plugin.messages().text("&aNPC stock added."));
}
private void funds(CommandSender sender, String[] args) throws SQLException {
if (args.length != 3) {
sender.sendMessage(plugin.messages().text("&eUsage: /cgnpc funds <id> <amount>"));
return;
}
Optional<CoalNpc> npc = plugin.npcService().findNpc(parseLong(args[1]));
long amount = CoalMoney.parsePositive(args[2]);
if (npc.isEmpty() || amount <= 0L) {
sender.sendMessage(plugin.messages().text("&cInvalid NPC or amount."));
return;
}
plugin.economyService().credit(npc.get().accountUuid(), amount, "npc_admin_funds");
sender.sendMessage(plugin.messages().text("&aNPC funded."));
}
private void haggle(CommandSender sender, String[] args) throws SQLException {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return;
}
if (args.length != 6) {
player.sendMessage(plugin.messages().text("&eUsage: /cgnpc haggle <id> <buy|sell> <material> <amount> <offer>"));
return;
}
Optional<CoalNpc> npc = plugin.npcService().findNpc(parseLong(args[1]));
Material material = Material.matchMaterial(args[3]);
int amount = (int) parseLong(args[4]);
long offer = CoalMoney.parsePositive(args[5]);
if (npc.isEmpty() || !npc.get().trader() || material == null || amount <= 0 || offer <= 0L) {
player.sendMessage(plugin.messages().text("&cInvalid trader, material, amount, or offer."));
return;
}
NpcService.HaggleQuote quote = plugin.npcService().quote(npc.get(), args[2], material, amount, offer);
if (!quote.reason().isEmpty()) {
player.sendMessage(plugin.messages().text("&c" + quote.reason()));
return;
}
String prompt = "NPC " + npc.get().name() + " is haggling. Mode " + quote.mode()
+ ", material " + material.name() + ", amount " + amount
+ ", player offer " + CoalMoney.format(offer) + " (" + offer + " coal cents), acceptable range "
+ CoalMoney.format(quote.floor()) + " to " + CoalMoney.format(quote.ceiling()) + " (" + quote.floor() + " to " + quote.ceiling() + " coal cents)"
+ ", deterministic counter " + CoalMoney.format(quote.counter()) + " (" + quote.counter() + " coal cents). Reply with JSON using counter as an integer coal-cent amount.";
player.sendMessage(plugin.messages().text("&7" + npc.get().name() + " considers the offer..."));
plugin.openRouterService().haggle(prompt, quote.floor(), quote.ceiling()).thenAccept(ai -> plugin.getServer().getScheduler().runTask(plugin, () -> {
try {
long finalPrice = ai.map(OpenRouterService.HaggleAiResult::counter).orElse(quote.counter());
boolean accepted = quote.accepted() || ai.map(OpenRouterService.HaggleAiResult::accept).orElse(false) && offer == finalPrice;
String line = ai.map(OpenRouterService.HaggleAiResult::message)
.orElse(accepted ? "Deal." : "I can do " + CoalMoney.format(finalPrice) + ".");
player.sendMessage(plugin.messages().text("&e" + npc.get().name() + ": &f" + line));
if (accepted) {
if (plugin.npcService().completeTrade(player, npc.get(), quote, offer)) {
player.sendMessage(plugin.messages().text("&aTrade complete for &f" + CoalMoney.format(offer) + "&a."));
} else {
player.sendMessage(plugin.messages().text("&cTrade failed. Check balance, stock, and inventory."));
}
} else {
player.sendMessage(plugin.messages().text("&7Counter offer: &f" + CoalMoney.format(finalPrice) + "&7. Run the haggle command again with that offer to accept."));
}
} catch (SQLException exception) {
player.sendMessage(plugin.messages().text("&cNPC trade failed."));
}
}));
}
private void usage(CommandSender sender) {
sender.sendMessage(plugin.messages().text("&eUsage: /cgnpc <zone|create|remove|list|stock|funds|haggle>"));
}
private long parseLong(String raw) {
try {
return Long.parseLong(raw);
} catch (NumberFormatException exception) {
return -1L;
}
}
}

View File

@@ -0,0 +1,74 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Permit;
import com.librewiki.coalgov.service.PermitService;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
public final class PermitCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public PermitCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only.");
return true;
}
if (args.length == 0) {
player.sendMessage(plugin.messages().text("&eUsage: /permit <buy|list>"));
return true;
}
try {
plugin.economyService().ensurePlayer(player);
switch (args[0].toLowerCase()) {
case "buy" -> buy(player, args);
case "list" -> list(player);
default -> player.sendMessage(plugin.messages().text("&eUsage: /permit <buy|list>"));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Permit command failed: " + exception.getMessage());
player.sendMessage(plugin.messages().text("&cPermit registry unavailable."));
}
return true;
}
private void buy(Player player, String[] args) throws SQLException {
if (args.length != 3 || !args[1].equalsIgnoreCase("mining")) {
player.sendMessage(plugin.messages().text("&eUsage: /permit buy mining <regionName>"));
return;
}
PermitService.BuyResult result = plugin.permitService().buyMining(player.getUniqueId(), args[2]);
if (!result.success()) {
player.sendMessage(plugin.messages().text("&c" + result.message()));
return;
}
player.sendMessage(plugin.messages().text("&aMining permit #" + result.id() + " bought for &f"
+ CoalMoney.format(result.cost()) + "&a. Expires " + Instant.ofEpochMilli(result.expiresAt()) + "."));
}
private void list(Player player) throws SQLException {
List<Permit> permits = plugin.permitService().listActive(player.getUniqueId());
if (permits.isEmpty()) {
player.sendMessage(plugin.messages().text("&eYou have no active permits."));
return;
}
player.sendMessage(plugin.messages().text("&eActive permits:"));
for (Permit permit : permits) {
String expiry = permit.expiresAt() == null ? "never" : Instant.ofEpochMilli(permit.expiresAt()).toString();
player.sendMessage(plugin.messages().raw("&7#" + permit.id() + " &f" + permit.permitType()
+ " " + (permit.regionName() == null ? "global" : permit.regionName()) + " expires " + expiry));
}
}
}

View File

@@ -0,0 +1,67 @@
package com.librewiki.coalgov.command;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.UUID;
public final class PoliceCommand implements CommandExecutor {
private final CoalGovPlugin plugin;
public PoliceCommand(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("coalgov.police")) {
sender.sendMessage(plugin.messages().text("&cYou lack permission."));
return true;
}
try {
if (args.length >= 1 && args[0].equalsIgnoreCase("fine")) {
fine(sender, args);
return true;
}
usage(sender);
} catch (SQLException exception) {
plugin.getLogger().warning("Police command failed: " + exception.getMessage());
sender.sendMessage(plugin.messages().text("&cPolice records unavailable."));
}
return true;
}
private void fine(CommandSender sender, String[] args) throws SQLException {
if (args.length < 4) {
sender.sendMessage(plugin.messages().text("&eUsage: /police fine <player> <amount> <reason>"));
return;
}
OfflinePlayer target = Bukkit.getOfflinePlayer(args[1]);
plugin.economyService().ensurePlayer(target);
long amount = CoalMoney.parsePositive(args[2]);
if (amount <= 0L) {
sender.sendMessage(plugin.messages().text("&cAmount must be greater than zero."));
return;
}
String reason = String.join(" ", Arrays.copyOfRange(args, 3, args.length));
UUID issuer = sender instanceof Player player ? player.getUniqueId() : null;
long id = plugin.governmentService().createFine(target.getUniqueId(), issuer, amount, reason);
sender.sendMessage(plugin.messages().text("&aFine #" + id + " issued to &f" + args[1] + " &afor &f" + CoalMoney.format(amount) + "&a."));
if (target.isOnline() && target.getPlayer() != null) {
target.getPlayer().sendMessage(plugin.messages().text("&cFine #" + id + ": &f" + CoalMoney.format(amount) + " &cfor " + reason + ". Use /coal fines pay " + id + "."));
}
}
private void usage(CommandSender sender) {
sender.sendMessage(plugin.messages().text("&eUsage: /police fine <player> <amount> <reason>"));
}
}

View File

@@ -0,0 +1,48 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.service.BuildProtectionService;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import java.sql.SQLException;
public final class BlockBreakListener implements Listener {
private final CoalGovPlugin plugin;
public BlockBreakListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Material type = event.getBlock().getType();
try {
if (!plugin.hasAdminBypass(event.getPlayer()) && plugin.claimMarkerService().isMarkerBlock(event.getBlock())) {
event.setCancelled(true);
event.getPlayer().sendMessage(plugin.messages().text("&cClaim corner markers are protected."));
return;
}
BuildProtectionService.BuildDecision decision = plugin.buildProtectionService()
.canModify(event.getPlayer(), event.getBlock().getLocation());
if (!decision.allowed()) {
event.setCancelled(true);
event.getPlayer().sendMessage(plugin.messages().text("&c" + decision.message()));
return;
}
if ((type == Material.COAL_ORE || type == Material.DEEPSLATE_COAL_ORE)
&& !plugin.miningService().canMineCoal(event.getPlayer(), event.getBlock().getLocation())) {
event.setCancelled(true);
event.getPlayer().sendMessage(plugin.messages().text("&cMining denied: this coal seam is regulated by the Ministry."));
}
} catch (SQLException exception) {
event.setCancelled(true);
plugin.getLogger().warning("Block break check failed: " + exception.getMessage());
event.getPlayer().sendMessage(plugin.messages().text("&cLand registry unavailable. Try again later."));
}
}
}

View File

@@ -0,0 +1,39 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.service.BuildProtectionService;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockPlaceEvent;
import java.sql.SQLException;
public final class BlockPlaceListener implements Listener {
private final CoalGovPlugin plugin;
public BlockPlaceListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
try {
if (!plugin.hasAdminBypass(event.getPlayer()) && plugin.claimMarkerService().isMarkerColumn(event.getBlockPlaced())) {
event.setCancelled(true);
event.getPlayer().sendMessage(plugin.messages().text("&cClaim corner markers cannot be covered."));
return;
}
BuildProtectionService.BuildDecision decision = plugin.buildProtectionService()
.canModify(event.getPlayer(), event.getBlockPlaced().getLocation());
if (!decision.allowed()) {
event.setCancelled(true);
event.getPlayer().sendMessage(plugin.messages().text("&c" + decision.message()));
}
} catch (SQLException exception) {
event.setCancelled(true);
plugin.getLogger().warning("Build protection check failed: " + exception.getMessage());
event.getPlayer().sendMessage(plugin.messages().text("&cLand registry unavailable. Try again later."));
}
}
}

View File

@@ -0,0 +1,31 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class ClaimWandListener implements Listener {
private final CoalGovPlugin plugin;
public ClaimWandListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.claimSelectionService().isWand(event.getItem())) {
return;
}
event.setCancelled(true);
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null) {
return;
}
if (event.getPlayer().isSneaking()) {
plugin.claimSelectionService().clear(event.getPlayer());
return;
}
plugin.claimSelectionService().addPoint(event.getPlayer(), event.getClickedBlock().getLocation());
}
}

View File

@@ -0,0 +1,27 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class DiviningRodListener implements Listener {
private final CoalGovPlugin plugin;
public DiviningRodListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.diviningRodService().isRod(event.getItem())) {
return;
}
event.setCancelled(true);
if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
plugin.diviningRodService().scan(event.getPlayer());
}
}

View File

@@ -0,0 +1,54 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.service.MarketService;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
import java.sql.SQLException;
public final class MarketListener implements Listener {
private final CoalGovPlugin plugin;
public MarketListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!MarketService.TITLE.equals(event.getView().getTitle())) {
return;
}
event.setCancelled(true);
if (!(event.getWhoClicked() instanceof Player player)) {
return;
}
try {
if (plugin.marketService().isSellAllSlot(event.getRawSlot())) {
plugin.marketService().sellAll(player);
player.closeInventory();
return;
}
ItemStack clicked = event.getCurrentItem();
if (clicked == null || clicked.getType() == Material.AIR) {
return;
}
plugin.marketService().buy(player, clicked.getType(), purchaseAmount(event.getClick()));
} catch (SQLException exception) {
plugin.getLogger().warning("Market click failed: " + exception.getMessage());
player.sendMessage(plugin.messages().text("&cMarket ledger unavailable."));
}
}
private int purchaseAmount(ClickType clickType) {
if (clickType.isShiftClick()) {
return 64;
}
return 1;
}
}

View File

@@ -0,0 +1,61 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.CoalNpc;
import com.librewiki.coalgov.util.CoalMoney;
import net.citizensnpcs.api.event.NPCRightClickEvent;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import java.sql.SQLException;
import java.util.Map;
public final class NpcListener implements Listener {
private final CoalGovPlugin plugin;
public NpcListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onNpcRightClick(NPCRightClickEvent event) {
try {
var coalNpc = plugin.npcService().findByCitizensId(event.getNPC().getId());
if (coalNpc.isEmpty()) {
return;
}
event.setCancelled(true);
showNpc(event.getClicker(), coalNpc.get());
} catch (SQLException exception) {
plugin.getLogger().warning("NPC click failed: " + exception.getMessage());
event.getClicker().sendMessage(plugin.messages().text("&cNPC registry unavailable."));
}
}
private void showNpc(org.bukkit.entity.Player player, CoalNpc npc) throws SQLException {
player.sendMessage(plugin.messages().text("&e" + npc.name() + " &7(" + npc.npcType().toLowerCase() + " #" + npc.id() + ")"));
player.sendMessage(plugin.messages().text("&7Balance: &f" + CoalMoney.format(plugin.npcService().balance(npc))));
Map<Material, Long> inventory = plugin.npcService().inventory(npc.id());
if (inventory.isEmpty()) {
player.sendMessage(plugin.messages().text("&7Inventory: &fempty"));
} else {
StringBuilder builder = new StringBuilder();
int shown = 0;
for (var entry : inventory.entrySet()) {
if (shown++ > 0) {
builder.append(", ");
}
builder.append(entry.getKey().name().toLowerCase()).append(" ").append(entry.getValue());
if (shown >= 6) {
break;
}
}
player.sendMessage(plugin.messages().text("&7Inventory: &f" + builder));
}
if (npc.trader()) {
player.sendMessage(plugin.messages().text("&7Haggle: &f/cgnpc haggle " + npc.id() + " buy <material> <amount> <offer>"));
player.sendMessage(plugin.messages().text("&7Or sell: &f/cgnpc haggle " + npc.id() + " sell <material> <amount> <offer>"));
}
}
}

View File

@@ -0,0 +1,25 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import java.sql.SQLException;
public final class PlayerJoinListener implements Listener {
private final CoalGovPlugin plugin;
public PlayerJoinListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
try {
plugin.economyService().ensurePlayer(event.getPlayer());
} catch (SQLException exception) {
plugin.getLogger().warning("Failed to ensure player row: " + exception.getMessage());
}
}
}

View File

@@ -0,0 +1,74 @@
package com.librewiki.coalgov.listener;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.inventory.FurnaceBurnEvent;
import org.bukkit.event.inventory.FurnaceStartSmeltEvent;
import java.sql.SQLException;
public final class SuperFurnaceListener implements Listener {
private final CoalGovPlugin plugin;
public SuperFurnaceListener(CoalGovPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
if (event.getBlockPlaced().getType() != Material.FURNACE
|| !plugin.superFurnaceService().isItem(event.getItemInHand())) {
return;
}
try {
plugin.superFurnaceService().add(event.getBlockPlaced());
event.getPlayer().sendMessage(plugin.messages().text("&aSuper furnace installed."));
} catch (SQLException exception) {
event.setCancelled(true);
plugin.getLogger().warning("Super furnace placement failed: " + exception.getMessage());
event.getPlayer().sendMessage(plugin.messages().text("&cFurnace registry unavailable."));
}
}
@EventHandler(ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
try {
if (!plugin.superFurnaceService().isSuperFurnace(event.getBlock())) {
return;
}
plugin.superFurnaceService().remove(event.getBlock());
event.setDropItems(false);
event.getBlock().getWorld().dropItemNaturally(event.getBlock().getLocation(), plugin.superFurnaceService().item());
} catch (SQLException exception) {
event.setCancelled(true);
plugin.getLogger().warning("Super furnace break failed: " + exception.getMessage());
event.getPlayer().sendMessage(plugin.messages().text("&cFurnace registry unavailable."));
}
}
@EventHandler(ignoreCancelled = true)
public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) {
try {
if (plugin.superFurnaceService().isSuperFurnace(event.getBlock())) {
event.setTotalCookTime(plugin.superFurnaceService().fasterCookTime(event.getTotalCookTime()));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Super furnace smelt check failed: " + exception.getMessage());
}
}
@EventHandler(ignoreCancelled = true)
public void onFurnaceBurn(FurnaceBurnEvent event) {
try {
if (plugin.superFurnaceService().isSuperFurnace(event.getBlock())) {
event.setBurnTime(plugin.superFurnaceService().shorterBurnTime(event.getBurnTime()));
}
} catch (SQLException exception) {
plugin.getLogger().warning("Super furnace burn check failed: " + exception.getMessage());
}
}
}

View File

@@ -0,0 +1,65 @@
package com.librewiki.coalgov.model;
import java.util.UUID;
import java.util.List;
public record Claim(
long id,
UUID ownerUuid,
String world,
int x1,
int z1,
int x2,
int z2,
String claimType,
Long expiresAt,
long taxDue,
long createdAt,
long purchaseCost,
List<ClaimPoint> vertices
) {
public Claim(
long id,
UUID ownerUuid,
String world,
int x1,
int z1,
int x2,
int z2,
String claimType,
Long expiresAt,
long taxDue,
long createdAt
) {
this(id, ownerUuid, world, x1, z1, x2, z2, claimType, expiresAt, taxDue, createdAt, -1L, List.of());
}
public boolean contains(String worldName, int x, int z) {
if (!world.equals(worldName) || x < x1 || x > x2 || z < z1 || z > z2) {
return false;
}
if (vertices == null || vertices.size() < 3) {
return true;
}
return containsPolygon(x + 0.5D, z + 0.5D);
}
public boolean isPolygon() {
return vertices != null && vertices.size() >= 3;
}
private boolean containsPolygon(double x, double z) {
boolean inside = false;
int size = vertices.size();
for (int i = 0, j = size - 1; i < size; j = i++) {
ClaimPoint a = vertices.get(i);
ClaimPoint b = vertices.get(j);
boolean intersects = ((a.z() > z) != (b.z() > z))
&& (x < (double) (b.x() - a.x()) * (z - a.z()) / (double) (b.z() - a.z()) + a.x());
if (intersects) {
inside = !inside;
}
}
return inside;
}
}

View File

@@ -0,0 +1,4 @@
package com.librewiki.coalgov.model;
public record ClaimPoint(int x, int z) {
}

View File

@@ -0,0 +1,24 @@
package com.librewiki.coalgov.model;
import java.util.UUID;
public record CoalNpc(
long id,
int citizensId,
UUID accountUuid,
String npcType,
String name,
long zoneId,
String world,
double x,
double y,
double z
) {
public boolean trader() {
return npcType.equalsIgnoreCase("TRADER");
}
public boolean worker() {
return npcType.equalsIgnoreCase("WORKER");
}
}

View File

@@ -0,0 +1,13 @@
package com.librewiki.coalgov.model;
import java.util.UUID;
public record CoalTransaction(
long id,
UUID fromUuid,
UUID toUuid,
long amount,
String reason,
long createdAt
) {
}

View File

@@ -0,0 +1,17 @@
package com.librewiki.coalgov.model;
import java.util.UUID;
public record Fine(
long id,
UUID playerUuid,
UUID issuerUuid,
long amount,
String reason,
Long paidAt,
long createdAt
) {
public boolean paid() {
return paidAt != null;
}
}

View File

@@ -0,0 +1,11 @@
package com.librewiki.coalgov.model;
public enum LandClass {
FREEHOLD,
LEASE,
GOVERNMENT,
PUBLIC,
MINING_CONCESSION,
PROTECTED_PRESERVE,
BORDER_ZONE
}

View File

@@ -0,0 +1,17 @@
package com.librewiki.coalgov.model;
public record LandRegion(
long id,
String name,
String world,
int x1,
int z1,
int x2,
int z2,
LandClass landClass,
long createdAt
) {
public boolean contains(String worldName, int x, int z) {
return world.equals(worldName) && x >= x1 && x <= x2 && z >= z1 && z <= z2;
}
}

View File

@@ -0,0 +1,37 @@
package com.librewiki.coalgov.model;
import java.util.List;
public record NpcZone(
long id,
String name,
String world,
int x1,
int z1,
int x2,
int z2,
List<ClaimPoint> vertices
) {
public boolean contains(String worldName, int x, int z) {
if (!world.equals(worldName) || x < x1 || x > x2 || z < z1 || z > z2) {
return false;
}
if (vertices == null || vertices.size() < 3) {
return true;
}
boolean inside = false;
int size = vertices.size();
double px = x + 0.5D;
double pz = z + 0.5D;
for (int i = 0, j = size - 1; i < size; j = i++) {
ClaimPoint a = vertices.get(i);
ClaimPoint b = vertices.get(j);
boolean intersects = ((a.z() > pz) != (b.z() > pz))
&& (px < (double) (b.x() - a.x()) * (pz - a.z()) / (double) (b.z() - a.z()) + a.x());
if (intersects) {
inside = !inside;
}
}
return inside;
}
}

View File

@@ -0,0 +1,16 @@
package com.librewiki.coalgov.model;
import java.util.UUID;
public record Permit(
long id,
UUID ownerUuid,
PermitType permitType,
String regionName,
Long expiresAt,
long createdAt
) {
public boolean isActive(long now) {
return expiresAt == null || expiresAt > now;
}
}

View File

@@ -0,0 +1,7 @@
package com.librewiki.coalgov.model;
public enum PermitType {
MINING,
BORDER_PASS,
GOVERNMENT_CONTRACTOR
}

View File

@@ -0,0 +1,43 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.Optional;
public final class BuildProtectionService {
private final CoalGovPlugin plugin;
private final ClaimService claimService;
public BuildProtectionService(CoalGovPlugin plugin, ClaimService claimService) {
this.plugin = plugin;
this.claimService = claimService;
}
public BuildDecision canModify(Player player, Location location) throws SQLException {
if (plugin.hasAdminBypass(player)) {
return BuildDecision.allow();
}
Optional<Claim> claim = claimService.claimAt(location);
if (claim.isEmpty()) {
return BuildDecision.deny("This land is unclaimed and protected by the Ministry.");
}
if (!claim.get().ownerUuid().equals(player.getUniqueId())) {
return BuildDecision.deny("This claim belongs to another citizen.");
}
return BuildDecision.allow();
}
public record BuildDecision(boolean allowed, String message) {
public static BuildDecision allow() {
return new BuildDecision(true, "");
}
public static BuildDecision deny(String message) {
return new BuildDecision(false, message);
}
}
}

View File

@@ -0,0 +1,103 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import java.sql.SQLException;
public final class ClaimMarkerService {
private static final Material MARKER_MATERIAL = Material.GOLD_BLOCK;
private final CoalGovPlugin plugin;
private final ClaimService claimService;
public ClaimMarkerService(CoalGovPlugin plugin, ClaimService claimService) {
this.plugin = plugin;
this.claimService = claimService;
}
public void placeMarkers(Claim claim) {
World world = plugin.getServer().getWorld(claim.world());
if (world == null) {
return;
}
if (claim.isPolygon()) {
for (var point : claim.vertices()) {
placeMarker(world, point.x(), point.z());
}
return;
}
placeMarker(world, claim.x1(), claim.z1());
placeMarker(world, claim.x1(), claim.z2());
placeMarker(world, claim.x2(), claim.z1());
placeMarker(world, claim.x2(), claim.z2());
}
public void removeMarkers(Claim claim) {
World world = plugin.getServer().getWorld(claim.world());
if (world == null) {
return;
}
if (claim.isPolygon()) {
for (var point : claim.vertices()) {
removeMarkerColumn(world, point.x(), point.z());
}
return;
}
removeMarkerColumn(world, claim.x1(), claim.z1());
removeMarkerColumn(world, claim.x1(), claim.z2());
removeMarkerColumn(world, claim.x2(), claim.z1());
removeMarkerColumn(world, claim.x2(), claim.z2());
}
public void placeAllLoadedMarkers() {
try {
for (Claim claim : claimService.listAll()) {
placeMarkers(claim);
}
} catch (SQLException exception) {
plugin.getLogger().warning("Failed to place claim markers: " + exception.getMessage());
}
}
public boolean isMarkerBlock(Block block) throws SQLException {
if (block.getType() != MARKER_MATERIAL) {
return false;
}
return isMarkerColumn(block);
}
public boolean isMarkerColumn(Block block) throws SQLException {
return claimService.claimAt(block.getLocation())
.map(claim -> isCorner(claim, block.getX(), block.getZ()))
.orElse(false);
}
private void placeMarker(World world, int x, int z) {
int y = world.getHighestBlockYAt(x, z);
Block highest = world.getBlockAt(x, y, z);
if (highest.getType() == MARKER_MATERIAL) {
return;
}
world.getBlockAt(x, y + 1, z).setType(MARKER_MATERIAL, false);
}
private void removeMarkerColumn(World world, int x, int z) {
for (int y = world.getMinHeight(); y < world.getMaxHeight(); y++) {
Block block = world.getBlockAt(x, y, z);
if (block.getType() == MARKER_MATERIAL) {
block.setType(Material.AIR, false);
}
}
}
private boolean isCorner(Claim claim, int x, int z) {
if (claim.isPolygon()) {
return claim.vertices().stream().anyMatch(point -> point.x() == x && point.z() == z);
}
return (x == claim.x1() || x == claim.x2()) && (z == claim.z1() || z == claim.z2());
}
}

View File

@@ -0,0 +1,166 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.ClaimPoint;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public final class ClaimSelectionService {
private final CoalGovPlugin plugin;
private final NamespacedKey wandKey;
private final Map<UUID, List<Location>> selections = new HashMap<>();
private final Map<UUID, List<Location>> markerPreviews = new HashMap<>();
public ClaimSelectionService(CoalGovPlugin plugin) {
this.plugin = plugin;
this.wandKey = new NamespacedKey(plugin, "claim_wand");
}
public ItemStack wand() {
ItemStack item = new ItemStack(Material.BLAZE_ROD);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(plugin.messages().raw("&6CoalGov Claim Wand"));
meta.setLore(List.of(
plugin.messages().raw("&7Right-click blocks to add polygon points."),
plugin.messages().raw("&7Sneak right-click to clear.")
));
meta.getPersistentDataContainer().set(wandKey, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
return item;
}
public boolean isWand(ItemStack item) {
if (item == null || item.getType() != Material.BLAZE_ROD || !item.hasItemMeta()) {
return false;
}
return item.getItemMeta().getPersistentDataContainer().has(wandKey, PersistentDataType.BYTE);
}
public void addPoint(Player player, Location clicked) {
List<Location> points = selections.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>());
if (!points.isEmpty() && !points.get(0).getWorld().equals(clicked.getWorld())) {
restoreMarkerPreviews(player);
points.clear();
}
points.add(clicked.toBlockLocation());
player.sendMessage(plugin.messages().text("&aPolygon point " + points.size() + " set at "
+ clicked.getBlockX() + ", " + clicked.getBlockZ() + "."));
show(player);
}
public void clear(Player player) {
restoreMarkerPreviews(player);
selections.remove(player.getUniqueId());
player.sendMessage(plugin.messages().text("&aClaim polygon cleared."));
}
public List<Location> points(Player player) {
return selections.getOrDefault(player.getUniqueId(), List.of());
}
public List<ClaimPoint> claimPoints(Player player) {
List<ClaimPoint> points = new ArrayList<>();
for (Location location : points(player)) {
points.add(new ClaimPoint(location.getBlockX(), location.getBlockZ()));
}
return points;
}
public void consume(Player player) {
restoreMarkerPreviews(player);
selections.remove(player.getUniqueId());
}
public void show(Player player) {
List<Location> points = points(player);
if (points.isEmpty()) {
return;
}
showMarkerPreviews(player, points);
Particle.DustOptions dust = new Particle.DustOptions(Color.fromRGB(64, 210, 255), 1.4f);
Particle.DustOptions vertexDust = new Particle.DustOptions(Color.fromRGB(255, 220, 80), 1.8f);
for (Location point : points) {
spawn(player, point.getWorld(), point.getBlockX(), point.getBlockZ(), vertexDust, 8);
}
for (int i = 1; i < points.size(); i++) {
drawLine(player, points.get(i - 1), points.get(i), dust);
}
if (points.size() >= 3) {
drawLine(player, points.get(points.size() - 1), points.get(0), dust);
}
}
private void drawLine(Player player, Location a, Location b, Particle.DustOptions dust) {
if (!a.getWorld().equals(b.getWorld())) {
return;
}
double dx = b.getBlockX() - a.getBlockX();
double dz = b.getBlockZ() - a.getBlockZ();
int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(dx), Math.abs(dz)) / 2.0D));
for (int i = 0; i <= steps; i++) {
double t = (double) i / (double) steps;
int x = (int) Math.round(a.getBlockX() + dx * t);
int z = (int) Math.round(a.getBlockZ() + dz * t);
spawn(player, a.getWorld(), x, z, dust, 1);
}
}
private void spawn(Player player, World world, int x, int z, Particle.DustOptions dust, int count) {
double y = world.getHighestBlockYAt(x, z) + 1.2D;
player.spawnParticle(Particle.DUST, x + 0.5D, y, z + 0.5D, count, 0.15D, 0.2D, 0.15D, 0.0D, dust);
}
private void showMarkerPreviews(Player player, List<Location> points) {
restoreMarkerPreviews(player);
List<Location> previews = new ArrayList<>();
for (Location point : points) {
World world = point.getWorld();
if (world == null) {
continue;
}
Location marker = markerLocation(world, point.getBlockX(), point.getBlockZ());
player.sendBlockChange(marker, Material.GOLD_BLOCK.createBlockData());
previews.add(marker);
}
markerPreviews.put(player.getUniqueId(), previews);
}
private void restoreMarkerPreviews(Player player) {
List<Location> previews = markerPreviews.remove(player.getUniqueId());
if (previews == null) {
return;
}
for (Location preview : previews) {
World world = preview.getWorld();
if (world == null) {
continue;
}
Block block = world.getBlockAt(preview);
player.sendBlockChange(preview, block.getBlockData());
}
}
private Location markerLocation(World world, int x, int z) {
int y = world.getHighestBlockYAt(x, z);
Block highest = world.getBlockAt(x, y, z);
if (highest.getType() == Material.GOLD_BLOCK) {
return highest.getLocation();
}
return world.getBlockAt(x, y + 1, z).getLocation();
}
}

View File

@@ -0,0 +1,225 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.model.Claim;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.storage.ClaimRepository;
import com.librewiki.coalgov.util.CoalMoney;
import com.librewiki.coalgov.util.Cuboid2D;
import org.bukkit.Location;
import org.bukkit.configuration.file.FileConfiguration;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public final class ClaimService {
private final ClaimRepository repository;
private final LandService landService;
private final EconomyService economyService;
private FileConfiguration config;
public ClaimService(ClaimRepository repository, LandService landService, EconomyService economyService, FileConfiguration config) {
this.repository = repository;
this.landService = landService;
this.economyService = economyService;
this.config = config;
}
public void setConfig(FileConfiguration config) {
this.config = config;
}
public BuyResult buy(UUID owner, Location center, String claimType, int radius) throws SQLException {
String type = claimType.toLowerCase();
if (!type.equals("homestead") && !type.equals("industrial")) {
return BuyResult.fail("Claim type must be homestead or industrial.");
}
int maxRadius = config.getInt("claims." + type + ".max_radius");
if (radius <= 0 || radius > maxRadius) {
return BuyResult.fail("Radius must be between 1 and " + maxRadius + ".");
}
Cuboid2D area = new Cuboid2D(
center.getWorld().getName(),
center.getBlockX() - radius,
center.getBlockZ() - radius,
center.getBlockX() + radius,
center.getBlockZ() + radius
);
if (repository.overlaps(area)) {
return BuyResult.fail("That claim overlaps an existing claim.");
}
if (landService.blocksPrivateClaim(area)) {
return BuyResult.fail("That land class cannot be claimed.");
}
long cost = adjustedCost(owner, type, area.area(), area);
if (cost > 0L && !economyService.charge(owner, cost, "claim_purchase")) {
return BuyResult.fail("You need " + CoalMoney.format(cost) + " for that claim.");
}
long id = repository.create(owner, area, type.toUpperCase(), null, cost, List.of());
return BuyResult.success(id, cost);
}
public BuyResult buyPolygon(UUID owner, String world, String claimType, List<ClaimPoint> vertices) throws SQLException {
String type = claimType.toLowerCase();
if (!type.equals("homestead") && !type.equals("industrial")) {
return BuyResult.fail("Claim type must be homestead or industrial.");
}
if (vertices.size() < 3) {
return BuyResult.fail("Use the claim wand to set at least 3 polygon points.");
}
Cuboid2D area = bounds(world, vertices);
int maxRadius = config.getInt("claims." + type + ".max_radius");
int maxWidth = maxRadius * 2 + 1;
if ((area.x2() - area.x1() + 1) > maxWidth || (area.z2() - area.z1() + 1) > maxWidth) {
return BuyResult.fail("Polygon bounds exceed max " + type + " size.");
}
if (repository.overlaps(area)) {
return BuyResult.fail("That claim overlaps an existing claim.");
}
if (landService.blocksPrivateClaim(area)) {
return BuyResult.fail("That land class cannot be claimed.");
}
long cost = adjustedCost(owner, type, polygonArea(vertices), area);
if (cost > 0L && !economyService.charge(owner, cost, "claim_purchase")) {
return BuyResult.fail("You need " + CoalMoney.format(cost) + " for that claim.");
}
long id = repository.create(owner, area, type.toUpperCase(), null, cost, vertices);
return BuyResult.success(id, cost);
}
public Optional<Claim> claimAt(Location location) throws SQLException {
return repository.findAt(location.getWorld().getName(), location.getBlockX(), location.getBlockZ());
}
public List<Claim> list(UUID owner) throws SQLException {
return repository.findByOwner(owner);
}
public List<Claim> listAll() throws SQLException {
return repository.listAll();
}
public Optional<Claim> findOwnedById(UUID owner, long id) throws SQLException {
return repository.findOwnedById(owner, id);
}
public SellResult sell(UUID owner, long id, long refund) throws SQLException {
Optional<Claim> claim = repository.findOwnedById(owner, id);
if (claim.isEmpty()) {
return SellResult.fail("No owned claim with that id.");
}
if (refund < 0L && !economyService.charge(owner, Math.abs(refund), "claim_sale_depletion")) {
return SellResult.fail("You need " + CoalMoney.format(Math.abs(refund)) + " to settle extracted land value.");
}
if (!repository.deleteOwned(owner, id)) {
if (refund < 0L) {
economyService.credit(owner, Math.abs(refund), "claim_sale_depletion_refund");
}
return SellResult.fail("Could not sell that claim.");
}
if (refund > 0L) {
economyService.credit(owner, refund, "claim_sale");
}
return SellResult.success(claim.get(), refund);
}
public boolean abandon(UUID owner, long id) throws SQLException {
return repository.deleteOwned(owner, id);
}
public TransferResult transfer(UUID owner, long id, UUID newOwner) throws SQLException {
if (owner.equals(newOwner)) {
return TransferResult.fail("You already own that claim.");
}
Optional<Claim> claim = repository.findOwnedById(owner, id);
if (claim.isEmpty()) {
return TransferResult.fail("No owned claim with that id.");
}
if (!repository.transferOwned(owner, id, newOwner)) {
return TransferResult.fail("Could not transfer that claim.");
}
return TransferResult.success(claim.get());
}
public long claimPurchaseCost(Claim claim) {
if (claim.purchaseCost() >= 0L) {
return claim.purchaseCost();
}
return assessedClaimCost(claim);
}
public long assessedClaimCost(Claim claim) {
return cost(claim.claimType().toLowerCase(), (long) (claim.x2() - claim.x1() + 1) * (claim.z2() - claim.z1() + 1));
}
private long cost(String type, long area) {
double base = config.getDouble("claims." + type + ".base_cost");
double perBlock = config.getDouble("claims." + type + ".cost_per_block");
return CoalMoney.fromCoalConfig(base + area * perBlock);
}
private long adjustedCost(UUID owner, String type, long areaBlocks, Cuboid2D bounds) throws SQLException {
long cost = cost(type, areaBlocks);
if (!type.equals("homestead") || !config.getBoolean("claims.homestead.first_claim.free", true)) {
return cost;
}
if (!list(owner).isEmpty()) {
return cost;
}
int freeRadius = config.getInt("claims.homestead.first_claim.max_radius", 12);
int maxWidth = freeRadius * 2 + 1;
if ((bounds.x2() - bounds.x1() + 1) > maxWidth || (bounds.z2() - bounds.z1() + 1) > maxWidth) {
return cost;
}
return 0L;
}
private Cuboid2D bounds(String world, List<ClaimPoint> vertices) {
int minX = vertices.stream().mapToInt(ClaimPoint::x).min().orElse(0);
int maxX = vertices.stream().mapToInt(ClaimPoint::x).max().orElse(0);
int minZ = vertices.stream().mapToInt(ClaimPoint::z).min().orElse(0);
int maxZ = vertices.stream().mapToInt(ClaimPoint::z).max().orElse(0);
return new Cuboid2D(world, minX, minZ, maxX, maxZ);
}
private long polygonArea(List<ClaimPoint> vertices) {
long sum = 0L;
for (int i = 0, j = vertices.size() - 1; i < vertices.size(); j = i++) {
ClaimPoint a = vertices.get(j);
ClaimPoint b = vertices.get(i);
sum += (long) a.x() * b.z() - (long) b.x() * a.z();
}
return Math.max(1L, Math.round(Math.abs(sum) / 2.0D));
}
public record BuyResult(boolean success, String message, long id, long cost) {
public static BuyResult fail(String message) {
return new BuyResult(false, message, -1L, 0L);
}
public static BuyResult success(long id, long cost) {
return new BuyResult(true, "", id, cost);
}
}
public record SellResult(boolean success, String message, Claim claim, long refund) {
public static SellResult fail(String message) {
return new SellResult(false, message, null, 0L);
}
public static SellResult success(Claim claim, long refund) {
return new SellResult(true, "", claim, refund);
}
}
public record TransferResult(boolean success, String message, Claim claim) {
public static TransferResult fail(String message) {
return new TransferResult(false, message, null);
}
public static TransferResult success(Claim claim) {
return new TransferResult(true, "", claim);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
public final class ClaimVisualService {
private static final int REPEATS = 12;
private static final int PERIOD_TICKS = 10;
private static final int STEP_BLOCKS = 2;
private final CoalGovPlugin plugin;
public ClaimVisualService(CoalGovPlugin plugin) {
this.plugin = plugin;
}
public void show(Player player, Claim claim) {
World world = plugin.getServer().getWorld(claim.world());
if (world == null) {
player.sendMessage(plugin.messages().text("&cThat claim's world is not loaded."));
return;
}
if (!player.getWorld().equals(world)) {
player.sendMessage(plugin.messages().text("&cThat claim is in world " + claim.world() + "."));
return;
}
Particle.DustOptions edgeDust = new Particle.DustOptions(Color.fromRGB(255, 196, 64), 1.4f);
Particle.DustOptions cornerDust = new Particle.DustOptions(Color.fromRGB(255, 80, 64), 1.8f);
new BukkitRunnable() {
private int remaining = REPEATS;
@Override
public void run() {
if (remaining-- <= 0 || !player.isOnline()) {
cancel();
return;
}
draw(player, world, claim, edgeDust, cornerDust);
}
}.runTaskTimer(plugin, 0L, PERIOD_TICKS);
}
private void draw(Player player, World world, Claim claim, Particle.DustOptions edgeDust, Particle.DustOptions cornerDust) {
if (claim.isPolygon()) {
for (int i = 0; i < claim.vertices().size(); i++) {
var a = claim.vertices().get(i);
var b = claim.vertices().get((i + 1) % claim.vertices().size());
drawLine(player, world, a.x(), a.z(), b.x(), b.z(), edgeDust);
spawnCorner(player, world, a.x(), a.z(), cornerDust);
}
return;
}
for (int x = claim.x1(); x <= claim.x2(); x += STEP_BLOCKS) {
spawnEdge(player, world, x, claim.z1(), edgeDust);
spawnEdge(player, world, x, claim.z2(), edgeDust);
}
for (int z = claim.z1(); z <= claim.z2(); z += STEP_BLOCKS) {
spawnEdge(player, world, claim.x1(), z, edgeDust);
spawnEdge(player, world, claim.x2(), z, edgeDust);
}
spawnCorner(player, world, claim.x1(), claim.z1(), cornerDust);
spawnCorner(player, world, claim.x1(), claim.z2(), cornerDust);
spawnCorner(player, world, claim.x2(), claim.z1(), cornerDust);
spawnCorner(player, world, claim.x2(), claim.z2(), cornerDust);
}
private void spawnEdge(Player player, World world, int x, int z, Particle.DustOptions dust) {
double y = world.getHighestBlockYAt(x, z) + 1.15;
player.spawnParticle(Particle.DUST, x + 0.5, y, z + 0.5, 1, 0.0, 0.15, 0.0, 0.0, dust);
}
private void drawLine(Player player, World world, int x1, int z1, int x2, int z2, Particle.DustOptions dust) {
double dx = x2 - x1;
double dz = z2 - z1;
int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(dx), Math.abs(dz)) / STEP_BLOCKS));
for (int i = 0; i <= steps; i++) {
double t = (double) i / (double) steps;
spawnEdge(player, world, (int) Math.round(x1 + dx * t), (int) Math.round(z1 + dz * t), dust);
}
}
private void spawnCorner(Player player, World world, int x, int z, Particle.DustOptions dust) {
double y = world.getHighestBlockYAt(x, z) + 1.25;
player.spawnParticle(Particle.DUST, x + 0.5, y, z + 0.5, 8, 0.25, 0.35, 0.25, 0.0, dust);
}
}

View File

@@ -0,0 +1,176 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public final class DiviningRodService {
private static final List<Material> ORES = List.of(
Material.COAL_ORE,
Material.DEEPSLATE_COAL_ORE,
Material.COPPER_ORE,
Material.DEEPSLATE_COPPER_ORE,
Material.IRON_ORE,
Material.DEEPSLATE_IRON_ORE,
Material.GOLD_ORE,
Material.DEEPSLATE_GOLD_ORE,
Material.REDSTONE_ORE,
Material.DEEPSLATE_REDSTONE_ORE,
Material.LAPIS_ORE,
Material.DEEPSLATE_LAPIS_ORE,
Material.DIAMOND_ORE,
Material.DEEPSLATE_DIAMOND_ORE,
Material.EMERALD_ORE,
Material.DEEPSLATE_EMERALD_ORE,
Material.NETHER_GOLD_ORE,
Material.NETHER_QUARTZ_ORE,
Material.ANCIENT_DEBRIS
);
private final CoalGovPlugin plugin;
private final NamespacedKey rodKey;
private final Map<UUID, Long> lastUseMillis = new HashMap<>();
public DiviningRodService(CoalGovPlugin plugin) {
this.plugin = plugin;
this.rodKey = new NamespacedKey(plugin, "divining_rod");
}
public ItemStack rod() {
ItemStack item = new ItemStack(Material.BLAZE_ROD);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(plugin.messages().raw("&dCoalGov Divining Rod"));
meta.setLore(List.of(
plugin.messages().raw("&7Right-click to test the ground for ore."),
plugin.messages().raw("&7Issued by the Ministry Geological Survey.")
));
meta.getPersistentDataContainer().set(rodKey, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
return item;
}
public boolean isRod(ItemStack item) {
if (item == null || item.getType() != Material.BLAZE_ROD || !item.hasItemMeta()) {
return false;
}
return item.getItemMeta().getPersistentDataContainer().has(rodKey, PersistentDataType.BYTE);
}
public void scan(Player player) {
long remaining = cooldownRemainingMillis(player);
if (remaining > 0L) {
player.sendMessage(plugin.messages().text("&cThe rod is still settling. Try again in "
+ Math.ceil(remaining / 1000.0D) + "s."));
return;
}
lastUseMillis.put(player.getUniqueId(), System.currentTimeMillis());
ScanHit hit = nearestOre(player.getLocation());
if (hit == null) {
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.35f, 0.55f);
player.spawnParticle(Particle.ASH, player.getLocation().add(0.0D, 1.0D, 0.0D), 10, 0.35D, 0.2D, 0.35D, 0.0D);
player.sendMessage(plugin.messages().text("&7The divining rod stays still. No ore signal nearby."));
return;
}
double strength = 1.0D - Math.min(1.0D, hit.distance() / Math.max(1.0D, scanDepth()));
float pitch = (float) (0.75D + strength);
int particles = 8 + (int) Math.round(strength * 18.0D);
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_RESONATE, 0.75f, pitch);
player.spawnParticle(Particle.DUST, player.getLocation().add(0.0D, 1.0D, 0.0D), particles,
0.4D, 0.25D, 0.4D, 0.0D, new Particle.DustOptions(Color.fromRGB(190, 80, 255), 1.1f));
player.sendMessage(plugin.messages().text("&dThe divining rod pulls " + direction(hit)
+ " toward &f" + readable(hit.material()) + " &7(" + Math.round(hit.distance()) + " blocks, "
+ signalName(strength) + " signal)&d."));
}
private long cooldownRemainingMillis(Player player) {
long cooldownMillis = Math.max(0L, Math.round(plugin.getConfig()
.getDouble("divining_rod.cooldown_seconds", 5.0D) * 1000.0D));
long lastUse = lastUseMillis.getOrDefault(player.getUniqueId(), 0L);
return Math.max(0L, cooldownMillis - (System.currentTimeMillis() - lastUse));
}
private ScanHit nearestOre(Location origin) {
World world = origin.getWorld();
if (world == null) {
return null;
}
int radius = scanRadius();
int depth = scanDepth();
int originX = origin.getBlockX();
int originY = origin.getBlockY();
int originZ = origin.getBlockZ();
int minY = Math.max(world.getMinHeight(), originY - depth);
ScanHit nearest = null;
for (int x = originX - radius; x <= originX + radius; x++) {
for (int z = originZ - radius; z <= originZ + radius; z++) {
for (int y = originY; y >= minY; y--) {
Material material = world.getBlockAt(x, y, z).getType();
if (!ORES.contains(material)) {
continue;
}
double distance = origin.distance(new Location(world, x + 0.5D, y + 0.5D, z + 0.5D));
if (nearest == null || distance < nearest.distance()) {
nearest = new ScanHit(material, x - originX, y - originY, z - originZ, distance);
}
}
}
}
return nearest;
}
private int scanRadius() {
return Math.max(0, plugin.getConfig().getInt("divining_rod.scan_radius", 4));
}
private int scanDepth() {
return Math.max(1, plugin.getConfig().getInt("divining_rod.scan_depth", 48));
}
private String direction(ScanHit hit) {
if (hit.dx() == 0 && hit.dz() == 0) {
return "straight down";
}
String northSouth = hit.dz() < 0 ? "north" : hit.dz() > 0 ? "south" : "";
String eastWest = hit.dx() < 0 ? "west" : hit.dx() > 0 ? "east" : "";
if (northSouth.isEmpty()) {
return eastWest + " and down";
}
if (eastWest.isEmpty()) {
return northSouth + " and down";
}
return northSouth + "-" + eastWest + " and down";
}
private String signalName(double strength) {
if (strength >= 0.75D) {
return "strong";
}
if (strength >= 0.45D) {
return "steady";
}
return "faint";
}
private String readable(Material material) {
return material.name().toLowerCase().replace('_', ' ');
}
private record ScanHit(Material material, int dx, int dy, int dz, double distance) {
}
}

View File

@@ -0,0 +1,60 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.storage.EconomyRepository;
import org.bukkit.OfflinePlayer;
import java.sql.SQLException;
import java.util.UUID;
public final class EconomyService {
private final EconomyRepository repository;
private final long startingBalance;
public EconomyService(EconomyRepository repository, long startingBalance) {
this.repository = repository;
this.startingBalance = startingBalance;
}
public void ensurePlayer(OfflinePlayer player) throws SQLException {
repository.ensurePlayer(player, startingBalance);
}
public void ensureAccount(UUID uuid, String name) throws SQLException {
repository.ensureAccount(uuid, name, startingBalance);
}
public long balance(UUID uuid) throws SQLException {
return repository.balance(uuid);
}
public boolean deposit(UUID uuid, long amount) throws SQLException {
return repository.add(uuid, amount, null, uuid, "deposit");
}
public boolean withdraw(UUID uuid, long amount) throws SQLException {
return repository.subtract(uuid, amount, uuid, null, "withdrawal");
}
public boolean pay(UUID from, UUID to, long amount) throws SQLException {
return repository.transfer(from, to, amount, "payment");
}
public boolean grant(UUID uuid, long amount) throws SQLException {
return repository.add(uuid, amount, null, uuid, "admin_grant");
}
public boolean credit(UUID uuid, long amount, String reason) throws SQLException {
if (amount <= 0) {
return true;
}
return repository.add(uuid, amount, null, uuid, reason);
}
public boolean take(UUID uuid, long amount) throws SQLException {
return repository.subtract(uuid, amount, uuid, null, "admin_take");
}
public boolean charge(UUID uuid, long amount, String reason) throws SQLException {
return repository.subtract(uuid, amount, uuid, null, reason);
}
}

View File

@@ -0,0 +1,136 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.model.Fine;
import com.librewiki.coalgov.storage.GovernmentRepository;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.configuration.file.FileConfiguration;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public final class GovernmentService {
private final GovernmentRepository repository;
private final EconomyService economyService;
private FileConfiguration config;
public GovernmentService(GovernmentRepository repository, EconomyService economyService, FileConfiguration config) {
this.repository = repository;
this.economyService = economyService;
this.config = config;
}
public void setConfig(FileConfiguration config) {
this.config = config;
}
public long treasuryBalance() throws SQLException {
return repository.treasuryBalance();
}
public boolean grantFromTreasury(UUID target, long amount) throws SQLException {
if (amount <= 0L || !repository.spendTreasury(amount)) {
return false;
}
if (!economyService.credit(target, amount, "treasury_grant")) {
repository.addTreasury(amount);
return false;
}
return true;
}
public long marketSaleTax(UUID playerUuid, long gross) throws SQLException {
return transactionTax(playerUuid, gross, "government.taxes.market_sale_percent");
}
public long marketPurchaseTax(UUID playerUuid, long gross) throws SQLException {
return transactionTax(playerUuid, gross, "government.taxes.market_purchase_percent");
}
public void collectTax(long amount) throws SQLException {
repository.addTreasury(amount);
}
public boolean taxExempt(UUID uuid) throws SQLException {
return repository.taxExempt(uuid);
}
public void setTaxExempt(UUID uuid, boolean exempt) throws SQLException {
repository.setTaxExempt(uuid, exempt);
}
public long createFine(UUID playerUuid, UUID issuerUuid, long amount, String reason) throws SQLException {
if (amount <= 0L) {
return -1L;
}
return repository.createFine(playerUuid, issuerUuid, amount, reason);
}
public List<Fine> unpaidFines(UUID playerUuid) throws SQLException {
return repository.unpaidFines(playerUuid);
}
public PayFineResult payFine(UUID playerUuid, long id) throws SQLException {
Optional<Fine> fine = repository.findUnpaidFine(playerUuid, id);
if (fine.isEmpty()) {
return PayFineResult.fail("No unpaid fine with that id.");
}
return pay(playerUuid, fine.get());
}
public PayFineResult payAllFines(UUID playerUuid) throws SQLException {
List<Fine> fines = repository.unpaidFines(playerUuid);
if (fines.isEmpty()) {
return PayFineResult.fail("You have no unpaid fines.");
}
long total = fines.stream().mapToLong(Fine::amount).sum();
if (economyService.balance(playerUuid) < total) {
return PayFineResult.fail("You need " + CoalMoney.format(total) + " to pay all fines.");
}
long paid = 0L;
for (Fine fine : fines) {
PayFineResult result = pay(playerUuid, fine);
if (!result.success()) {
return paid > 0L
? PayFineResult.success(paid, "Paid " + CoalMoney.format(paid) + " before one fine failed.")
: result;
}
paid += result.amount();
}
return PayFineResult.success(paid, "");
}
private PayFineResult pay(UUID playerUuid, Fine fine) throws SQLException {
if (!economyService.charge(playerUuid, fine.amount(), "fine_payment")) {
return PayFineResult.fail("Balance too low.");
}
if (!repository.markFinePaid(fine.id())) {
economyService.credit(playerUuid, fine.amount(), "fine_payment_refund");
return PayFineResult.fail("Fine payment failed.");
}
repository.addTreasury(fine.amount());
return PayFineResult.success(fine.amount(), "");
}
private long transactionTax(UUID playerUuid, long gross, String configPath) throws SQLException {
if (gross <= 0L || repository.taxExempt(playerUuid)) {
return 0L;
}
double percent = Math.max(0.0D, config.getDouble(configPath, 0.0D));
if (percent <= 0.0D) {
return 0L;
}
return Math.min(gross, Math.max(0L, Math.round(gross * percent / 100.0D)));
}
public record PayFineResult(boolean success, String message, long amount) {
public static PayFineResult fail(String message) {
return new PayFineResult(false, message, 0L);
}
public static PayFineResult success(long amount, String message) {
return new PayFineResult(true, message, amount);
}
}
}

View File

@@ -0,0 +1,145 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import java.util.LinkedHashMap;
import java.util.Map;
public final class LandAppraisalService {
private final CoalGovPlugin plugin;
private final ClaimService claimService;
public LandAppraisalService(CoalGovPlugin plugin, ClaimService claimService) {
this.plugin = plugin;
this.claimService = claimService;
}
public Appraisal appraise(Claim claim) {
World world = plugin.getServer().getWorld(claim.world());
if (world == null) {
long base = claimService.claimPurchaseCost(claim);
return new Appraisal(base, claimService.assessedClaimCost(claim), 0.0D, 0.0D, 0.0D, 0.0D, 0L, Map.of(), false);
}
Map<Material, Double> prices = resourcePrices();
Map<Material, Long> resources = new LinkedHashMap<>();
long solid = 0L;
long checked = 0L;
double resourceValue = 0.0D;
for (int x = claim.x1(); x <= claim.x2(); x++) {
for (int z = claim.z1(); z <= claim.z2(); z++) {
int top = world.getHighestBlockYAt(x, z);
for (int y = world.getMinHeight(); y <= top; y++) {
Block block = world.getBlockAt(x, y, z);
Material type = block.getType();
checked++;
if (!type.isAir()) {
solid++;
}
resourceValue += addResource(resources, prices, type, 1L);
resourceValue += inventoryResourceValue(block, resources, prices);
}
}
}
double intactRatio = checked == 0L ? 1.0D : (double) solid / (double) checked;
double minMultiplier = plugin.getConfig().getDouble("claims.resale.min_condition_multiplier", 0.25D);
minMultiplier = Math.max(0.0D, Math.min(1.0D, minMultiplier));
double conditionMultiplier = minMultiplier + ((1.0D - minMultiplier) * intactRatio);
long originalCost = claimService.claimPurchaseCost(claim);
long assessedCost = claimService.assessedClaimCost(claim);
double conditionValue = originalCost * conditionMultiplier;
double depletionCharge = originalCost == 0L ? assessedCost * (1.0D - conditionMultiplier) : 0.0D;
long total = Math.round(conditionValue + resourceValue - depletionCharge);
return new Appraisal(originalCost, assessedCost, conditionMultiplier, conditionValue, resourceValue, depletionCharge, total, resources, true);
}
private double inventoryResourceValue(Block block, Map<Material, Long> resources, Map<Material, Double> prices) {
BlockState state = block.getState();
if (!(state instanceof InventoryHolder holder)) {
return 0.0D;
}
double value = 0.0D;
for (ItemStack item : holder.getInventory().getContents()) {
if (item == null || item.getType().isAir()) {
continue;
}
value += addResource(resources, prices, item.getType(), item.getAmount());
}
return value;
}
private double addResource(Map<Material, Long> resources, Map<Material, Double> prices, Material material, long amount) {
Double price = prices.get(material);
if (price == null || amount <= 0L) {
return 0.0D;
}
resources.merge(material, amount, Long::sum);
return CoalMoney.fromCoal(price) * amount;
}
private Map<Material, Double> resourcePrices() {
Map<Material, Double> prices = defaultResourcePrices();
ConfigurationSection section = plugin.getConfig().getConfigurationSection("claims.resale.resource_values");
if (section == null) {
return prices;
}
for (String key : section.getKeys(false)) {
Material material = Material.matchMaterial(key);
if (material != null) {
prices.put(material, section.getDouble(key));
}
}
return prices;
}
private Map<Material, Double> defaultResourcePrices() {
Map<Material, Double> prices = new LinkedHashMap<>();
prices.put(Material.COBBLESTONE, 0.02D);
prices.put(Material.COAL, 1.0D);
prices.put(Material.COAL_BLOCK, 9.0D);
prices.put(Material.RAW_COPPER, 0.5D);
prices.put(Material.COPPER_INGOT, 1.0D);
prices.put(Material.RAW_IRON, 1.0D);
prices.put(Material.IRON_INGOT, 2.0D);
prices.put(Material.RAW_GOLD, 2.0D);
prices.put(Material.GOLD_INGOT, 4.0D);
prices.put(Material.DIAMOND, 16.0D);
prices.put(Material.EMERALD, 12.0D);
return prices;
}
public record Appraisal(
long originalCost,
long assessedCost,
double conditionMultiplier,
double conditionValue,
double rawResourceValue,
double depletionCharge,
long totalValue,
Map<Material, Long> resources,
boolean worldLoaded
) {
public long resourceValue() {
return Math.max(0L, Math.round(rawResourceValue));
}
public long depletionChargeRounded() {
return Math.max(0L, Math.round(depletionCharge));
}
public int conditionPercent() {
return (int) Math.round(conditionMultiplier * 100.0D);
}
}
}

View File

@@ -0,0 +1,69 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.model.LandRegion;
import com.librewiki.coalgov.storage.LandRepository;
import com.librewiki.coalgov.util.Cuboid2D;
import org.bukkit.Location;
import org.bukkit.configuration.file.FileConfiguration;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public final class LandService {
private final LandRepository repository;
private FileConfiguration config;
public LandService(LandRepository repository, FileConfiguration config) {
this.repository = repository;
this.config = config;
}
public void setConfig(FileConfiguration config) {
this.config = config;
}
public Optional<LandRegion> regionAt(Location location) throws SQLException {
return repository.findAt(location.getWorld().getName(), location.getBlockX(), location.getBlockZ());
}
public LandClass classAt(Location location) throws SQLException {
return regionAt(location).map(LandRegion::landClass).orElse(defaultLandClass());
}
public Optional<LandRegion> findByName(String name) throws SQLException {
return repository.findByName(name);
}
public boolean create(String name, Cuboid2D area, LandClass landClass) throws SQLException {
return repository.create(name, area, landClass);
}
public boolean delete(String name) throws SQLException {
return repository.delete(name);
}
public List<LandRegion> listAll() throws SQLException {
return repository.listAll();
}
public boolean blocksPrivateClaim(Cuboid2D area) throws SQLException {
return repository.overlaps(
area,
LandClass.GOVERNMENT,
LandClass.PUBLIC,
LandClass.MINING_CONCESSION,
LandClass.PROTECTED_PRESERVE,
LandClass.BORDER_ZONE
);
}
private LandClass defaultLandClass() {
try {
return LandClass.valueOf(config.getString("land.default_land_class", "FREEHOLD").toUpperCase());
} catch (IllegalArgumentException exception) {
return LandClass.FREEHOLD;
}
}
}

View File

@@ -0,0 +1,336 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.storage.MarketRepository;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class MarketService {
public static final String TITLE = "Ministry Exchange";
private static final int SELL_ALL_SLOT = 4;
private final CoalGovPlugin plugin;
private final MarketRepository repository;
public MarketService(CoalGovPlugin plugin, MarketRepository repository) {
this.plugin = plugin;
this.repository = repository;
}
public void open(Player player) throws SQLException {
Inventory inventory = Bukkit.createInventory(null, 45, TITLE);
inventory.setItem(SELL_ALL_SLOT, button(Material.EMERALD, "&aSell appraised resources", List.of(
"&7Sells all priced resources in your inventory.",
"&7Price rises when Ministry stock is low."
)));
int slot = 9;
for (Material material : basePrices().keySet()) {
if (slot >= inventory.getSize()) {
break;
}
Quote quote = quote(material);
inventory.setItem(slot++, button(material, "&eBuy " + readable(material), List.of(
"&7Click: buy 1",
"&7Shift-click: buy 64",
"&7Stock: " + quote.stock(),
"&7Buy 1: " + CoalMoney.format(buyCost(quote, 1)),
"&7Buy 64: " + CoalMoney.format(buyCost(quote, 64)),
"&7Sell: " + CoalMoney.format(CoalMoney.fromCoal(quote.sellUnitPrice() * 64.0D)) + " per 64"
)));
}
player.openInventory(inventory);
}
public void sellAll(Player player) throws SQLException {
Map<Material, Double> prices = basePrices();
Map<Material, Long> simulatedStock = new LinkedHashMap<>();
Map<Material, Long> sold = new LinkedHashMap<>();
long grossCredit = 0L;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item == null || item.getType().isAir()) {
continue;
}
Material material = item.getType();
Double basePrice = prices.get(material);
if (basePrice == null) {
continue;
}
long stock = simulatedStock.containsKey(material)
? simulatedStock.get(material)
: repository.stock(material, initialStock(material));
grossCredit += sellCredit(material, basePrice, stock, item.getAmount());
simulatedStock.put(material, stock + item.getAmount());
sold.merge(material, (long) item.getAmount(), Long::sum);
}
if (grossCredit <= 0L) {
player.sendMessage(plugin.messages().text("&cNo appraised resources with enough value found in your inventory."));
return;
}
long tax = plugin.governmentService().marketSaleTax(player.getUniqueId(), grossCredit);
long credit = Math.max(0L, grossCredit - tax);
plugin.economyService().credit(player.getUniqueId(), credit, "market_sale");
plugin.governmentService().collectTax(tax);
removeSoldItems(player, sold);
for (var entry : sold.entrySet()) {
repository.addStock(entry.getKey(), entry.getValue());
}
if (tax > 0L) {
player.sendMessage(plugin.messages().text("&aSold resources for &f" + CoalMoney.format(credit) + " &aafter &f" + CoalMoney.format(tax) + " &atax."));
} else {
player.sendMessage(plugin.messages().text("&aSold resources for &f" + CoalMoney.format(credit) + "&a."));
}
}
public void buy(Player player, Material material, int amount) throws SQLException {
if (!basePrices().containsKey(material)) {
return;
}
if (amount <= 0) {
player.sendMessage(plugin.messages().text("&cInvalid purchase amount."));
return;
}
Quote quote = quote(material);
if (quote.stock() < amount) {
player.sendMessage(plugin.messages().text("&cThe Ministry has fewer than " + amount + " " + readable(material) + " in stock."));
return;
}
long cost = buyCost(quote, amount);
long tax = plugin.governmentService().marketPurchaseTax(player.getUniqueId(), cost);
long total = cost + tax;
if (plugin.economyService().balance(player.getUniqueId()) < total) {
player.sendMessage(plugin.messages().text("&cYou need " + CoalMoney.format(total) + "."));
return;
}
if (!hasRoomFor(player, material, amount)) {
player.sendMessage(plugin.messages().text("&cYou need more inventory space."));
return;
}
if (!plugin.economyService().charge(player.getUniqueId(), total, "market_purchase")) {
player.sendMessage(plugin.messages().text("&cPurchase failed."));
return;
}
if (!repository.removeStock(material, amount)) {
plugin.economyService().credit(player.getUniqueId(), total, "market_refund");
player.sendMessage(plugin.messages().text("&cThe Ministry ran out of stock."));
return;
}
if (!player.getInventory().addItem(new ItemStack(material, amount)).isEmpty()) {
repository.addStock(material, amount);
plugin.economyService().credit(player.getUniqueId(), total, "market_refund");
player.sendMessage(plugin.messages().text("&cYou need more inventory space."));
return;
}
plugin.governmentService().collectTax(tax);
if (tax > 0L) {
player.sendMessage(plugin.messages().text("&aBought " + amount + " " + readable(material) + " for &f" + CoalMoney.format(total) + "&a, including &f" + CoalMoney.format(tax) + " &atax."));
} else {
player.sendMessage(plugin.messages().text("&aBought " + amount + " " + readable(material) + " for &f" + CoalMoney.format(cost) + "&a."));
}
}
public boolean isSellAllSlot(int slot) {
return slot == SELL_ALL_SLOT;
}
public Map<Material, Double> basePrices() {
Map<Material, Double> prices = defaultPrices();
ConfigurationSection section = plugin.getConfig().getConfigurationSection("market.prices");
if (section == null) {
section = plugin.getConfig().getConfigurationSection("claims.resale.resource_values");
}
if (section == null) {
return prices;
}
for (String key : section.getKeys(false)) {
Material material = Material.matchMaterial(key);
if (material != null) {
prices.put(material, section.getDouble(key));
}
}
return prices;
}
private Quote quote(Material material) throws SQLException {
double basePrice = basePrices().get(material);
long stock = repository.stock(material, initialStock(material));
return quote(material, basePrice, stock);
}
private Quote quote(Material material, double basePrice, long stock) {
return new Quote(stock, sellUnitPrice(material, basePrice, stock), buyUnitPrice(material, basePrice, stock));
}
private long sellCredit(Material material, double basePrice, long startingStock, int amount) {
double total = 0.0D;
for (int index = 0; index < amount; index++) {
total += sellUnitPrice(material, basePrice, startingStock + index);
}
return Math.max(1L, CoalMoney.fromCoal(total));
}
private double sellUnitPrice(Material material, double basePrice, long stock) {
double target = Math.max(1.0D, targetStock());
double multiplier = target / Math.max(1.0D, stock);
multiplier = Math.max(minMultiplier(), Math.min(maxMultiplier(), multiplier));
return Math.max(coalEquivalentFloor(material), basePrice * multiplier * sellMultiplier());
}
private double buyUnitPrice(Material material, double basePrice, long stock) {
double sellPrice = sellUnitPrice(material, basePrice, stock);
double target = Math.max(1.0D, targetStock());
double multiplier = target / Math.max(1.0D, stock);
multiplier = Math.max(minMultiplier(), Math.min(maxMultiplier(), multiplier));
return Math.max(sellPrice, basePrice * multiplier * buyMultiplier());
}
private long buyCost(Quote quote, int amount) {
return Math.max(1L, CoalMoney.fromCoal(quote.buyUnitPrice() * amount));
}
private double coalEquivalentFloor(Material material) {
if (material == Material.COAL || material == Material.CHARCOAL) {
return 1.0D;
}
if (material == Material.COAL_BLOCK) {
return 9.0D;
}
String name = material.name();
if (name.endsWith("_LOG") || name.endsWith("_STEM")) {
return 1.0D;
}
if (name.endsWith("_PLANKS")) {
return 1.5D / 8.0D;
}
if (material == Material.STICK) {
return 0.5D / 8.0D;
}
return 0.0D;
}
private boolean hasRoomFor(Player player, Material material, int amount) {
int remaining = amount;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item == null || item.getType().isAir()) {
remaining -= material.getMaxStackSize();
} else if (item.getType() == material) {
remaining -= Math.max(0, item.getMaxStackSize() - item.getAmount());
}
if (remaining <= 0) {
return true;
}
}
return false;
}
private void removeSoldItems(Player player, Map<Material, Long> sold) {
Map<Material, Long> remainingByMaterial = new LinkedHashMap<>(sold);
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item == null || item.getType().isAir()) {
continue;
}
Long remaining = remainingByMaterial.get(item.getType());
if (remaining == null || remaining <= 0L) {
continue;
}
if (remaining >= item.getAmount()) {
remainingByMaterial.put(item.getType(), remaining - item.getAmount());
item.setAmount(0);
} else {
item.setAmount((int) (item.getAmount() - remaining));
remainingByMaterial.put(item.getType(), 0L);
}
}
}
private long initialStock() {
return plugin.getConfig().getLong("market.dynamic.initial_stock", 1024L);
}
private long initialStock(Material material) {
return plugin.getConfig().getLong("market.initial_stock." + material.name(), initialStock());
}
private long targetStock() {
return plugin.getConfig().getLong("market.dynamic.target_stock", 1024L);
}
private double minMultiplier() {
return plugin.getConfig().getDouble("market.dynamic.min_multiplier", 0.35D);
}
private double maxMultiplier() {
return plugin.getConfig().getDouble("market.dynamic.max_multiplier", 3.0D);
}
private double sellMultiplier() {
return plugin.getConfig().getDouble("market.dynamic.sell_multiplier", 0.85D);
}
private double buyMultiplier() {
return plugin.getConfig().getDouble("market.dynamic.buy_multiplier", 1.15D);
}
private ItemStack button(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(plugin.messages().raw(name));
meta.setLore(lore.stream().map(plugin.messages()::raw).toList());
item.setItemMeta(meta);
return item;
}
private Map<Material, Double> defaultPrices() {
Map<Material, Double> prices = new LinkedHashMap<>();
prices.put(Material.DIRT, 0.01D);
prices.put(Material.COBBLESTONE, 0.02D);
prices.put(Material.SAND, 0.05D);
prices.put(Material.GRAVEL, 0.04D);
prices.put(Material.OAK_LOG, 0.25D);
prices.put(Material.SPRUCE_LOG, 0.25D);
prices.put(Material.BIRCH_LOG, 0.25D);
prices.put(Material.TORCH, 0.10D);
prices.put(Material.COAL, 1.0D);
prices.put(Material.COAL_BLOCK, 9.0D);
prices.put(Material.RAW_COPPER, 0.5D);
prices.put(Material.IRON_INGOT, 2.0D);
prices.put(Material.GOLD_INGOT, 4.0D);
prices.put(Material.DIAMOND, 16.0D);
prices.put(Material.EMERALD, 12.0D);
prices.put(Material.WHEAT, 0.35D);
prices.put(Material.BREAD, 1.25D);
prices.put(Material.APPLE, 0.75D);
prices.put(Material.CARROT, 0.25D);
prices.put(Material.POTATO, 0.20D);
prices.put(Material.BAKED_POTATO, 0.45D);
prices.put(Material.BEETROOT, 0.20D);
prices.put(Material.MELON_SLICE, 0.15D);
prices.put(Material.SWEET_BERRIES, 0.20D);
prices.put(Material.GLOW_BERRIES, 0.35D);
prices.put(Material.COOKIE, 0.25D);
prices.put(Material.PUMPKIN_PIE, 1.0D);
prices.put(Material.COOKED_CHICKEN, 1.25D);
prices.put(Material.COOKED_PORKCHOP, 1.5D);
prices.put(Material.COOKED_BEEF, 1.75D);
prices.put(Material.COOKED_MUTTON, 1.5D);
prices.put(Material.COOKED_RABBIT, 1.25D);
prices.put(Material.COOKED_COD, 1.0D);
return prices;
}
private String readable(Material material) {
return material.name().toLowerCase().replace('_', ' ');
}
private record Quote(long stock, double sellUnitPrice, double buyUnitPrice) {
}
}

View File

@@ -0,0 +1,41 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.Claim;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.model.LandRegion;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.Optional;
public final class MiningService {
private final CoalGovPlugin plugin;
private final LandService landService;
private final ClaimService claimService;
private final PermitService permitService;
public MiningService(CoalGovPlugin plugin, LandService landService, ClaimService claimService, PermitService permitService) {
this.plugin = plugin;
this.landService = landService;
this.claimService = claimService;
this.permitService = permitService;
}
public boolean canMineCoal(Player player, Location location) throws SQLException {
if (plugin.hasAdminBypass(player)) {
return true;
}
Optional<LandRegion> region = landService.regionAt(location);
LandClass landClass = region.map(LandRegion::landClass).orElse(landService.classAt(location));
if (landClass == LandClass.FREEHOLD) {
Optional<Claim> claim = claimService.claimAt(location);
return claim.isPresent() && claim.get().ownerUuid().equals(player.getUniqueId());
}
if (landClass == LandClass.MINING_CONCESSION) {
return region.isPresent() && permitService.hasMiningPermit(player.getUniqueId(), region.get().name());
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.model.CoalNpc;
import com.librewiki.coalgov.model.NpcZone;
import com.librewiki.coalgov.storage.NpcRepository;
import com.librewiki.coalgov.util.CoalMoney;
import net.citizensnpcs.api.CitizensAPI;
import net.citizensnpcs.api.npc.NPC;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
public final class NpcService {
private final CoalGovPlugin plugin;
private final NpcRepository repository;
private final Random random = new Random();
public NpcService(CoalGovPlugin plugin, NpcRepository repository) {
this.plugin = plugin;
this.repository = repository;
}
public long createZone(String name, String world, List<ClaimPoint> points) throws SQLException {
return repository.createZone(name, world, points);
}
public long createNpc(String type, String name, NpcZone zone, Location location) throws SQLException {
NPC npc = CitizensAPI.getNPCRegistry().createNPC(EntityType.VILLAGER, name);
npc.spawn(location);
npc.setProtected(true);
UUID accountUuid = UUID.nameUUIDFromBytes(("coalgov:npc:" + npc.getId()).getBytes(StandardCharsets.UTF_8));
plugin.economyService().ensureAccount(accountUuid, "NPC-" + name);
return repository.createNpc(npc.getId(), accountUuid, type, name, zone.id(),
location.getWorld().getName(), location.getX(), location.getY(), location.getZ());
}
public boolean removeNpc(long id) throws SQLException {
Optional<CoalNpc> coalNpc = repository.findNpc(id);
if (coalNpc.isPresent()) {
NPC npc = CitizensAPI.getNPCRegistry().getById(coalNpc.get().citizensId());
if (npc != null) {
npc.destroy();
}
}
return repository.deleteNpc(id);
}
public Optional<CoalNpc> findNpc(long id) throws SQLException {
return repository.findNpc(id);
}
public Optional<CoalNpc> findByCitizensId(int citizensId) throws SQLException {
return repository.findByCitizensId(citizensId);
}
public Optional<NpcZone> findZone(String name) throws SQLException {
return repository.findZone(name);
}
public List<CoalNpc> listNpcs() throws SQLException {
return repository.listNpcs();
}
public List<NpcZone> listZones() throws SQLException {
return repository.listZones();
}
public Map<Material, Long> inventory(long npcId) throws SQLException {
return repository.inventory(npcId);
}
public void addInventory(long npcId, Material material, long amount) throws SQLException {
repository.addInventory(npcId, material, amount);
}
public long balance(CoalNpc npc) throws SQLException {
return plugin.economyService().balance(npc.accountUuid());
}
public HaggleQuote quote(CoalNpc npc, String mode, Material material, int amount, long offer) throws SQLException {
double base = plugin.marketService().basePrices().getOrDefault(material, 1.0D);
long fair = Math.max(1L, CoalMoney.fromCoal(base * amount));
int maxDiscount = plugin.getConfig().getInt("npc.trader.max_discount_percent", 15);
int maxSurcharge = plugin.getConfig().getInt("npc.trader.max_surcharge_percent", 20);
if (mode.equalsIgnoreCase("buy")) {
long stock = repository.inventoryAmount(npc.id(), material);
long listPrice = Math.max(1L, Math.round(fair * 1.10D));
long minimum = Math.max(1L, Math.round(listPrice * (100.0D - maxDiscount) / 100.0D));
long counter = Math.max(minimum, Math.min(listPrice, offer <= 0L ? listPrice : Math.round((offer + listPrice) / 2.0D)));
boolean possible = stock >= amount;
boolean accepted = possible && offer >= minimum;
return new HaggleQuote(mode.toLowerCase(), material, amount, offer, minimum, listPrice, counter, accepted, possible ? "" : "I do not have enough stock.");
}
long maximum = Math.max(1L, Math.round(fair * (100.0D + maxSurcharge) / 100.0D));
long target = Math.max(1L, Math.round(fair * 0.90D));
long counter = Math.min(maximum, Math.max(target, offer <= 0L ? target : Math.round((offer + target) / 2.0D)));
boolean possible = balance(npc) >= offer;
boolean accepted = possible && offer <= maximum;
return new HaggleQuote(mode.toLowerCase(), material, amount, offer, target, maximum, counter, accepted, possible ? "" : "I do not have enough coal.");
}
public boolean completeTrade(Player player, CoalNpc npc, HaggleQuote quote, long finalPrice) throws SQLException {
if (quote.mode().equals("buy")) {
if (repository.inventoryAmount(npc.id(), quote.material()) < quote.amount()) {
return false;
}
if (!plugin.economyService().charge(player.getUniqueId(), finalPrice, "npc_purchase")) {
return false;
}
repository.removeInventory(npc.id(), quote.material(), quote.amount());
plugin.economyService().credit(npc.accountUuid(), finalPrice, "npc_sale");
player.getInventory().addItem(new ItemStack(quote.material(), quote.amount()));
return true;
}
if (!playerHas(player, quote.material(), quote.amount()) || balance(npc) < finalPrice) {
return false;
}
if (!plugin.economyService().charge(npc.accountUuid(), finalPrice, "npc_purchase")) {
return false;
}
removePlayerItems(player, quote.material(), quote.amount());
plugin.economyService().credit(player.getUniqueId(), finalPrice, "npc_sale");
repository.addInventory(npc.id(), quote.material(), quote.amount());
return true;
}
public void tickWorkers() {
try {
for (CoalNpc coalNpc : repository.listNpcs()) {
if (!coalNpc.worker()) {
continue;
}
NPC npc = CitizensAPI.getNPCRegistry().getById(coalNpc.citizensId());
Optional<NpcZone> zone = repository.findZone(coalNpc.zoneId());
if (npc == null || zone.isEmpty()) {
continue;
}
if (!npc.isSpawned()) {
World world = Bukkit.getWorld(coalNpc.world());
if (world != null) {
npc.spawn(new Location(world, coalNpc.x(), coalNpc.y(), coalNpc.z()));
}
}
if (npc.isSpawned() && !npc.getNavigator().isNavigating()) {
Location target = randomPoint(zone.get());
if (target != null) {
npc.getNavigator().setTarget(target);
}
}
Material material = Material.matchMaterial(plugin.getConfig().getString("npc.worker.produce_material", "BREAD"));
int amount = plugin.getConfig().getInt("npc.worker.produce_amount", 2);
if (material != null && amount > 0) {
repository.addInventory(coalNpc.id(), material, amount);
}
}
} catch (SQLException exception) {
plugin.getLogger().warning("NPC worker tick failed: " + exception.getMessage());
}
}
private Location randomPoint(NpcZone zone) {
World world = Bukkit.getWorld(zone.world());
if (world == null) {
return null;
}
for (int tries = 0; tries < 32; tries++) {
int x = zone.x1() + random.nextInt(Math.max(1, zone.x2() - zone.x1() + 1));
int z = zone.z1() + random.nextInt(Math.max(1, zone.z2() - zone.z1() + 1));
if (zone.contains(world.getName(), x, z)) {
return new Location(world, x + 0.5D, world.getHighestBlockYAt(x, z), z + 0.5D);
}
}
return new Location(world, zone.x1() + 0.5D, world.getHighestBlockYAt(zone.x1(), zone.z1()), zone.z1() + 0.5D);
}
private boolean playerHas(Player player, Material material, int amount) {
int found = 0;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item != null && item.getType() == material) {
found += item.getAmount();
}
}
return found >= amount;
}
private void removePlayerItems(Player player, Material material, int amount) {
int remaining = amount;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item == null || item.getType() != material) {
continue;
}
int take = Math.min(remaining, item.getAmount());
item.setAmount(item.getAmount() - take);
remaining -= take;
if (remaining <= 0) {
return;
}
}
}
public record HaggleQuote(String mode, Material material, int amount, long offer, long floor, long ceiling,
long counter, boolean accepted, String reason) {
}
}

View File

@@ -0,0 +1,104 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.util.CoalMoney;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class OpenRouterService {
private static final Pattern MESSAGE_PATTERN = Pattern.compile("\"message\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
private static final Pattern COUNTER_PATTERN = Pattern.compile("\"counter\"\\s*:\\s*(\\d+)");
private static final Pattern ACCEPT_PATTERN = Pattern.compile("\"accept\"\\s*:\\s*(true|false)", Pattern.CASE_INSENSITIVE);
private final CoalGovPlugin plugin;
private final HttpClient client = HttpClient.newHttpClient();
public OpenRouterService(CoalGovPlugin plugin) {
this.plugin = plugin;
}
public CompletableFuture<Optional<HaggleAiResult>> haggle(String prompt, long minCounter, long maxCounter) {
if (!plugin.getConfig().getBoolean("npc.ai.enabled", true)) {
return CompletableFuture.completedFuture(Optional.empty());
}
String key = plugin.getConfig().getString("npc.ai.api_key", "");
if (key == null || key.isBlank()) {
return CompletableFuture.completedFuture(Optional.empty());
}
String model = plugin.getConfig().getString("npc.ai.model", "openai/gpt-4.1-mini");
int timeoutMs = plugin.getConfig().getInt("npc.ai.timeout_ms", 4000);
String body = """
{"model":"%s","messages":[{"role":"system","content":"You are a Minecraft CoalGov NPC trader. Return only compact JSON with accept boolean, counter integer, and message string. The counter must stay within the provided bounds."},{"role":"user","content":"%s"}],"temperature":0.8}
""".formatted(escape(model), escape(prompt));
HttpRequest request = HttpRequest.newBuilder(URI.create("https://openrouter.ai/api/v1/chat/completions"))
.timeout(Duration.ofMillis(timeoutMs))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> parse(response.body(), minCounter, maxCounter))
.exceptionally(exception -> Optional.empty());
}
private Optional<HaggleAiResult> parse(String response, long minCounter, long maxCounter) {
String content = extractContent(response);
Matcher counterMatcher = COUNTER_PATTERN.matcher(content);
Matcher acceptMatcher = ACCEPT_PATTERN.matcher(content);
Matcher messageMatcher = MESSAGE_PATTERN.matcher(content);
if (!counterMatcher.find()) {
return Optional.empty();
}
long counter = Long.parseLong(counterMatcher.group(1));
counter = Math.max(minCounter, Math.min(maxCounter, counter));
boolean accept = acceptMatcher.find() && Boolean.parseBoolean(acceptMatcher.group(1));
String message = messageMatcher.find() ? unescape(messageMatcher.group(1)) : "I can work with " + CoalMoney.format(counter) + ".";
return Optional.of(new HaggleAiResult(accept, counter, message));
}
private String extractContent(String response) {
int index = response.indexOf("\"content\"");
if (index < 0) {
return response;
}
int start = response.indexOf('"', index + 9);
if (start < 0) {
return response;
}
start++;
StringBuilder builder = new StringBuilder();
boolean escaped = false;
for (int i = start; i < response.length(); i++) {
char c = response.charAt(i);
if (escaped) {
builder.append(c);
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
break;
} else {
builder.append(c);
}
}
return builder.toString();
}
private String escape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
}
private String unescape(String value) {
return value.replace("\\\"", "\"").replace("\\n", " ").replace("\\\\", "\\");
}
public record HaggleAiResult(boolean accept, long counter, String message) {
}
}

View File

@@ -0,0 +1,67 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.model.LandRegion;
import com.librewiki.coalgov.model.Permit;
import com.librewiki.coalgov.model.PermitType;
import com.librewiki.coalgov.storage.PermitRepository;
import com.librewiki.coalgov.util.CoalMoney;
import org.bukkit.configuration.file.FileConfiguration;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public final class PermitService {
private final PermitRepository repository;
private final LandService landService;
private final EconomyService economyService;
private FileConfiguration config;
public PermitService(PermitRepository repository, LandService landService, EconomyService economyService, FileConfiguration config) {
this.repository = repository;
this.landService = landService;
this.economyService = economyService;
this.config = config;
}
public void setConfig(FileConfiguration config) {
this.config = config;
}
public BuyResult buyMining(UUID owner, String regionName) throws SQLException {
Optional<LandRegion> region = landService.findByName(regionName);
if (region.isEmpty()) {
return BuyResult.fail("No land region named " + regionName + ".");
}
if (region.get().landClass() != LandClass.MINING_CONCESSION) {
return BuyResult.fail("That region is not a mining concession.");
}
long cost = CoalMoney.fromCoalConfig(config.getDouble("permits.mining.cost", 64.0D));
if (!economyService.charge(owner, cost, "permit_purchase")) {
return BuyResult.fail("You need " + CoalMoney.format(cost) + " for that permit.");
}
long expiresAt = System.currentTimeMillis() + config.getLong("permits.mining.duration_hours", 72L) * 60L * 60L * 1000L;
long id = repository.create(owner, PermitType.MINING, region.get().name(), expiresAt);
return BuyResult.success(id, cost, expiresAt);
}
public boolean hasMiningPermit(UUID owner, String regionName) throws SQLException {
return repository.hasActive(owner, PermitType.MINING, regionName, System.currentTimeMillis());
}
public List<Permit> listActive(UUID owner) throws SQLException {
return repository.listActive(owner, System.currentTimeMillis());
}
public record BuyResult(boolean success, String message, long id, long cost, long expiresAt) {
public static BuyResult fail(String message) {
return new BuyResult(false, message, -1L, 0L, 0L);
}
public static BuyResult success(long id, long cost, long expiresAt) {
return new BuyResult(true, "", id, cost, expiresAt);
}
}
}

View File

@@ -0,0 +1,97 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.util.Cuboid2D;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.Sign;
import org.bukkit.block.data.Rotatable;
import org.bukkit.block.sign.Side;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public final class SpawnGuideService {
private final CoalGovPlugin plugin;
public SpawnGuideService(CoalGovPlugin plugin) {
this.plugin = plugin;
}
public void setup(Player player) throws SQLException {
Location center = player.getLocation().toBlockLocation();
World world = player.getWorld();
world.setSpawnLocation(center);
if (plugin.getConfig().getBoolean("spawn.guide.create_government_region", true)) {
createSpawnRegion(center);
}
placeGuide(center);
}
private void createSpawnRegion(Location center) throws SQLException {
int radius = plugin.getConfig().getInt("spawn.protection_radius", 32);
Cuboid2D area = new Cuboid2D(
center.getWorld().getName(),
center.getBlockX() - radius,
center.getBlockZ() - radius,
center.getBlockX() + radius,
center.getBlockZ() + radius
);
plugin.landService().delete("spawn");
plugin.landService().create("spawn", area, LandClass.GOVERNMENT);
}
private void placeGuide(Location center) {
List<String> lines = guideLines();
int signs = Math.max(1, (int) Math.ceil(lines.size() / 4.0D));
int startX = center.getBlockX() - signs + 1;
int z = center.getBlockZ() + 3;
for (int i = 0; i < signs; i++) {
int x = startX + i * 2;
int y = center.getWorld().getHighestBlockYAt(x, z);
Block base = center.getWorld().getBlockAt(x, y, z);
base.setType(Material.POLISHED_ANDESITE);
Block signBlock = center.getWorld().getBlockAt(x, y + 1, z);
signBlock.setType(Material.OAK_SIGN);
if (signBlock.getBlockData() instanceof Rotatable rotatable) {
rotatable.setRotation(org.bukkit.block.BlockFace.SOUTH);
signBlock.setBlockData(rotatable);
}
if (signBlock.getState() instanceof Sign sign) {
for (int line = 0; line < 4; line++) {
int index = i * 4 + line;
sign.getSide(Side.FRONT).setLine(line, index < lines.size() ? lines.get(index) : "");
}
sign.update(true, false);
}
}
}
private List<String> guideLines() {
ConfigurationSection section = plugin.getConfig().getConfigurationSection("spawn.guide");
List<String> lines = section == null ? List.of() : section.getStringList("lines");
if (!lines.isEmpty()) {
return lines;
}
List<String> defaults = new ArrayList<>();
defaults.add("COALGOV BASIN");
defaults.add("Free first");
defaults.add("homestead:");
defaults.add("/claim buy");
defaults.add("Use /claim wand");
defaults.add("for polygons");
defaults.add("Coal seams:");
defaults.add("redwood ridges");
defaults.add("Permits:");
defaults.add("/permit buy");
defaults.add("mining <region>");
defaults.add("/market trades");
return defaults;
}
}

View File

@@ -0,0 +1,83 @@
package com.librewiki.coalgov.service;
import com.librewiki.coalgov.CoalGovPlugin;
import com.librewiki.coalgov.storage.SuperFurnaceRepository;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.block.Block;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import java.sql.SQLException;
import java.util.List;
public final class SuperFurnaceService {
private static final int SPEED_MULTIPLIER = 5;
private static final int FUEL_MULTIPLIER = 4;
private final CoalGovPlugin plugin;
private final SuperFurnaceRepository repository;
private final NamespacedKey itemKey;
private final NamespacedKey recipeKey;
public SuperFurnaceService(CoalGovPlugin plugin, SuperFurnaceRepository repository) {
this.plugin = plugin;
this.repository = repository;
this.itemKey = new NamespacedKey(plugin, "super_furnace");
this.recipeKey = new NamespacedKey(plugin, "super_furnace_recipe");
}
public void registerRecipe() {
Bukkit.removeRecipe(recipeKey);
ShapedRecipe recipe = new ShapedRecipe(recipeKey, item());
recipe.shape("SSS", "SFS", "SSS");
recipe.setIngredient('S', Material.STONE);
recipe.setIngredient('F', Material.FURNACE);
Bukkit.addRecipe(recipe);
}
public ItemStack item() {
ItemStack item = new ItemStack(Material.FURNACE);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(plugin.messages().raw("&6CoalGov Super Furnace"));
meta.setLore(List.of(
plugin.messages().raw("&75x smelting speed."),
plugin.messages().raw("&7Burns fuel 4x faster."),
plugin.messages().raw("&7Crafted with stone around a furnace.")
));
meta.getPersistentDataContainer().set(itemKey, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
return item;
}
public boolean isItem(ItemStack item) {
if (item == null || item.getType() != Material.FURNACE || !item.hasItemMeta()) {
return false;
}
return item.getItemMeta().getPersistentDataContainer().has(itemKey, PersistentDataType.BYTE);
}
public void add(Block block) throws SQLException {
repository.add(block.getLocation());
}
public void remove(Block block) throws SQLException {
repository.remove(block.getLocation());
}
public boolean isSuperFurnace(Block block) throws SQLException {
return block.getType() == Material.FURNACE && repository.exists(block.getLocation());
}
public int fasterCookTime(int cookTime) {
return Math.max(1, (int) Math.ceil(cookTime / (double) SPEED_MULTIPLIER));
}
public int shorterBurnTime(int burnTime) {
return Math.max(1, (int) Math.floor(burnTime / (double) FUEL_MULTIPLIER));
}
}

View File

@@ -0,0 +1,7 @@
package com.librewiki.coalgov.service;
public final class TaxService {
public void runMaintenance() {
// Placeholder for future lease expiration and recurring tax collection.
}
}

View File

@@ -0,0 +1,227 @@
package com.librewiki.coalgov.storage;
import com.librewiki.coalgov.model.Claim;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.util.Cuboid2D;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public final class ClaimRepository {
private final Database database;
public ClaimRepository(Database database) {
this.database = database;
}
public long create(UUID owner, Cuboid2D area, String claimType, Long expiresAt) throws SQLException {
return create(owner, area, claimType, expiresAt, -1L, List.of());
}
public long create(UUID owner, Cuboid2D area, String claimType, Long expiresAt, long purchaseCost, List<ClaimPoint> vertices) throws SQLException {
boolean originalAutoCommit = database.connection().getAutoCommit();
database.connection().setAutoCommit(false);
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO claims(owner_uuid, world, x1, z1, x2, z2, claim_type, expires_at, tax_due, purchase_cost, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
""", Statement.RETURN_GENERATED_KEYS)) {
insert.setString(1, owner.toString());
insert.setString(2, area.world());
insert.setInt(3, area.x1());
insert.setInt(4, area.z1());
insert.setInt(5, area.x2());
insert.setInt(6, area.z2());
insert.setString(7, claimType);
if (expiresAt == null) {
insert.setObject(8, null);
} else {
insert.setLong(8, expiresAt);
}
insert.setLong(9, purchaseCost);
insert.setLong(10, System.currentTimeMillis());
insert.executeUpdate();
try (ResultSet keys = insert.getGeneratedKeys()) {
long id = keys.next() ? keys.getLong(1) : -1L;
insertVertices(id, vertices);
database.connection().commit();
return id;
}
} catch (SQLException exception) {
database.connection().rollback();
throw exception;
} finally {
database.connection().setAutoCommit(originalAutoCommit);
}
}
public boolean deleteOwned(UUID owner, long id) throws SQLException {
boolean originalAutoCommit = database.connection().getAutoCommit();
database.connection().setAutoCommit(false);
try {
try (PreparedStatement deleteVertices = database.connection().prepareStatement(
"DELETE FROM claim_vertices WHERE claim_id = ?")) {
deleteVertices.setLong(1, id);
deleteVertices.executeUpdate();
}
try (PreparedStatement delete = database.connection().prepareStatement(
"DELETE FROM claims WHERE id = ? AND owner_uuid = ?")) {
delete.setLong(1, id);
delete.setString(2, owner.toString());
boolean deleted = delete.executeUpdate() == 1;
database.connection().commit();
return deleted;
}
} catch (SQLException exception) {
database.connection().rollback();
throw exception;
} finally {
database.connection().setAutoCommit(originalAutoCommit);
}
}
public Optional<Claim> findAt(String world, int x, int z) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT * FROM claims
WHERE world = ? AND x1 <= ? AND x2 >= ? AND z1 <= ? AND z2 >= ?
ORDER BY id DESC
""")) {
statement.setString(1, world);
statement.setInt(2, x);
statement.setInt(3, x);
statement.setInt(4, z);
statement.setInt(5, z);
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
Claim claim = map(result);
if (claim.contains(world, x, z)) {
return Optional.of(claim);
}
}
return Optional.empty();
}
}
}
public List<Claim> findByOwner(UUID owner) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM claims WHERE owner_uuid = ? ORDER BY id")) {
statement.setString(1, owner.toString());
return list(statement);
}
}
public List<Claim> listAll() throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM claims ORDER BY id")) {
return list(statement);
}
}
public Optional<Claim> findOwnedById(UUID owner, long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM claims WHERE owner_uuid = ? AND id = ?")) {
statement.setString(1, owner.toString());
statement.setLong(2, id);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(map(result)) : Optional.empty();
}
}
}
public boolean transferOwned(UUID owner, long id, UUID newOwner) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"UPDATE claims SET owner_uuid = ? WHERE id = ? AND owner_uuid = ?")) {
statement.setString(1, newOwner.toString());
statement.setLong(2, id);
statement.setString(3, owner.toString());
return statement.executeUpdate() == 1;
}
}
public boolean overlaps(Cuboid2D area) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT 1 FROM claims
WHERE world = ? AND x1 <= ? AND x2 >= ? AND z1 <= ? AND z2 >= ?
LIMIT 1
""")) {
statement.setString(1, area.world());
statement.setInt(2, area.x2());
statement.setInt(3, area.x1());
statement.setInt(4, area.z2());
statement.setInt(5, area.z1());
try (ResultSet result = statement.executeQuery()) {
return result.next();
}
}
}
private List<Claim> list(PreparedStatement statement) throws SQLException {
List<Claim> claims = new ArrayList<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
claims.add(map(result));
}
}
return claims;
}
private Claim map(ResultSet result) throws SQLException {
long expiresAt = result.getLong("expires_at");
Long nullableExpiresAt = result.wasNull() ? null : expiresAt;
return new Claim(
result.getLong("id"),
UUID.fromString(result.getString("owner_uuid")),
result.getString("world"),
result.getInt("x1"),
result.getInt("z1"),
result.getInt("x2"),
result.getInt("z2"),
result.getString("claim_type"),
nullableExpiresAt,
result.getLong("tax_due"),
result.getLong("created_at"),
result.getLong("purchase_cost"),
vertices(result.getLong("id"))
);
}
private void insertVertices(long claimId, List<ClaimPoint> vertices) throws SQLException {
if (claimId <= 0L || vertices == null || vertices.isEmpty()) {
return;
}
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO claim_vertices(claim_id, vertex_order, x, z)
VALUES (?, ?, ?, ?)
""")) {
for (int i = 0; i < vertices.size(); i++) {
ClaimPoint point = vertices.get(i);
insert.setLong(1, claimId);
insert.setInt(2, i);
insert.setInt(3, point.x());
insert.setInt(4, point.z());
insert.addBatch();
}
insert.executeBatch();
}
}
private List<ClaimPoint> vertices(long claimId) throws SQLException {
List<ClaimPoint> points = new ArrayList<>();
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT x, z FROM claim_vertices WHERE claim_id = ? ORDER BY vertex_order")) {
statement.setLong(1, claimId);
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
points.add(new ClaimPoint(result.getInt("x"), result.getInt("z")));
}
}
}
return points;
}
}

View File

@@ -0,0 +1,275 @@
package com.librewiki.coalgov.storage;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public final class Database {
private final File databaseFile;
private Connection connection;
public Database(File dataFolder) {
this.databaseFile = new File(dataFolder, "coalgov.db");
}
public void open() throws SQLException {
if (connection != null && !connection.isClosed()) {
return;
}
databaseFile.getParentFile().mkdirs();
connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFile.getAbsolutePath());
try (Statement statement = connection.createStatement()) {
statement.execute("PRAGMA foreign_keys = ON");
statement.execute("PRAGMA journal_mode = WAL");
}
initialize();
}
public Connection connection() {
return connection;
}
public void close() {
if (connection == null) {
return;
}
try {
connection.close();
} catch (SQLException ignored) {
}
}
private void initialize() throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS players (
uuid TEXT PRIMARY KEY,
name TEXT NOT NULL,
coal_balance INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_uuid TEXT NOT NULL,
world TEXT NOT NULL,
x1 INTEGER NOT NULL,
z1 INTEGER NOT NULL,
x2 INTEGER NOT NULL,
z2 INTEGER NOT NULL,
claim_type TEXT NOT NULL,
expires_at INTEGER,
tax_due INTEGER NOT NULL DEFAULT 0,
purchase_cost INTEGER NOT NULL DEFAULT -1,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS claim_vertices (
claim_id INTEGER NOT NULL,
vertex_order INTEGER NOT NULL,
x INTEGER NOT NULL,
z INTEGER NOT NULL,
PRIMARY KEY (claim_id, vertex_order)
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS land_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
world TEXT NOT NULL,
x1 INTEGER NOT NULL,
z1 INTEGER NOT NULL,
x2 INTEGER NOT NULL,
z2 INTEGER NOT NULL,
land_class TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS permits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_uuid TEXT NOT NULL,
permit_type TEXT NOT NULL,
region_name TEXT,
expires_at INTEGER,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_uuid TEXT,
to_uuid TEXT,
amount INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS market_stock (
material TEXT PRIMARY KEY,
stock INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS treasury (
id TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS tax_exemptions (
uuid TEXT PRIMARY KEY,
exempt INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS fines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_uuid TEXT NOT NULL,
issuer_uuid TEXT,
amount INTEGER NOT NULL,
reason TEXT NOT NULL,
paid_at INTEGER,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS super_furnaces (
world TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
z INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (world, x, y, z)
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS npc_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
world TEXT NOT NULL,
x1 INTEGER NOT NULL,
z1 INTEGER NOT NULL,
x2 INTEGER NOT NULL,
z2 INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS npc_zone_vertices (
zone_id INTEGER NOT NULL,
vertex_order INTEGER NOT NULL,
x INTEGER NOT NULL,
z INTEGER NOT NULL,
PRIMARY KEY (zone_id, vertex_order)
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS coalgov_npcs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
citizens_id INTEGER NOT NULL UNIQUE,
account_uuid TEXT NOT NULL UNIQUE,
npc_type TEXT NOT NULL,
name TEXT NOT NULL,
zone_id INTEGER NOT NULL,
world TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
z REAL NOT NULL,
created_at INTEGER NOT NULL
)
""");
statement.execute("""
CREATE TABLE IF NOT EXISTS npc_inventory (
npc_id INTEGER NOT NULL,
material TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (npc_id, material)
)
""");
statement.execute("CREATE INDEX IF NOT EXISTS idx_claims_location ON claims(world, x1, z1, x2, z2)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_claim_vertices_claim ON claim_vertices(claim_id)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_land_location ON land_regions(world, x1, z1, x2, z2)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_permits_owner ON permits(owner_uuid, permit_type, region_name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_fines_player ON fines(player_uuid, paid_at)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_coalgov_npcs_citizens ON coalgov_npcs(citizens_id)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_npc_zone_vertices_zone ON npc_zone_vertices(zone_id)");
}
ensureColumn("claims", "purchase_cost", "INTEGER NOT NULL DEFAULT -1");
migrateCoalBalancesToCents();
}
private void migrateCoalBalancesToCents() throws SQLException {
String version = metaValue("economy_units");
if ("coal_cents_v1".equals(version)) {
return;
}
boolean originalAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try (Statement statement = connection.createStatement()) {
statement.execute("UPDATE players SET coal_balance = coal_balance * 100");
statement.execute("UPDATE transactions SET amount = amount * 100");
statement.execute("UPDATE treasury SET balance = balance * 100");
statement.execute("UPDATE fines SET amount = amount * 100");
statement.execute("UPDATE claims SET tax_due = tax_due * 100");
statement.execute("UPDATE claims SET purchase_cost = purchase_cost * 100 WHERE purchase_cost >= 0");
setMetaValue("economy_units", "coal_cents_v1");
connection.commit();
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(originalAutoCommit);
}
}
private String metaValue(String key) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("SELECT value FROM schema_meta WHERE key = ?")) {
statement.setString(1, key);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getString("value") : null;
}
}
}
private void setMetaValue(String key, String value) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schema_meta(key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""")) {
statement.setString(1, key);
statement.setString(2, value);
statement.executeUpdate();
}
}
private void ensureColumn(String table, String column, String definition) throws SQLException {
try (Statement statement = connection.createStatement();
var result = statement.executeQuery("PRAGMA table_info(" + table + ")")) {
while (result.next()) {
if (column.equalsIgnoreCase(result.getString("name"))) {
return;
}
}
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
}
}
}

View File

@@ -0,0 +1,133 @@
package com.librewiki.coalgov.storage;
import org.bukkit.OfflinePlayer;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
public final class EconomyRepository {
private final Database database;
public EconomyRepository(Database database) {
this.database = database;
}
public void ensurePlayer(OfflinePlayer player, long startingBalance) throws SQLException {
String name = player.getName() == null ? player.getUniqueId().toString() : player.getName();
ensureAccount(player.getUniqueId(), name, startingBalance);
}
public void ensureAccount(UUID uuid, String name, long startingBalance) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO players(uuid, name, coal_balance, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(uuid) DO UPDATE SET name = excluded.name
""")) {
insert.setString(1, uuid.toString());
insert.setString(2, name);
insert.setLong(3, startingBalance);
insert.setLong(4, System.currentTimeMillis());
insert.executeUpdate();
}
}
public long balance(UUID uuid) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT coal_balance FROM players WHERE uuid = ?")) {
statement.setString(1, uuid.toString());
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getLong("coal_balance") : 0L;
}
}
}
public boolean add(UUID uuid, long amount, UUID fromUuid, UUID toUuid, String reason) throws SQLException {
if (amount <= 0) {
return false;
}
try (PreparedStatement update = database.connection().prepareStatement(
"UPDATE players SET coal_balance = coal_balance + ? WHERE uuid = ?")) {
update.setLong(1, amount);
update.setString(2, uuid.toString());
if (update.executeUpdate() != 1) {
return false;
}
}
recordTransaction(fromUuid, toUuid, amount, reason);
return true;
}
public boolean subtract(UUID uuid, long amount, UUID fromUuid, UUID toUuid, String reason) throws SQLException {
if (amount <= 0) {
return false;
}
try (PreparedStatement update = database.connection().prepareStatement("""
UPDATE players SET coal_balance = coal_balance - ?
WHERE uuid = ? AND coal_balance >= ?
""")) {
update.setLong(1, amount);
update.setString(2, uuid.toString());
update.setLong(3, amount);
if (update.executeUpdate() != 1) {
return false;
}
}
recordTransaction(fromUuid, toUuid, amount, reason);
return true;
}
public boolean transfer(UUID from, UUID to, long amount, String reason) throws SQLException {
if (amount <= 0 || from.equals(to)) {
return false;
}
boolean originalAutoCommit = database.connection().getAutoCommit();
database.connection().setAutoCommit(false);
try {
try (PreparedStatement debit = database.connection().prepareStatement("""
UPDATE players SET coal_balance = coal_balance - ?
WHERE uuid = ? AND coal_balance >= ?
""")) {
debit.setLong(1, amount);
debit.setString(2, from.toString());
debit.setLong(3, amount);
if (debit.executeUpdate() != 1) {
database.connection().rollback();
return false;
}
}
try (PreparedStatement credit = database.connection().prepareStatement(
"UPDATE players SET coal_balance = coal_balance + ? WHERE uuid = ?")) {
credit.setLong(1, amount);
credit.setString(2, to.toString());
if (credit.executeUpdate() != 1) {
database.connection().rollback();
return false;
}
}
recordTransaction(from, to, amount, reason);
database.connection().commit();
return true;
} catch (SQLException exception) {
database.connection().rollback();
throw exception;
} finally {
database.connection().setAutoCommit(originalAutoCommit);
}
}
private void recordTransaction(UUID fromUuid, UUID toUuid, long amount, String reason) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO transactions(from_uuid, to_uuid, amount, reason, created_at)
VALUES (?, ?, ?, ?, ?)
""")) {
insert.setString(1, fromUuid == null ? null : fromUuid.toString());
insert.setString(2, toUuid == null ? null : toUuid.toString());
insert.setLong(3, amount);
insert.setString(4, reason);
insert.setLong(5, System.currentTimeMillis());
insert.executeUpdate();
}
}
}

View File

@@ -0,0 +1,171 @@
package com.librewiki.coalgov.storage;
import com.librewiki.coalgov.model.Fine;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public final class GovernmentRepository {
private static final String TREASURY_ID = "main";
private final Database database;
public GovernmentRepository(Database database) {
this.database = database;
}
public long treasuryBalance() throws SQLException {
ensureTreasury();
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT balance FROM treasury WHERE id = ?")) {
statement.setString(1, TREASURY_ID);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getLong("balance") : 0L;
}
}
}
public void addTreasury(long amount) throws SQLException {
if (amount <= 0L) {
return;
}
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO treasury(id, balance)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET balance = balance + excluded.balance
""")) {
statement.setString(1, TREASURY_ID);
statement.setLong(2, amount);
statement.executeUpdate();
}
}
public boolean spendTreasury(long amount) throws SQLException {
if (amount <= 0L) {
return false;
}
ensureTreasury();
try (PreparedStatement statement = database.connection().prepareStatement("""
UPDATE treasury SET balance = balance - ?
WHERE id = ? AND balance >= ?
""")) {
statement.setLong(1, amount);
statement.setString(2, TREASURY_ID);
statement.setLong(3, amount);
return statement.executeUpdate() == 1;
}
}
public void setTaxExempt(UUID uuid, boolean exempt) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO tax_exemptions(uuid, exempt, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(uuid) DO UPDATE SET exempt = excluded.exempt, updated_at = excluded.updated_at
""")) {
statement.setString(1, uuid.toString());
statement.setInt(2, exempt ? 1 : 0);
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate();
}
}
public boolean taxExempt(UUID uuid) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT exempt FROM tax_exemptions WHERE uuid = ?")) {
statement.setString(1, uuid.toString());
try (ResultSet result = statement.executeQuery()) {
return result.next() && result.getInt("exempt") == 1;
}
}
}
public long createFine(UUID playerUuid, UUID issuerUuid, long amount, String reason) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO fines(player_uuid, issuer_uuid, amount, reason, created_at)
VALUES (?, ?, ?, ?, ?)
""", Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, playerUuid.toString());
statement.setString(2, issuerUuid == null ? null : issuerUuid.toString());
statement.setLong(3, amount);
statement.setString(4, reason);
statement.setLong(5, System.currentTimeMillis());
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
return keys.next() ? keys.getLong(1) : -1L;
}
}
}
public List<Fine> unpaidFines(UUID playerUuid) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT * FROM fines
WHERE player_uuid = ? AND paid_at IS NULL
ORDER BY id
""")) {
statement.setString(1, playerUuid.toString());
return list(statement);
}
}
public Optional<Fine> findUnpaidFine(UUID playerUuid, long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT * FROM fines
WHERE player_uuid = ? AND id = ? AND paid_at IS NULL
""")) {
statement.setString(1, playerUuid.toString());
statement.setLong(2, id);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(map(result)) : Optional.empty();
}
}
}
public boolean markFinePaid(long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
UPDATE fines SET paid_at = ?
WHERE id = ? AND paid_at IS NULL
""")) {
statement.setLong(1, System.currentTimeMillis());
statement.setLong(2, id);
return statement.executeUpdate() == 1;
}
}
private void ensureTreasury() throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"INSERT INTO treasury(id, balance) VALUES (?, 0) ON CONFLICT(id) DO NOTHING")) {
statement.setString(1, TREASURY_ID);
statement.executeUpdate();
}
}
private List<Fine> list(PreparedStatement statement) throws SQLException {
List<Fine> fines = new ArrayList<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
fines.add(map(result));
}
}
return fines;
}
private Fine map(ResultSet result) throws SQLException {
String issuer = result.getString("issuer_uuid");
long paidAt = result.getLong("paid_at");
boolean paidAtNull = result.wasNull();
return new Fine(
result.getLong("id"),
UUID.fromString(result.getString("player_uuid")),
issuer == null ? null : UUID.fromString(issuer),
result.getLong("amount"),
result.getString("reason"),
paidAtNull ? null : paidAt,
result.getLong("created_at")
);
}
}

View File

@@ -0,0 +1,123 @@
package com.librewiki.coalgov.storage;
import com.librewiki.coalgov.model.LandClass;
import com.librewiki.coalgov.model.LandRegion;
import com.librewiki.coalgov.util.Cuboid2D;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class LandRepository {
private final Database database;
public LandRepository(Database database) {
this.database = database;
}
public boolean create(String name, Cuboid2D area, LandClass landClass) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO land_regions(name, world, x1, z1, x2, z2, land_class, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""")) {
insert.setString(1, name);
insert.setString(2, area.world());
insert.setInt(3, area.x1());
insert.setInt(4, area.z1());
insert.setInt(5, area.x2());
insert.setInt(6, area.z2());
insert.setString(7, landClass.name());
insert.setLong(8, System.currentTimeMillis());
return insert.executeUpdate() == 1;
}
}
public boolean delete(String name) throws SQLException {
try (PreparedStatement delete = database.connection().prepareStatement(
"DELETE FROM land_regions WHERE lower(name) = lower(?)")) {
delete.setString(1, name);
return delete.executeUpdate() == 1;
}
}
public Optional<LandRegion> findByName(String name) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM land_regions WHERE lower(name) = lower(?)")) {
statement.setString(1, name);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(map(result)) : Optional.empty();
}
}
}
public Optional<LandRegion> findAt(String world, int x, int z) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT * FROM land_regions
WHERE world = ? AND x1 <= ? AND x2 >= ? AND z1 <= ? AND z2 >= ?
ORDER BY id DESC LIMIT 1
""")) {
statement.setString(1, world);
statement.setInt(2, x);
statement.setInt(3, x);
statement.setInt(4, z);
statement.setInt(5, z);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(map(result)) : Optional.empty();
}
}
}
public List<LandRegion> listAll() throws SQLException {
try (Statement statement = database.connection().createStatement();
ResultSet result = statement.executeQuery("SELECT * FROM land_regions ORDER BY name")) {
List<LandRegion> regions = new ArrayList<>();
while (result.next()) {
regions.add(map(result));
}
return regions;
}
}
public boolean overlaps(Cuboid2D area, LandClass... blockedClasses) throws SQLException {
StringBuilder sql = new StringBuilder("""
SELECT land_class FROM land_regions
WHERE world = ? AND x1 <= ? AND x2 >= ? AND z1 <= ? AND z2 >= ?
""");
try (PreparedStatement statement = database.connection().prepareStatement(sql.toString())) {
statement.setString(1, area.world());
statement.setInt(2, area.x2());
statement.setInt(3, area.x1());
statement.setInt(4, area.z2());
statement.setInt(5, area.z1());
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
LandClass actual = LandClass.valueOf(result.getString("land_class"));
for (LandClass blocked : blockedClasses) {
if (actual == blocked) {
return true;
}
}
}
}
}
return false;
}
private LandRegion map(ResultSet result) throws SQLException {
return new LandRegion(
result.getLong("id"),
result.getString("name"),
result.getString("world"),
result.getInt("x1"),
result.getInt("z1"),
result.getInt("x2"),
result.getInt("z2"),
LandClass.valueOf(result.getString("land_class")),
result.getLong("created_at")
);
}
}

View File

@@ -0,0 +1,69 @@
package com.librewiki.coalgov.storage;
import org.bukkit.Material;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class MarketRepository {
private final Database database;
public MarketRepository(Database database) {
this.database = database;
}
public long stock(Material material, long initialStock) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO market_stock(material, stock, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(material) DO NOTHING
""")) {
insert.setString(1, material.name());
insert.setLong(2, Math.max(0L, initialStock));
insert.setLong(3, System.currentTimeMillis());
insert.executeUpdate();
}
try (PreparedStatement select = database.connection().prepareStatement(
"SELECT stock FROM market_stock WHERE material = ?")) {
select.setString(1, material.name());
try (ResultSet result = select.executeQuery()) {
return result.next() ? result.getLong("stock") : 0L;
}
}
}
public void addStock(Material material, long amount) throws SQLException {
if (amount <= 0) {
return;
}
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO market_stock(material, stock, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(material) DO UPDATE SET
stock = stock + excluded.stock,
updated_at = excluded.updated_at
""")) {
statement.setString(1, material.name());
statement.setLong(2, amount);
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate();
}
}
public boolean removeStock(Material material, long amount) throws SQLException {
if (amount <= 0) {
return false;
}
try (PreparedStatement statement = database.connection().prepareStatement("""
UPDATE market_stock SET stock = stock - ?, updated_at = ?
WHERE material = ? AND stock >= ?
""")) {
statement.setLong(1, amount);
statement.setLong(2, System.currentTimeMillis());
statement.setString(3, material.name());
statement.setLong(4, amount);
return statement.executeUpdate() == 1;
}
}
}

View File

@@ -0,0 +1,270 @@
package com.librewiki.coalgov.storage;
import com.librewiki.coalgov.model.ClaimPoint;
import com.librewiki.coalgov.model.CoalNpc;
import com.librewiki.coalgov.model.NpcZone;
import org.bukkit.Material;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class NpcRepository {
private final Database database;
public NpcRepository(Database database) {
this.database = database;
}
public long createZone(String name, String world, List<ClaimPoint> vertices) throws SQLException {
int x1 = vertices.stream().mapToInt(ClaimPoint::x).min().orElse(0);
int x2 = vertices.stream().mapToInt(ClaimPoint::x).max().orElse(0);
int z1 = vertices.stream().mapToInt(ClaimPoint::z).min().orElse(0);
int z2 = vertices.stream().mapToInt(ClaimPoint::z).max().orElse(0);
boolean originalAutoCommit = database.connection().getAutoCommit();
database.connection().setAutoCommit(false);
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO npc_zones(name, world, x1, z1, x2, z2, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", Statement.RETURN_GENERATED_KEYS)) {
insert.setString(1, name);
insert.setString(2, world);
insert.setInt(3, x1);
insert.setInt(4, z1);
insert.setInt(5, x2);
insert.setInt(6, z2);
insert.setLong(7, System.currentTimeMillis());
insert.executeUpdate();
try (ResultSet keys = insert.getGeneratedKeys()) {
long id = keys.next() ? keys.getLong(1) : -1L;
insertVertices(id, vertices);
database.connection().commit();
return id;
}
} catch (SQLException exception) {
database.connection().rollback();
throw exception;
} finally {
database.connection().setAutoCommit(originalAutoCommit);
}
}
public Optional<NpcZone> findZone(String name) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM npc_zones WHERE name = ?")) {
statement.setString(1, name);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(mapZone(result)) : Optional.empty();
}
}
}
public Optional<NpcZone> findZone(long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT * FROM npc_zones WHERE id = ?")) {
statement.setLong(1, id);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(mapZone(result)) : Optional.empty();
}
}
}
public List<NpcZone> listZones() throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("SELECT * FROM npc_zones ORDER BY id")) {
List<NpcZone> zones = new ArrayList<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
zones.add(mapZone(result));
}
}
return zones;
}
}
public long createNpc(int citizensId, UUID accountUuid, String type, String name, long zoneId,
String world, double x, double y, double z) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO coalgov_npcs(citizens_id, account_uuid, npc_type, name, zone_id, world, x, y, z, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", Statement.RETURN_GENERATED_KEYS)) {
statement.setInt(1, citizensId);
statement.setString(2, accountUuid.toString());
statement.setString(3, type.toUpperCase());
statement.setString(4, name);
statement.setLong(5, zoneId);
statement.setString(6, world);
statement.setDouble(7, x);
statement.setDouble(8, y);
statement.setDouble(9, z);
statement.setLong(10, System.currentTimeMillis());
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
return keys.next() ? keys.getLong(1) : -1L;
}
}
}
public Optional<CoalNpc> findNpc(long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("SELECT * FROM coalgov_npcs WHERE id = ?")) {
statement.setLong(1, id);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(mapNpc(result)) : Optional.empty();
}
}
}
public Optional<CoalNpc> findByCitizensId(int citizensId) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("SELECT * FROM coalgov_npcs WHERE citizens_id = ?")) {
statement.setInt(1, citizensId);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? Optional.of(mapNpc(result)) : Optional.empty();
}
}
}
public List<CoalNpc> listNpcs() throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("SELECT * FROM coalgov_npcs ORDER BY id")) {
List<CoalNpc> npcs = new ArrayList<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
npcs.add(mapNpc(result));
}
}
return npcs;
}
}
public boolean deleteNpc(long id) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("DELETE FROM coalgov_npcs WHERE id = ?")) {
statement.setLong(1, id);
return statement.executeUpdate() == 1;
}
}
public void addInventory(long npcId, Material material, long amount) throws SQLException {
if (amount <= 0L) {
return;
}
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO npc_inventory(npc_id, material, amount)
VALUES (?, ?, ?)
ON CONFLICT(npc_id, material) DO UPDATE SET amount = amount + excluded.amount
""")) {
statement.setLong(1, npcId);
statement.setString(2, material.name());
statement.setLong(3, amount);
statement.executeUpdate();
}
}
public boolean removeInventory(long npcId, Material material, long amount) throws SQLException {
if (amount <= 0L) {
return false;
}
try (PreparedStatement statement = database.connection().prepareStatement("""
UPDATE npc_inventory SET amount = amount - ?
WHERE npc_id = ? AND material = ? AND amount >= ?
""")) {
statement.setLong(1, amount);
statement.setLong(2, npcId);
statement.setString(3, material.name());
statement.setLong(4, amount);
return statement.executeUpdate() == 1;
}
}
public long inventoryAmount(long npcId, Material material) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT amount FROM npc_inventory WHERE npc_id = ? AND material = ?")) {
statement.setLong(1, npcId);
statement.setString(2, material.name());
try (ResultSet result = statement.executeQuery()) {
return result.next() ? result.getLong("amount") : 0L;
}
}
}
public Map<Material, Long> inventory(long npcId) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT material, amount FROM npc_inventory WHERE npc_id = ? AND amount > 0 ORDER BY material")) {
statement.setLong(1, npcId);
Map<Material, Long> inventory = new LinkedHashMap<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
Material material = Material.matchMaterial(result.getString("material"));
if (material != null) {
inventory.put(material, result.getLong("amount"));
}
}
}
return inventory;
}
}
private void insertVertices(long zoneId, List<ClaimPoint> vertices) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO npc_zone_vertices(zone_id, vertex_order, x, z)
VALUES (?, ?, ?, ?)
""")) {
for (int index = 0; index < vertices.size(); index++) {
ClaimPoint point = vertices.get(index);
insert.setLong(1, zoneId);
insert.setInt(2, index);
insert.setInt(3, point.x());
insert.setInt(4, point.z());
insert.addBatch();
}
insert.executeBatch();
}
}
private NpcZone mapZone(ResultSet result) throws SQLException {
long id = result.getLong("id");
return new NpcZone(
id,
result.getString("name"),
result.getString("world"),
result.getInt("x1"),
result.getInt("z1"),
result.getInt("x2"),
result.getInt("z2"),
vertices(id)
);
}
private CoalNpc mapNpc(ResultSet result) throws SQLException {
return new CoalNpc(
result.getLong("id"),
result.getInt("citizens_id"),
UUID.fromString(result.getString("account_uuid")),
result.getString("npc_type"),
result.getString("name"),
result.getLong("zone_id"),
result.getString("world"),
result.getDouble("x"),
result.getDouble("y"),
result.getDouble("z")
);
}
private List<ClaimPoint> vertices(long zoneId) throws SQLException {
List<ClaimPoint> points = new ArrayList<>();
try (PreparedStatement statement = database.connection().prepareStatement(
"SELECT x, z FROM npc_zone_vertices WHERE zone_id = ? ORDER BY vertex_order")) {
statement.setLong(1, zoneId);
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
points.add(new ClaimPoint(result.getInt("x"), result.getInt("z")));
}
}
}
return points;
}
}

View File

@@ -0,0 +1,89 @@
package com.librewiki.coalgov.storage;
import com.librewiki.coalgov.model.Permit;
import com.librewiki.coalgov.model.PermitType;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public final class PermitRepository {
private final Database database;
public PermitRepository(Database database) {
this.database = database;
}
public long create(UUID owner, PermitType permitType, String regionName, Long expiresAt) throws SQLException {
try (PreparedStatement insert = database.connection().prepareStatement("""
INSERT INTO permits(owner_uuid, permit_type, region_name, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
""", Statement.RETURN_GENERATED_KEYS)) {
insert.setString(1, owner.toString());
insert.setString(2, permitType.name());
insert.setString(3, regionName);
if (expiresAt == null) {
insert.setObject(4, null);
} else {
insert.setLong(4, expiresAt);
}
insert.setLong(5, System.currentTimeMillis());
insert.executeUpdate();
try (ResultSet keys = insert.getGeneratedKeys()) {
return keys.next() ? keys.getLong(1) : -1L;
}
}
}
public boolean hasActive(UUID owner, PermitType permitType, String regionName, long now) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT 1 FROM permits
WHERE owner_uuid = ? AND permit_type = ? AND (region_name IS NULL OR lower(region_name) = lower(?))
AND (expires_at IS NULL OR expires_at > ?)
LIMIT 1
""")) {
statement.setString(1, owner.toString());
statement.setString(2, permitType.name());
statement.setString(3, regionName);
statement.setLong(4, now);
try (ResultSet result = statement.executeQuery()) {
return result.next();
}
}
}
public List<Permit> listActive(UUID owner, long now) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT * FROM permits
WHERE owner_uuid = ? AND (expires_at IS NULL OR expires_at > ?)
ORDER BY id
""")) {
statement.setString(1, owner.toString());
statement.setLong(2, now);
List<Permit> permits = new ArrayList<>();
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
permits.add(map(result));
}
}
return permits;
}
}
private Permit map(ResultSet result) throws SQLException {
long expiresAt = result.getLong("expires_at");
Long nullableExpiresAt = result.wasNull() ? null : expiresAt;
return new Permit(
result.getLong("id"),
UUID.fromString(result.getString("owner_uuid")),
PermitType.valueOf(result.getString("permit_type")),
result.getString("region_name"),
nullableExpiresAt,
result.getLong("created_at")
);
}
}

View File

@@ -0,0 +1,55 @@
package com.librewiki.coalgov.storage;
import org.bukkit.Location;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public final class SuperFurnaceRepository {
private final Database database;
public SuperFurnaceRepository(Database database) {
this.database = database;
}
public void add(Location location) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
INSERT INTO super_furnaces(world, x, y, z, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(world, x, y, z) DO NOTHING
""")) {
bindLocation(statement, location);
statement.setLong(5, System.currentTimeMillis());
statement.executeUpdate();
}
}
public void remove(Location location) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
DELETE FROM super_furnaces
WHERE world = ? AND x = ? AND y = ? AND z = ?
""")) {
bindLocation(statement, location);
statement.executeUpdate();
}
}
public boolean exists(Location location) throws SQLException {
try (PreparedStatement statement = database.connection().prepareStatement("""
SELECT 1 FROM super_furnaces
WHERE world = ? AND x = ? AND y = ? AND z = ?
""")) {
bindLocation(statement, location);
try (var result = statement.executeQuery()) {
return result.next();
}
}
}
private void bindLocation(PreparedStatement statement, Location location) throws SQLException {
statement.setString(1, location.getWorld().getName());
statement.setInt(2, location.getBlockX());
statement.setInt(3, location.getBlockY());
statement.setInt(4, location.getBlockZ());
}
}

View File

@@ -0,0 +1,69 @@
package com.librewiki.coalgov.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
public final class CoalMoney {
public static final long CENTS_PER_COAL = 100L;
private CoalMoney() {
}
public static long fromCoal(double coal) {
return Math.round(coal * CENTS_PER_COAL);
}
public static long fromCoalConfig(double coal) {
return Math.max(0L, fromCoal(coal));
}
public static String format(long cents) {
boolean negative = cents < 0L;
long absolute = Math.abs(cents);
long coal = absolute / CENTS_PER_COAL;
long coalCents = absolute % CENTS_PER_COAL;
String prefix = negative ? "-" : "";
if (coal > 0L && coalCents > 0L) {
return prefix + coal + " coal " + coalCents + "cc";
}
if (coal > 0L) {
return prefix + coal + " coal";
}
return prefix + coalCents + "cc";
}
public static long parsePositive(String raw) {
long amount = parse(raw);
return amount > 0L ? amount : -1L;
}
public static long parse(String raw) {
if (raw == null) {
return -1L;
}
String normalized = raw.trim().toLowerCase();
if (normalized.isEmpty() || normalized.startsWith("-")) {
return -1L;
}
try {
if (normalized.endsWith("cc")) {
return Long.parseLong(normalized.substring(0, normalized.length() - 2));
}
if (normalized.endsWith("c")) {
return parseDecimalCoal(normalized.substring(0, normalized.length() - 1));
}
if (normalized.endsWith("coal")) {
return parseDecimalCoal(normalized.substring(0, normalized.length() - 4));
}
return parseDecimalCoal(normalized);
} catch (ArithmeticException | NumberFormatException exception) {
return -1L;
}
}
private static long parseDecimalCoal(String raw) {
BigDecimal coal = new BigDecimal(raw.trim());
BigDecimal cents = coal.multiply(BigDecimal.valueOf(CENTS_PER_COAL));
return cents.setScale(0, RoundingMode.HALF_UP).longValueExact();
}
}

View File

@@ -0,0 +1,30 @@
package com.librewiki.coalgov.util;
public record Cuboid2D(String world, int x1, int z1, int x2, int z2) {
public Cuboid2D {
int minX = Math.min(x1, x2);
int maxX = Math.max(x1, x2);
int minZ = Math.min(z1, z2);
int maxZ = Math.max(z1, z2);
x1 = minX;
x2 = maxX;
z1 = minZ;
z2 = maxZ;
}
public boolean contains(String worldName, int x, int z) {
return world.equals(worldName) && x >= x1 && x <= x2 && z >= z1 && z <= z2;
}
public boolean overlaps(Cuboid2D other) {
return world.equals(other.world)
&& x1 <= other.x2
&& x2 >= other.x1
&& z1 <= other.z2
&& z2 >= other.z1;
}
public long area() {
return (long) (x2 - x1 + 1) * (z2 - z1 + 1);
}
}

View File

@@ -0,0 +1,29 @@
package com.librewiki.coalgov.util;
import org.bukkit.ChatColor;
import org.bukkit.configuration.file.FileConfiguration;
public final class Messages {
private final FileConfiguration config;
public Messages(FileConfiguration config) {
this.config = config;
}
public String prefix() {
return color(config.getString("messages.prefix", "&8[&6CoalGov&8]&r "));
}
public String text(String message) {
return prefix() + color(message);
}
public String raw(String message) {
return color(message);
}
@SuppressWarnings("deprecation")
private String color(String message) {
return ChatColor.translateAlternateColorCodes('&', message);
}
}

View File

@@ -0,0 +1,152 @@
economy:
starting_balance: 0
deposit_coal: true
deposit_coal_blocks: true
government:
taxes:
market_sale_percent: 5.0
market_purchase_percent: 5.0
npc:
ai:
enabled: true
api_key: ""
model: "openai/gpt-4.1-mini"
timeout_ms: 4000
trader:
max_discount_percent: 15
max_surcharge_percent: 20
worker:
tick_interval_seconds: 20
produce_material: BREAD
produce_amount: 2
claims:
resale:
min_condition_multiplier: 0.25
resource_values:
COBBLESTONE: 0.02
COAL: 1.0
COAL_BLOCK: 9.0
RAW_COPPER: 0.5
COPPER_INGOT: 1.0
RAW_IRON: 1.0
IRON_INGOT: 2.0
RAW_GOLD: 2.0
GOLD_INGOT: 4.0
DIAMOND: 32.0
EMERALD: 12.0
homestead:
base_cost: 32
cost_per_block: 0.05
max_radius: 45
first_claim:
free: true
max_radius: 12
industrial:
base_cost: 128
cost_per_block: 0.15
max_radius: 96
permits:
mining:
cost: 64
duration_hours: 72
divining_rod:
scan_radius: 4
scan_depth: 48
cooldown_seconds: 5
market:
dynamic:
initial_stock: 1024
target_stock: 1024
min_multiplier: 0.35
max_multiplier: 3.0
sell_multiplier: 0.85
buy_multiplier: 1.15
initial_stock:
WHEAT: 0
BREAD: 0
APPLE: 0
CARROT: 0
POTATO: 0
BAKED_POTATO: 0
BEETROOT: 0
MELON_SLICE: 0
SWEET_BERRIES: 0
GLOW_BERRIES: 0
COOKIE: 0
PUMPKIN_PIE: 0
COOKED_CHICKEN: 0
COOKED_PORKCHOP: 0
COOKED_BEEF: 0
COOKED_MUTTON: 0
COOKED_RABBIT: 0
COOKED_COD: 0
prices:
DIRT: 0.01
COBBLESTONE: 0.02
SAND: 0.05
GRAVEL: 0.04
OAK_LOG: 0.25
SPRUCE_LOG: 0.25
BIRCH_LOG: 0.25
TORCH: 0.10
COAL: 1.0
COAL_BLOCK: 9.0
RAW_COPPER: 0.5
COPPER_INGOT: 1.0
RAW_IRON: 1.0
IRON_INGOT: 2.0
RAW_GOLD: 2.0
GOLD_INGOT: 4.0
DIAMOND: 32.0
EMERALD: 12.0
WHEAT: 0.35
BREAD: 1.25
APPLE: 0.75
CARROT: 0.25
POTATO: 0.20
BAKED_POTATO: 0.45
BEETROOT: 0.20
MELON_SLICE: 0.15
SWEET_BERRIES: 0.20
GLOW_BERRIES: 0.35
COOKIE: 0.25
PUMPKIN_PIE: 1.0
COOKED_CHICKEN: 1.25
COOKED_PORKCHOP: 1.5
COOKED_BEEF: 1.75
COOKED_MUTTON: 1.5
COOKED_RABBIT: 1.25
COOKED_COD: 1.0
land:
default_land_class: FREEHOLD
deny_mining_in_government: true
deny_mining_in_protected_preserve: true
deny_mining_in_border_zone: true
spawn:
protection_radius: 32
guide:
create_government_region: true
lines:
- "COALGOV BASIN"
- "Free first"
- "homestead:"
- "/claim buy"
- "Use /claim wand"
- "for polygons"
- "Coal seams:"
- "redwood ridges"
- "Permits:"
- "/permit buy"
- "mining <region>"
- "/market trades"
messages:
prefix: "&8[&6CoalGov&8]&r "

View File

@@ -0,0 +1,70 @@
name: CoalGov
version: 1.0.0
main: com.librewiki.coalgov.CoalGovPlugin
api-version: '1.21'
author: LibreWiki
description: Coal-backed land, claims, permits, and mining governance.
depend: [Citizens]
commands:
coal:
description: CoalGov economy commands.
usage: /coal <balance|deposit|withdraw|pay|fines>
permission: coalgov.coal
claim:
description: CoalGov claim commands.
usage: /claim <wand|buy|info|list|show|appraise|sell|transfer|abandon>
permission: coalgov.claim
permit:
description: CoalGov permit commands.
usage: /permit <buy|list>
permission: coalgov.permit
land:
description: CoalGov land commands.
usage: /land <info|create|delete|list>
market:
description: CoalGov resource market.
usage: /market
permission: coalgov.market
diviningrod:
description: Receive a CoalGov divining rod.
usage: /diviningrod
permission: coalgov.diviningrod
police:
description: CoalGov police commands.
usage: /police <fine>
permission: coalgov.police
cgnpc:
description: CoalGov NPC commands.
usage: /cgnpc <zone|create|remove|list|stock|funds|haggle>
permission: coalgov.admin
coalgov:
description: CoalGov admin commands.
usage: /coalgov admin <balance|grant|take|reload|spawn|bypass|rod|treasury|tax>
permission: coalgov.admin
permissions:
coalgov.admin:
description: Allows CoalGov admin commands. Gameplay bypass must be enabled with /coalgov admin bypass on.
default: op
coalgov.coal:
description: Allows coal economy commands.
default: true
coalgov.claim:
description: Allows claim commands.
default: true
coalgov.permit:
description: Allows permit commands.
default: true
coalgov.land.info:
description: Allows land info commands.
default: true
coalgov.market:
description: Allows market trading.
default: true
coalgov.diviningrod:
description: Allows receiving a divining rod.
default: true
coalgov.police:
description: Allows issuing police fines.
default: op