Files
CustomNPC-Plus/src/main/java/noppes/npcs/controllers/AuctionController.java
T

1251 lines
45 KiB
Java
Raw Normal View History

2026-01-23 21:53:12 -05:00
package noppes.npcs.controllers;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.item.ItemStack;
import net.minecraft.nbt.CompressedStreamTools;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.nbt.NBTTagList;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.ChatComponentText;
import net.minecraft.util.EnumChatFormatting;
import noppes.npcs.CustomNpcs;
import noppes.npcs.LogWriter;
import noppes.npcs.config.ConfigMarket;
import noppes.npcs.constants.EnumAuctionSort;
import noppes.npcs.constants.EnumAuctionStatus;
import noppes.npcs.constants.EnumClaimType;
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.util.CustomNPCsThreader;
import java.io.File;
import java.io.FileInputStream;
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.
*
* Thread Safety:
* - All data modifications go through synchronized methods or use proper locking
* - Player indices are maintained for O(1) lookups
* - Saves are performed asynchronously on the CNPC+ thread
*
* Performance:
* - Auction processing runs every 20 seconds (400 ticks) to reduce server load
* - Player-specific queries use indexed maps for fast lookups
* - Read operations use read locks, write operations use write locks
*/
public class AuctionController {
public static AuctionController Instance;
// Main data stores
private final Map<String, AuctionListing> listings = new ConcurrentHashMap<>();
private final Map<String, AuctionClaim> claims = new ConcurrentHashMap<>();
private final List<AuctionNotification> pendingNotifications = Collections.synchronizedList(new ArrayList<>());
// =========================================
// Player Indices for O(1) Lookups
// =========================================
/** Listings by seller UUID - for quick "my listings" lookups */
private final Map<UUID, Set<String>> playerListingIds = new ConcurrentHashMap<>();
/** Listings where player is current high bidder */
private final Map<UUID, Set<String>> playerBidIds = new ConcurrentHashMap<>();
/** Claims by player UUID - for quick "my claims" lookups */
private final Map<UUID, Set<String>> playerClaimIds = 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);
/** Flag to prevent concurrent saves */
private final AtomicBoolean saving = new AtomicBoolean(false);
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;
public AuctionController() {
Instance = this;
load();
}
public static AuctionController getInstance() {
if (Instance == null || needsNewInstance()) {
Instance = new AuctionController();
}
return Instance;
}
private static boolean needsNewInstance() {
if (Instance == null) return true;
File file = CustomNpcs.getWorldSaveDirectory();
if (file == null) return false;
return !Instance.filePath.equals(file.getAbsolutePath());
}
// =========================================
// Tick Processing
// =========================================
public void onServerTick() {
if (!ConfigMarket.AuctionEnabled) return;
tickCounter++;
saveTickCounter++;
// Process ended auctions every 20 seconds
if (tickCounter >= PROCESS_INTERVAL_TICKS) {
tickCounter = 0;
processEndedAuctions();
processExpiredClaims();
}
// Save dirty data periodically
if (saveTickCounter >= SAVE_INTERVAL_TICKS) {
saveTickCounter = 0;
if (dirty.get()) {
saveAsync();
}
}
}
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;
}
}
if (changed) {
markDirty();
}
} finally {
dataLock.writeLock().unlock();
}
}
private void endAuction(AuctionListing listing) {
// Must be called within write lock
listing.status = EnumAuctionStatus.ENDED;
// Remove from seller's active listings
removeFromPlayerListings(listing.sellerUUID, listing.id);
// Remove from bidder's active bids if any
if (listing.highBidderUUID != null) {
removeFromPlayerBids(listing.highBidderUUID, listing.id);
}
2026-01-24 01:57:46 -05:00
// Get item display name safely
String itemDisplayName = listing.item != null ? listing.item.getDisplayName() : "Unknown Item";
2026-01-23 21:53:12 -05:00
if (listing.hasBids()) {
// Auction sold - create claims
2026-01-24 01:57:46 -05:00
AuctionClaim itemClaim = AuctionClaim.createItemWonClaim(
2026-01-23 21:53:12 -05:00
listing.highBidderUUID, listing.highBidderName, listing.id, listing.item);
addClaim(itemClaim);
long saleAmount = listing.currentBid;
long tax = (long) (saleAmount * ConfigMarket.SalesTaxPercent);
long sellerReceives = saleAmount - tax;
AuctionClaim currencyClaim = AuctionClaim.createCurrencyClaim(
2026-01-24 01:57:46 -05:00
listing.sellerUUID, listing.sellerName, listing.id, sellerReceives,
itemDisplayName, listing.highBidderName);
2026-01-23 21:53:12 -05:00
addClaim(currencyClaim);
// Notifications
queueNotification(listing.highBidderUUID, EnumNotificationType.AUCTION_WON, listing.id,
2026-01-24 01:57:46 -05:00
"You won the auction for " + itemDisplayName + "!");
2026-01-23 21:53:12 -05:00
queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id,
2026-01-24 01:57:46 -05:00
"Your " + itemDisplayName + " sold for " + saleAmount + " " + ConfigMarket.CurrencyName + "!");
2026-01-23 21:53:12 -05:00
2026-01-24 01:57:46 -05:00
logAuction("SOLD", listing.sellerName, itemDisplayName, saleAmount,
2026-01-23 21:53:12 -05:00
"Winner: " + listing.highBidderName + ", Tax: " + tax);
} else {
// No bids - return item to seller
2026-01-24 01:57:46 -05:00
AuctionClaim returnClaim = AuctionClaim.createItemReturnedClaim(
2026-01-23 21:53:12 -05:00
listing.sellerUUID, listing.sellerName, listing.id, listing.item);
addClaim(returnClaim);
queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_EXPIRED, listing.id,
2026-01-24 01:57:46 -05:00
"Your auction for " + itemDisplayName + " expired with no bids.");
2026-01-23 21:53:12 -05:00
2026-01-24 01:57:46 -05:00
logAuction("EXPIRED", listing.sellerName, itemDisplayName, listing.startingPrice, "No bids");
2026-01-23 21:53:12 -05:00
}
}
private void processExpiredClaims() {
int expirationDays = ConfigMarket.ClaimExpirationDays;
dataLock.writeLock().lock();
try {
Iterator<Map.Entry<String, AuctionClaim>> 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
// =========================================
private void addToPlayerListings(UUID playerUUID, String listingId) {
playerListingIds.computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()).add(listingId);
}
private void removeFromPlayerListings(UUID playerUUID, String listingId) {
Set<String> ids = playerListingIds.get(playerUUID);
if (ids != null) {
ids.remove(listingId);
if (ids.isEmpty()) {
playerListingIds.remove(playerUUID);
}
}
}
private void addToPlayerBids(UUID playerUUID, String listingId) {
playerBidIds.computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()).add(listingId);
}
private void removeFromPlayerBids(UUID playerUUID, String listingId) {
Set<String> ids = playerBidIds.get(playerUUID);
if (ids != null) {
ids.remove(listingId);
if (ids.isEmpty()) {
playerBidIds.remove(playerUUID);
}
}
}
private void addToPlayerClaims(UUID playerUUID, String claimId) {
playerClaimIds.computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()).add(claimId);
}
private void removeFromPlayerClaims(UUID playerUUID, String claimId) {
Set<String> ids = playerClaimIds.get(playerUUID);
if (ids != null) {
ids.remove(claimId);
if (ids.isEmpty()) {
playerClaimIds.remove(playerUUID);
}
}
}
/** 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.";
}
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();
}
}
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
AuctionClaim refundClaim = AuctionClaim.createRefundClaim(
2026-01-24 01:57:46 -05:00
listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid,
listing.item.getDisplayName(), listing.sellerName);
2026-01-23 21:53:12 -05:00
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
2026-01-24 01:57:46 -05:00
AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim(
2026-01-23 21:53:12 -05:00
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
2026-01-24 01:57:46 -05:00
AuctionClaim itemClaim = AuctionClaim.createItemReturnedClaim(
2026-01-23 21:53:12 -05:00
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();
}
}
// =========================================
// Bidding
// =========================================
public String placeBid(String listingId, EntityPlayer player, long bidAmount) {
if (!ConfigMarket.AuctionEnabled) {
return "Auction House 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;
if (!currency.canAfford(bidAmount)) {
return "You cannot afford this bid.";
}
// Store previous bidder info for refund
UUID previousBidder = listing.highBidderUUID;
long previousBid = listing.currentBid;
// Deduct bid from new bidder FIRST
if (!currency.withdraw(bidAmount)) {
return "Failed to deduct bid amount.";
}
// Refund previous bidder if any
if (listing.hasBids() && previousBidder != null) {
AuctionClaim refundClaim = AuctionClaim.createRefundClaim(
2026-01-24 01:57:46 -05:00
previousBidder, listing.highBidderName, listing.id, previousBid,
listing.item.getDisplayName(), playerName);
2026-01-23 21:53:12 -05:00
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();
}
}
public String buyout(String listingId, EntityPlayer player) {
if (!ConfigMarket.AuctionEnabled) {
return "Auction House 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
if (listing.hasBids() && listing.highBidderUUID != null) {
AuctionClaim refundClaim = AuctionClaim.createRefundClaim(
2026-01-24 01:57:46 -05:00
listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid,
listing.item.getDisplayName(), playerName);
2026-01-23 21:53:12 -05:00
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
2026-01-24 01:57:46 -05:00
AuctionClaim itemClaim = AuctionClaim.createItemWonClaim(playerUUID, playerName, listing.id, listing.item);
2026-01-23 21:53:12 -05:00
addClaim(itemClaim);
long saleAmount = listing.buyoutPrice;
long tax = (long) (saleAmount * ConfigMarket.SalesTaxPercent);
long sellerReceives = saleAmount - tax;
AuctionClaim currencyClaim = AuctionClaim.createCurrencyClaim(
2026-01-24 01:57:46 -05:00
listing.sellerUUID, listing.sellerName, listing.id, sellerReceives,
listing.item.getDisplayName(), playerName);
2026-01-23 21:53:12 -05:00
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();
}
}
// =========================================
// Claims
// =========================================
/**
* Get player claims using indexed lookup - O(n) where n = player's claims, not all claims
*/
public List<AuctionClaim> getPlayerClaims(UUID playerUUID) {
Set<String> claimIds = playerClaimIds.get(playerUUID);
if (claimIds == null || claimIds.isEmpty()) {
return new ArrayList<>();
}
List<AuctionClaim> playerClaims = new ArrayList<>();
for (String claimId : claimIds) {
AuctionClaim claim = claims.get(claimId);
if (claim != null && !claim.claimed) {
playerClaims.add(claim);
}
}
return playerClaims;
}
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();
}
}
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();
}
}
public int claimAll(EntityPlayer player) {
int claimedCount = 0;
List<AuctionClaim> playerClaims = getPlayerClaims(player.getUniqueID());
for (AuctionClaim claim : playerClaims) {
String result;
if (claim.type.isItem()) {
result = claimItem(claim.id, player);
} else {
result = claimCurrency(claim.id, player);
}
if (result == null) {
claimedCount++;
}
}
return claimedCount;
}
// =========================================
// Queries - Using Indexed Lookups
// =========================================
public List<AuctionListing> getActiveListings(AuctionFilter filter, int page, int pageSize) {
List<AuctionListing> result = new ArrayList<>();
dataLock.readLock().lock();
try {
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);
}
} finally {
dataLock.readLock().unlock();
}
// Sort
sortListings(result, filter.sortBy);
// Paginate
int start = page * pageSize;
int end = Math.min(start + pageSize, result.size());
if (start >= result.size()) {
return new ArrayList<>();
}
return new ArrayList<>(result.subList(start, end));
}
private void sortListings(List<AuctionListing> list, EnumAuctionSort sortBy) {
Comparator<AuctionListing> comparator;
switch (sortBy) {
case ENDING_SOON:
comparator = Comparator.comparingLong(AuctionListing::getTimeRemaining);
break;
case PRICE_LOW:
comparator = Comparator.comparingLong(AuctionListing::getEffectivePrice);
break;
case PRICE_HIGH:
comparator = Comparator.comparingLong(AuctionListing::getEffectivePrice).reversed();
break;
case MOST_BIDS:
comparator = Comparator.comparingInt(l -> -l.bidCount);
break;
case NEWEST:
default:
comparator = Comparator.comparingLong(l -> -l.createdTime);
break;
}
list.sort(comparator);
}
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++;
}
} finally {
dataLock.readLock().unlock();
}
return count;
}
public AuctionListing getListing(String listingId) {
return listings.get(listingId);
}
/**
* Get a player's current currency balance.
*/
public long getPlayerBalance(EntityPlayer player) {
PlayerData playerData = PlayerData.get(player);
if (playerData == null || playerData.currencyData == null) {
return 0;
}
return playerData.currencyData.getBalance();
}
/**
* Fast O(1) lookup for player listing count using index
*/
public int getPlayerListingCountFast(UUID playerUUID) {
Set<String> ids = playerListingIds.get(playerUUID);
return ids != null ? ids.size() : 0;
}
/**
* Legacy method - still iterates for accuracy (in case index is stale)
*/
public int getPlayerListingCount(UUID playerUUID) {
return getPlayerListingCountFast(playerUUID);
}
/**
* Get player's active listings using indexed lookup
*/
public List<AuctionListing> getPlayerActiveListings(UUID playerUUID) {
Set<String> listingIds = playerListingIds.get(playerUUID);
if (listingIds == null || listingIds.isEmpty()) {
return new ArrayList<>();
}
List<AuctionListing> result = new ArrayList<>();
for (String listingId : listingIds) {
AuctionListing listing = listings.get(listingId);
if (listing != null && listing.status == EnumAuctionStatus.ACTIVE) {
result.add(listing);
}
}
return result;
}
/**
* Get all active auctions where the player is currently the highest bidder.
* Uses indexed lookup for O(1) access to player's bid set.
*/
public List<AuctionListing> getPlayerActiveBids(UUID playerUUID) {
Set<String> bidListingIds = playerBidIds.get(playerUUID);
if (bidListingIds == null || bidListingIds.isEmpty()) {
return new ArrayList<>();
}
List<AuctionListing> result = new ArrayList<>();
for (String listingId : bidListingIds) {
AuctionListing listing = listings.get(listingId);
if (listing != null && listing.status == EnumAuctionStatus.ACTIVE &&
listing.highBidderUUID != null && listing.highBidderUUID.equals(playerUUID)) {
result.add(listing);
}
}
return result;
}
/**
* Get the total number of trade slots used by a player.
* Uses indexed lookups for O(1) performance.
*/
public int getPlayerTradeSlotCount(UUID playerUUID) {
int count = 0;
// Active listings
Set<String> listingIds = playerListingIds.get(playerUUID);
if (listingIds != null) count += listingIds.size();
// Active bids
Set<String> bidIds = playerBidIds.get(playerUUID);
if (bidIds != null) count += bidIds.size();
// Pending claims
Set<String> claimIds = playerClaimIds.get(playerUUID);
if (claimIds != null) count += claimIds.size();
return count;
}
private int getMaxListingsForPlayer(EntityPlayer player) {
// TODO: Check permissions for customnpcs.auction.slots.X
return ConfigMarket.DefaultMaxListings;
}
// =========================================
// Notifications
// =========================================
private void queueNotification(UUID playerUUID, EnumNotificationType type, String listingId, String message) {
AuctionNotification notification = new AuctionNotification(playerUUID, type, listingId, message);
pendingNotifications.add(notification);
// Try to send immediately if player is online
EntityPlayerMP player = getOnlinePlayer(playerUUID);
if (player != null) {
sendNotification(player, notification);
notification.sent = true;
}
}
public void onPlayerLogin(EntityPlayer player) {
UUID playerUUID = player.getUniqueID();
boolean hasClaims = !getPlayerClaims(playerUUID).isEmpty();
synchronized (pendingNotifications) {
Iterator<AuctionNotification> 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();
}
}
}
if (hasClaims) {
player.addChatMessage(new ChatComponentText(EnumChatFormatting.GOLD +
"[Auction House] " + EnumChatFormatting.YELLOW + "You have items or currency to claim!"));
}
}
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;
}
}
return null;
}
// =========================================
// Logging
// =========================================
private void logAuction(String action, String playerName, String itemName, long amount, String details) {
if (!ConfigMarket.AuctionLoggingEnabled) return;
boolean shouldLog = false;
switch (action) {
case "CREATED":
shouldLog = ConfigMarket.LogAuctionCreated;
break;
case "BID":
shouldLog = ConfigMarket.LogAuctionBid;
break;
case "BUYOUT":
shouldLog = ConfigMarket.LogAuctionBuyout;
break;
case "SOLD":
shouldLog = ConfigMarket.LogAuctionSold;
break;
case "EXPIRED":
shouldLog = ConfigMarket.LogAuctionExpired;
break;
case "CANCELLED":
shouldLog = ConfigMarket.LogAuctionCancelled;
break;
case "CLAIMED":
case "CLAIM_EXPIRED":
shouldLog = ConfigMarket.LogAuctionClaimed;
break;
}
if (shouldLog) {
String logMessage = String.format("[AUCTION:%s] Player: %s, Item: %s, Amount: %d, Details: %s",
action, playerName, itemName, amount, details);
LogWriter.info(logMessage);
}
}
// =========================================
// Save / Load - Async on CNPC+ Thread
// =========================================
/** Mark data as dirty - will be saved on next save cycle */
private void markDirty() {
dirty.set(true);
}
/** Save synchronously - used for server shutdown */
public void save() {
if (!dirty.compareAndSet(true, false)) {
return; // Nothing to save
}
saveInternal();
}
/** Save asynchronously on CNPC+ thread */
public void saveAsync() {
if (!dirty.compareAndSet(true, false)) {
return; // Nothing to save
}
if (!saving.compareAndSet(false, true)) {
// Another save is in progress, mark dirty again
dirty.set(true);
return;
}
// Take a snapshot of the data for async save
final NBTTagCompound compound;
dataLock.readLock().lock();
try {
compound = writeToNBT(new NBTTagCompound());
} finally {
dataLock.readLock().unlock();
}
CustomNPCsThreader.customNPCThread.execute(() -> {
try {
saveToFile(compound);
} catch (Exception e) {
LogWriter.error("Error saving auction data asynchronously", e);
// Mark dirty again so we retry
dirty.set(true);
} finally {
saving.set(false);
}
});
}
/** 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();
}
}
private void saveToFile(NBTTagCompound compound) {
try {
File saveDir = CustomNpcs.getWorldSaveDirectory();
if (saveDir == null) return;
File fileNew = new File(saveDir, "auction.dat_new");
File fileOld = new File(saveDir, "auction.dat_old");
File fileCurrent = new File(saveDir, "auction.dat");
CompressedStreamTools.writeCompressed(compound, new FileOutputStream(fileNew));
// Backup rotation
if (fileOld.exists()) fileOld.delete();
if (fileCurrent.exists()) fileCurrent.renameTo(fileOld);
if (fileCurrent.exists()) fileCurrent.delete();
fileNew.renameTo(fileCurrent);
if (fileNew.exists()) fileNew.delete();
} catch (Exception e) {
LogWriter.error("Error saving auction data to file", e);
}
}
public void load() {
listings.clear();
claims.clear();
pendingNotifications.clear();
playerListingIds.clear();
playerBidIds.clear();
playerClaimIds.clear();
File saveDir = CustomNpcs.getWorldSaveDirectory();
if (saveDir == null) return;
filePath = saveDir.getAbsolutePath();
// Try current file
try {
File file = new File(saveDir, "auction.dat");
if (file.exists()) {
loadFromFile(file);
rebuildIndices();
return;
}
} catch (Exception e) {
LogWriter.error("Error loading auction.dat, trying backup", e);
}
// Try backup file
try {
File file = new File(saveDir, "auction.dat_old");
if (file.exists()) {
loadFromFile(file);
rebuildIndices();
LogWriter.info("Loaded auction data from backup file");
}
} catch (Exception e) {
LogWriter.error("Error loading auction.dat_old", e);
}
}
/** Rebuild all indices from loaded data */
private void rebuildIndices() {
playerListingIds.clear();
playerBidIds.clear();
playerClaimIds.clear();
// Index listings
for (AuctionListing listing : listings.values()) {
if (listing.status == EnumAuctionStatus.ACTIVE) {
addToPlayerListings(listing.sellerUUID, listing.id);
if (listing.highBidderUUID != null) {
addToPlayerBids(listing.highBidderUUID, listing.id);
}
}
}
// Index claims
for (AuctionClaim claim : claims.values()) {
if (!claim.claimed) {
addToPlayerClaims(claim.playerUUID, claim.id);
}
}
LogWriter.info("Rebuilt auction indices: " + playerListingIds.size() + " sellers, " +
playerBidIds.size() + " bidders, " + playerClaimIds.size() + " claimants");
}
private void loadFromFile(File file) throws Exception {
NBTTagCompound compound = CompressedStreamTools.readCompressed(new FileInputStream(file));
readFromNBT(compound);
}
public NBTTagCompound writeToNBT(NBTTagCompound compound) {
// Listings
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);
}
// 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);
}
}
// =========================================
// Admin Methods
// =========================================
public List<AuctionListing> getAllListings() {
return new ArrayList<>(listings.values());
}
public void adminCancelListing(String listingId, EntityPlayer admin, String reason) {
cancelListing(listingId, admin, true);
}
public void clearClaimedData() {
dataLock.writeLock().lock();
try {
// Remove claimed claims
claims.entrySet().removeIf(entry -> entry.getValue().claimed);
// 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;
}
/**
* Check if a player is the seller of a listing.
* Used for GUI visibility checks.
*/
public boolean isPlayerSeller(String listingId, UUID playerUUID) {
AuctionListing listing = listings.get(listingId);
return listing != null && listing.isSeller(playerUUID);
}
}