diff --git a/1.11_Market_Update.md b/1.11_Market_Update.md index e528e721b..ba7786d0a 100644 --- a/1.11_Market_Update.md +++ b/1.11_Market_Update.md @@ -44,8 +44,8 @@ The currency system provides a unified economy for CNPC+ features. ### Key Files | File | Purpose | |------|---------| -| `PlayerCurrencyData.java` | Per-player currency storage, Vault delegation | -| `IPlayerCurrencyData.java` | API interface for currency operations | +| `PlayerTradeData.java` | Per-player trade data (currency + auction claims), Vault delegation | +| `IPlayerTradeData.java` | API interface for trade operations (currency + claims) | | `ConfigMarket.java` | Currency configuration (UseVault, CurrencyName, StartingBalance, MaxBalance) | | `VaultUtil.java` | Vault API integration via reflection | @@ -67,6 +67,9 @@ player.withdrawCurrency(amount) // Remove currency (returns success) player.canAffordCurrency(amount) // Check if can afford player.isUsingVaultCurrency() // Check if Vault is active player.getFormattedCurrencyBalance() // Get formatted string + +// Via IPlayerData +player.getData().getTradeData() // Get IPlayerTradeData interface ``` --- @@ -125,7 +128,7 @@ Each trade slot can have: #### Implementation Details - Currency cost stored as `long[] currencyCost[18]` in RoleTrader -- Uses `data.currencyData.getBalance()` and `data.currencyData.withdraw()` +- Uses `data.tradeData.getBalance()` and `data.tradeData.withdraw()` - Automatically uses Vault when `ConfigMarket.UseVault` is enabled - NBT serialization for persistence ✅ @@ -165,20 +168,22 @@ Full API available via `IRoleTrader` interface. ``` ┌─────────────────────────────────────────────────────────────────┐ │ AuctionController │ -│ - Manages all auction data with player indices │ +│ - Manages auction listings with player indices │ │ - Handles listing/bidding/buyout logic │ -│ - Race-condition protected with ReentrantReadWriteLock │ -│ - Async saves on CNPC+ thread │ -│ - 20-second update cycle for performance │ +│ - Uses ConcurrentHashMap for thread-safety (no locks) │ +│ - Async saves on CNPC+ thread every 30 seconds │ +│ - 30-second update cycle for processing ended auctions │ └─────────────────────────────────────────────────────────────────┘ │ Uses PlayerCurrencyData (Vault-aware) │ ┌─────────────────────────────────────────────────────────────────┐ -│ AuctionNotification │ -│ - Tracks pending notifications per player │ -│ - Sends notifications on login │ -│ - Types: Won, Outbid, Sold, Expired, Claimed │ +│ PlayerTradeData │ +│ - Combined currency + auction claims (shared across profiles) │ +│ - Currency: Vault integration with local fallback │ +│ - Claims: Thread-safe CopyOnWriteArrayList for async additions │ +│ - Expired claims processed on login │ +│ - Notifications sent immediately to online players │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -328,14 +333,26 @@ Example: Auction ends in 30 seconds, someone bids → Timer resets to 2:00 ┌─────────────────────────────────────────────────────────────────┐ │ AuctionController │ │ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ ReentrantRWLock │ │ AtomicBoolean │ │ CNPC+ Thread │ │ -│ │ (dataLock) │ │ dirty, saving │ │ (async saves) │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────────┐ ┌─────────────────┐ ┌───────────────┐ │ +│ │ ConcurrentHashMap │ │ AtomicBoolean │ │ CNPC+ Thread │ │ +│ │ (listings, indices) │ │ dirty, saving │ │ (async saves) │ │ +│ └─────────────────────┘ └─────────────────┘ └───────────────┘ │ │ │ -│ Read operations → Read Lock (concurrent) │ -│ Write operations → Write Lock (exclusive) │ -│ Save operations → Async on CNPC+ thread │ +│ All operations use ConcurrentHashMap - no explicit locking │ +│ Minecraft is single-threaded for gameplay - inherent safety │ +│ Async saves snapshot data before writing to disk │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ PlayerTradeData │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Currency: long balance (or Vault delegation) │ │ +│ │ Claims: CopyOnWriteArrayList │ │ +│ │ - Thread-safe for concurrent read/write │ │ +│ │ - Claims can be added from CNPC+ thread for offline players │ │ +│ │ - Stored in PlayerData (persisted with player, not auctions) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -345,29 +362,33 @@ Example: Auction ends in 30 seconds, someone bids → Timer resets to 2:00 |-------|------|---------| | `playerListingIds` | `Map>` | Active listings by seller | | `playerBidIds` | `Map>` | Listings where player has active bid | -| `playerClaimIds` | `Map>` | Unclaimed claims by player | + +**Claims are now stored in PlayerData:** +- `PlayerTradeData.claims` - CopyOnWriteArrayList per player +- Accessed via `PlayerData.get(player).tradeData` +- Shared across all profile slots (combined with currency) **Performance Impact:** - `getPlayerListingCount()`: O(n) → O(1) - `getPlayerActiveListings()`: O(n) → O(k) where k = player's listings - `getPlayerActiveBids()`: O(n) → O(k) -- `getPlayerClaims()`: O(n) → O(k) +- `getPlayerClaims()`: Direct access to player's claims list ### 4.3 Update Intervals | Operation | Interval | Rationale | |-----------|----------|-----------| -| Process ended auctions | 20 seconds (400 ticks) | Reduces server load | -| Process expired claims | 20 seconds (400 ticks) | Same cycle as auctions | -| Save dirty data | 60 seconds (1200 ticks) | Batches writes, reduces I/O | +| Process ended auctions | 30 seconds (600 ticks) | Reduces server load | +| Save auction data | 30 seconds (600 ticks) | Batches writes, reduces I/O | +| Process expired claims | On player login | Per-player, not global tick | -### 4.4 Race Condition Prevention +### 4.4 Data Safety -1. **Bid vs Auction End** - Double-check `isExpired()` inside write lock -2. **Currency Deduction** - Withdraw before updating listing -3. **Previous Bidder Refund** - Store info before modification -4. **Concurrent Saves** - `AtomicBoolean` prevents duplicates -5. **Index Consistency** - All updates within same write lock +1. **Currency Deduction** - Withdraw before updating listing +2. **Previous Bidder Refund** - Store info before modification +3. **Concurrent Saves** - `AtomicBoolean` prevents duplicates +4. **Offline Player Claims** - Loaded async on CNPC+ thread, saved to PlayerData +5. **Claim Migration** - Old claims in auction.dat migrated to PlayerData on load ### 4.5 Server Shutdown Handling @@ -397,7 +418,7 @@ controllers/data/ AuctionListing.java ✅ AuctionClaim.java ✅ AuctionFilter.java ✅ - AuctionNotification.java ✅ + PlayerTradeData.java ✅ (currency + claims storage per player) roles/ RoleAuctioneer.java ✅ @@ -451,6 +472,7 @@ ClientProxy.java ✅ Handle auction GUI types DataAdvanced.java ✅ Handle Auctioneer role ConfigMarket.java ✅ Added Auction section LoginPacket.java ✅ Sync auction config to client +PlayerData.java ✅ Added tradeData field (currency + claims, shared across profiles) en_US.lang ✅ Added auction lang keys ``` @@ -472,11 +494,15 @@ public static long MaxBalance = Long.MAX_VALUE; // Balance cap // ========================================= // Auction (Market.Auction) // ========================================= +public static final int MAX_TRADE_SLOTS = 45; // 9x5 grid maximum +public static final int MIN_TRADE_SLOTS = 1; // Minimum allowed + public static boolean AuctionEnabled = true; public static int AuctionDurationHours = 24; // Auction length (hours) public static long ListingFee = 10; // Fee to create listing +public static long MinimumListingPrice = 1; // Minimum starting price public static double SalesTaxPercent = 0.05; // 5% tax on sales -public static int DefaultMaxListings = 5; // Per player +public static int DefaultMaxTrades = 8; // Per player (listings + bids + claims) public static int SnipeProtectionMinutes = 2; // Anti-snipe timer public static int ClaimExpirationDays = 20; // Days before unclaimed deleted public static double MinBidIncrementPercent = 0.05; // 5% minimum bid increase @@ -484,7 +510,7 @@ public static double MinBidIncrementPercent = 0.05; // 5% minimum bid increase // ========================================= // Auction Logging // ========================================= -public static boolean AuctionLoggingEnabled = true; +public static boolean AuctionLoggingEnabled = false; public static boolean LogAuctionCreated = true; public static boolean LogAuctionBid = true; public static boolean LogAuctionBuyout = true; @@ -494,6 +520,21 @@ public static boolean LogAuctionCancelled = true; public static boolean LogAuctionClaimed = true; ``` +### Trade Slot Permissions + +Players can have more than the default trade slots via permissions: + +| Permission | Description | +|------------|-------------| +| `customnpcs.auction.trades.*` | Unlimited trade slots (up to 45) | +| `customnpcs.auction.trades.X` | Exactly X trade slots (where X is 1-45) | + +**How it works:** +- Default is 8 trade slots +- Permissions only **increase** the limit (permissions below 8 are ignored) +- Maximum is capped at 45 (9x5 grid) +- Trade slots = Active Listings + Active Bids + Pending Claims + --- ## 7. Commands @@ -539,7 +580,6 @@ Allows interaction with the Auction House without needing an Auctioneer NPC. ### Remaining Work - [ ] **GuiManageAuction (Admin GUI)** - Admin interface to view all auctions, cancel listings, manage claims -- [ ] **Permission system integration** - Per-player auction slot limits via `customnpcs.auction.slots.X` permissions (TODO at `AuctionController.java:899`) - [ ] Additional testing ### Implemented Since Initial Plan @@ -548,6 +588,25 @@ Allows interaction with the Auction House without needing an Auctioneer NPC. - `/auction list [page]` - View active auctions from command line - `/auction view ` - View a player's auction activity (listings, bids, claims) - `/auction open` - Open Auction House GUI without needing an Auctioneer NPC +- [x] **Permission system integration** - Per-player trade slot limits via `customnpcs.auction.trades.X` permissions + - Default: 8 trade slots + - Maximum: 45 (9x5 grid) + - Synced to client when viewing My Trades + - Cached on login for performance +- [x] **Minimum Listing Price** - Configurable minimum starting price (default: 1) +- [x] **Buyout enabled by default** - Buyout toggle is ON by default when creating listings +- [x] **Search by seller** - Filter can now search by item name OR seller name +- [x] **Rebid prevention** - Players cannot rebid on auctions where they're already the highest bidder +- [x] **Buyout price difference** - When buying out an auction you're bidding on, only pay the difference +- [x] **Immediate claim cleanup** - Claims are removed from storage immediately when claimed +- [x] **Listing cleanup** - Ended listings are removed when all related claims are processed +- [x] **Live notifications** - Notifications sent immediately to online players instead of queued +- [x] **Login claim summary** - Shows detailed claim summary on login (X items and Y currency to claim) +- [x] **PlayerTradeData** - Merged currency + claims into single class (shared across profiles) +- [x] **No locking system** - Pure ConcurrentHashMap for better performance +- [x] **30-second intervals** - Combined process/save cycle for simplicity +- [x] **Async offline player claims** - Claims added to offline players via CNPC+ thread +- [x] **Claim migration** - Old claims in auction.dat automatically migrated to PlayerData --- @@ -555,11 +614,13 @@ Allows interaction with the Auction House without needing an Auctioneer NPC. | Decision | Choice | Rationale | |----------|--------|-----------| -| Claims System | Everything to claims | Prevents offline handling issues | +| Claims Storage | In PlayerData, not auction.dat | Claims persist with player, reduces auction.dat size, allows offline claim addition | +| Thread Safety | ConcurrentHashMap (no locks) | Minecraft is single-threaded for gameplay; locks add overhead without benefit | | Vault Integration | Via PlayerCurrencyData | Single point of delegation, reused by Trader & Auction | -| Update Interval | 20 seconds | Performance vs responsiveness tradeoff | +| Update Interval | 30 seconds | Performance vs responsiveness tradeoff | | Player Indices | ConcurrentHashMap + Sets | O(1) lookups, thread-safe | | Async Saves | CNPC+ Thread | Doesn't block server tick | +| Offline Claims | Load PlayerData async, add claim, save | Claims can be added to offline players without blocking | | Config Sync | LoginPacket → AuctionClientConfig | Client never accesses server config directly | --- diff --git a/Auction_Item_Blacklist_Plan.md b/Auction_Item_Blacklist_Plan.md new file mode 100644 index 000000000..d9dfd9075 --- /dev/null +++ b/Auction_Item_Blacklist_Plan.md @@ -0,0 +1,218 @@ +# Auction Item Blacklist Feature Plan + +## Overview + +Add the ability to prevent certain items from being listed on the Auction House via a configurable blacklist system. + +--- + +## 1. Blacklist Types + +### 1.1 Item ID Blacklist +- Block specific items by registry name (e.g., `minecraft:diamond_sword`, `customnpcs:npcSoulstoneFilled`) +- Supports wildcard patterns (e.g., `minecraft:*_sword` for all swords) + +### 1.2 Mod Blacklist +- Block all items from a specific mod (e.g., `appliedenergistics2`, `thaumcraft`) +- Useful for blocking items from tech/magic mods that could disrupt economy + +### 1.3 NBT Pattern Blacklist (Advanced) +- Block items with specific NBT tags (e.g., items with "Soulbound" enchant, creative-only items) +- Pattern matching on NBT paths + +--- + +## 2. Configuration + +### 2.1 Config File Location +``` +config/CustomNPC+/auction_blacklist.cfg +``` + +### 2.2 Config Structure +```java +// In ConfigMarket.java or separate ConfigAuctionBlacklist.java +public static String[] BlacklistedItems = new String[]{}; +public static String[] BlacklistedMods = new String[]{}; +public static String[] BlacklistedNBTPatterns = new String[]{}; +public static boolean EnableBlacklist = true; +public static boolean ShowBlacklistReason = true; +``` + +### 2.3 Example Configuration +```cfg +# Items that cannot be listed on the Auction House +S:BlacklistedItems < + minecraft:bedrock + minecraft:command_block + minecraft:barrier + customnpcs:npcSoulstoneFilled + customnpcs:npcMoney* + > + +# Mods whose items are completely blocked +S:BlacklistedMods < + projecte + equivalentexchange3 + > + +# NBT patterns that block items (advanced) +S:BlacklistedNBTPatterns < + tag.ench[*].id=34 + tag.Unbreakable=1 + > +``` + +--- + +## 3. Implementation + +### 3.1 New Files +``` +controllers/data/AuctionBlacklist.java - Blacklist checking logic +config/ConfigAuctionBlacklist.java - Configuration handling +``` + +### 3.2 AuctionBlacklist.java +```java +public class AuctionBlacklist { + private static Set blacklistedItems = new HashSet<>(); + private static Set blacklistedMods = new HashSet<>(); + private static List blacklistedNBT = new ArrayList<>(); + + public static boolean isBlacklisted(ItemStack item) { + if (item == null) return true; + + // Check item registry name + String registryName = GameRegistry.findUniqueIdentifierFor(item.getItem()).toString(); + if (matchesPattern(registryName, blacklistedItems)) return true; + + // Check mod + String modId = GameRegistry.findUniqueIdentifierFor(item.getItem()).modId; + if (blacklistedMods.contains(modId.toLowerCase())) return true; + + // Check NBT patterns + if (item.hasTagCompound()) { + for (NBTPattern pattern : blacklistedNBT) { + if (pattern.matches(item.getTagCompound())) return true; + } + } + + return false; + } + + public static String getBlacklistReason(ItemStack item) { + // Returns human-readable reason why item is blocked + } + + public static void reload() { + // Reload from config + } +} +``` + +### 3.3 Integration Points + +#### AuctionController.createListing() +```java +// Add at start of createListing method: +if (AuctionBlacklist.isBlacklisted(item)) { + if (ConfigMarket.ShowBlacklistReason) { + return "This item cannot be listed: " + AuctionBlacklist.getBlacklistReason(item); + } + return "This item cannot be listed on the Auction House."; +} +``` + +#### GuiAuctionSell (optional visual feedback) +- Gray out confirm button if item is blacklisted +- Show tooltip explaining why item can't be listed + +--- + +## 4. Commands + +### 4.1 Admin Commands +``` +/auction blacklist add +/auction blacklist remove +/auction blacklist list +/auction blacklist reload +/auction blacklist check +``` + +### 4.2 Examples +``` +/auction blacklist add item minecraft:diamond_block +/auction blacklist add mod projecte +/auction blacklist add nbt tag.ench[*].id=34 +/auction blacklist check held +``` + +--- + +## 5. Default Blacklist (Recommended) + +### 5.1 Vanilla Items +- `minecraft:command_block*` +- `minecraft:barrier` +- `minecraft:bedrock` +- `minecraft:structure_block` +- `minecraft:structure_void` + +### 5.2 CustomNPC+ Items +- `customnpcs:npcSoulstoneFilled` (contains NPC data) +- `customnpcs:npcWand` (admin tool) +- `customnpcs:npcMobCloner` (admin tool) +- `customnpcs:npcScripter` (admin tool) + +--- + +## 6. Future Considerations + +### 6.1 Whitelist Mode +- Invert the blacklist to only allow specific items +- Useful for strict economy servers + +### 6.2 Category-Based Blocking +- Block by item category (weapons, tools, armor, etc.) +- Integration with Minecraft's creative tabs + +### 6.3 Price Restrictions +- Set minimum/maximum prices for specific items +- Prevent price manipulation on valuable items + +### 6.4 Per-Player Overrides +- Permission to bypass blacklist: `customnpcs.auction.blacklist.bypass` +- Admin listing capability + +--- + +## 7. Files to Create/Modify + +### New Files +``` +config/ConfigAuctionBlacklist.java +controllers/data/AuctionBlacklist.java +``` + +### Modified Files +``` +AuctionController.java - Add blacklist check in createListing +AuctionCommand.java - Add blacklist subcommands +en_US.lang - Add blacklist messages +1.11_Market_Update.md - Document feature +``` + +--- + +## 8. Estimated Scope + +| Component | Complexity | Priority | +|-----------|------------|----------| +| Basic item blacklist | Low | High | +| Mod blacklist | Low | High | +| NBT pattern matching | Medium | Medium | +| Admin commands | Low | Medium | +| GUI feedback | Low | Low | +| Whitelist mode | Low | Low | diff --git a/src/api b/src/api index c8a2fb982..2e5d86993 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit c8a2fb9824de8488627f66eed2a5254ee8690016 +Subproject commit 2e5d86993cd47e6df4572323514d4fe2166c570c diff --git a/src/main/java/kamkeel/npcs/command/AuctionCommand.java b/src/main/java/kamkeel/npcs/command/AuctionCommand.java index 056116eba..a6e05a401 100644 --- a/src/main/java/kamkeel/npcs/command/AuctionCommand.java +++ b/src/main/java/kamkeel/npcs/command/AuctionCommand.java @@ -28,7 +28,7 @@ public class AuctionCommand extends CommandKamkeelBase { @Override public String getDescription() { - return "Auction House operations"; + return "Auction operations"; } @SubCommand( @@ -135,7 +135,7 @@ public class AuctionCommand extends CommandKamkeelBase { } @SubCommand( - desc = "Open the Auction House GUI", + desc = "Open the Auction GUI", usage = "", permission = 0 ) @@ -154,7 +154,7 @@ public class AuctionCommand extends CommandKamkeelBase { // Open the auction GUI without requiring an NPC NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerAuction, null); - sendResult(sender, "Opening Auction House..."); + sendResult(sender, "Opening Auction..."); } @Override diff --git a/src/main/java/kamkeel/npcs/command/MoneyCommand.java b/src/main/java/kamkeel/npcs/command/MoneyCommand.java index 8b9fa72eb..ee034243d 100644 --- a/src/main/java/kamkeel/npcs/command/MoneyCommand.java +++ b/src/main/java/kamkeel/npcs/command/MoneyCommand.java @@ -4,8 +4,8 @@ import net.minecraft.command.CommandException; import net.minecraft.command.ICommandSender; import noppes.npcs.config.ConfigMarket; import noppes.npcs.controllers.PlayerDataController; -import noppes.npcs.controllers.data.PlayerCurrencyData; import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.controllers.data.PlayerTradeData; import java.util.List; @@ -38,7 +38,7 @@ public class MoneyCommand extends CommandKamkeelBase { } for (PlayerData playerdata : dataList) { - PlayerCurrencyData currency = playerdata.currencyData; + PlayerTradeData currency = playerdata.tradeData; long balance = currency.getBalance(); sendResult(sender, String.format("Player \u00A7b%s\u00A77 has \u00A76%,d %s", playerdata.playername, balance, ConfigMarket.CurrencyName)); @@ -72,7 +72,7 @@ public class MoneyCommand extends CommandKamkeelBase { } for (PlayerData playerdata : dataList) { - PlayerCurrencyData currency = playerdata.currencyData; + PlayerTradeData currency = playerdata.tradeData; if (currency.deposit(amount)) { playerdata.save(); sendResult(sender, String.format("Gave \u00A76%,d %s\u00A77 to player \u00A7b%s\u00A77. New balance: \u00A76%,d", @@ -111,7 +111,7 @@ public class MoneyCommand extends CommandKamkeelBase { } for (PlayerData playerdata : dataList) { - PlayerCurrencyData currency = playerdata.currencyData; + PlayerTradeData currency = playerdata.tradeData; if (currency.withdraw(amount)) { playerdata.save(); sendResult(sender, String.format("Withdrew \u00A76%,d %s\u00A77 from player \u00A7b%s\u00A77. New balance: \u00A76%,d", @@ -150,7 +150,7 @@ public class MoneyCommand extends CommandKamkeelBase { } for (PlayerData playerdata : dataList) { - PlayerCurrencyData currency = playerdata.currencyData; + PlayerTradeData currency = playerdata.tradeData; currency.setBalance(amount); playerdata.save(); sendResult(sender, String.format("Set balance of player \u00A7b%s\u00A77 to \u00A76%,d %s", diff --git a/src/main/java/kamkeel/npcs/controllers/ProfileController.java b/src/main/java/kamkeel/npcs/controllers/ProfileController.java index f7ac06bb1..103da1309 100644 --- a/src/main/java/kamkeel/npcs/controllers/ProfileController.java +++ b/src/main/java/kamkeel/npcs/controllers/ProfileController.java @@ -832,6 +832,15 @@ public class ProfileController implements IProfileHandler { else compound = (NBTTagCompound) compound.copy(); playerData.setNBT(compound); + + // Trade data is shared across all profile slots - copy from player's live data + PlayerData liveData = PlayerData.get(player); + if (liveData != null) { + NBTTagCompound tradeCompound = new NBTTagCompound(); + liveData.tradeData.writeToNBT(tradeCompound); + playerData.tradeData.readFromNBT(tradeCompound); + } + return playerData; } diff --git a/src/main/java/kamkeel/npcs/controllers/data/profile/CNPCData.java b/src/main/java/kamkeel/npcs/controllers/data/profile/CNPCData.java index ccbae74ef..e4eb04dfe 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/profile/CNPCData.java +++ b/src/main/java/kamkeel/npcs/controllers/data/profile/CNPCData.java @@ -5,6 +5,7 @@ import net.minecraft.entity.player.EntityPlayer; import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.nbt.NBTTagCompound; import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.controllers.data.PlayerTradeData; import java.util.ArrayList; import java.util.List; @@ -19,7 +20,10 @@ public class CNPCData implements IProfileData { @Override public NBTTagCompound getCurrentNBT(EntityPlayer player) { PlayerData customNPCData = PlayerData.get(player); - return customNPCData.getNBT(); + NBTTagCompound compound = customNPCData.getNBT(); + compound.removeTag(PlayerTradeData.NBT_KEY); + + return compound; } @Override @@ -32,6 +36,10 @@ public class CNPCData implements IProfileData { @Override public void setNBT(EntityPlayer player, NBTTagCompound replace) { PlayerData customNPCData = PlayerData.get(player); + + NBTTagCompound sharedData = new NBTTagCompound(); + customNPCData.tradeData.writeToNBT(sharedData); + if (replace.hasNoTags()) { PlayerData newData = new PlayerData(); newData.player = player; @@ -39,6 +47,8 @@ public class CNPCData implements IProfileData { } else { customNPCData.setNBT(replace); } + + customNPCData.tradeData.readFromNBT(sharedData); customNPCData.updateClient = true; } diff --git a/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java b/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java index fca09473f..3ce74eb4c 100644 --- a/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java +++ b/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java @@ -338,6 +338,9 @@ public class AuctionActionPacket extends AbstractPacket { NBTTagCompound response = new NBTTagCompound(); response.setBoolean("TradesData", true); + // Send player's max trade slots (permission-aware) + response.setInteger("MaxTradeSlots", controller.getMaxTradesForPlayer(player)); + // Active listings (items player is selling) List listings = controller.getPlayerActiveListings(player.getUniqueID()); NBTTagList listingsNBT = new NBTTagList(); @@ -354,8 +357,8 @@ public class AuctionActionPacket extends AbstractPacket { } response.setTag("ActiveBids", bidsNBT); - // Claims (items/currency to claim) - List claims = controller.getPlayerClaims(player.getUniqueID()); + // Claims (items/currency to claim) - now from PlayerData + List claims = controller.getPlayerClaims(player); NBTTagList claimsNBT = new NBTTagList(); for (AuctionClaim claim : claims) { claimsNBT.appendTag(claim.writeToNBT(new NBTTagCompound())); @@ -488,7 +491,7 @@ public class AuctionActionPacket extends AbstractPacket { private void sendError(EntityPlayerMP player, String message) { player.addChatMessage(new net.minecraft.util.ChatComponentText( - net.minecraft.util.EnumChatFormatting.RED + "[Auction House] " + message)); + net.minecraft.util.EnumChatFormatting.RED + "[Auction] " + message)); } private void sendTradesUpdate(EntityPlayerMP player) { diff --git a/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java b/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java index 5b7cb7dfe..a04628962 100644 --- a/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java +++ b/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java @@ -53,7 +53,7 @@ public class GetTraderData extends AbstractPacket { // Player balance PlayerData data = PlayerData.get(player); - compound.setLong("Balance", data.currencyData.getBalance()); + compound.setLong("Balance", data.tradeData.getBalance()); // Stock info compound.setBoolean("StockEnabled", role.stock.enableStock); diff --git a/src/main/java/noppes/npcs/CustomNpcsPermissions.java b/src/main/java/noppes/npcs/CustomNpcsPermissions.java index c83e92c22..0d758ad41 100644 --- a/src/main/java/noppes/npcs/CustomNpcsPermissions.java +++ b/src/main/java/noppes/npcs/CustomNpcsPermissions.java @@ -110,6 +110,9 @@ public class CustomNpcsPermissions { public static final Permission PROFILE_REGION_BYPASS = new Permission("customnpcs.profile.region-bypass"); public static final Permission PROFILE_MAX = new Permission("customnpcs.profile.max.*"); + // Auction + public static final Permission AUCTION_TRADES_MAX = new Permission("customnpcs.auction.trades.*"); + public static CustomNpcsPermissions Instance; private Method hasPermission; diff --git a/src/main/java/noppes/npcs/ServerTickHandler.java b/src/main/java/noppes/npcs/ServerTickHandler.java index 27995f3cb..2639c6ab5 100644 --- a/src/main/java/noppes/npcs/ServerTickHandler.java +++ b/src/main/java/noppes/npcs/ServerTickHandler.java @@ -68,6 +68,11 @@ public class ServerTickHandler { playerData.onLogout(); } + // Clear auction permission cache on logout + if (AuctionController.Instance != null) { + AuctionController.Instance.onPlayerLogout(event.player.getUniqueID()); + } + // Save and unload the player's profile data on logout ProfileController.Instance.logout(event.player); } diff --git a/src/main/java/noppes/npcs/client/AuctionClientConfig.java b/src/main/java/noppes/npcs/client/AuctionClientConfig.java index aaae441bf..7d6854e4c 100644 --- a/src/main/java/noppes/npcs/client/AuctionClientConfig.java +++ b/src/main/java/noppes/npcs/client/AuctionClientConfig.java @@ -15,11 +15,11 @@ import net.minecraft.nbt.NBTTagCompound; public class AuctionClientConfig { // Cached values from server private static long listingFee = 0; - private static String currencyName = "Gold"; - private static int auctionDurationHours = 48; + private static String currencyName = "Coins"; + private static int auctionDurationHours = 24; private static double minBidIncrement = 0.05; - private static int maxActiveListings = 10; - private static int claimExpirationDays = 30; + private static int maxActiveListings = 8; + private static int claimExpirationDays = 20; private static boolean auctionEnabled = false; /** @@ -47,11 +47,11 @@ public class AuctionClientConfig { */ public static void reset() { listingFee = 0; - currencyName = "Gold"; - auctionDurationHours = 48; + currencyName = "Coins"; + auctionDurationHours = 24; minBidIncrement = 0.05; - maxActiveListings = 10; - claimExpirationDays = 30; + maxActiveListings = 8; + claimExpirationDays = 20; auctionEnabled = false; } diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java index 4f67c781c..8b288c83f 100644 --- a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java @@ -45,7 +45,7 @@ public class GuiAuctionSell extends GuiAuctionInterface implements ITextfieldLis private final ContainerAuctionSell sellContainer; private long startingPrice = 0; private long buyoutPrice = 0; - private boolean allowBuyout = false; + private boolean allowBuyout = true; private String errorMessage = null; // Buttons diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java index 0d6e3d98d..21b0f6f01 100644 --- a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java @@ -55,7 +55,8 @@ public class GuiAuctionTrades extends GuiAuctionInterface implements IGuiData { public GuiAuctionTrades(EntityNPCInterface npc, ContainerAuctionTrades container) { super(npc, container); this.tradesContainer = container; - this.maxTradeSlots = AuctionClientConfig.getMaxActiveListings() * 2; + // Initial value from client config, updated when server sends trades data + this.maxTradeSlots = AuctionClientConfig.getMaxActiveListings(); } @Override @@ -372,6 +373,8 @@ public class GuiAuctionTrades extends GuiAuctionInterface implements IGuiData { if (compound.hasKey("TradesData")) { // Full trades data received from server tradesContainer.setTradesData(compound); + // Update max trade slots from server (permission-aware) + this.maxTradeSlots = tradesContainer.getMaxTradeSlots(); clearPending(); } else if (compound.hasKey("TradesUpdate")) { // Legacy flag - should not happen now but keep for safety diff --git a/src/main/java/noppes/npcs/config/ConfigMarket.java b/src/main/java/noppes/npcs/config/ConfigMarket.java index ff7c2af47..2ba7c7c92 100644 --- a/src/main/java/noppes/npcs/config/ConfigMarket.java +++ b/src/main/java/noppes/npcs/config/ConfigMarket.java @@ -11,16 +11,13 @@ import java.util.Arrays; public class ConfigMarket { public static Configuration config; - public final static String MARKET = "Market"; public final static String CURRENCY = "Market.Currency"; - public final static String TRADER = "Market.Trader"; public final static String AUCTION = "Market.Auction"; public final static String AUCTION_LOGGING = "Market.Auction.Logging"; - // ========================================= - // Market General - // ========================================= - public static boolean MarketEnabled = true; + // Max trades grid = 9x5 = 45 slots + public static final int MAX_TRADE_SLOTS = 45; + public static final int MIN_TRADE_SLOTS = 1; // ========================================= // Currency Settings @@ -30,20 +27,15 @@ public class ConfigMarket { public static long StartingBalance = 0; public static long MaxBalance = Long.MAX_VALUE; - // ========================================= - // Trader Settings - // ========================================= - public static boolean EnableStockByDefault = false; - public static String DefaultResetType = "NONE"; - // ========================================= // Auction Settings // ========================================= public static boolean AuctionEnabled = true; public static int AuctionDurationHours = 24; public static long ListingFee = 10; + public static long MinimumListingPrice = 1; public static double SalesTaxPercent = 0.05; - public static int DefaultMaxListings = 5; + public static int DefaultMaxTrades = 8; public static int SnipeProtectionMinutes = 2; public static int ClaimExpirationDays = 20; public static double MinBidIncrementPercent = 0.05; @@ -67,18 +59,12 @@ public class ConfigMarket { try { config.load(); - // ========================================= - // Market General - // ========================================= - MarketEnabled = config.get(MARKET, "Enable Market System", true, - "Enable the market/economy system").getBoolean(true); - // ========================================= // Currency Settings // ========================================= config.setCategoryComment(CURRENCY, "CNPC+ Currency system settings. When UseVault is enabled, all currency operations use Vault instead of the built-in system."); - UseVault = config.get(CURRENCY, "Use Vault", true, + UseVault = config.get(CURRENCY, "Use Vault", false, "If true, use Vault API for all currency operations instead of CNPC+ built-in currency. The built-in currency data will be preserved but unused.").getBoolean(false); CurrencyName = config.get(CURRENCY, "Currency Name", "Coins", @@ -90,24 +76,13 @@ public class ConfigMarket { MaxBalance = config.get(CURRENCY, "Max Balance", Integer.MAX_VALUE, "Maximum currency balance a player can have").getInt(Integer.MAX_VALUE); - // ========================================= - // Trader Settings - // ========================================= - config.setCategoryComment(TRADER, "Trader stock system settings"); - - EnableStockByDefault = config.get(TRADER, "Enable Stock By Default", false, - "Enable stock system by default for new traders").getBoolean(false); - - DefaultResetType = config.get(TRADER, "Default Reset Type", "NONE", - "Default stock reset type (NONE, MCDAILY, MCWEEKLY, RLDAILY, RLWEEKLY)").getString(); - // ========================================= // Auction Settings // ========================================= - config.setCategoryComment(AUCTION, "Auction House system settings. The Auction House allows players to list items for sale with bidding and buyout options."); + config.setCategoryComment(AUCTION, "Auction system settings. The Auction allows players to list items for sale with bidding and buyout options."); - AuctionEnabled = config.get(AUCTION, "Enable Auction House", true, - "Enable the Auction House system").getBoolean(true); + AuctionEnabled = config.get(AUCTION, "Enable Auction", true, + "Enable the Auction system").getBoolean(true); AuctionDurationHours = config.get(AUCTION, "Auction Duration Hours", 24, "Default duration for auctions in hours").getInt(24); @@ -115,11 +90,16 @@ public class ConfigMarket { ListingFee = config.get(AUCTION, "Listing Fee", 10, "Flat fee charged when creating a listing").getInt(10); + MinimumListingPrice = config.get(AUCTION, "Minimum Listing Price", 1, + "Minimum starting price for auction listings").getInt(1); + if (MinimumListingPrice < 1) MinimumListingPrice = 1; + SalesTaxPercent = config.get(AUCTION, "Sales Tax Percent", 0.05, "Percentage of sale price taken as tax (0.05 = 5%). Tax is deleted as a currency sink.").getDouble(0.05); - DefaultMaxListings = config.get(AUCTION, "Default Max Listings", 5, - "Default maximum number of active listings per player").getInt(5); + int maxTrades = config.get(AUCTION, "Default Max Trades", 8, + "Default maximum number of trade slots per player (listings + bids + claims). Min: 1, Max: 45. Players can have more via customnpcs.auction.trades.X permissions.").getInt(8); + DefaultMaxTrades = Math.max(MIN_TRADE_SLOTS, Math.min(MAX_TRADE_SLOTS, maxTrades)); SnipeProtectionMinutes = config.get(AUCTION, "Snipe Protection Minutes", 2, "When a bid is placed with less than this many minutes remaining, the auction is extended to this duration").getInt(2); @@ -136,7 +116,7 @@ public class ConfigMarket { // ========================================= // Auction Logging Settings // ========================================= - config.setCategoryComment(AUCTION_LOGGING, "Auction House logging settings. Enable specific log types to track auction activity."); + config.setCategoryComment(AUCTION_LOGGING, "Auction logging settings. Enable specific log types to track auction activity."); AuctionLoggingEnabled = config.get(AUCTION_LOGGING, "Enable Auction Logging", false, "Master switch for auction logging. If false, no auction events are logged.").getBoolean(false); @@ -163,10 +143,6 @@ public class ConfigMarket { "Log when claims are collected").getBoolean(true); // Set category order - config.setCategoryPropertyOrder(MARKET, new ArrayList<>(Arrays.asList( - "Enable Market System" - ))); - config.setCategoryPropertyOrder(CURRENCY, new ArrayList<>(Arrays.asList( "Use Vault", "Currency Name", @@ -174,17 +150,13 @@ public class ConfigMarket { "Max Balance" ))); - config.setCategoryPropertyOrder(TRADER, new ArrayList<>(Arrays.asList( - "Enable Stock By Default", - "Default Reset Type" - ))); - config.setCategoryPropertyOrder(AUCTION, new ArrayList<>(Arrays.asList( - "Enable Auction House", + "Enable Auction", "Auction Duration Hours", "Listing Fee", + "Minimum Listing Price", "Sales Tax Percent", - "Default Max Listings", + "Default Max Trades", "Snipe Protection Minutes", "Claim Expiration Days", "Min Bid Increment Percent", diff --git a/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java b/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java index 772cba7ce..eab491c70 100644 --- a/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java +++ b/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java @@ -31,6 +31,7 @@ public class ContainerAuctionTrades extends ContainerAuction { private List activeBids = new ArrayList<>(); private List claims = new ArrayList<>(); private int hiddenSlot = -1; + private int maxTradeSlots = 8; // Player's max trade slots (permission-aware, synced from server) // Slot type tracking for proper accessor methods private int[] slotTypes = new int[SLOT_COUNT]; // 0=empty, 1=selling, 2=bidding, 3=claim @@ -72,6 +73,11 @@ public class ContainerAuctionTrades extends ContainerAuction { // Clear existing data clearData(); + // Read player's max trade slots (permission-aware) + if (compound.hasKey("MaxTradeSlots")) { + maxTradeSlots = compound.getInteger("MaxTradeSlots"); + } + List newListings = new ArrayList<>(); List newBids = new ArrayList<>(); List newClaims = new ArrayList<>(); @@ -341,4 +347,7 @@ public class ContainerAuctionTrades extends ContainerAuction { } public int getHiddenSlot() { return hiddenSlot; } + + /** Get player's max trade slots (synced from server based on permissions) */ + public int getMaxTradeSlots() { return maxTradeSlots; } } diff --git a/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java b/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java index e6b4c86c8..807190816 100644 --- a/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java +++ b/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java @@ -105,7 +105,7 @@ public class ContainerNPCTrader extends ContainerNpcInterface { long currencyCost = role.getCurrencyCost(i); if (currencyCost > 0) { PlayerData data = PlayerData.get(entityplayer); - if (data.currencyData.getBalance() < currencyCost) { + if (data.tradeData.getBalance() < currencyCost) { return null; // Can't afford currency cost } } @@ -117,7 +117,7 @@ public class ContainerNPCTrader extends ContainerNpcInterface { // Withdraw currency cost (after item consumption to maintain order) if (currencyCost > 0) { PlayerData data = PlayerData.get(entityplayer); - data.currencyData.withdraw(currencyCost); + data.tradeData.withdraw(currencyCost); } // Consume stock diff --git a/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java b/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java index 58e4b8d86..9b8c1f995 100644 --- a/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java +++ b/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java @@ -25,7 +25,7 @@ public class AuctionConfigSync { compound.setString("CurrencyName", getEffectiveCurrencyName()); compound.setInteger("AuctionDurationHours", ConfigMarket.AuctionDurationHours); compound.setDouble("MinBidIncrement", ConfigMarket.MinBidIncrementPercent); - compound.setInteger("MaxActiveListings", ConfigMarket.DefaultMaxListings); + compound.setInteger("MaxActiveListings", ConfigMarket.DefaultMaxTrades); compound.setInteger("ClaimExpirationDays", ConfigMarket.ClaimExpirationDays); return compound; } diff --git a/src/main/java/noppes/npcs/controllers/AuctionController.java b/src/main/java/noppes/npcs/controllers/AuctionController.java index 708c3bb0e..375767deb 100644 --- a/src/main/java/noppes/npcs/controllers/AuctionController.java +++ b/src/main/java/noppes/npcs/controllers/AuctionController.java @@ -10,7 +10,12 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.util.ChatComponentText; import net.minecraft.util.EnumChatFormatting; import noppes.npcs.CustomNpcs; +import noppes.npcs.CustomNpcsPermissions; import noppes.npcs.LogWriter; +import noppes.npcs.api.entity.IPlayer; +import noppes.npcs.api.handler.IAuctionHandler; +import noppes.npcs.api.handler.data.IAuctionListing; +import noppes.npcs.api.item.IItemStack; import noppes.npcs.config.ConfigMarket; import noppes.npcs.constants.EnumAuctionSort; import noppes.npcs.constants.EnumAuctionStatus; @@ -19,9 +24,8 @@ import noppes.npcs.constants.EnumNotificationType; import noppes.npcs.controllers.data.AuctionClaim; import noppes.npcs.controllers.data.AuctionFilter; import noppes.npcs.controllers.data.AuctionListing; -import noppes.npcs.controllers.data.AuctionNotification; -import noppes.npcs.controllers.data.PlayerCurrencyData; import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.controllers.data.PlayerTradeData; import noppes.npcs.util.CustomNPCsThreader; import java.io.File; @@ -30,28 +34,26 @@ import java.io.FileOutputStream; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * Main controller for the Auction House system. + * Main controller for the Auction system. * * Thread Safety: - * - All data modifications go through synchronized methods or use proper locking - * - Player indices are maintained for O(1) lookups + * - Uses ConcurrentHashMap for all data stores - no locking required + * - Claims are stored in PlayerData, not here - reduces contention * - Saves are performed asynchronously on the CNPC+ thread * * Performance: - * - Auction processing runs every 20 seconds (400 ticks) to reduce server load + * - Auction processing runs every 30 seconds (600 ticks) + * - Save interval is every 30 seconds (600 ticks) * - Player-specific queries use indexed maps for fast lookups - * - Read operations use read locks, write operations use write locks */ -public class AuctionController { +public class AuctionController implements IAuctionHandler { public static AuctionController Instance; - // Main data stores + // Main data stores - listings only, claims are stored in PlayerData private final Map listings = new ConcurrentHashMap<>(); - private final Map claims = new ConcurrentHashMap<>(); - private final List pendingNotifications = Collections.synchronizedList(new ArrayList<>()); + // Claims are now stored in PlayerData.tradeData, not here // ========================================= // Player Indices for O(1) Lookups @@ -63,16 +65,13 @@ public class AuctionController { /** Listings where player is current high bidder */ private final Map> playerBidIds = new ConcurrentHashMap<>(); - /** Claims by player UUID - for quick "my claims" lookups */ - private final Map> playerClaimIds = new ConcurrentHashMap<>(); + /** Cached max trade slots per player - computed on login, cleared on logout */ + private final Map playerMaxTradesCache = new ConcurrentHashMap<>(); // ========================================= // Threading & State // ========================================= - /** Lock for data modifications - allows concurrent reads */ - private final ReentrantReadWriteLock dataLock = new ReentrantReadWriteLock(); - /** Flag indicating data needs to be saved */ private final AtomicBoolean dirty = new AtomicBoolean(false); @@ -82,12 +81,8 @@ public class AuctionController { private String filePath = ""; private int tickCounter = 0; - /** Process auctions every 20 seconds (400 ticks) for performance */ - private static final int PROCESS_INTERVAL_TICKS = 400; - - /** Save dirty data every 60 seconds (1200 ticks) */ - private static final int SAVE_INTERVAL_TICKS = 1200; - private int saveTickCounter = 0; + /** Process auctions and save every 30 seconds (600 ticks) */ + private static final int TICK_INTERVAL = 600; public AuctionController() { Instance = this; @@ -116,18 +111,11 @@ public class AuctionController { if (!ConfigMarket.AuctionEnabled) return; tickCounter++; - saveTickCounter++; - // Process ended auctions every 20 seconds - if (tickCounter >= PROCESS_INTERVAL_TICKS) { + // Process auctions and save every 30 seconds + if (tickCounter >= TICK_INTERVAL) { tickCounter = 0; processEndedAuctions(); - processExpiredClaims(); - } - - // Save dirty data periodically - if (saveTickCounter >= SAVE_INTERVAL_TICKS) { - saveTickCounter = 0; if (dirty.get()) { saveAsync(); } @@ -135,25 +123,19 @@ public class AuctionController { } private void processEndedAuctions() { - dataLock.writeLock().lock(); - try { - boolean changed = false; - for (AuctionListing listing : listings.values()) { - if (listing.status == EnumAuctionStatus.ACTIVE && listing.isExpired()) { - endAuction(listing); - changed = true; - } + boolean changed = false; + for (AuctionListing listing : listings.values()) { + if (listing.status == EnumAuctionStatus.ACTIVE && listing.isExpired()) { + endAuction(listing); + changed = true; } - if (changed) { - markDirty(); - } - } finally { - dataLock.writeLock().unlock(); + } + if (changed) { + markDirty(); } } private void endAuction(AuctionListing listing) { - // Must be called within write lock listing.status = EnumAuctionStatus.ENDED; // Remove from seller's active listings @@ -171,7 +153,7 @@ public class AuctionController { // Auction sold - create claims AuctionClaim itemClaim = AuctionClaim.createItemWonClaim( listing.highBidderUUID, listing.highBidderName, listing.id, listing.item); - addClaim(itemClaim); + addClaimToPlayer(listing.highBidderUUID, itemClaim); long saleAmount = listing.currentBid; long tax = (long) (saleAmount * ConfigMarket.SalesTaxPercent); @@ -180,12 +162,12 @@ public class AuctionController { AuctionClaim currencyClaim = AuctionClaim.createCurrencyClaim( listing.sellerUUID, listing.sellerName, listing.id, sellerReceives, itemDisplayName, listing.highBidderName); - addClaim(currencyClaim); + addClaimToPlayer(listing.sellerUUID, currencyClaim); // Notifications - queueNotification(listing.highBidderUUID, EnumNotificationType.AUCTION_WON, listing.id, + sendNotificationToPlayer(listing.highBidderUUID, EnumNotificationType.AUCTION_WON, listing.id, "You won the auction for " + itemDisplayName + "!"); - queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id, + sendNotificationToPlayer(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id, "Your " + itemDisplayName + " sold for " + saleAmount + " " + ConfigMarket.CurrencyName + "!"); logAuction("SOLD", listing.sellerName, itemDisplayName, saleAmount, @@ -194,45 +176,15 @@ public class AuctionController { // No bids - return item to seller AuctionClaim returnClaim = AuctionClaim.createItemReturnedClaim( listing.sellerUUID, listing.sellerName, listing.id, listing.item); - addClaim(returnClaim); + addClaimToPlayer(listing.sellerUUID, returnClaim); - queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_EXPIRED, listing.id, + sendNotificationToPlayer(listing.sellerUUID, EnumNotificationType.AUCTION_EXPIRED, listing.id, "Your auction for " + itemDisplayName + " expired with no bids."); logAuction("EXPIRED", listing.sellerName, itemDisplayName, listing.startingPrice, "No bids"); } } - private void processExpiredClaims() { - int expirationDays = ConfigMarket.ClaimExpirationDays; - - dataLock.writeLock().lock(); - try { - Iterator> iterator = claims.entrySet().iterator(); - boolean changed = false; - - while (iterator.hasNext()) { - AuctionClaim claim = iterator.next().getValue(); - if (!claim.claimed && claim.isExpired(expirationDays)) { - logAuction("CLAIM_EXPIRED", claim.playerName, - claim.type.isItem() ? (claim.item != null ? claim.item.getDisplayName() : "Unknown") : "Currency", - claim.currency, "Claim expired after " + expirationDays + " days"); - - // Remove from player index - removeFromPlayerClaims(claim.playerUUID, claim.id); - iterator.remove(); - changed = true; - } - } - - if (changed) { - markDirty(); - } - } finally { - dataLock.writeLock().unlock(); - } - } - // ========================================= // Index Management // ========================================= @@ -265,144 +217,139 @@ public class AuctionController { } } - private void addToPlayerClaims(UUID playerUUID, String claimId) { - playerClaimIds.computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()).add(claimId); - } + /** + * Add a claim to a player's trade data. + * If player is online, adds directly. If offline, loads their data file and updates it. + */ + private void addClaimToPlayer(UUID playerUUID, AuctionClaim claim) { + if (playerUUID == null || claim == null) return; - private void removeFromPlayerClaims(UUID playerUUID, String claimId) { - Set ids = playerClaimIds.get(playerUUID); - if (ids != null) { - ids.remove(claimId); - if (ids.isEmpty()) { - playerClaimIds.remove(playerUUID); - } + PlayerData data = PlayerDataController.Instance.getData(playerUUID); + if (data != null) { + data.tradeData.addClaim(claim); + data.updateClient = true; + data.save(); } } - /** Add a claim and update indices */ - private void addClaim(AuctionClaim claim) { - claims.put(claim.id, claim); - addToPlayerClaims(claim.playerUUID, claim.id); - } - // ========================================= // Listing Management // ========================================= public String createListing(EntityPlayer player, ItemStack item, long startingPrice, long buyoutPrice) { if (!ConfigMarket.AuctionEnabled) { - return "Auction House is disabled."; + return "Auction is disabled."; + } + + // Validate minimum price + if (startingPrice < ConfigMarket.MinimumListingPrice) { + return "Starting price must be at least " + ConfigMarket.MinimumListingPrice + " " + ConfigMarket.CurrencyName + "."; + } + + // Validate buyout price if set + if (buyoutPrice > 0 && buyoutPrice < startingPrice) { + return "Buyout price must be higher than starting price."; } UUID playerUUID = player.getUniqueID(); String playerName = player.getCommandSenderName(); - dataLock.writeLock().lock(); - try { - // Check listing limit - int currentListings = getPlayerListingCountFast(playerUUID); - int maxListings = getMaxListingsForPlayer(player); - if (currentListings >= maxListings) { - return "You have reached your maximum listings (" + maxListings + ")."; - } - - // Check listing fee - PlayerData playerData = PlayerData.get(player); - if (playerData == null) { - return "Could not access player data."; - } - - PlayerCurrencyData currency = playerData.currencyData; - long fee = ConfigMarket.ListingFee; - if (!currency.canAfford(fee)) { - return "You cannot afford the listing fee (" + fee + " " + ConfigMarket.CurrencyName + ")."; - } - - // Deduct fee - if (!currency.withdraw(fee)) { - return "Failed to deduct listing fee."; - } - - // Calculate duration - long durationMs = ConfigMarket.AuctionDurationHours * 60L * 60L * 1000L; - - // Create listing - AuctionListing listing = new AuctionListing(playerUUID, playerName, item, startingPrice, buyoutPrice, durationMs); - listings.put(listing.id, listing); - addToPlayerListings(playerUUID, listing.id); - - markDirty(); - - logAuction("CREATED", playerName, item.getDisplayName(), startingPrice, - "Buyout: " + (buyoutPrice > 0 ? buyoutPrice : "None") + ", Fee: " + fee); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + // Check trade slot limit (listings + bids + claims) + int currentTrades = getPlayerTradeSlotCount(player); + int maxTrades = getMaxTradesForPlayer(player); + if (currentTrades >= maxTrades) { + return "You have reached your maximum trade slots (" + maxTrades + ")."; } + + // Check listing fee + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { + return "Could not access player data."; + } + + PlayerTradeData currency = playerData.tradeData; + long fee = ConfigMarket.ListingFee; + if (!currency.canAfford(fee)) { + return "You cannot afford the listing fee (" + fee + " " + ConfigMarket.CurrencyName + ")."; + } + + // Deduct fee + if (!currency.withdraw(fee)) { + return "Failed to deduct listing fee."; + } + + // Calculate duration + long durationMs = ConfigMarket.AuctionDurationHours * 60L * 60L * 1000L; + + // Create listing + AuctionListing listing = new AuctionListing(playerUUID, playerName, item, startingPrice, buyoutPrice, durationMs); + listings.put(listing.id, listing); + addToPlayerListings(playerUUID, listing.id); + + markDirty(); + + logAuction("CREATED", playerName, item.getDisplayName(), startingPrice, + "Buyout: " + (buyoutPrice > 0 ? buyoutPrice : "None") + ", Fee: " + fee); + + return null; // Success } public String cancelListing(String listingId, EntityPlayer player, boolean isAdmin) { - dataLock.writeLock().lock(); - try { - AuctionListing listing = listings.get(listingId); - if (listing == null) { - return "Listing not found."; - } - - if (!listing.status.canCancel()) { - return "This auction cannot be cancelled."; - } - - UUID playerUUID = player.getUniqueID(); - if (!isAdmin && !listing.isSeller(playerUUID)) { - return "You can only cancel your own listings."; - } - - // Handle penalty if there are bids - if (listing.hasBids()) { - long penalty = (long) (listing.currentBid * ConfigMarket.CancellationPenaltyPercent); - - // Refund bidder (no item - can't rebid on cancelled auction) - AuctionClaim refundClaim = AuctionClaim.createRefundClaim( - listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid, - listing.item.getDisplayName(), listing.sellerName, null); - addClaim(refundClaim); - - // Remove from bidder's active bids - removeFromPlayerBids(listing.highBidderUUID, listing.id); - - queueNotification(listing.highBidderUUID, EnumNotificationType.AUCTION_OUTBID, listing.id, - "The auction for " + listing.item.getDisplayName() + " was cancelled. Your bid has been refunded."); - - // Return item to seller - AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim( - listing.sellerUUID, listing.sellerName, listing.id, listing.item); - addClaim(itemClaim); - - logAuction("CANCELLED", listing.sellerName, listing.item.getDisplayName(), listing.currentBid, - "Penalty: " + penalty + ", Bidder refunded: " + listing.highBidderName + - (isAdmin ? ", Cancelled by admin: " + player.getCommandSenderName() : "")); - } else { - // No bids - just return item - AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim( - listing.sellerUUID, listing.sellerName, listing.id, listing.item); - addClaim(itemClaim); - - logAuction("CANCELLED", listing.sellerName, listing.item.getDisplayName(), listing.startingPrice, - "No bids" + (isAdmin ? ", Cancelled by admin: " + player.getCommandSenderName() : "")); - } - - // Remove from seller's active listings - removeFromPlayerListings(listing.sellerUUID, listing.id); - - listing.status = EnumAuctionStatus.CANCELLED; - markDirty(); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + AuctionListing listing = listings.get(listingId); + if (listing == null) { + return "Listing not found."; } + + if (!listing.status.canCancel()) { + return "This auction cannot be cancelled."; + } + + UUID playerUUID = player.getUniqueID(); + if (!isAdmin && !listing.isSeller(playerUUID)) { + return "You can only cancel your own listings."; + } + + // Handle penalty if there are bids + if (listing.hasBids()) { + long penalty = (long) (listing.currentBid * ConfigMarket.CancellationPenaltyPercent); + + // Refund bidder (no item - can't rebid on cancelled auction) + AuctionClaim refundClaim = AuctionClaim.createRefundClaim( + listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid, + listing.item.getDisplayName(), listing.sellerName, null); + addClaimToPlayer(listing.highBidderUUID, refundClaim); + + // Remove from bidder's active bids + removeFromPlayerBids(listing.highBidderUUID, listing.id); + + sendNotificationToPlayer(listing.highBidderUUID, EnumNotificationType.AUCTION_OUTBID, listing.id, + "The auction for " + listing.item.getDisplayName() + " was cancelled. Your bid has been refunded."); + + // Return item to seller + AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim( + listing.sellerUUID, listing.sellerName, listing.id, listing.item); + addClaimToPlayer(listing.sellerUUID, itemClaim); + + logAuction("CANCELLED", listing.sellerName, listing.item.getDisplayName(), listing.currentBid, + "Penalty: " + penalty + ", Bidder refunded: " + listing.highBidderName + + (isAdmin ? ", Cancelled by admin: " + player.getCommandSenderName() : "")); + } else { + // No bids - just return item + AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim( + listing.sellerUUID, listing.sellerName, listing.id, listing.item); + addClaimToPlayer(listing.sellerUUID, itemClaim); + + logAuction("CANCELLED", listing.sellerName, listing.item.getDisplayName(), listing.startingPrice, + "No bids" + (isAdmin ? ", Cancelled by admin: " + player.getCommandSenderName() : "")); + } + + // Remove from seller's active listings + removeFromPlayerListings(listing.sellerUUID, listing.id); + + listing.status = EnumAuctionStatus.CANCELLED; + markDirty(); + + return null; // Success } // ========================================= @@ -411,309 +358,332 @@ public class AuctionController { public String placeBid(String listingId, EntityPlayer player, long bidAmount) { if (!ConfigMarket.AuctionEnabled) { - return "Auction House is disabled."; + return "Auction is disabled."; } UUID playerUUID = player.getUniqueID(); String playerName = player.getCommandSenderName(); - dataLock.writeLock().lock(); - try { - AuctionListing listing = listings.get(listingId); - if (listing == null) { - return "Listing not found."; - } - - // Double-check status and expiry inside lock to prevent race conditions - if (!listing.status.canBid()) { - return "This auction has ended."; - } - - if (listing.isExpired()) { - return "This auction has ended."; - } - - // CRITICAL: Prevent bidding on own items - if (listing.isSeller(playerUUID)) { - return "You cannot bid on your own auction."; - } - - // Check minimum bid - long minBid = listing.getMinimumBid(ConfigMarket.MinBidIncrementPercent); - if (bidAmount < minBid) { - return "Bid must be at least " + minBid + " " + ConfigMarket.CurrencyName + "."; - } - - // Check currency - PlayerData playerData = PlayerData.get(player); - if (playerData == null) { - return "Could not access player data."; - } - - PlayerCurrencyData currency = playerData.currencyData; - - // Store previous bidder info - UUID previousBidder = listing.highBidderUUID; - long previousBid = listing.currentBid; - boolean isSameBidder = previousBidder != null && previousBidder.equals(playerUUID); - - // Calculate amount to charge - long amountToCharge = isSameBidder ? (bidAmount - previousBid) : bidAmount; - - if (!currency.canAfford(amountToCharge)) { - return "You cannot afford this bid."; - } - - // Deduct bid from new bidder FIRST - if (!currency.withdraw(amountToCharge)) { - return "Failed to deduct bid amount."; - } - - // Refund previous bidder if different player (include item for rebid option) - if (listing.hasBids() && previousBidder != null && !isSameBidder) { - AuctionClaim refundClaim = AuctionClaim.createRefundClaim( - previousBidder, listing.highBidderName, listing.id, previousBid, - listing.item.getDisplayName(), playerName, listing.item); - addClaim(refundClaim); - - // Remove from previous bidder's active bids - removeFromPlayerBids(previousBidder, listing.id); - - queueNotification(previousBidder, EnumNotificationType.AUCTION_OUTBID, listing.id, - "You were outbid on " + listing.item.getDisplayName() + "!"); - } - - // Update listing - listing.highBidderUUID = playerUUID; - listing.highBidderName = playerName; - listing.currentBid = bidAmount; - listing.bidCount++; - - // Add to new bidder's active bids - addToPlayerBids(playerUUID, listing.id); - - // Snipe protection - listing.extendForSnipeProtection(ConfigMarket.SnipeProtectionMinutes); - - markDirty(); - - logAuction("BID", playerName, listing.item.getDisplayName(), bidAmount, - "Bids: " + listing.bidCount + ", Seller: " + listing.sellerName); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + AuctionListing listing = listings.get(listingId); + if (listing == null) { + return "Listing not found."; } + + // Check status and expiry + if (!listing.status.canBid()) { + return "This auction has ended."; + } + + if (listing.isExpired()) { + return "This auction has ended."; + } + + // CRITICAL: Prevent bidding on own items + if (listing.isSeller(playerUUID)) { + return "You cannot bid on your own auction."; + } + + // Prevent rebidding if already highest bidder + if (listing.isHighBidder(playerUUID)) { + return "You are already the highest bidder. Use buyout if you want to purchase immediately."; + } + + // Check minimum bid + long minBid = listing.getMinimumBid(ConfigMarket.MinBidIncrementPercent); + if (bidAmount < minBid) { + return "Bid must be at least " + minBid + " " + ConfigMarket.CurrencyName + "."; + } + + // Check currency + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { + return "Could not access player data."; + } + + PlayerTradeData currency = playerData.tradeData; + + // Store previous bidder info + UUID previousBidder = listing.highBidderUUID; + long previousBid = listing.currentBid; + + // Full bid amount charged since we don't allow rebidding + long amountToCharge = bidAmount; + + if (!currency.canAfford(amountToCharge)) { + return "You cannot afford this bid."; + } + + // Deduct bid from new bidder FIRST + if (!currency.withdraw(amountToCharge)) { + return "Failed to deduct bid amount."; + } + + // Refund previous bidder (include item for rebid option) + if (listing.hasBids() && previousBidder != null) { + AuctionClaim refundClaim = AuctionClaim.createRefundClaim( + previousBidder, listing.highBidderName, listing.id, previousBid, + listing.item.getDisplayName(), playerName, listing.item); + addClaimToPlayer(previousBidder, refundClaim); + + // Remove from previous bidder's active bids + removeFromPlayerBids(previousBidder, listing.id); + + sendNotificationToPlayer(previousBidder, EnumNotificationType.AUCTION_OUTBID, listing.id, + "You were outbid on " + listing.item.getDisplayName() + "!"); + } + + // Update listing + listing.highBidderUUID = playerUUID; + listing.highBidderName = playerName; + listing.currentBid = bidAmount; + listing.bidCount++; + + // Add to new bidder's active bids + addToPlayerBids(playerUUID, listing.id); + + // Snipe protection + listing.extendForSnipeProtection(ConfigMarket.SnipeProtectionMinutes); + + markDirty(); + + logAuction("BID", playerName, listing.item.getDisplayName(), bidAmount, + "Bids: " + listing.bidCount + ", Seller: " + listing.sellerName); + + return null; // Success } public String buyout(String listingId, EntityPlayer player) { if (!ConfigMarket.AuctionEnabled) { - return "Auction House is disabled."; + return "Auction is disabled."; } UUID playerUUID = player.getUniqueID(); String playerName = player.getCommandSenderName(); - dataLock.writeLock().lock(); - try { - AuctionListing listing = listings.get(listingId); - if (listing == null) { - return "Listing not found."; - } - - // Double-check status and expiry inside lock - if (!listing.status.canBid()) { - return "This auction has ended."; - } - - if (listing.isExpired()) { - return "This auction has ended."; - } - - if (!listing.hasBuyout()) { - return "This auction does not have a buyout price."; - } - - // CRITICAL: Prevent buying own items - if (listing.isSeller(playerUUID)) { - return "You cannot buy your own auction."; - } - - // Check currency - PlayerData playerData = PlayerData.get(player); - if (playerData == null) { - return "Could not access player data."; - } - - PlayerCurrencyData currency = playerData.currencyData; - if (!currency.canAfford(listing.buyoutPrice)) { - return "You cannot afford the buyout price."; - } - - // Deduct buyout FIRST - if (!currency.withdraw(listing.buyoutPrice)) { - return "Failed to deduct buyout amount."; - } - - // Refund previous bidder if any (no item - auction ended via buyout) - if (listing.hasBids() && listing.highBidderUUID != null) { - AuctionClaim refundClaim = AuctionClaim.createRefundClaim( - listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid, - listing.item.getDisplayName(), playerName, null); - addClaim(refundClaim); - - // Remove from previous bidder's active bids - removeFromPlayerBids(listing.highBidderUUID, listing.id); - - queueNotification(listing.highBidderUUID, EnumNotificationType.AUCTION_OUTBID, listing.id, - "The auction for " + listing.item.getDisplayName() + " was bought out. Your bid has been refunded."); - } - - // Create claims - AuctionClaim itemClaim = AuctionClaim.createItemWonClaim(playerUUID, playerName, listing.id, listing.item); - addClaim(itemClaim); - - long saleAmount = listing.buyoutPrice; - long tax = (long) (saleAmount * ConfigMarket.SalesTaxPercent); - long sellerReceives = saleAmount - tax; - - AuctionClaim currencyClaim = AuctionClaim.createCurrencyClaim( - listing.sellerUUID, listing.sellerName, listing.id, sellerReceives, - listing.item.getDisplayName(), playerName); - addClaim(currencyClaim); - - // Notifications - queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id, - "Your " + listing.item.getDisplayName() + " was bought out for " + saleAmount + " " + ConfigMarket.CurrencyName + "!"); - - // Remove from seller's active listings - removeFromPlayerListings(listing.sellerUUID, listing.id); - - listing.status = EnumAuctionStatus.ENDED; - listing.highBidderUUID = playerUUID; - listing.highBidderName = playerName; - listing.currentBid = listing.buyoutPrice; - - markDirty(); - - logAuction("BUYOUT", playerName, listing.item.getDisplayName(), listing.buyoutPrice, - "Seller: " + listing.sellerName + ", Tax: " + tax); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + AuctionListing listing = listings.get(listingId); + if (listing == null) { + return "Listing not found."; } + + // Check status and expiry + if (!listing.status.canBid()) { + return "This auction has ended."; + } + + if (listing.isExpired()) { + return "This auction has ended."; + } + + if (!listing.hasBuyout()) { + return "This auction does not have a buyout price."; + } + + // CRITICAL: Prevent buying own items + if (listing.isSeller(playerUUID)) { + return "You cannot buy your own auction."; + } + + // Check currency + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { + return "Could not access player data."; + } + + PlayerTradeData currency = playerData.tradeData; + + // If buyer is already the high bidder, only charge the difference + boolean isCurrentBidder = listing.isHighBidder(playerUUID); + long amountToCharge = isCurrentBidder + ? (listing.buyoutPrice - listing.currentBid) + : listing.buyoutPrice; + + if (!currency.canAfford(amountToCharge)) { + if (isCurrentBidder) { + return "You cannot afford the remaining " + amountToCharge + " " + ConfigMarket.CurrencyName + " for buyout."; + } + return "You cannot afford the buyout price."; + } + + // Deduct buyout amount + if (!currency.withdraw(amountToCharge)) { + return "Failed to deduct buyout amount."; + } + + // Refund previous bidder if any AND they're not the buyer (no item - auction ended via buyout) + if (listing.hasBids() && listing.highBidderUUID != null && !isCurrentBidder) { + AuctionClaim refundClaim = AuctionClaim.createRefundClaim( + listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid, + listing.item.getDisplayName(), playerName, null); + addClaimToPlayer(listing.highBidderUUID, refundClaim); + + // Remove from previous bidder's active bids + removeFromPlayerBids(listing.highBidderUUID, listing.id); + + sendNotificationToPlayer(listing.highBidderUUID, EnumNotificationType.AUCTION_OUTBID, listing.id, + "The auction for " + listing.item.getDisplayName() + " was bought out. Your bid has been refunded."); + } else if (isCurrentBidder) { + // Current bidder is buying out - just remove from their active bids + removeFromPlayerBids(playerUUID, listing.id); + } + + // Create claims + AuctionClaim itemClaim = AuctionClaim.createItemWonClaim(playerUUID, playerName, listing.id, listing.item); + addClaimToPlayer(playerUUID, itemClaim); + + long saleAmount = listing.buyoutPrice; + long tax = (long) (saleAmount * ConfigMarket.SalesTaxPercent); + long sellerReceives = saleAmount - tax; + + AuctionClaim currencyClaim = AuctionClaim.createCurrencyClaim( + listing.sellerUUID, listing.sellerName, listing.id, sellerReceives, + listing.item.getDisplayName(), playerName); + addClaimToPlayer(listing.sellerUUID, currencyClaim); + + // Notifications + sendNotificationToPlayer(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id, + "Your " + listing.item.getDisplayName() + " was bought out for " + saleAmount + " " + ConfigMarket.CurrencyName + "!"); + + // Remove from seller's active listings + removeFromPlayerListings(listing.sellerUUID, listing.id); + + listing.status = EnumAuctionStatus.ENDED; + listing.highBidderUUID = playerUUID; + listing.highBidderName = playerName; + listing.currentBid = listing.buyoutPrice; + + markDirty(); + + logAuction("BUYOUT", playerName, listing.item.getDisplayName(), listing.buyoutPrice, + "Seller: " + listing.sellerName + ", Tax: " + tax); + + return null; // Success } // ========================================= - // Claims + // Claims - Now stored in PlayerData // ========================================= /** - * Get player claims using indexed lookup - O(n) where n = player's claims, not all claims + * Get player claims from PlayerData. */ - public List getPlayerClaims(UUID playerUUID) { - Set claimIds = playerClaimIds.get(playerUUID); - if (claimIds == null || claimIds.isEmpty()) { + public List getPlayerClaims(EntityPlayer player) { + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { return new ArrayList<>(); } + return playerData.tradeData.getClaimsList(); + } - List playerClaims = new ArrayList<>(); - for (String claimId : claimIds) { - AuctionClaim claim = claims.get(claimId); - if (claim != null && !claim.claimed) { - playerClaims.add(claim); - } + /** + * Get player claims by UUID (for login notification). + */ + public List getPlayerClaims(UUID playerUUID) { + EntityPlayerMP onlinePlayer = (EntityPlayerMP) PlayerDataController.getPlayerFromUUID(playerUUID); + if (onlinePlayer != null) { + return getPlayerClaims(onlinePlayer); } - return playerClaims; + return new ArrayList<>(); } public String claimItem(String claimId, EntityPlayer player) { - dataLock.writeLock().lock(); - try { - AuctionClaim claim = claims.get(claimId); - if (claim == null) { - return "Claim not found."; - } - - if (claim.claimed) { - return "Already claimed."; - } - - if (!claim.isForPlayer(player.getUniqueID())) { - return "This claim is not for you."; - } - - if (!claim.type.isItem()) { - return "This is not an item claim."; - } - - if (claim.item == null) { - return "Item data is missing."; - } - - // Check inventory space - if (!player.inventory.addItemStackToInventory(claim.item.copy())) { - return "Your inventory is full."; - } - - claim.claimed = true; - removeFromPlayerClaims(claim.playerUUID, claim.id); - markDirty(); - - logAuction("CLAIMED", player.getCommandSenderName(), claim.item.getDisplayName(), 0, "Item claimed"); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { + return "Could not access player data."; } + + AuctionClaim claim = playerData.tradeData.getClaimInternal(claimId); + if (claim == null) { + return "Claim not found."; + } + + if (claim.claimed) { + return "Already claimed."; + } + + if (!claim.type.isItem()) { + return "This is not an item claim."; + } + + if (claim.item == null) { + return "Item data is missing."; + } + + // Check inventory space + if (!player.inventory.addItemStackToInventory(claim.item.copy())) { + return "Your inventory is full."; + } + + // Remove claim from player's auction data + playerData.tradeData.claimAndRemove(claimId); + playerData.updateClient = true; + playerData.save(); + + // Cleanup listing if no more claims + cleanupListingIfComplete(claim.listingId); + + logAuction("CLAIMED", player.getCommandSenderName(), claim.item.getDisplayName(), 0, "Item claimed"); + + return null; // Success } public String claimCurrency(String claimId, EntityPlayer player) { - dataLock.writeLock().lock(); - try { - AuctionClaim claim = claims.get(claimId); - if (claim == null) { - return "Claim not found."; - } - - if (claim.claimed) { - return "Already claimed."; - } - - if (!claim.isForPlayer(player.getUniqueID())) { - return "This claim is not for you."; - } - - if (!claim.type.isCurrency()) { - return "This is not a currency claim."; - } - - PlayerData playerData = PlayerData.get(player); - if (playerData == null) { - return "Could not access player data."; - } - - if (!playerData.currencyData.deposit(claim.currency)) { - return "Failed to deposit currency."; - } - - claim.claimed = true; - removeFromPlayerClaims(claim.playerUUID, claim.id); - markDirty(); - - logAuction("CLAIMED", player.getCommandSenderName(), ConfigMarket.CurrencyName, claim.currency, - claim.type == EnumClaimType.REFUND ? "Refund claimed" : "Sale proceeds claimed"); - - return null; // Success - } finally { - dataLock.writeLock().unlock(); + PlayerData playerData = PlayerData.get(player); + if (playerData == null) { + return "Could not access player data."; } + + AuctionClaim claim = playerData.tradeData.getClaimInternal(claimId); + if (claim == null) { + return "Claim not found."; + } + + if (claim.claimed) { + return "Already claimed."; + } + + if (!claim.type.isCurrency()) { + return "This is not a currency claim."; + } + + if (!playerData.tradeData.deposit(claim.currency)) { + return "Failed to deposit currency."; + } + + // Remove claim from player's auction data + playerData.tradeData.claimAndRemove(claimId); + playerData.updateClient = true; + playerData.save(); + + // Cleanup listing if no more claims + cleanupListingIfComplete(claim.listingId); + + logAuction("CLAIMED", player.getCommandSenderName(), ConfigMarket.CurrencyName, claim.currency, + claim.type == EnumClaimType.REFUND ? "Refund claimed" : "Sale proceeds claimed"); + + return null; // Success + } + + /** + * Clean up a listing if it's ended and no longer needed. + * Now just removes ended listings after some delay since claims are in PlayerData. + */ + private void cleanupListingIfComplete(String listingId) { + if (listingId == null || listingId.isEmpty()) return; + + AuctionListing listing = listings.get(listingId); + if (listing == null) return; + + // Only cleanup ended/cancelled listings + if (listing.status == EnumAuctionStatus.ACTIVE) return; + + // Remove ended listings (claims are now in PlayerData, not here) + listings.remove(listingId); + markDirty(); } public int claimAll(EntityPlayer player) { int claimedCount = 0; - List playerClaims = getPlayerClaims(player.getUniqueID()); + List playerClaims = getPlayerClaims(player); for (AuctionClaim claim : playerClaims) { String result; @@ -737,21 +707,17 @@ public class AuctionController { public List getActiveListings(AuctionFilter filter, int page, int pageSize) { List result = new ArrayList<>(); - dataLock.readLock().lock(); - try { - for (AuctionListing listing : listings.values()) { - if (listing.status != EnumAuctionStatus.ACTIVE) continue; + for (AuctionListing listing : listings.values()) { + if (listing.status != EnumAuctionStatus.ACTIVE) continue; - // Apply search filter - if (filter.hasSearchText()) { - String itemName = listing.item != null ? listing.item.getDisplayName() : ""; - if (!filter.matchesSearch(itemName)) continue; - } - - result.add(listing); + // Apply search filter (searches item name AND seller name) + if (filter.hasSearchText()) { + String itemName = listing.item != null ? listing.item.getDisplayName() : ""; + String sellerName = listing.sellerName != null ? listing.sellerName : ""; + if (!filter.matchesSearch(itemName, sellerName)) continue; } - } finally { - dataLock.readLock().unlock(); + + result.add(listing); } // Sort @@ -791,22 +757,19 @@ public class AuctionController { public int getTotalActiveListings(AuctionFilter filter) { int count = 0; - dataLock.readLock().lock(); - try { - for (AuctionListing listing : listings.values()) { - if (listing.status != EnumAuctionStatus.ACTIVE) continue; - if (filter.hasSearchText()) { - String itemName = listing.item != null ? listing.item.getDisplayName() : ""; - if (!filter.matchesSearch(itemName)) continue; - } - count++; + for (AuctionListing listing : listings.values()) { + if (listing.status != EnumAuctionStatus.ACTIVE) continue; + if (filter.hasSearchText()) { + String itemName = listing.item != null ? listing.item.getDisplayName() : ""; + String sellerName = listing.sellerName != null ? listing.sellerName : ""; + if (!filter.matchesSearch(itemName, sellerName)) continue; } - } finally { - dataLock.readLock().unlock(); + count++; } return count; } + @Override public AuctionListing getListing(String listingId) { return listings.get(listingId); } @@ -816,25 +779,18 @@ public class AuctionController { */ public long getPlayerBalance(EntityPlayer player) { PlayerData playerData = PlayerData.get(player); - if (playerData == null || playerData.currencyData == null) { + if (playerData == null || playerData.tradeData == null) { return 0; } - return playerData.currencyData.getBalance(); + return playerData.tradeData.getBalance(); } /** - * Fast O(1) lookup for player listing count using index - */ - public int getPlayerListingCountFast(UUID playerUUID) { - Set ids = playerListingIds.get(playerUUID); - return ids != null ? ids.size() : 0; - } - - /** - * Legacy method - still iterates for accuracy (in case index is stale) + * Get player listing count using index */ public int getPlayerListingCount(UUID playerUUID) { - return getPlayerListingCountFast(playerUUID); + Set ids = playerListingIds.get(playerUUID); + return ids != null ? ids.size() : 0; } /** @@ -879,7 +835,28 @@ public class AuctionController { /** * Get the total number of trade slots used by a player. - * Uses indexed lookups for O(1) performance. + * Includes active listings, active bids, and pending claims. + */ + public int getPlayerTradeSlotCount(EntityPlayer player) { + UUID playerUUID = player.getUniqueID(); + int count = 0; + // Active listings + Set listingIds = playerListingIds.get(playerUUID); + if (listingIds != null) count += listingIds.size(); + // Active bids + Set bidIds = playerBidIds.get(playerUUID); + if (bidIds != null) count += bidIds.size(); + // Pending claims (from PlayerData) + PlayerData playerData = PlayerData.get(player); + if (playerData != null) { + count += playerData.tradeData.getClaimCount(); + } + return count; + } + + /** + * Get the total number of trade slots used by a player (UUID version). + * For offline players, only returns listings + bids (claims are in their PlayerData). */ public int getPlayerTradeSlotCount(UUID playerUUID) { int count = 0; @@ -889,71 +866,168 @@ public class AuctionController { // Active bids Set bidIds = playerBidIds.get(playerUUID); if (bidIds != null) count += bidIds.size(); - // Pending claims - Set claimIds = playerClaimIds.get(playerUUID); - if (claimIds != null) count += claimIds.size(); + // Can't get claims for offline player without loading their data return count; } - private int getMaxListingsForPlayer(EntityPlayer player) { - // TODO: Check permissions for customnpcs.auction.slots.X - return ConfigMarket.DefaultMaxListings; + /** + * Get the maximum trade slots for a player. + * Uses cached value if available, otherwise computes from permissions. + */ + public int getMaxTradesForPlayer(EntityPlayer player) { + UUID playerUUID = player.getUniqueID(); + + // Check cache first + Integer cached = playerMaxTradesCache.get(playerUUID); + if (cached != null) { + return cached; + } + + // Compute and cache + int maxTrades = computeMaxTradesForPlayer(player); + playerMaxTradesCache.put(playerUUID, maxTrades); + return maxTrades; + } + + /** + * Compute max trade slots from permissions. + * Checks for customnpcs.auction.trades.X permissions where X is the slot count. + * Uses the highest permission found, or DefaultMaxTrades if no permissions or lower. + * Capped at MAX_TRADE_SLOTS (45). + */ + private int computeMaxTradesForPlayer(EntityPlayer player) { + // Check for unlimited permission + if (CustomNpcsPermissions.hasCustomPermission(player, "customnpcs.auction.trades.*")) { + return ConfigMarket.MAX_TRADE_SLOTS; + } + + // Find highest allowed slot count from permissions + int highestAllowed = 0; + for (int i = 1; i <= ConfigMarket.MAX_TRADE_SLOTS; i++) { + String perm = "customnpcs.auction.trades." + i; + if (CustomNpcsPermissions.hasCustomPermission(player, perm)) { + highestAllowed = i; + } + } + + // Use default if no permissions found or permission is lower than default + if (highestAllowed == 0 || highestAllowed < ConfigMarket.DefaultMaxTrades) { + highestAllowed = ConfigMarket.DefaultMaxTrades; + } + + // Cap at maximum + return Math.min(highestAllowed, ConfigMarket.MAX_TRADE_SLOTS); + } + + /** + * Refresh a player's cached max trades (call when permissions change). + */ + public void refreshPlayerMaxTrades(EntityPlayer player) { + UUID playerUUID = player.getUniqueID(); + int maxTrades = computeMaxTradesForPlayer(player); + playerMaxTradesCache.put(playerUUID, maxTrades); + } + + /** + * Clear a player's cached max trades (call on logout). + */ + public void clearPlayerMaxTradesCache(UUID playerUUID) { + playerMaxTradesCache.remove(playerUUID); } // ========================================= // Notifications // ========================================= - private void queueNotification(UUID playerUUID, EnumNotificationType type, String listingId, String message) { - AuctionNotification notification = new AuctionNotification(playerUUID, type, listingId, message); - pendingNotifications.add(notification); - + /** + * Send a notification to a player. + * If player is online, sends immediately. Otherwise, discards (claims will notify on login). + */ + private void sendNotificationToPlayer(UUID playerUUID, EnumNotificationType type, String listingId, String message) { // Try to send immediately if player is online - EntityPlayerMP player = getOnlinePlayer(playerUUID); + EntityPlayerMP player = (EntityPlayerMP) PlayerDataController.getPlayerFromUUID(playerUUID); if (player != null) { - sendNotification(player, notification); - notification.sent = true; + sendNotificationMessage(player, type, message); } + // If offline, no need to store - they'll get notified about claims on login + } + + /** + * Send a notification chat message to a player. + */ + private void sendNotificationMessage(EntityPlayerMP player, EnumNotificationType type, String message) { + EnumChatFormatting color; + switch (type) { + case AUCTION_WON: + case AUCTION_SOLD: + color = EnumChatFormatting.GREEN; + break; + case AUCTION_OUTBID: + color = EnumChatFormatting.YELLOW; + break; + case AUCTION_EXPIRED: + color = EnumChatFormatting.RED; + break; + default: + color = EnumChatFormatting.GRAY; + } + player.addChatMessage(new ChatComponentText( + EnumChatFormatting.GOLD + "[Auction] " + color + message)); } public void onPlayerLogin(EntityPlayer player) { - UUID playerUUID = player.getUniqueID(); - boolean hasClaims = !getPlayerClaims(playerUUID).isEmpty(); + // Cache player's max trades permission + refreshPlayerMaxTrades(player); - synchronized (pendingNotifications) { - Iterator iterator = pendingNotifications.iterator(); - while (iterator.hasNext()) { - AuctionNotification notification = iterator.next(); - if (notification.isForPlayer(playerUUID) && !notification.sent) { - sendNotification((EntityPlayerMP) player, notification); - notification.sent = true; - iterator.remove(); + // Process expired claims for this player + PlayerData playerData = PlayerData.get(player); + if (playerData != null) { + int expired = playerData.tradeData.processExpiredClaims(); + if (expired > 0) { + playerData.updateClient = true; + playerData.save(); + logAuction("CLAIM_EXPIRED", player.getCommandSenderName(), "Multiple", expired, + expired + " claims expired on login"); + } + } + + // Check for pending claims and notify + List claims = getPlayerClaims(player); + if (!claims.isEmpty()) { + int itemClaims = 0; + int currencyClaims = 0; + long totalCurrency = 0; + + for (AuctionClaim claim : claims) { + if (claim.type.isItem()) { + itemClaims++; + } else { + currencyClaims++; + totalCurrency += claim.currency; } } - } - if (hasClaims) { - player.addChatMessage(new ChatComponentText(EnumChatFormatting.GOLD + - "[Auction House] " + EnumChatFormatting.YELLOW + "You have items or currency to claim!")); - } - } + StringBuilder msg = new StringBuilder(); + msg.append(EnumChatFormatting.GOLD).append("[Auction] "); + msg.append(EnumChatFormatting.YELLOW).append("You have "); - private void sendNotification(EntityPlayerMP player, AuctionNotification notification) { - player.addChatMessage(new ChatComponentText(EnumChatFormatting.GOLD + - "[Auction House] " + EnumChatFormatting.WHITE + notification.message)); - } - - private EntityPlayerMP getOnlinePlayer(UUID uuid) { - MinecraftServer server = MinecraftServer.getServer(); - if (server == null) return null; - - for (Object obj : server.getConfigurationManager().playerEntityList) { - EntityPlayerMP player = (EntityPlayerMP) obj; - if (player.getUniqueID().equals(uuid)) { - return player; + if (itemClaims > 0 && currencyClaims > 0) { + msg.append(itemClaims).append(" item(s) and "); + msg.append(String.format("%,d", totalCurrency)).append(" ").append(ConfigMarket.CurrencyName); + } else if (itemClaims > 0) { + msg.append(itemClaims).append(" item(s)"); + } else { + msg.append(String.format("%,d", totalCurrency)).append(" ").append(ConfigMarket.CurrencyName); } + msg.append(" to claim!"); + + player.addChatMessage(new ChatComponentText(msg.toString())); } - return null; + } + + public void onPlayerLogout(UUID playerUUID) { + // Clear cached permission + clearPlayerMaxTradesCache(playerUUID); } // ========================================= @@ -1026,13 +1100,7 @@ public class AuctionController { } // Take a snapshot of the data for async save - final NBTTagCompound compound; - dataLock.readLock().lock(); - try { - compound = writeToNBT(new NBTTagCompound()); - } finally { - dataLock.readLock().unlock(); - } + final NBTTagCompound compound = writeToNBT(new NBTTagCompound()); CustomNPCsThreader.customNPCThread.execute(() -> { try { @@ -1049,14 +1117,11 @@ public class AuctionController { /** Internal synchronous save */ private void saveInternal() { - dataLock.readLock().lock(); try { NBTTagCompound compound = writeToNBT(new NBTTagCompound()); saveToFile(compound); } catch (Exception e) { LogWriter.error("Error saving auction data", e); - } finally { - dataLock.readLock().unlock(); } } @@ -1085,11 +1150,8 @@ public class AuctionController { public void load() { listings.clear(); - claims.clear(); - pendingNotifications.clear(); playerListingIds.clear(); playerBidIds.clear(); - playerClaimIds.clear(); File saveDir = CustomNpcs.getWorldSaveDirectory(); if (saveDir == null) return; @@ -1124,7 +1186,6 @@ public class AuctionController { private void rebuildIndices() { playerListingIds.clear(); playerBidIds.clear(); - playerClaimIds.clear(); // Index listings for (AuctionListing listing : listings.values()) { @@ -1136,15 +1197,9 @@ public class AuctionController { } } - // Index claims - for (AuctionClaim claim : claims.values()) { - if (!claim.claimed) { - addToPlayerClaims(claim.playerUUID, claim.id); - } - } - + // Claims are now stored in PlayerData, not indexed here LogWriter.info("Rebuilt auction indices: " + playerListingIds.size() + " sellers, " + - playerBidIds.size() + " bidders, " + playerClaimIds.size() + " claimants"); + playerBidIds.size() + " bidders"); } private void loadFromFile(File file) throws Exception { @@ -1153,55 +1208,178 @@ public class AuctionController { } public NBTTagCompound writeToNBT(NBTTagCompound compound) { - // Listings + // Listings only - claims are stored in PlayerData NBTTagList listingsList = new NBTTagList(); for (AuctionListing listing : listings.values()) { listingsList.appendTag(listing.writeToNBT(new NBTTagCompound())); } compound.setTag("Listings", listingsList); - // Claims - NBTTagList claimsList = new NBTTagList(); - for (AuctionClaim claim : claims.values()) { - claimsList.appendTag(claim.writeToNBT(new NBTTagCompound())); - } - compound.setTag("Claims", claimsList); - - // Pending Notifications - NBTTagList notificationsList = new NBTTagList(); - synchronized (pendingNotifications) { - for (AuctionNotification notification : pendingNotifications) { - if (!notification.sent) { - notificationsList.appendTag(notification.writeToNBT(new NBTTagCompound())); - } - } - } - compound.setTag("PendingNotifications", notificationsList); - return compound; } public void readFromNBT(NBTTagCompound compound) { - // Listings NBTTagList listingsList = compound.getTagList("Listings", 10); for (int i = 0; i < listingsList.tagCount(); i++) { AuctionListing listing = AuctionListing.fromNBT(listingsList.getCompoundTagAt(i)); listings.put(listing.id, listing); } + } - // Claims - NBTTagList claimsList = compound.getTagList("Claims", 10); - for (int i = 0; i < claimsList.tagCount(); i++) { - AuctionClaim claim = AuctionClaim.fromNBT(claimsList.getCompoundTagAt(i)); - claims.put(claim.id, claim); - } + // ========================================= + // IAuctionHandler Implementation + // ========================================= - // Pending Notifications - NBTTagList notificationsList = compound.getTagList("PendingNotifications", 10); - for (int i = 0; i < notificationsList.tagCount(); i++) { - AuctionNotification notification = AuctionNotification.fromNBT(notificationsList.getCompoundTagAt(i)); - pendingNotifications.add(notification); + @Override + public boolean isEnabled() { + return ConfigMarket.AuctionEnabled; + } + + @Override + public IAuctionListing[] getActiveListings() { + List active = new ArrayList<>(); + for (AuctionListing listing : listings.values()) { + if (listing.status == EnumAuctionStatus.ACTIVE && !listing.isExpired()) { + active.add(listing); + } } + return active.toArray(new IAuctionListing[0]); + } + + @Override + public IAuctionListing[] getListingsBySeller(String sellerUUID) { + if (sellerUUID == null) return new IAuctionListing[0]; + try { + UUID uuid = UUID.fromString(sellerUUID); + List result = getPlayerActiveListings(uuid); + return result.toArray(new IAuctionListing[0]); + } catch (IllegalArgumentException e) { + return new IAuctionListing[0]; + } + } + + @Override + public IAuctionListing[] getListingsByBidder(String bidderUUID) { + if (bidderUUID == null) return new IAuctionListing[0]; + try { + UUID uuid = UUID.fromString(bidderUUID); + List result = getPlayerActiveBids(uuid); + return result.toArray(new IAuctionListing[0]); + } catch (IllegalArgumentException e) { + return new IAuctionListing[0]; + } + } + + @Override + public int getActiveListingCount() { + int count = 0; + for (AuctionListing listing : listings.values()) { + if (listing.status == EnumAuctionStatus.ACTIVE && !listing.isExpired()) { + count++; + } + } + return count; + } + + @Override + public IAuctionListing createListing(IPlayer player, IItemStack item, long startingPrice, long buyoutPrice) { + if (player == null || item == null) return null; + EntityPlayer entityPlayer = (EntityPlayer) player.getMCEntity(); + ItemStack mcItem = item.getMCItemStack(); + String result = createListing(entityPlayer, mcItem, startingPrice, buyoutPrice); + if (result != null) { + return null; // Failed + } + // Find the just-created listing + Set playerListings = playerListingIds.get(entityPlayer.getUniqueID()); + if (playerListings != null && !playerListings.isEmpty()) { + // Get the most recent one + long latestTime = 0; + AuctionListing latestListing = null; + for (String id : playerListings) { + AuctionListing listing = listings.get(id); + if (listing != null && listing.createdTime > latestTime) { + latestTime = listing.createdTime; + latestListing = listing; + } + } + return latestListing; + } + return null; + } + + @Override + public String placeBid(String listingId, IPlayer player, long amount) { + if (player == null) return "Player is null"; + EntityPlayer entityPlayer = (EntityPlayer) player.getMCEntity(); + return placeBid(listingId, entityPlayer, amount); + } + + @Override + public String buyout(String listingId, IPlayer player) { + if (player == null) return "Player is null"; + EntityPlayer entityPlayer = (EntityPlayer) player.getMCEntity(); + return buyout(listingId, entityPlayer); + } + + @Override + public String cancelListing(String listingId, IPlayer player, boolean isAdmin) { + if (player == null) return "Player is null"; + EntityPlayer entityPlayer = (EntityPlayer) player.getMCEntity(); + return cancelListing(listingId, entityPlayer, isAdmin); + } + + @Override + public long getListingFee() { + return ConfigMarket.ListingFee; + } + + @Override + public double getSalesTaxPercent() { + return ConfigMarket.SalesTaxPercent; + } + + @Override + public double getMinBidIncrementPercent() { + return ConfigMarket.MinBidIncrementPercent; + } + + @Override + public int getAuctionDurationHours() { + return ConfigMarket.AuctionDurationHours; + } + + @Override + public int getSnipeProtectionMinutes() { + return ConfigMarket.SnipeProtectionMinutes; + } + + @Override + public String getCurrencyName() { + return ConfigMarket.CurrencyName; + } + + @Override + public long getMinimumListingPrice() { + return ConfigMarket.MinimumListingPrice; + } + + @Override + public IAuctionListing[] searchListings(String searchText) { + if (searchText == null || searchText.trim().isEmpty()) { + return getActiveListings(); + } + String search = searchText.toLowerCase().trim(); + List result = new ArrayList<>(); + for (AuctionListing listing : listings.values()) { + if (listing.status != EnumAuctionStatus.ACTIVE || listing.isExpired()) continue; + String itemName = listing.item != null ? listing.item.getDisplayName().toLowerCase() : ""; + String sellerName = listing.sellerName != null ? listing.sellerName.toLowerCase() : ""; + if (itemName.contains(search) || sellerName.contains(search)) { + result.add(listing); + } + } + return result.toArray(new IAuctionListing[0]); } // ========================================= @@ -1216,33 +1394,16 @@ public class AuctionController { cancelListing(listingId, admin, true); } - public void clearClaimedData() { - dataLock.writeLock().lock(); - try { - // Remove claimed claims - claims.entrySet().removeIf(entry -> entry.getValue().claimed); + public void clearEndedListings() { + // Remove ended/cancelled listings (claims are in PlayerData now) + listings.entrySet().removeIf(entry -> + entry.getValue().status == EnumAuctionStatus.CLAIMED || + entry.getValue().status == EnumAuctionStatus.ENDED || + entry.getValue().status == EnumAuctionStatus.CANCELLED); - // Remove ended listings that have no unclaimed claims - listings.entrySet().removeIf(entry -> - entry.getValue().status == EnumAuctionStatus.CLAIMED || - (entry.getValue().status == EnumAuctionStatus.ENDED && - !hasUnclaimedClaims(entry.getValue().id))); - - // Rebuild indices - rebuildIndices(); - markDirty(); - } finally { - dataLock.writeLock().unlock(); - } - } - - private boolean hasUnclaimedClaims(String listingId) { - for (AuctionClaim claim : claims.values()) { - if (listingId.equals(claim.listingId) && !claim.claimed) { - return true; - } - } - return false; + // Rebuild indices + rebuildIndices(); + markDirty(); } /** diff --git a/src/main/java/noppes/npcs/controllers/PlayerDataController.java b/src/main/java/noppes/npcs/controllers/PlayerDataController.java index c8964b31d..235e2a9f8 100644 --- a/src/main/java/noppes/npcs/controllers/PlayerDataController.java +++ b/src/main/java/noppes/npcs/controllers/PlayerDataController.java @@ -364,6 +364,17 @@ public class PlayerDataController { return data; } + public PlayerData getData(UUID uuid) { + EntityPlayer player = getPlayerFromUUID(uuid); + PlayerData data; + if (player == null) { + data = new PlayerData(); + data.setNBT(PlayerDataController.Instance.loadPlayerData(uuid.toString())); + } else + data = PlayerData.get(player); + return data; + } + public void addPlayerMessage(String username, PlayerMail mail) { mail.time = System.currentTimeMillis(); diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java b/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java index 4d489a166..ad4ae3238 100644 --- a/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java +++ b/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java @@ -2,11 +2,15 @@ package noppes.npcs.controllers.data; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.api.handler.data.IAuctionClaim; +import noppes.npcs.api.item.IItemStack; +import noppes.npcs.config.ConfigMarket; import noppes.npcs.constants.EnumClaimType; +import noppes.npcs.scripted.NpcAPI; import java.util.UUID; -public class AuctionClaim { +public class AuctionClaim implements IAuctionClaim { public String id; public UUID playerUUID; public String playerName; @@ -100,6 +104,95 @@ public class AuctionClaim { return playerUUID != null && playerUUID.equals(uuid); } + // ========================================= + // IAuctionClaim Implementation + // ========================================= + + @Override + public String getId() { + return id; + } + + @Override + public String getPlayerUUID() { + return playerUUID != null ? playerUUID.toString() : null; + } + + @Override + public String getPlayerName() { + return playerName; + } + + @Override + public String getListingId() { + return listingId; + } + + @Override + public int getType() { + return type.ordinal(); + } + + @Override + public boolean isItemClaim() { + return type == EnumClaimType.ITEM; + } + + @Override + public boolean isCurrencyClaim() { + return type == EnumClaimType.CURRENCY; + } + + @Override + public boolean isRefundClaim() { + return type == EnumClaimType.REFUND; + } + + @Override + public IItemStack getItem() { + return item != null ? NpcAPI.Instance().getIItemStack(item) : null; + } + + @Override + public String getItemName() { + return itemName; + } + + @Override + public long getCurrency() { + return currency; + } + + @Override + public String getOtherPlayerName() { + return otherPlayerName; + } + + @Override + public long getCreatedTime() { + return createdTime; + } + + @Override + public boolean isClaimed() { + return claimed; + } + + @Override + public boolean isReturnedItem() { + return isReturned; + } + + @Override + public long getDaysUntilExpiration() { + return getDaysUntilExpiration(ConfigMarket.ClaimExpirationDays); + } + + @Override + public boolean isExpired() { + return isExpired(ConfigMarket.ClaimExpirationDays); + } + public NBTTagCompound writeToNBT(NBTTagCompound compound) { compound.setString("ID", id); compound.setString("PlayerUUID", playerUUID != null ? playerUUID.toString() : ""); diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java b/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java index 49ed0a29c..95bf2ed24 100644 --- a/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java +++ b/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java @@ -8,7 +8,7 @@ import java.util.regex.Pattern; /** * Filter for auction listings. - * Supports search by item name and sorting options. + * Supports search by item name or seller name and sorting options. */ public class AuctionFilter { private static final Pattern COLOR_CODE_PATTERN = Pattern.compile("§[0-9a-fk-or]", Pattern.CASE_INSENSITIVE); @@ -37,26 +37,42 @@ public class AuctionFilter { return stripped.toLowerCase().trim(); } - /** Check if item name matches search text */ - public boolean matchesSearch(String itemName) { + /** Check if item name or seller name matches search text */ + public boolean matchesSearch(String itemName, String sellerName) { if (!hasSearchText()) return true; - String normalized = normalizeForSearch(itemName); - return normalized.contains(normalizedSearch); + String normalizedItem = normalizeForSearch(itemName); + String normalizedSeller = normalizeForSearch(sellerName); + // Match if search text is found in either item name or seller name + return normalizedItem.contains(normalizedSearch) || normalizedSeller.contains(normalizedSearch); } - /** Advanced search - all words must be present */ - public boolean matchesSearchAdvanced(String itemName) { + /** Legacy method - only checks item name */ + public boolean matchesSearch(String itemName) { + return matchesSearch(itemName, ""); + } + + /** Advanced search - all words must be present in item name OR seller name */ + public boolean matchesSearchAdvanced(String itemName, String sellerName) { if (!hasSearchText()) return true; - String normalized = normalizeForSearch(itemName); + String normalizedItem = normalizeForSearch(itemName); + String normalizedSeller = normalizeForSearch(sellerName); String[] words = normalizedSearch.split("\\s+"); for (String word : words) { - if (!word.isEmpty() && !normalized.contains(word)) { - return false; + if (!word.isEmpty()) { + // Word must be in item name OR seller name + if (!normalizedItem.contains(word) && !normalizedSeller.contains(word)) { + return false; + } } } return true; } + /** Legacy method - only checks item name */ + public boolean matchesSearchAdvanced(String itemName) { + return matchesSearchAdvanced(itemName, ""); + } + public void reset() { searchText = ""; sortBy = EnumAuctionSort.NEWEST; diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionListing.java b/src/main/java/noppes/npcs/controllers/data/AuctionListing.java index 3106a9fb2..6d57942c1 100644 --- a/src/main/java/noppes/npcs/controllers/data/AuctionListing.java +++ b/src/main/java/noppes/npcs/controllers/data/AuctionListing.java @@ -2,11 +2,15 @@ package noppes.npcs.controllers.data; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.api.handler.data.IAuctionListing; +import noppes.npcs.api.item.IItemStack; +import noppes.npcs.config.ConfigMarket; import noppes.npcs.constants.EnumAuctionStatus; +import noppes.npcs.scripted.NpcAPI; import java.util.UUID; -public class AuctionListing { +public class AuctionListing implements IAuctionListing { public String id; public UUID sellerUUID; public String sellerName; @@ -40,22 +44,6 @@ public class AuctionListing { this.endTime = this.createdTime + durationMs; } - public boolean hasBids() { - return bidCount > 0 && highBidderUUID != null; - } - - public boolean hasBuyout() { - return buyoutPrice > 0; - } - - public boolean isExpired() { - return System.currentTimeMillis() >= endTime; - } - - public boolean isActive() { - return status == EnumAuctionStatus.ACTIVE && !isExpired(); - } - public long getTimeRemaining() { return Math.max(0, endTime - System.currentTimeMillis()); } @@ -88,6 +76,136 @@ public class AuctionListing { } } + // ========================================= + // IAuctionListing Implementation + // ========================================= + + @Override + public String getId() { + return id; + } + + @Override + public String getSellerUUID() { + return sellerUUID != null ? sellerUUID.toString() : null; + } + + @Override + public String getSellerName() { + return sellerName; + } + + @Override + public IItemStack getItem() { + return item != null ? NpcAPI.Instance().getIItemStack(item) : null; + } + + @Override + public long getStartingPrice() { + return startingPrice; + } + + @Override + public long getBuyoutPrice() { + return buyoutPrice; + } + + @Override + public long getCurrentBid() { + return currentBid; + } + + @Override + public String getHighBidderUUID() { + return highBidderUUID != null ? highBidderUUID.toString() : null; + } + + @Override + public String getHighBidderName() { + return highBidderName; + } + + @Override + public int getBidCount() { + return bidCount; + } + + @Override + public long getCreatedTime() { + return createdTime; + } + + @Override + public long getEndTime() { + return endTime; + } + + @Override + public long getRemainingTime() { + return getTimeRemaining(); + } + + @Override + public int getStatus() { + return status.ordinal(); + } + + @Override + public long getMinimumBid() { + return getMinimumBid(ConfigMarket.MinBidIncrementPercent); + } + + @Override + public boolean isSeller(String playerUUID) { + if (playerUUID == null || sellerUUID == null) return false; + return sellerUUID.toString().equals(playerUUID); + } + + @Override + public boolean isHighBidder(String playerUUID) { + if (playerUUID == null || highBidderUUID == null) return false; + return highBidderUUID.toString().equals(playerUUID); + } + + @Override + public String getRemainingTimeFormatted() { + long remaining = getTimeRemaining(); + if (remaining <= 0) return "Ended"; + + long hours = remaining / (60 * 60 * 1000); + long minutes = (remaining % (60 * 60 * 1000)) / (60 * 1000); + + if (hours > 0) { + return hours + "h " + minutes + "m"; + } else if (minutes > 0) { + return minutes + "m"; + } else { + long seconds = (remaining % (60 * 1000)) / 1000; + return seconds + "s"; + } + } + + // Override existing methods with @Override annotation + @Override + public boolean hasBids() { + return bidCount > 0 && highBidderUUID != null; + } + + @Override + public boolean hasBuyout() { + return buyoutPrice > 0; + } + + @Override + public boolean isExpired() { + return System.currentTimeMillis() >= endTime; + } + + @Override + public boolean isActive() { + return status == EnumAuctionStatus.ACTIVE && !isExpired(); + } + public NBTTagCompound writeToNBT(NBTTagCompound compound) { compound.setString("ID", id); compound.setString("SellerUUID", sellerUUID != null ? sellerUUID.toString() : ""); diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java b/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java deleted file mode 100644 index b2b8c4b57..000000000 --- a/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java +++ /dev/null @@ -1,232 +0,0 @@ -package noppes.npcs.controllers.data; - -import kamkeel.npcs.util.VaultUtil; -import net.minecraft.nbt.NBTTagCompound; -import noppes.npcs.api.handler.IPlayerCurrencyData; -import noppes.npcs.config.ConfigMarket; - -/** - * Stores player currency balance. - * This data is NOT profile slot bound - all profile slots share the same currency pool. - * When Vault API is available and UseVault config is enabled, operations delegate to Vault. - * The built-in CNPC+ currency data is always preserved in NBT, but unused when Vault is active. - */ -public class PlayerCurrencyData implements IPlayerCurrencyData { - private final PlayerData playerData; - - // Built-in CNPC+ currency (always saved, used as fallback) - private long balance; - private long lifetimeEarned; - private long lifetimeSpent; - - public PlayerCurrencyData(PlayerData playerData) { - this.playerData = playerData; - this.balance = ConfigMarket.StartingBalance; - this.lifetimeEarned = 0; - this.lifetimeSpent = 0; - } - - /** - * Check if Vault should be used for currency operations. - * Requires both config enabled AND Vault actually available. - */ - private boolean shouldUseVault() { - return ConfigMarket.UseVault && VaultUtil.isEnabled(); - } - - /** - * Get player name for Vault operations - */ - private String getPlayerName() { - if (playerData != null && playerData.player != null) { - return playerData.player.getCommandSenderName(); - } - return playerData != null ? playerData.playername : null; - } - - public void readFromNBT(NBTTagCompound compound) { - if (compound.hasKey("CurrencyBalance")) { - this.balance = compound.getLong("CurrencyBalance"); - } else { - this.balance = ConfigMarket.StartingBalance; - } - this.lifetimeEarned = compound.getLong("CurrencyLifetimeEarned"); - this.lifetimeSpent = compound.getLong("CurrencyLifetimeSpent"); - } - - public void writeToNBT(NBTTagCompound compound) { - // Always save built-in currency data (preserved even when using Vault) - compound.setLong("CurrencyBalance", this.balance); - compound.setLong("CurrencyLifetimeEarned", this.lifetimeEarned); - compound.setLong("CurrencyLifetimeSpent", this.lifetimeSpent); - } - - /** - * Get the current balance - */ - @Override - public long getBalance() { - if (shouldUseVault()) { - String playerName = getPlayerName(); - if (playerName != null) { - return (long) VaultUtil.getBalance(playerName); - } - } - return balance; - } - - /** - * Set the balance directly (use with caution) - */ - @Override - public void setBalance(long balance) { - if (balance < 0) { - balance = 0; - } - if (balance > ConfigMarket.MaxBalance) { - balance = ConfigMarket.MaxBalance; - } - - if (shouldUseVault()) { - String playerName = getPlayerName(); - if (playerName != null) { - VaultUtil.setBalance(playerName, balance); - return; - } - } - this.balance = balance; - } - - /** - * Add currency to balance (deposit) - * - * @param amount Amount to add - * @return true if successful, false if would exceed max balance - */ - @Override - public boolean deposit(long amount) { - if (amount <= 0) { - return false; - } - - if (shouldUseVault()) { - String playerName = getPlayerName(); - if (playerName != null) { - boolean success = VaultUtil.addMoney(playerName, amount); - if (success) { - this.lifetimeEarned += amount; - } - return success; - } - } - - // Built-in currency - long newBalance = this.balance + amount; - if (newBalance > ConfigMarket.MaxBalance) { - return false; - } - if (newBalance < this.balance) { - // Overflow protection - return false; - } - this.balance = newBalance; - this.lifetimeEarned += amount; - return true; - } - - /** - * Remove currency from balance (withdraw) - * - * @param amount Amount to remove - * @return true if successful, false if insufficient funds - */ - @Override - public boolean withdraw(long amount) { - if (amount <= 0) { - return false; - } - - if (shouldUseVault()) { - String playerName = getPlayerName(); - if (playerName != null) { - boolean success = VaultUtil.withdrawMoney(playerName, amount); - if (success) { - this.lifetimeSpent += amount; - } - return success; - } - } - - // Built-in currency - if (this.balance < amount) { - return false; - } - this.balance -= amount; - this.lifetimeSpent += amount; - return true; - } - - /** - * Check if player can afford an amount - */ - @Override - public boolean canAfford(long amount) { - if (shouldUseVault()) { - String playerName = getPlayerName(); - if (playerName != null) { - return VaultUtil.has(playerName, amount); - } - } - return this.balance >= amount; - } - - /** - * Get lifetime earned currency - */ - @Override - public long getLifetimeEarned() { - return lifetimeEarned; - } - - /** - * Get lifetime spent currency - */ - @Override - public long getLifetimeSpent() { - return lifetimeSpent; - } - - /** - * Format the balance for display - */ - @Override - public String formatBalance() { - if (shouldUseVault()) { - return VaultUtil.format(getBalance()); - } - return formatAmount(this.balance); - } - - /** - * Format an amount for display - */ - public static String formatAmount(long amount) { - return String.format("%,d %s", amount, ConfigMarket.CurrencyName); - } - - /** - * Check if Vault is being used for currency operations. - * Returns true only if config is enabled AND Vault is actually available. - */ - @Override - public boolean isUsingVault() { - return shouldUseVault(); - } - - /** - * Check if Vault is configured (regardless of availability) - */ - public boolean isVaultConfigured() { - return ConfigMarket.UseVault; - } -} diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerData.java b/src/main/java/noppes/npcs/controllers/data/PlayerData.java index d2a949074..04e06a41e 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerData.java @@ -17,7 +17,7 @@ import noppes.npcs.CustomNpcs; import noppes.npcs.LogWriter; import noppes.npcs.api.entity.ICustomNpc; import noppes.npcs.api.handler.IPlayerBankData; -import noppes.npcs.api.handler.IPlayerCurrencyData; +import noppes.npcs.api.handler.IPlayerTradeData; import noppes.npcs.api.handler.IPlayerData; import noppes.npcs.api.handler.IPlayerDialogData; import noppes.npcs.api.handler.IPlayerFactionData; @@ -46,6 +46,8 @@ import java.io.FileOutputStream; import java.util.HashSet; import java.util.UUID; +import static noppes.npcs.util.CustomNPCsThreader.customNPCThread; + public class PlayerData implements IExtendedEntityProperties, IPlayerData { public PlayerDialogData dialogData = new PlayerDialogData(this); public PlayerBankData bankData = new PlayerBankData(this); @@ -60,8 +62,8 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { public DataSkinOverlays skinOverlays = new DataSkinOverlays(this); public MagicData magicData = new MagicData(); - // Currency data - NOT slot-bound, shared across all profile slots - public PlayerCurrencyData currencyData = new PlayerCurrencyData(this); + // Trade data (currency + auction claims) - shared across all profile slots + public PlayerTradeData tradeData = new PlayerTradeData(this); public ActionManager actionManager = new ActionManager(); public PlayerDataScript scriptData; @@ -134,6 +136,7 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { animationData.readFromNBT(data); effectData.readFromNBT(data); magicData.readToNBT(data); + tradeData.readFromNBT(data); if (player != null) { playername = player.getCommandSenderName(); @@ -176,6 +179,7 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { animationData.writeToNBT(compound); effectData.writeToNBT(compound); magicData.writeToNBT(compound); + tradeData.writeToNBT(compound); compound.setString("PlayerName", playername); compound.setString("UUID", uuid); @@ -228,6 +232,7 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { transportData.saveNBTData(compound); factionData.saveNBTData(compound); mailData.saveNBTData(compound); + tradeData.writeToNBT(compound); compound.setString("PlayerName", playername); compound.setString("UUID", uuid); DBCAddon.instance.writeToNBT(this, compound); @@ -241,6 +246,7 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { transportData.loadNBTData(data); factionData.loadNBTData(data); mailData.loadNBTData(data); + tradeData.readFromNBT(data); if (player != null) { playername = player.getCommandSenderName(); uuid = player.getPersistentID().toString(); @@ -401,8 +407,8 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { return mailData; } - public IPlayerCurrencyData getCurrencyData() { - return currencyData; + public IPlayerTradeData getTradeData() { + return tradeData; } public synchronized void save() { @@ -414,9 +420,6 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { } final NBTTagCompound compound = getNBT(); - // Currency data is saved at player level, NOT per profile slot - currencyData.writeToNBT(compound); - final String filename; if (ConfigMain.DatFormat) { filename = uuid + ".dat"; @@ -453,10 +456,6 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { if (data.hasNoTags()) { data = getNBT(); } - - // Load currency BEFORE setNBT - currency is player-level, NOT per profile slot - currencyData.readFromNBT(data); - setNBT(data); } diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerTradeData.java b/src/main/java/noppes/npcs/controllers/data/PlayerTradeData.java new file mode 100644 index 000000000..a0a8e0bb7 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/PlayerTradeData.java @@ -0,0 +1,414 @@ +package noppes.npcs.controllers.data; + +import kamkeel.npcs.util.VaultUtil; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.api.handler.IPlayerTradeData; +import noppes.npcs.api.handler.data.IAuctionClaim; +import noppes.npcs.config.ConfigMarket; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Stores player's trade-related data: currency and auction claims. + * This data is shared across all profile slots. + * Uses CopyOnWriteArrayList for thread-safety when adding claims asynchronously. + */ +public class PlayerTradeData implements IPlayerTradeData { + // NBT key used by trade data - excluded from per-slot profile saving + public static final String NBT_KEY = "TradeData"; + + private final PlayerData playerData; + + // ========================================= + // Currency Data + // ========================================= + + // Built-in CNPC+ currency (always saved, used as fallback) + private long balance; + private long lifetimeEarned; + private long lifetimeSpent; + + // ========================================= + // Auction Claims Data + // ========================================= + + // Thread-safe list for claims - can be modified from CNPC+ thread + private final CopyOnWriteArrayList claims = new CopyOnWriteArrayList<>(); + + public PlayerTradeData(PlayerData playerData) { + this.playerData = playerData; + this.balance = ConfigMarket.StartingBalance; + this.lifetimeEarned = 0; + this.lifetimeSpent = 0; + } + + // ========================================= + // Currency Methods + // ========================================= + + /** + * Check if Vault should be used for currency operations. + * Requires both config enabled AND Vault actually available. + */ + private boolean shouldUseVault() { + return ConfigMarket.UseVault && VaultUtil.isEnabled(); + } + + /** + * Get player name for Vault operations + */ + private String getPlayerName() { + if (playerData != null && playerData.player != null) { + return playerData.player.getCommandSenderName(); + } + return playerData != null ? playerData.playername : null; + } + + /** + * Get the current balance + */ + @Override + public long getBalance() { + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + return (long) VaultUtil.getBalance(playerName); + } + } + return balance; + } + + /** + * Set the balance directly (use with caution) + */ + @Override + public void setBalance(long balance) { + if (balance < 0) { + balance = 0; + } + if (balance > ConfigMarket.MaxBalance) { + balance = ConfigMarket.MaxBalance; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + VaultUtil.setBalance(playerName, balance); + return; + } + } + this.balance = balance; + } + + /** + * Add currency to balance (deposit) + * + * @param amount Amount to add + * @return true if successful, false if would exceed max balance + */ + @Override + public boolean deposit(long amount) { + if (amount <= 0) { + return false; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + boolean success = VaultUtil.addMoney(playerName, amount); + if (success) { + this.lifetimeEarned += amount; + } + return success; + } + } + + // Built-in currency + long newBalance = this.balance + amount; + if (newBalance > ConfigMarket.MaxBalance) { + return false; + } + if (newBalance < this.balance) { + // Overflow protection + return false; + } + this.balance = newBalance; + this.lifetimeEarned += amount; + return true; + } + + /** + * Remove currency from balance (withdraw) + * + * @param amount Amount to remove + * @return true if successful, false if insufficient funds + */ + @Override + public boolean withdraw(long amount) { + if (amount <= 0) { + return false; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + boolean success = VaultUtil.withdrawMoney(playerName, amount); + if (success) { + this.lifetimeSpent += amount; + } + return success; + } + } + + // Built-in currency + if (this.balance < amount) { + return false; + } + this.balance -= amount; + this.lifetimeSpent += amount; + return true; + } + + /** + * Check if player can afford an amount + */ + @Override + public boolean canAfford(long amount) { + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + return VaultUtil.has(playerName, amount); + } + } + return this.balance >= amount; + } + + /** + * Get lifetime earned currency + */ + @Override + public long getLifetimeEarned() { + return lifetimeEarned; + } + + /** + * Get lifetime spent currency + */ + @Override + public long getLifetimeSpent() { + return lifetimeSpent; + } + + /** + * Format the balance for display + */ + @Override + public String formatBalance() { + if (shouldUseVault()) { + return VaultUtil.format(getBalance()); + } + return formatAmount(this.balance); + } + + /** + * Format an amount for display + */ + public static String formatAmount(long amount) { + return String.format("%,d %s", amount, ConfigMarket.CurrencyName); + } + + /** + * Check if Vault is being used for currency operations. + * Returns true only if config is enabled AND Vault is actually available. + */ + @Override + public boolean isUsingVault() { + return shouldUseVault(); + } + + /** + * Check if Vault is configured (regardless of availability) + */ + public boolean isVaultConfigured() { + return ConfigMarket.UseVault; + } + + // ========================================= + // Auction Claims Methods + // ========================================= + + /** + * Add a claim to this player's pending claims. + * Thread-safe - can be called from any thread. + */ + public void addClaim(AuctionClaim claim) { + if (claim != null && !claim.claimed) { + claims.add(claim); + } + } + + /** + * Get all unclaimed claims for this player (internal use). + * Returns a copy of the list for safe iteration. + */ + public List getClaimsList() { + List result = new ArrayList<>(); + for (AuctionClaim claim : claims) { + if (!claim.claimed) { + result.add(claim); + } + } + return result; + } + + /** + * Get all unclaimed claims for this player (API). + */ + @Override + public IAuctionClaim[] getClaims() { + List result = getClaimsList(); + return result.toArray(new IAuctionClaim[0]); + } + + /** + * Get a specific claim by ID (internal use). + */ + public AuctionClaim getClaimInternal(String claimId) { + for (AuctionClaim claim : claims) { + if (claim.id.equals(claimId) && !claim.claimed) { + return claim; + } + } + return null; + } + + /** + * Get a specific claim by ID (API). + */ + @Override + public IAuctionClaim getClaim(String claimId) { + return getClaimInternal(claimId); + } + + /** + * Mark a claim as claimed and remove it. + */ + public boolean claimAndRemove(String claimId) { + for (AuctionClaim claim : claims) { + if (claim.id.equals(claimId) && !claim.claimed) { + claim.claimed = true; + claims.remove(claim); + return true; + } + } + return false; + } + + /** + * Get count of unclaimed claims. + */ + @Override + public int getClaimCount() { + int count = 0; + for (AuctionClaim claim : claims) { + if (!claim.claimed) { + count++; + } + } + return count; + } + + /** + * Check if player has any unclaimed claims. + */ + @Override + public boolean hasClaims() { + for (AuctionClaim claim : claims) { + if (!claim.claimed) { + return true; + } + } + return false; + } + + /** + * Process expired claims - removes claims older than expiration days. + * Returns number of claims removed. + */ + public int processExpiredClaims() { + int expirationDays = ConfigMarket.ClaimExpirationDays; + int removed = 0; + + Iterator iterator = claims.iterator(); + while (iterator.hasNext()) { + AuctionClaim claim = iterator.next(); + if (!claim.claimed && claim.isExpired(expirationDays)) { + claims.remove(claim); + removed++; + } + } + + return removed; + } + + // ========================================= + // NBT Serialization + // ========================================= + + public void readFromNBT(NBTTagCompound compound) { + NBTTagCompound tradeData = compound.getCompoundTag(NBT_KEY); + if (tradeData == null || tradeData.hasNoTags()) { + // No trade data found, use defaults + this.balance = ConfigMarket.StartingBalance; + this.lifetimeEarned = 0; + this.lifetimeSpent = 0; + claims.clear(); + return; + } + + // Currency + if (tradeData.hasKey("CurrencyBalance")) { + this.balance = tradeData.getLong("CurrencyBalance"); + } else { + this.balance = ConfigMarket.StartingBalance; + } + this.lifetimeEarned = tradeData.getLong("CurrencyLifetimeEarned"); + this.lifetimeSpent = tradeData.getLong("CurrencyLifetimeSpent"); + + // Claims + claims.clear(); + if (tradeData.hasKey("AuctionClaims")) { + NBTTagList claimsList = tradeData.getTagList("AuctionClaims", 10); + for (int i = 0; i < claimsList.tagCount(); i++) { + AuctionClaim claim = AuctionClaim.fromNBT(claimsList.getCompoundTagAt(i)); + if (!claim.claimed) { + claims.add(claim); + } + } + } + } + + public void writeToNBT(NBTTagCompound compound) { + NBTTagCompound tradeData = new NBTTagCompound(); + + // Currency - always save built-in currency data (preserved even when using Vault) + tradeData.setLong("CurrencyBalance", this.balance); + tradeData.setLong("CurrencyLifetimeEarned", this.lifetimeEarned); + tradeData.setLong("CurrencyLifetimeSpent", this.lifetimeSpent); + + // Claims + NBTTagList claimsList = new NBTTagList(); + for (AuctionClaim claim : claims) { + if (!claim.claimed) { + claimsList.appendTag(claim.writeToNBT(new NBTTagCompound())); + } + } + tradeData.setTag("AuctionClaims", claimsList); + + compound.setTag(NBT_KEY, tradeData); + } +} diff --git a/src/main/java/noppes/npcs/roles/RoleTrader.java b/src/main/java/noppes/npcs/roles/RoleTrader.java index 91a116efc..6aa69d357 100644 --- a/src/main/java/noppes/npcs/roles/RoleTrader.java +++ b/src/main/java/noppes/npcs/roles/RoleTrader.java @@ -426,7 +426,7 @@ public class RoleTrader extends RoleInterface { // Player balance PlayerData data = PlayerData.get(player); - compound.setLong("Balance", data.currencyData.getBalance()); + compound.setLong("Balance", data.tradeData.getBalance()); // Stock info compound.setBoolean("StockEnabled", stock.enableStock); diff --git a/src/main/java/noppes/npcs/scripted/NpcAPI.java b/src/main/java/noppes/npcs/scripted/NpcAPI.java index 1bdd20cfe..c80c18d28 100644 --- a/src/main/java/noppes/npcs/scripted/NpcAPI.java +++ b/src/main/java/noppes/npcs/scripted/NpcAPI.java @@ -65,6 +65,7 @@ import noppes.npcs.api.gui.ICustomGui; import noppes.npcs.api.handler.IAbilityHandler; import noppes.npcs.api.handler.IActionManager; import noppes.npcs.api.handler.IAnimationHandler; +import noppes.npcs.api.handler.IAuctionHandler; import noppes.npcs.api.handler.IAttributeHandler; import noppes.npcs.api.handler.ICloneHandler; import noppes.npcs.api.handler.ICustomEffectHandler; @@ -90,6 +91,7 @@ import noppes.npcs.config.ConfigScript; import noppes.npcs.constants.EnumAnimationPart; import noppes.npcs.containers.ContainerNpcInterface; import noppes.npcs.controllers.AnimationController; +import noppes.npcs.controllers.AuctionController; import noppes.npcs.controllers.ChunkController; import noppes.npcs.controllers.CustomEffectController; import noppes.npcs.controllers.DialogController; @@ -385,6 +387,15 @@ public class NpcAPI extends AbstractNpcAPI { return TelegraphController.Instance.create(type); } + @Override + public IAuctionHandler getAuctions() { + this.checkWorld(); + if (!noppes.npcs.config.ConfigMarket.AuctionEnabled) { + return null; + } + return AuctionController.getInstance(); + } + @Override public String[] getAllBiomeNames() { List biomes = new ArrayList<>(); diff --git a/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java b/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java index f2cfc9a34..a1b91e1db 100644 --- a/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java +++ b/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java @@ -901,36 +901,36 @@ public class ScriptPlayer extends ScriptLivingBase @Override public long getCurrencyBalance() { - return getData().currencyData.getBalance(); + return getData().tradeData.getBalance(); } @Override public void setCurrencyBalance(long amount) { - getData().currencyData.setBalance(amount); + getData().tradeData.setBalance(amount); } @Override public boolean depositCurrency(long amount) { - return getData().currencyData.deposit(amount); + return getData().tradeData.deposit(amount); } @Override public boolean withdrawCurrency(long amount) { - return getData().currencyData.withdraw(amount); + return getData().tradeData.withdraw(amount); } @Override public boolean canAffordCurrency(long amount) { - return getData().currencyData.canAfford(amount); + return getData().tradeData.canAfford(amount); } @Override public boolean isUsingVaultCurrency() { - return getData().currencyData.isUsingVault(); + return getData().tradeData.isUsingVault(); } @Override public String getFormattedCurrencyBalance() { - return getData().currencyData.formatBalance(); + return getData().tradeData.formatBalance(); } }