diff --git a/1.11_Market_Update.md b/1.11_Market_Update.md index 304965950..e512e7aa0 100644 --- a/1.11_Market_Update.md +++ b/1.11_Market_Update.md @@ -12,7 +12,7 @@ The 1.11 Market Update introduces a comprehensive economy system for CustomNPC+: ## 1. Currency System -### Status: IMPLEMENTED +### Status: COMPLETE ✅ The currency system provides a unified economy for CNPC+ features. @@ -20,6 +20,26 @@ The currency system provides a unified economy for CNPC+ features. - **Built-in CNPC+ Currency** - Stored in PlayerData, persists across profile slots - **Vault Integration** - Optional, when enabled all operations use Vault instead - **Scripting API** - Full access via `IPlayer` methods +- **Unified Usage** - Both Trader and Auction systems use `PlayerCurrencyData` which auto-delegates to Vault + +### Vault Integration Flow +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PlayerCurrencyData │ +│ │ +│ shouldUseVault() = ConfigMarket.UseVault && VaultUtil.isEnabled()│ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ All methods check shouldUseVault() first: │ │ +│ │ - getBalance() → VaultUtil.getBalance() or local balance │ │ +│ │ - withdraw() → VaultUtil.withdrawMoney() or local │ │ +│ │ - deposit() → VaultUtil.addMoney() or local │ │ +│ │ - canAfford() → VaultUtil.has() or local check │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ Used by: ContainerNPCTrader, AuctionController │ +└─────────────────────────────────────────────────────────────────┘ +``` ### Key Files | File | Purpose | @@ -53,11 +73,11 @@ player.getFormattedCurrencyBalance() // Get formatted string ## 2. Trader Enhancements -### Status: COMPLETE +### Status: COMPLETE ✅ --- -### 2.1 Stock System - IMPLEMENTED +### 2.1 Stock System - COMPLETE ✅ Traders can have limited stock that resets on configurable schedules. @@ -78,7 +98,7 @@ Traders can have limited stock that resets on configurable schedules. | `RLWEEKLY` | Resets every 7 real-life days | ✅ Working | | `RLCUSTOM` | Custom real-time interval | ✅ Working | -#### Stock UI Features - IMPLEMENTED +#### Stock UI Features - COMPLETE ✅ | Feature | Status | Notes | |---------|--------|-------| | Stock count per item | ✅ | Shows to right of output slot | @@ -90,22 +110,11 @@ Traders can have limited stock that resets on configurable schedules. | Reset stock button | ✅ | With confirmation dialog | | Reset cooldown button | ✅ | With confirmation dialog | -#### Key Files -| File | Purpose | -|------|---------| -| `TraderStock.java` | Core stock data class with per-player/global modes | -| `TraderStock.PlayerTraderStock` | Inner class for per-player tracking | -| `EnumStockReset.java` | Reset type enum with intervals | -| `SubGuiNpcTraderStock.java` | Main stock configuration GUI | -| `SubGuiNpcTraderStockSlots.java` | Per-slot stock limit editor | -| `SubGuiNpcTraderStockReset.java` | Stock reset confirmation | -| `SubGuiNpcTraderCooldownReset.java` | Cooldown reset confirmation | - --- -### 2.2 Trader Currency Support - IMPLEMENTED +### 2.2 Trader Currency Support - COMPLETE ✅ -Trades can require BOTH items AND currency (additive costs). +Trades can require BOTH items AND currency (additive costs). Uses `PlayerCurrencyData` which auto-delegates to Vault when enabled. #### Trade Entry Structure Each trade slot can have: @@ -114,30 +123,15 @@ Each trade slot can have: - **Currency 2** - Second item cost (existing) ✅ - **Currency Cost** - CNPC+ Currency / Vault cost ✅ NEW -#### Example Trades -| Sold Item | Item Cost | Currency Cost | -|-----------|-----------|---------------| -| Diamond | 2 Emeralds | $0 | -| Diamond Sword | 5 Diamonds | $100 | -| Special Armor | None | $500 | -| Enchanted Book | 1 Book + 10 Lapis | $50 | - #### Implementation Details - Currency cost stored as `long[] currencyCost[18]` in RoleTrader -- Default to 0 (no currency cost) for legacy compatibility ✅ -- Check both item AND currency requirements before purchase ✅ -- Withdraw currency on successful purchase ✅ +- Uses `data.currencyData.getBalance()` and `data.currencyData.withdraw()` +- Automatically uses Vault when `ConfigMarket.UseVault` is enabled - NBT serialization for persistence ✅ --- -### 2.3 Player Trader GUI - IMPLEMENTED - -#### Layout (256px wide) -- 3 columns × 6 rows = 18 trade slots -- Column width: 80px -- Per slot: [Currency1][Currency2] = [Output] -- Player inventory positioned below +### 2.3 Player Trader GUI - COMPLETE ✅ #### Display Features | Element | Position | Status | @@ -148,237 +142,132 @@ Each trade slot can have: | Player balance | Bottom-right | ✅ | | Status message | Bottom-left | ✅ On hover | -#### Status Messages (on hover) -| Condition | Message | Color | -|-----------|---------|-------| -| Out of stock | "Out of Stock ($COST)" | Red | -| Can't afford balance | "Insufficient Balance ($COST)" | Red | -| Missing item currency | "Unavailable ($COST)" | Red | -| Slot disabled | "Slot Disabled ($COST)" | Orange | -| Can purchase | "Available ($COST)" | Green | +--- -Cost suffix only appears if trade has currency cost > 0. +### 2.4 Multi-Player Sync - COMPLETE ✅ + +MarketRegistry provides linked market synchronization across all NPCs sharing the same market. --- -### 2.4 Multi-Player Sync - IMPLEMENTED +### 2.5 Scripting API - COMPLETE ✅ -#### MarketRegistry System -Central registry for linked market synchronization across all NPCs sharing the same market. - -| Component | Purpose | -|-----------|---------| -| `MarketRegistry.java` | Central registry tracking traders and viewers per market | -| `marketTraders` | `Map>` - All traders using each market | -| `marketViewers` | `Map>` - All players viewing each market | - -#### Viewer Tracking System -| Feature | Status | Notes | -|---------|--------|-------| -| Register viewer on GUI open | ✅ | For shared stock mode (linked or local) | -| Unregister on GUI close | ✅ | Via onContainerClosed | -| Sync after purchase | ✅ | All viewers updated market-wide | -| Per-player mode optimization | ✅ | Only sync to purchasing player | -| Disconnected player cleanup | ✅ | Removed before sync | -| NPC unload cleanup | ✅ | Via delete() → onUnload() | - -#### Sync Behavior by Mode -| Mode | Linked Market | Behavior | -|------|---------------|----------| -| Shared Stock | Yes | All viewers across ALL NPCs with same market see update immediately | -| Shared Stock | No | All viewers of THIS NPC see update immediately | -| Per-Player Stock | Yes/No | Only purchasing player receives update | - -#### Data Flow -``` -Purchase on NPC1 (market="shop1") - ↓ -consumeStock() → Market.save() → MarketRegistry.syncMarket("shop1") - ↓ -┌──────────────────────────────────────────────┐ -│ 1. Update ALL traders' stock data │ -│ (NPC1, NPC2, NPC3... all with "shop1") │ -│ 2. Sync ALL viewers across ALL those NPCs │ -└──────────────────────────────────────────────┘ -``` - ---- - -### 2.5 Key Files Modified/Created - -| File | Changes | -|------|---------| -| `MarketRegistry.java` | NEW - Central registry for linked market sync | -| `RoleTrader.java` | currencyCost[], viewer tracking (local + registry), syncAllViewers(), resetCooldown(), delete() cleanup | -| `ContainerNPCTrader.java` | Layout constants, currency/stock validation, viewer registration | -| `GuiNPCTrader.java` | Client-side countdown, stock display, status messages | -| `GetTraderData.java` | Network packet for requesting trader data (IGuiData pattern) | -| `TraderStock.java` | Stock management with per-player/global modes, thread-safe methods | -| `EnumStockReset.java` | Reset type definitions with lang key support | -| `SubGuiNpcTraderStock.java` | Stock configuration dialog, custom time validation | -| `SubGuiNpcTraderStockSlots.java` | Per-slot stock limits | -| `SubGuiNpcTraderStockReset.java` | Stock reset confirmation | -| `SubGuiNpcTraderCooldownReset.java` | Cooldown reset confirmation | -| `GuiNPCInterface.java` | Fixed SubGui background tiling for smaller widths | -| `Market.java` | Fixed save logic for linked markets | -| `IRoleTrader.java` | Extended API with stock manipulation methods | -| `ScriptRoleTrader.java` | Implemented new API methods | - ---- - -### 2.6 Resolved Issues - -All previously identified issues have been fixed: - -#### Critical Issues - FIXED -| Issue | Fix | -|-------|-----| -| Market load overwrites reset | Reordered `interact()` to load market BEFORE stock reset check | -| recordHistory flag inverted | Removed inverted early return in `Market.save()` | -| API returns wrong item | Fixed `getSellOption()` to return `inventorySold` instead of `inventoryCurrency` | - -#### Medium Priority - FIXED -| Issue | Fix | -|-------|-----| -| Thread safety | Added `synchronized` to `getAvailableStock()`, `consumeStock()`, `resetStock()` | -| Per-player cleanup | `playerStock.clear()` called on stock reset | -| Linked market sync | Implemented `MarketRegistry` for market-wide sync | -| Custom time validation | Added min validation (1 tick MC, 1000ms RL) in `SubGuiNpcTraderStock` | - -#### Low Priority - FIXED -| Issue | Fix | -|-------|-----| -| lastPurchaseTime unused | Removed from `PlayerTraderStock` | -| Currency format | Verified working correctly for all ranges | -| Hardcoded reset types | Now uses `EnumStockReset.getDisplayNames()` with lang keys | - ---- - -### 2.7 Scripting API - IMPLEMENTED - -Full API available via `IRoleTrader` interface: - -```javascript -// Stock Configuration -trader.isStockEnabled() -trader.setStockEnabled(boolean) -trader.isPerPlayerStock() -trader.setPerPlayerStock(boolean) -trader.getStockResetType() // Returns int (0-6 for EnumStockReset) -trader.setStockResetType(int) -trader.getCustomResetTime() -trader.setCustomResetTime(long) - -// Stock Limits -trader.getMaxStock(slot) // Max stock for slot (-1 = unlimited) -trader.setMaxStock(slot, amount) - -// Stock Queries -trader.getAvailableStock(slot) // Global mode -trader.getAvailableStock(slot, player) // Per-player mode -trader.getCurrentStock(slot) // Current stock (global) -trader.getPlayerPurchased(slot, player) // Amount purchased by player - -// Stock Manipulation -trader.setCurrentStock(slot, amount) // Set current stock (global) -trader.setPlayerPurchased(slot, player, amount) // Set player's purchased amount -trader.resetStock() // Reset all stock to max -trader.resetCooldown() // Reset cooldown timer - -// Time Queries -trader.getLastResetTime() // Timestamp of last reset -trader.getTimeUntilReset() // Time remaining until next reset - -// Currency Cost System -trader.getCurrencyCost(slot) -trader.setCurrencyCost(slot, amount) -trader.hasCurrencyCost(slot) -``` +Full API available via `IRoleTrader` interface. --- ## 3. Auction House System -### Status: NOT STARTED (Fresh implementation) +### Status: COMPLETE ✅ ### 3.1 Architecture Overview ``` -┌─────────────────────────────────────────────────────────────┐ -│ AuctionController │ -│ - Manages all auction data │ -│ - Handles listing/bidding/buyout logic │ -│ - Processes claims │ -│ - Saves/loads auction data │ -└─────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ AuctionController │ +│ - Manages all auction data with player indices │ +│ - Handles listing/bidding/buyout logic │ +│ - Race-condition protected with ReentrantReadWriteLock │ +│ - Async saves on CNPC+ thread │ +│ - 20-second update cycle for performance │ +└─────────────────────────────────────────────────────────────────┘ │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ AuctionNotification │ -│ - Tracks pending notifications per player │ -│ - Sends notifications on login │ -│ - Types: Won, Outbid, Sold, Expired, Claimed │ -└─────────────────────────────────────────────────────────────┘ + Uses PlayerCurrencyData (Vault-aware) + │ +┌─────────────────────────────────────────────────────────────────┐ +│ AuctionNotification │ +│ - Tracks pending notifications per player │ +│ - Sends notifications on login │ +│ - Types: Won, Outbid, Sold, Expired, Claimed │ +└─────────────────────────────────────────────────────────────────┘ ``` -### 3.2 GUI Structure +### 3.2 Vault Integration + +The Auction system uses `PlayerCurrencyData` for all currency operations: +- `AuctionController.createListing()` → `currencyData.withdraw(fee)` +- `AuctionController.placeBid()` → `currencyData.withdraw(bidAmount)` +- `AuctionController.buyout()` → `currencyData.withdraw(buyoutPrice)` +- `AuctionController.claimCurrency()` → `currencyData.deposit(amount)` + +**When Vault is enabled** (`ConfigMarket.UseVault = true`): +- All operations automatically use Vault economy +- No code changes required - `PlayerCurrencyData` handles delegation + +### 3.3 GUI Structure ``` -GuiAuctionHouse (Base GUI - opened via Auctioneer NPC) +GuiAuctionInterface (Base GUI - opened via Auctioneer NPC) │ - ├── GuiAuction (Browse/Search auctions) - │ - Paginated item display (ContainerAuction) - │ - Next/Back page buttons - │ - Filter button → opens GuiAuctionFilter - │ - My Auctions button → opens GuiAuctionClaims - │ - Sell button → opens GuiAuctionSell - │ - Click item → SubGuiAuctionDetails + ├── GuiAuctionListing (Browse auctions - PAGE_LISTINGS) + │ - 9x5 grid of auction items + │ - Filter button with tooltip showing current settings + │ - Click item → Opens GuiAuctionBidding + │ - Pagination with up/down arrows │ - ├── GuiAuctionSell (Create new listing) - │ - Item slot for item to sell - │ - Starting price input - │ - Buyout price input (0 = no buyout) + ├── GuiAuctionSell (Create new listing - PAGE_SELL) + │ - Sell slot for item to auction (cloned from inventory) + │ - Click inventory: left=add stack, right=add 1 + │ - Click sell slot: left=clear, right=remove 1 + │ - Starting price + buyout price text fields + │ - Allow Buyout toggle button │ - Listing fee display - │ - Create button + │ - Confirm button with coin icon │ - ├── GuiAuctionClaims (My Auctions / Claims) - │ - Active listings tab - │ - Pending claims tab (items + currency to collect) - │ - Cancel listing button - │ - Claim all button + ├── GuiAuctionTrades (My Trades - PAGE_CLAIMS) + │ - 9x5 grid showing: active listings, active bids, claims + │ - Blue tint: active listings/bids + │ - Green tint: claimable items + │ - Red tint: refunds + │ - Confirmation system for cancel/claim operations │ - └── GuiAuctionFilter (Filter settings) - - Search text - - Sort options (EnumAuctionSort) - - Price range (min/max) - - Show my listings only toggle + ├── GuiAuctionBidding (View listing details) + │ - Item display slot + │ - Auction info (seller, price, time, bids) + │ - Bid button → SubGuiAuctionBid + │ - Buy Now button → SubGuiAuctionBuyNow + │ - Own listings: buttons hidden, view-only + │ + ├── SubGuiAuctionBid (Bid confirmation popup) + │ - Shows minimum bid, player balance + │ - Bid amount input field + │ - Place Bid / Cancel buttons + │ + ├── SubGuiAuctionBuyNow (Buyout confirmation popup) + │ - Shows buyout price, player balance + │ - Yes / Cancel buttons + │ + └── SubGuiAuctionSearch (Filter popup) + - Search text input + - Apply/Cancel buttons ``` -### 3.3 Admin GUI +### 3.4 Client Config Sync -``` -GuiManageAuction (Admin tool - like ManageMagic, ManageQuests) - │ - ├── Active Auctions Tab - │ - View all auctions - │ - Cancel any auction (with reason) - │ - View auction details - │ - ├── Blacklist Tab (Stub for future) - │ - Add/remove blacklisted items - │ - Add/remove blacklisted mods - │ - └── Settings Tab - - View current config values - - Quick reference for config file +The client uses `AuctionClientConfig` to cache server settings: +- Synced via `LoginPacket` on player join +- Client never uses `ConfigMarket` directly for display +- Ensures server authority over fees, currency name, etc. + +```java +// Server sends config in LoginPacket +AuctionClientConfig.writeToNBT(compound); // Server side + +// Client receives and caches +AuctionClientConfig.readFromNBT(compound); // Client side + +// Client GUI uses cached values +AuctionClientConfig.getListingFee(); +AuctionClientConfig.getCurrencyName(); +AuctionClientConfig.getMinBidIncrement(); ``` -### 3.4 Data Structures +### 3.5 Data Structures #### AuctionListing ```java public class AuctionListing { - int id; // Unique listing ID + String id; // Unique listing ID (UUID-based) UUID sellerUUID; // Seller's UUID String sellerName; // Seller's display name ItemStack item; // Item being sold @@ -397,9 +286,9 @@ public class AuctionListing { #### AuctionClaim ```java public class AuctionClaim { - int id; // Unique claim ID + String id; // Unique claim ID UUID playerUUID; // Player who can claim - int listingId; // Related auction listing + String listingId; // Related auction listing EnumClaimType type; // ITEM, CURRENCY, REFUND ItemStack item; // Item to claim (if type = ITEM) long currency; // Currency to claim (if type = CURRENCY/REFUND) @@ -408,113 +297,19 @@ public class AuctionClaim { } ``` -#### AuctionFilter -```java -public class AuctionFilter { - String searchText; // Item name search - EnumAuctionSort sortBy; // Sort order - long minPrice; // Minimum price filter - long maxPrice; // Maximum price filter (0 = no max) - boolean myListingsOnly; // Show only my listings -} -``` +### 3.6 Security Features -#### Enums -```java -public enum EnumAuctionStatus { - ACTIVE, // Auction is live - ENDED, // Auction ended (winner determined) - CANCELLED, // Cancelled by seller or admin - CLAIMED // All claims processed -} +All actions are validated server-side: -public enum EnumAuctionSort { - NEWEST, // Most recently listed - ENDING_SOON, // Ending soonest - PRICE_LOW, // Lowest current price - PRICE_HIGH, // Highest current price - MOST_BIDS // Most bid activity -} +| Check | Location | Description | +|-------|----------|-------------| +| Own item bid prevention | `placeBid()` | `listing.isSeller(playerUUID)` check | +| Own item buyout prevention | `buyout()` | `listing.isSeller(playerUUID)` check | +| Auction expiry check | `placeBid()/buyout()` | Double-check inside write lock | +| Currency validation | All actions | Verify balance before operations | +| Claim ownership | `claimItem()/claimCurrency()` | `claim.isForPlayer(playerUUID)` check | -public enum EnumClaimType { - ITEM, // Winner claims item - CURRENCY, // Seller claims sale currency - REFUND // Outbid player claims refund -} -``` - -### 3.5 AuctionController - -Main controller handling all auction logic. - -#### Core Methods -```java -// Listing Management -int createListing(EntityPlayer seller, ItemStack item, long startingPrice, long buyoutPrice) -void cancelListing(int listingId, EntityPlayer player, boolean isAdmin) -List getActiveListings(AuctionFilter filter, int page, int pageSize) -AuctionListing getListing(int listingId) -int getPlayerListingCount(UUID playerUUID) - -// Bidding -boolean placeBid(int listingId, EntityPlayer bidder, long bidAmount) -boolean buyout(int listingId, EntityPlayer buyer) - -// Claims -List getPlayerClaims(UUID playerUUID) -boolean claimItem(int claimId, EntityPlayer player) -boolean claimCurrency(int claimId, EntityPlayer player) -void claimAll(EntityPlayer player) - -// Lifecycle -void processEndedAuctions() // Called on tick, creates claims for ended auctions -void onPlayerLogin(EntityPlayer player) // Send pending notifications - -// Persistence -void save() -void load() -``` - -#### Auction Flow - -**Creating a Listing:** -1. Validate item not blacklisted -2. Check player has listing slots available -3. Deduct listing fee from player -4. Remove item from player inventory -5. Create AuctionListing with ACTIVE status -6. Save - -**Placing a Bid:** -1. Validate auction is ACTIVE -2. Validate bid > current bid + minimum increment -3. Validate bidder has enough currency -4. Deduct bid amount from bidder -5. If previous bidder exists, create REFUND claim for them -6. Update listing with new bid/bidder -7. Apply snipe protection if needed -8. Save - -**Buyout:** -1. Validate auction is ACTIVE and has buyout price -2. Validate buyer has enough currency -3. Deduct buyout amount from buyer -4. If previous bidder exists, create REFUND claim -5. Create ITEM claim for buyer -6. Create CURRENCY claim for seller (minus sales tax) -7. Set status to ENDED -8. Save - -**Auction Ends (no buyout):** -1. If bids exist: - - Create ITEM claim for high bidder - - Create CURRENCY claim for seller (minus sales tax) -2. If no bids: - - Create ITEM claim for seller (item returned) -3. Set status to ENDED -4. Save - -### 3.6 Snipe Protection +### 3.7 Snipe Protection When a bid is placed with less than `SnipeProtectionMinutes` remaining: - Extend the auction end time TO `SnipeProtectionMinutes` from now @@ -523,307 +318,217 @@ When a bid is placed with less than `SnipeProtectionMinutes` remaining: Example: Auction ends in 30 seconds, someone bids → Timer resets to 2:00 -### 3.7 Notifications - -#### AuctionNotification Class -```java -public class AuctionNotification { - UUID playerUUID; - EnumNotificationType type; - int listingId; - String message; - long timestamp; -} - -public enum EnumNotificationType { - AUCTION_WON, // You won an auction - AUCTION_OUTBID, // You were outbid - AUCTION_SOLD, // Your item sold - AUCTION_EXPIRED, // Your auction expired (no bids) - CLAIM_READY // You have items/currency to claim -} -``` - -Notifications are queued and sent when player logs in. - -### 3.8 Permissions - -| Permission | Default | Description | -|------------|---------|-------------| -| `customnpcs.auction.use` | true | Can use auction house | -| `customnpcs.auction.sell` | true | Can create listings | -| `customnpcs.auction.slots.X` | 5 | Max listing slots (X = number) | -| `customnpcs.auction.admin` | op | Admin auction management | - --- -## 4. Configuration +## 4. AuctionController Performance & Thread Safety -### ConfigMarket.java Structure +### 4.1 Threading Model -```java -// ========================================= -// Market General -// ========================================= -public static boolean MarketEnabled = true; - -// ========================================= -// Currency (Market.Currency) -// ========================================= -public static boolean UseVault = false; -public static String CurrencyName = "Coins"; -public static long StartingBalance = 0; -public static long MaxBalance = Long.MAX_VALUE; - -// ========================================= -// Trader (Market.Trader) -// ========================================= -public static boolean EnableStockByDefault = false; -public static String DefaultResetType = "NONE"; - -// ========================================= -// Auction (Market.Auction) -// ========================================= -public static boolean AuctionEnabled = true; -public static int AuctionDuration = 24; // Hours -public static long ListingFee = 10; // Flat fee to create listing -public static double SalesTaxPercent = 0.05; // 5% tax on sales -public static int DefaultMaxListings = 5; // Per player -public static int SnipeProtectionMinutes = 2; // Snipe protection timer -public static int ClaimExpirationDays = 30; // Days before unclaimed items deleted -public static double MinBidIncrementPercent = 0.05; // 5% minimum bid increase ``` +┌─────────────────────────────────────────────────────────────────┐ +│ AuctionController │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ReentrantRWLock │ │ AtomicBoolean │ │ CNPC+ Thread │ │ +│ │ (dataLock) │ │ dirty, saving │ │ (async saves) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ Read operations → Read Lock (concurrent) │ +│ Write operations → Write Lock (exclusive) │ +│ Save operations → Async on CNPC+ thread │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Player Indices for O(1) Lookups + +| Index | Type | Purpose | +|-------|------|---------| +| `playerListingIds` | `Map>` | Active listings by seller | +| `playerBidIds` | `Map>` | Listings where player has active bid | +| `playerClaimIds` | `Map>` | Unclaimed claims by player | + +**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) + +### 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 | + +### 4.4 Race Condition Prevention + +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 + +### 4.5 Server Shutdown Handling + +On `FMLServerStoppedEvent`: +1. `AuctionController.save()` called synchronously +2. Uses CNPC+ thread (single-threaded executor) +3. All pending saves complete before shutdown --- ## 5. File Structure -### New Files to Create +### New Files Created ``` constants/ - EnumAuctionStatus.java - EnumAuctionSort.java - EnumClaimType.java - EnumNotificationType.java + EnumAuctionStatus.java ✅ + EnumAuctionSort.java ✅ + EnumClaimType.java ✅ + EnumNotificationType.java ✅ controllers/ - AuctionController.java - AuctionNotification.java + AuctionController.java ✅ (with threading, indices, Vault support) controllers/data/ - AuctionListing.java - AuctionClaim.java - AuctionFilter.java + AuctionListing.java ✅ + AuctionClaim.java ✅ + AuctionFilter.java ✅ + AuctionNotification.java ✅ roles/ - RoleAuctioneer.java + RoleAuctioneer.java ✅ containers/ - ContainerAuction.java + ContainerAuction.java ✅ + ContainerAuctionListing.java ✅ + ContainerAuctionBidding.java ✅ + ContainerAuctionTrades.java ✅ + ContainerAuctionSell.java ✅ + InventoryAuctionDisplay.java ✅ + SlotAuctionDisplay.java ✅ + +client/ + AuctionClientConfig.java ✅ (client-side config cache) client/gui/player/ - GuiAuctionHouse.java (Base GUI) - GuiAuction.java (Browse auctions) - GuiAuctionSell.java (Create listing) - GuiAuctionClaims.java (My auctions/claims) - GuiAuctionFilter.java (Filter settings) - SubGuiAuctionDetails.java (View listing details) + GuiAuctionInterface.java ✅ + GuiAuctionListing.java ✅ + GuiAuctionSell.java ✅ + GuiAuctionTrades.java ✅ + GuiAuctionBidding.java ✅ + SubGuiAuctionSearch.java ✅ + SubGuiAuctionBid.java ✅ + SubGuiAuctionBuyNow.java ✅ + AuctionTooltipHandler.java ✅ -client/gui/global/ - GuiManageAuction.java (Admin management) +client/gui/util/ + GuiAuctionNavButton.java ✅ -network/packets/ - AuctionDataPacket.java (Server → Client data sync) - AuctionActionPacket.java (Client → Server actions) +network/packets/player/ + AuctionDataPacket.java ✅ + AuctionActionPacket.java ✅ ``` -### Files to Modify +### Files Modified ``` -CustomNpcs.java - Initialize AuctionController -ServerTickHandler.java - Process ended auctions, login notifications -EnumRoleType.java - Add Auctioneer -EnumGuiType.java - Add auction GUI types -DataAdvanced.java - Handle Auctioneer role -PacketHandler.java - Register auction packets -CustomNpcsPermissions.java - Auction permissions -ConfigMarket.java - Add Auction section -RoleTrader.java - Add currency cost per trade -ContainerNPCTrader.java - Currency + stock integration -en_US.lang - All auction lang keys +CustomNpcs.java ✅ Initialize AuctionController, save on shutdown +EnumRoleType.java ✅ Added Auctioneer +EnumGuiType.java ✅ Added PlayerAuction, PlayerAuctionSell, PlayerAuctionTrades, PlayerAuctionBidding +EnumPlayerPacket.java ✅ Added AuctionData, AuctionAction +CommonProxy.java ✅ Handle auction container types +ClientProxy.java ✅ Handle auction GUI types +DataAdvanced.java ✅ Handle Auctioneer role +ConfigMarket.java ✅ Added Auction section +LoginPacket.java ✅ Sync auction config to client +en_US.lang ✅ Added auction lang keys ``` --- -## 6. Data Storage +## 6. Configuration -### Auction Data File -Location: `customnpcs/auction.dat` +### ConfigMarket.java Structure -Structure: -``` -NBTTagCompound root - ├── NextListingId: int - ├── NextClaimId: int - ├── Listings: NBTTagList - │ └── [AuctionListing NBT...] - ├── Claims: NBTTagList - │ └── [AuctionClaim NBT...] - └── PendingNotifications: NBTTagList - └── [AuctionNotification NBT...] +```java +// ========================================= +// Currency (Market.Currency) +// ========================================= +public static boolean UseVault = false; // Use Vault economy +public static String CurrencyName = "Coins"; // Display name +public static long StartingBalance = 0; // New player balance +public static long MaxBalance = Long.MAX_VALUE; // Balance cap + +// ========================================= +// Auction (Market.Auction) +// ========================================= +public static boolean AuctionEnabled = true; +public static int AuctionDurationHours = 48; // Auction length +public static long ListingFee = 10; // Fee to create listing +public static double SalesTaxPercent = 0.05; // 5% tax on sales +public static int DefaultMaxListings = 10; // Per player +public static int SnipeProtectionMinutes = 2; // Anti-snipe timer +public static int ClaimExpirationDays = 30; // Days before unclaimed deleted +public static double MinBidIncrementPercent = 0.05; // 5% minimum bid increase + +// ========================================= +// Auction Logging +// ========================================= +public static boolean AuctionLoggingEnabled = true; +public static boolean LogAuctionCreated = true; +public static boolean LogAuctionBid = true; +public static boolean LogAuctionBuyout = true; +public static boolean LogAuctionSold = true; +public static boolean LogAuctionExpired = true; +public static boolean LogAuctionCancelled = true; +public static boolean LogAuctionClaimed = true; ``` --- -## 7. Lang Keys +## 7. Implementation Status -```properties -# Auction House -auction.title=Auction House -auction.browse=Browse -auction.sell=Sell -auction.claims=My Auctions -auction.filter=Filter +### Phase 1: Currency System - COMPLETE ✅ +### Phase 2: Trader Enhancements - COMPLETE ✅ +### Phase 3: Auction Core - COMPLETE ✅ +### Phase 4: Auction GUI - COMPLETE ✅ +### Phase 5: Auction Network - COMPLETE ✅ +### Phase 6: Performance & Safety - COMPLETE ✅ -# Browse -auction.nextPage=Next > -auction.prevPage=< Prev -auction.page=Page %d of %d -auction.noResults=No auctions found -auction.refresh=Refresh +1. ~~Threading model with ReentrantReadWriteLock~~ +2. ~~Player indices for O(1) lookups~~ +3. ~~20-second update cycle~~ +4. ~~Async saves on CNPC+ thread~~ +5. ~~Race condition prevention~~ +6. ~~Server shutdown handling~~ +7. ~~Own listing view (buttons hidden)~~ +8. ~~Server-side security checks~~ -# Listing Display -auction.seller=Seller: %s -auction.currentBid=Current Bid: %s -auction.buyout=Buyout: %s -auction.timeLeft=Time Left: %s -auction.bids=%d bids -auction.noBids=No bids +### Remaining Work -# Create Listing -auction.sell.title=Create Listing -auction.sell.startingPrice=Starting Price -auction.sell.buyoutPrice=Buyout Price -auction.sell.noBuyout=(0 = no buyout) -auction.sell.fee=Listing Fee: %s -auction.sell.create=Create Listing -auction.sell.noItem=Place an item to sell - -# Claims -auction.claims.title=My Auctions -auction.claims.active=Active Listings -auction.claims.pending=Pending Claims -auction.claims.claimAll=Claim All -auction.claims.cancel=Cancel Listing -auction.claims.empty=No pending claims - -# Filter -auction.filter.title=Filter Auctions -auction.filter.search=Search -auction.filter.minPrice=Min Price -auction.filter.maxPrice=Max Price -auction.filter.myOnly=My Listings Only -auction.filter.apply=Apply - -# Sort Options -auction.sort.newest=Newest -auction.sort.endingSoon=Ending Soon -auction.sort.priceLow=Price: Low to High -auction.sort.priceHigh=Price: High to Low -auction.sort.mostBids=Most Bids - -# Notifications -auction.notify.won=You won the auction for %s! -auction.notify.outbid=You were outbid on %s! -auction.notify.sold=Your %s sold for %s! -auction.notify.expired=Your auction for %s expired with no bids. -auction.notify.claim=You have items to claim at the Auction House. - -# Errors -auction.error.notEnoughCurrency=Not enough currency! -auction.error.auctionEnded=This auction has ended. -auction.error.bidTooLow=Bid must be at least %s. -auction.error.maxListings=You have reached your maximum listings. -auction.error.blacklisted=This item cannot be auctioned. -auction.error.noAuctioneer=You must visit an Auctioneer NPC. - -# Trader Currency -trader.currencyCost=Currency Cost -trader.outOfStock=Out of Stock -trader.stock=Stock: %d -``` +- [ ] GuiManageAuction (Admin GUI) +- [ ] Permission system integration +- [ ] Additional testing --- -## 8. Implementation Order - -### Phase 1: Trader Currency Integration - COMPLETE ✅ -1. ~~Add `currencyCost` field to RoleTrader trades~~ -2. ~~Update ContainerNPCTrader to check/deduct currency~~ -3. ~~Update trader setup GUI with currency input~~ -4. ~~Test with existing item trades (backward compatibility)~~ - -### Phase 2: Trader Stock UI - COMPLETE ✅ -1. ~~Add stock display to player trader GUI~~ -2. ~~Show "Out of Stock" indicator~~ -3. ~~Verify reset timers working~~ -4. ~~Real-time client-side countdown~~ -5. ~~Status messages with costs~~ -6. ~~Reset cooldown button with confirmation~~ - -### Phase 2.5: Trader Sync System - COMPLETE ✅ -1. ~~Implement MarketRegistry for linked market sync~~ -2. ~~Viewer tracking (market-wide and local)~~ -3. ~~NPC unload cleanup~~ -4. ~~Thread safety for stock operations~~ - -### Phase 3: Auction Core -1. Create enums (EnumAuctionStatus, EnumAuctionSort, etc.) -2. Create data classes (AuctionListing, AuctionClaim, AuctionFilter) -3. Create AuctionController with core logic -4. Create RoleAuctioneer -5. Add to CustomNpcs initialization - -### Phase 4: Auction GUI -1. Create GuiAuctionHouse base -2. Create GuiAuction (browse) -3. Create GuiAuctionSell -4. Create GuiAuctionClaims -5. Create GuiAuctionFilter -6. Create SubGuiAuctionDetails -7. Create ContainerAuction - -### Phase 5: Auction Network -1. Create AuctionDataPacket -2. Create AuctionActionPacket -3. Register packets - -### Phase 6: Admin & Polish -1. Create GuiManageAuction -2. Add permissions -3. Add notifications system -4. Add lang keys -5. Testing - ---- - -## 9. Design Decisions +## 8. Design Decisions | Decision | Choice | Rationale | |----------|--------|-----------| | Claims System | Everything to claims | Prevents offline handling issues | -| Auction Duration | Single config value | Simplicity over flexibility | -| Snipe Protection | Reset to X minutes | Fair bidding, prevents last-second sniping | -| Listing Fees | Flat fee only | Simple, predictable | -| Sales Tax | Percentage of sale | Scales with item value | -| Data Storage | Single .dat file | Consistent with quests/dialogs | -| Blacklist | Stub for now | Complex feature for later | -| Currency per Trade | Additive with items | Backward compatible, flexible | +| Vault Integration | Via PlayerCurrencyData | Single point of delegation, reused by Trader & Auction | +| Update Interval | 20 seconds | Performance vs responsiveness tradeoff | +| Player Indices | ConcurrentHashMap + Sets | O(1) lookups, thread-safe | +| Async Saves | CNPC+ Thread | Doesn't block server tick | +| Config Sync | LoginPacket → AuctionClientConfig | Client never accesses server config directly | --- -## 10. Future Considerations (Not in 1.11) +## 9. Future Considerations (Not in 1.11) - Category-based filtering - Advanced blacklist system (items, mods, NBT patterns) diff --git a/src/main/java/kamkeel/npcs/network/PacketHandler.java b/src/main/java/kamkeel/npcs/network/PacketHandler.java index 1405b44c9..8d008ceea 100644 --- a/src/main/java/kamkeel/npcs/network/PacketHandler.java +++ b/src/main/java/kamkeel/npcs/network/PacketHandler.java @@ -59,6 +59,7 @@ import kamkeel.npcs.network.packets.player.FollowerPacket; import kamkeel.npcs.network.packets.player.GetNPCRole; import kamkeel.npcs.network.packets.player.GetTraderData; import kamkeel.npcs.network.packets.player.InputDevicePacket; +import kamkeel.npcs.network.packets.player.AuctionActionPacket; import kamkeel.npcs.network.packets.player.MailActionPacket; import kamkeel.npcs.network.packets.player.SaveBookPacket; import kamkeel.npcs.network.packets.player.SaveSignPacket; @@ -591,6 +592,9 @@ public class PacketHandler { PLAYER_PACKET.registerPacket(new GetNPCRole()); PLAYER_PACKET.registerPacket(new GetTraderData()); + // Auction Packets + PLAYER_PACKET.registerPacket(new AuctionActionPacket()); + PLAYER_PACKET.registerPacket(new InputDevicePacket()); PLAYER_PACKET.registerPacket(new ScreenSizePacket()); diff --git a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java index ebcbdfc59..a5c6c8ba9 100644 --- a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java +++ b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java @@ -62,4 +62,7 @@ public enum EnumDataPacket { // Ability System TELEGRAPH_SPAWN, TELEGRAPH_REMOVE, + + // Auction System + AUCTION_DATA, } diff --git a/src/main/java/kamkeel/npcs/network/packets/data/LoginPacket.java b/src/main/java/kamkeel/npcs/network/packets/data/LoginPacket.java index c341bd02e..2717d6277 100644 --- a/src/main/java/kamkeel/npcs/network/packets/data/LoginPacket.java +++ b/src/main/java/kamkeel/npcs/network/packets/data/LoginPacket.java @@ -12,8 +12,11 @@ import kamkeel.npcs.network.enums.EnumSyncType; import kamkeel.npcs.network.packets.player.SyncRevisionInfoPacket; import kamkeel.npcs.util.ByteBufUtils; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.client.AuctionClientConfig; import noppes.npcs.client.ClientCacheHandler; import noppes.npcs.config.ConfigMain; +import noppes.npcs.controllers.AuctionConfigSync; import java.io.IOException; import java.util.EnumMap; @@ -57,6 +60,11 @@ public final class LoginPacket extends AbstractPacket { out.writeInt(entry.getKey().ordinal()); out.writeInt(entry.getValue()); } + + // Send auction config (server-side sync) + NBTTagCompound auctionConfig = new NBTTagCompound(); + AuctionConfigSync.writeToNBT(auctionConfig); + ByteBufUtils.writeNBT(out, auctionConfig); } @SideOnly(Side.CLIENT) @@ -76,6 +84,10 @@ public final class LoginPacket extends AbstractPacket { ClientCacheHandler.setActiveServer(serverKey, serverRevisions); + // Read auction config + NBTTagCompound auctionConfig = ByteBufUtils.readNBT(in); + AuctionClientConfig.readFromNBT(auctionConfig); + PacketClient.sendClient(new SyncRevisionInfoPacket( serverKey, ClientCacheHandler.getLastServerKey(), diff --git a/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java b/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java new file mode 100644 index 000000000..45d38068b --- /dev/null +++ b/src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java @@ -0,0 +1,410 @@ +package kamkeel.npcs.network.packets.player; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import io.netty.buffer.ByteBuf; +import kamkeel.npcs.network.AbstractPacket; +import kamkeel.npcs.network.PacketChannel; +import kamkeel.npcs.network.PacketClient; +import kamkeel.npcs.network.PacketHandler; +import kamkeel.npcs.network.enums.EnumPlayerPacket; +import kamkeel.npcs.network.packets.data.large.GuiDataPacket; +import kamkeel.npcs.util.ByteBufUtils; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.NoppesUtilServer; +import noppes.npcs.config.ConfigMarket; +import noppes.npcs.constants.EnumGuiType; +import noppes.npcs.constants.EnumRoleType; +import noppes.npcs.controllers.AuctionController; +import noppes.npcs.controllers.data.AuctionFilter; +import noppes.npcs.controllers.data.AuctionListing; + +import java.io.IOException; +import java.util.List; + +/** + * Handles all auction-related client-server communication. + */ +public class AuctionActionPacket extends AbstractPacket { + public static final String packetName = "Player|Auction"; + private static final int PAGE_SIZE = 45; // 9x5 grid + + private Action action; + private String listingId; + private long amount; + private NBTTagCompound data; + + public AuctionActionPacket() {} + + private AuctionActionPacket(Action action) { + this.action = action; + } + + private AuctionActionPacket(Action action, String listingId) { + this.action = action; + this.listingId = listingId; + } + + private AuctionActionPacket(Action action, String listingId, long amount) { + this.action = action; + this.listingId = listingId; + this.amount = amount; + } + + @Override + public boolean needsNPC() { return true; } + + // ========================================= + // Client-side static methods + // ========================================= + + @SideOnly(Side.CLIENT) + public static void placeBid(String listingId, long bidAmount) { + AuctionActionPacket packet = new AuctionActionPacket(Action.Bid, listingId, bidAmount); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void buyout(String listingId) { + AuctionActionPacket packet = new AuctionActionPacket(Action.Buyout, listingId); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void cancelListing(String listingId) { + AuctionActionPacket packet = new AuctionActionPacket(Action.Cancel, listingId); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void claimItem(String claimId) { + AuctionActionPacket packet = new AuctionActionPacket(Action.ClaimItem, claimId); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void claimCurrency(String claimId) { + AuctionActionPacket packet = new AuctionActionPacket(Action.ClaimCurrency, claimId); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void createListing(ItemStack item, long startingPrice, long buyoutPrice) { + AuctionActionPacket packet = new AuctionActionPacket(Action.CreateListing); + packet.amount = startingPrice; + packet.listingId = String.valueOf(buyoutPrice); + packet.data = new NBTTagCompound(); + item.writeToNBT(packet.data); + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void openPage(int page) { + AuctionActionPacket packet = new AuctionActionPacket(Action.OpenPage); + packet.amount = page; + PacketClient.sendClient(packet); + } + + @SideOnly(Side.CLIENT) + public static void openBidding(String listingId) { + AuctionActionPacket packet = new AuctionActionPacket(Action.OpenBidding, listingId); + PacketClient.sendClient(packet); + } + + /** Request listings from server with filter and page */ + @SideOnly(Side.CLIENT) + public static void requestListings(AuctionFilter filter, int page) { + AuctionActionPacket packet = new AuctionActionPacket(Action.RequestListings); + packet.amount = page; + packet.data = filter.writeToNBT(new NBTTagCompound()); + PacketClient.sendClient(packet); + } + + // ========================================= + // Packet Implementation + // ========================================= + + @Override + public Enum getType() { return EnumPlayerPacket.AuctionAction; } + + @Override + public PacketChannel getChannel() { return PacketHandler.PLAYER_PACKET; } + + @Override + @SideOnly(Side.CLIENT) + public void sendData(ByteBuf out) throws IOException { + out.writeInt(action.ordinal()); + + switch (action) { + case Bid: + ByteBufUtils.writeString(out, listingId); + out.writeLong(amount); + break; + case Buyout: + case Cancel: + case ClaimItem: + case ClaimCurrency: + ByteBufUtils.writeString(out, listingId); + break; + case CreateListing: + out.writeLong(amount); + ByteBufUtils.writeString(out, listingId); + ByteBufUtils.writeNBT(out, data); + break; + case OpenPage: + out.writeInt((int) amount); + break; + case OpenBidding: + ByteBufUtils.writeString(out, listingId); + break; + case RequestListings: + out.writeInt((int) amount); + ByteBufUtils.writeNBT(out, data); + break; + } + } + + @Override + public void receiveData(ByteBuf in, EntityPlayer player) throws IOException { + if (!(player instanceof EntityPlayerMP)) return; + EntityPlayerMP playerMP = (EntityPlayerMP) player; + + if (npc.advanced.role != EnumRoleType.Auctioneer) return; + if (!ConfigMarket.AuctionEnabled) return; + + Action requestedAction = Action.values()[in.readInt()]; + AuctionController controller = AuctionController.getInstance(); + String result = null; + + switch (requestedAction) { + case Bid: + result = handleBid(in, playerMP, controller); + break; + + case Buyout: + result = handleBuyout(in, playerMP, controller); + break; + + case Cancel: + result = handleCancel(in, playerMP, controller); + break; + + case ClaimItem: + result = handleClaimItem(in, playerMP, controller); + break; + + case ClaimCurrency: + result = handleClaimCurrency(in, playerMP, controller); + break; + + case CreateListing: + result = handleCreateListing(in, playerMP, controller); + break; + + case OpenPage: + handleOpenPage(in, playerMP); + break; + + case OpenBidding: + handleOpenBidding(in, playerMP, controller); + break; + + case RequestListings: + handleRequestListings(in, playerMP, controller); + break; + } + + if (result != null) { + sendError(playerMP, result); + } + } + + // ========================================= + // Action Handlers + // ========================================= + + private String handleBid(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String id = ByteBufUtils.readString(in); + long amount = in.readLong(); + String result = controller.placeBid(id, player, amount); + if (result == null) { + sendListingsUpdate(player); + } + return result; + } + + private String handleBuyout(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String id = ByteBufUtils.readString(in); + String result = controller.buyout(id, player); + if (result == null) { + sendListingsUpdate(player); + } + return result; + } + + private String handleCancel(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String id = ByteBufUtils.readString(in); + String result = controller.cancelListing(id, player, false); + if (result == null) { + sendTradesUpdate(player); + } + return result; + } + + private String handleClaimItem(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String id = ByteBufUtils.readString(in); + String result = controller.claimItem(id, player); + if (result == null) { + sendTradesUpdate(player); + } + return result; + } + + private String handleClaimCurrency(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String id = ByteBufUtils.readString(in); + String result = controller.claimCurrency(id, player); + if (result == null) { + sendTradesUpdate(player); + } + return result; + } + + private String handleCreateListing(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + long startingPrice = in.readLong(); + String buyoutStr = ByteBufUtils.readString(in); + long buyoutPrice = 0; + try { buyoutPrice = Long.parseLong(buyoutStr); } catch (NumberFormatException ignored) {} + + NBTTagCompound itemNBT = ByteBufUtils.readNBT(in); + if (itemNBT == null) return "No item data received."; + + ItemStack item = ItemStack.loadItemStackFromNBT(itemNBT); + if (item == null) return "Invalid item data."; + + // Remove item from inventory first + if (!removeItemFromInventory(player, item)) { + return "Item not found in inventory."; + } + + String result = controller.createListing(player, item, startingPrice, buyoutPrice); + if (result != null) { + // Failed - return item + player.inventory.addItemStackToInventory(item); + } + return result; + } + + private void handleOpenPage(ByteBuf in, EntityPlayerMP player) { + int page = in.readInt(); + EnumGuiType guiType; + switch (page) { + case 0: guiType = EnumGuiType.PlayerAuction; break; + case 1: guiType = EnumGuiType.PlayerAuctionSell; break; + case 2: guiType = EnumGuiType.PlayerAuctionTrades; break; + default: guiType = EnumGuiType.PlayerAuction; break; + } + NoppesUtilServer.sendOpenGui(player, guiType, npc); + } + + private void handleOpenBidding(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + String listingId = ByteBufUtils.readString(in); + AuctionListing listing = controller.getListing(listingId); + + if (listing == null || !listing.isActive()) { + sendError(player, "Listing not found or has ended."); + return; + } + + // Open bidding GUI + NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerAuctionBidding, npc); + + // Check if this is the player's own listing + boolean isOwnListing = listing.isSeller(player.getUniqueID()); + + // Send listing data + NBTTagCompound response = new NBTTagCompound(); + response.setBoolean("BiddingData", true); + response.setTag("Listing", listing.writeToNBT(new NBTTagCompound())); + response.setLong("Balance", controller.getPlayerBalance(player)); + response.setBoolean("IsOwnListing", isOwnListing); + GuiDataPacket.sendGuiData(player, response); + } + + private void handleRequestListings(ByteBuf in, EntityPlayerMP player, AuctionController controller) throws IOException { + int page = in.readInt(); + NBTTagCompound filterNBT = ByteBufUtils.readNBT(in); + + AuctionFilter filter = new AuctionFilter(); + if (filterNBT != null) { + filter.readFromNBT(filterNBT); + } + + // Get listings for this page + List listings = controller.getActiveListings(filter, page, PAGE_SIZE); + int totalListings = controller.getTotalActiveListings(filter); + int totalPages = Math.max(1, (int) Math.ceil((double) totalListings / PAGE_SIZE)); + + // Build response + NBTTagCompound response = new NBTTagCompound(); + response.setBoolean("ListingsData", true); + response.setInteger("Page", page); + response.setInteger("TotalPages", totalPages); + response.setInteger("TotalListings", totalListings); + + NBTTagList listingsList = new NBTTagList(); + for (AuctionListing listing : listings) { + listingsList.appendTag(listing.writeToNBT(new NBTTagCompound())); + } + response.setTag("Listings", listingsList); + + GuiDataPacket.sendGuiData(player, response); + } + + // ========================================= + // Helpers + // ========================================= + + private boolean removeItemFromInventory(EntityPlayerMP player, ItemStack target) { + for (int i = 0; i < player.inventory.getSizeInventory(); i++) { + ItemStack stack = player.inventory.getStackInSlot(i); + if (stack != null && ItemStack.areItemStacksEqual(stack, target)) { + player.inventory.setInventorySlotContents(i, null); + return true; + } + } + return false; + } + + private void sendError(EntityPlayerMP player, String message) { + player.addChatMessage(new net.minecraft.util.ChatComponentText( + net.minecraft.util.EnumChatFormatting.RED + "[Auction House] " + message)); + } + + private void sendTradesUpdate(EntityPlayerMP player) { + NBTTagCompound compound = new NBTTagCompound(); + compound.setBoolean("TradesUpdate", true); + GuiDataPacket.sendGuiData(player, compound); + } + + private void sendListingsUpdate(EntityPlayerMP player) { + NBTTagCompound compound = new NBTTagCompound(); + compound.setBoolean("ListingsRefresh", true); + GuiDataPacket.sendGuiData(player, compound); + } + + private enum Action { + Bid, + Buyout, + Cancel, + ClaimItem, + ClaimCurrency, + CreateListing, + OpenPage, + OpenBidding, + RequestListings + } +} diff --git a/src/main/java/noppes/npcs/CommonProxy.java b/src/main/java/noppes/npcs/CommonProxy.java index 251272c1d..6fefe01c1 100644 --- a/src/main/java/noppes/npcs/CommonProxy.java +++ b/src/main/java/noppes/npcs/CommonProxy.java @@ -14,6 +14,11 @@ import net.minecraftforge.common.util.FakePlayer; import noppes.npcs.api.IWorld; import noppes.npcs.blocks.tiles.TileNpcContainer; import noppes.npcs.constants.EnumGuiType; +import noppes.npcs.containers.ContainerAuction; +import noppes.npcs.containers.ContainerAuctionBidding; +import noppes.npcs.containers.ContainerAuctionTrades; +import noppes.npcs.containers.ContainerAuctionListing; +import noppes.npcs.containers.ContainerAuctionSell; import noppes.npcs.containers.ContainerAnvilRepair; import noppes.npcs.containers.ContainerCarpentryBench; import noppes.npcs.containers.ContainerCrate; @@ -92,6 +97,18 @@ public class CommonProxy implements IGuiHandler { if (gui == EnumGuiType.PlayerTrader) return new ContainerNPCTrader(npc, player); + if (gui == EnumGuiType.PlayerAuction) + return new ContainerAuctionListing(npc, player); + + if (gui == EnumGuiType.PlayerAuctionSell) + return new ContainerAuctionSell(npc, player); + + if (gui == EnumGuiType.PlayerAuctionTrades) + return new ContainerAuctionTrades(npc, player); + + if (gui == EnumGuiType.PlayerAuctionBidding) + return new ContainerAuctionBidding(npc, player); + if (gui == EnumGuiType.PlayerCarpentryBench) return new ContainerCarpentryBench(player.inventory, player.worldObj, x, y, z); diff --git a/src/main/java/noppes/npcs/CustomNpcs.java b/src/main/java/noppes/npcs/CustomNpcs.java index c5b82d72a..cb9241865 100644 --- a/src/main/java/noppes/npcs/CustomNpcs.java +++ b/src/main/java/noppes/npcs/CustomNpcs.java @@ -55,6 +55,7 @@ import noppes.npcs.config.ConfigMain; import noppes.npcs.config.LoadConfiguration; import noppes.npcs.config.legacy.LegacyConfig; import noppes.npcs.controllers.AnimationController; +import noppes.npcs.controllers.AuctionController; import noppes.npcs.controllers.BankController; import noppes.npcs.controllers.ChunkController; import noppes.npcs.controllers.CustomEffectController; @@ -369,6 +370,7 @@ public class CustomNpcs { public void started(FMLServerStartedEvent event) { RecipeController.Instance.load(); new BankController(); + new AuctionController(); DialogController.Instance.load(); QuestController.Instance.load(); ScriptController.HasStart = true; @@ -389,6 +391,12 @@ public class CustomNpcs { ScriptController.Instance.saveForgeScripts(); ScriptController.Instance.savePlayerScripts(); ScriptController.Instance.saveGlobalNpcScripts(); + + // Save auction data synchronously on shutdown + if (AuctionController.Instance != null) { + AuctionController.Instance.save(); + } + if (FMLCommonHandler.instance().getSide().isClient()) clientJaninoCompiler = null; globalJaninoCompiler = null; diff --git a/src/main/java/noppes/npcs/DataAdvanced.java b/src/main/java/noppes/npcs/DataAdvanced.java index 45ecfe4a8..f03714506 100644 --- a/src/main/java/noppes/npcs/DataAdvanced.java +++ b/src/main/java/noppes/npcs/DataAdvanced.java @@ -18,6 +18,7 @@ import noppes.npcs.roles.JobGuard; import noppes.npcs.roles.JobHealer; import noppes.npcs.roles.JobItemGiver; import noppes.npcs.roles.JobSpawner; +import noppes.npcs.roles.RoleAuctioneer; import noppes.npcs.roles.RoleBank; import noppes.npcs.roles.RoleCompanion; import noppes.npcs.roles.RoleFollower; @@ -218,6 +219,8 @@ public class DataAdvanced { npc.roleInterface = new RoleCompanion(npc); else if (role == EnumRoleType.Mount && !(npc.roleInterface instanceof RoleMount)) npc.roleInterface = new RoleMount(npc); + else if (role == EnumRoleType.Auctioneer && !(npc.roleInterface instanceof RoleAuctioneer)) + npc.roleInterface = new RoleAuctioneer(npc); } public void setJob(int i) { diff --git a/src/main/java/noppes/npcs/ServerTickHandler.java b/src/main/java/noppes/npcs/ServerTickHandler.java index ea42ce84c..27995f3cb 100644 --- a/src/main/java/noppes/npcs/ServerTickHandler.java +++ b/src/main/java/noppes/npcs/ServerTickHandler.java @@ -6,6 +6,7 @@ import cpw.mods.fml.common.gameevent.TickEvent; import cpw.mods.fml.common.gameevent.TickEvent.Phase; import kamkeel.npcs.controllers.ProfileController; import kamkeel.npcs.controllers.SyncController; +import noppes.npcs.controllers.AuctionController; import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.world.WorldServer; import noppes.npcs.controllers.ScriptController; @@ -19,6 +20,11 @@ public class ServerTickHandler { public void onServerTick(TickEvent.ServerTickEvent event) { if (event.phase == Phase.END) { ActionManager.GLOBAL.tick(); + + // Process auction system tick + if (AuctionController.Instance != null) { + AuctionController.Instance.onServerTick(); + } } } @@ -43,6 +49,11 @@ public class ServerTickHandler { SyncController.beginLogin(player); SyncController.syncEffects(player); ScriptController.Instance.syncClientScripts(player); + + // Send auction notifications on login + if (AuctionController.Instance != null) { + AuctionController.Instance.onPlayerLogin(player); + } } @SubscribeEvent diff --git a/src/main/java/noppes/npcs/client/AuctionClientConfig.java b/src/main/java/noppes/npcs/client/AuctionClientConfig.java new file mode 100644 index 000000000..aaae441bf --- /dev/null +++ b/src/main/java/noppes/npcs/client/AuctionClientConfig.java @@ -0,0 +1,66 @@ +package noppes.npcs.client; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.nbt.NBTTagCompound; + +/** + * Client-side cache for auction configuration synced from server. + * Prevents client from using local ConfigMarket which may differ from server settings. + * + * IMPORTANT: This class is CLIENT-ONLY. Server-side NBT writing is done in + * AuctionConfigSync to avoid @SideOnly crashes on dedicated servers. + */ +@SideOnly(Side.CLIENT) +public class AuctionClientConfig { + // Cached values from server + private static long listingFee = 0; + private static String currencyName = "Gold"; + private static int auctionDurationHours = 48; + private static double minBidIncrement = 0.05; + private static int maxActiveListings = 10; + private static int claimExpirationDays = 30; + private static boolean auctionEnabled = false; + + /** + * Update cached config from server NBT data. + * Called when receiving login packet. + */ + public static void readFromNBT(NBTTagCompound compound) { + if (compound == null) return; + + auctionEnabled = compound.getBoolean("AuctionEnabled"); + listingFee = compound.getLong("ListingFee"); + currencyName = compound.getString("CurrencyName"); + auctionDurationHours = compound.getInteger("AuctionDurationHours"); + minBidIncrement = compound.getDouble("MinBidIncrement"); + maxActiveListings = compound.getInteger("MaxActiveListings"); + claimExpirationDays = compound.getInteger("ClaimExpirationDays"); + + if (currencyName.isEmpty()) { + currencyName = "Gold"; + } + } + + /** + * Reset to defaults (called on disconnect). + */ + public static void reset() { + listingFee = 0; + currencyName = "Gold"; + auctionDurationHours = 48; + minBidIncrement = 0.05; + maxActiveListings = 10; + claimExpirationDays = 30; + auctionEnabled = false; + } + + // Getters + public static boolean isAuctionEnabled() { return auctionEnabled; } + public static long getListingFee() { return listingFee; } + public static String getCurrencyName() { return currencyName; } + public static int getAuctionDurationHours() { return auctionDurationHours; } + public static double getMinBidIncrement() { return minBidIncrement; } + public static int getMaxActiveListings() { return maxActiveListings; } + public static int getClaimExpirationDays() { return claimExpirationDays; } +} diff --git a/src/main/java/noppes/npcs/client/ClientProxy.java b/src/main/java/noppes/npcs/client/ClientProxy.java index 656efc97b..8eea9e27b 100644 --- a/src/main/java/noppes/npcs/client/ClientProxy.java +++ b/src/main/java/noppes/npcs/client/ClientProxy.java @@ -108,6 +108,10 @@ import noppes.npcs.client.gui.player.GuiCrate; import noppes.npcs.client.gui.player.GuiMailbox; import noppes.npcs.client.gui.player.GuiMailmanWrite; import noppes.npcs.client.gui.player.GuiNPCBankChest; +import noppes.npcs.client.gui.player.GuiAuctionBidding; +import noppes.npcs.client.gui.player.GuiAuctionTrades; +import noppes.npcs.client.gui.player.GuiAuctionListing; +import noppes.npcs.client.gui.player.GuiAuctionSell; import noppes.npcs.client.gui.player.GuiNPCTrader; import noppes.npcs.client.gui.player.GuiNpcAnvil; import noppes.npcs.client.gui.player.GuiNpcCarpentryBench; @@ -193,6 +197,11 @@ import noppes.npcs.containers.ContainerNPCFollower; import noppes.npcs.containers.ContainerNPCFollowerHire; import noppes.npcs.containers.ContainerNPCFollowerSetup; import noppes.npcs.containers.ContainerNPCInv; +import noppes.npcs.containers.ContainerAuction; +import noppes.npcs.containers.ContainerAuctionBidding; +import noppes.npcs.containers.ContainerAuctionTrades; +import noppes.npcs.containers.ContainerAuctionListing; +import noppes.npcs.containers.ContainerAuctionSell; import noppes.npcs.containers.ContainerNPCTrader; import noppes.npcs.containers.ContainerNPCTraderSetup; import noppes.npcs.containers.ContainerNpcItemGiver; @@ -484,6 +493,18 @@ public class ClientProxy extends CommonProxy { else if (gui == EnumGuiType.PlayerTrader) return new GuiNPCTrader(npc, (ContainerNPCTrader) container); + else if (gui == EnumGuiType.PlayerAuction) + return new GuiAuctionListing(npc, (ContainerAuctionListing) container); + + else if (gui == EnumGuiType.PlayerAuctionSell) + return new GuiAuctionSell(npc, (ContainerAuctionSell) container); + + else if (gui == EnumGuiType.PlayerAuctionTrades) + return new GuiAuctionTrades(npc, (ContainerAuctionTrades) container); + + else if (gui == EnumGuiType.PlayerAuctionBidding) + return new GuiAuctionBidding(npc, (ContainerAuctionBidding) container); + else if (gui == EnumGuiType.PlayerBankSmall || gui == EnumGuiType.PlayerBankUnlock || gui == EnumGuiType.PlayerBankUprade || gui == EnumGuiType.PlayerBankLarge) return new GuiNPCBankChest(npc, (ContainerNPCBankInterface) container); diff --git a/src/main/java/noppes/npcs/client/gui/player/AuctionTooltipHandler.java b/src/main/java/noppes/npcs/client/gui/player/AuctionTooltipHandler.java new file mode 100644 index 000000000..f8f58e29b --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/AuctionTooltipHandler.java @@ -0,0 +1,213 @@ +package noppes.npcs.client.gui.player; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.containers.ContainerAuctionListing; +import noppes.npcs.containers.ContainerAuctionTrades; +import noppes.npcs.controllers.data.AuctionClaim; +import noppes.npcs.controllers.data.AuctionListing; + +import java.util.List; + +/** + * Handles adding auction information to item tooltips when viewing auction GUIs. + * Called from MixinItem when Item.addInformation is invoked. + */ +@SideOnly(Side.CLIENT) +public class AuctionTooltipHandler { + + /** + * Add auction information to an item's tooltip if we're in an auction GUI. + */ + public static void addAuctionInfo(ItemStack stack, List tooltip) { + if (stack == null || tooltip == null) return; + + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null || mc.currentScreen == null) return; + + GuiScreen screen = mc.currentScreen; + + // Check if we're in an auction listing GUI + if (screen instanceof GuiAuctionListing) { + addListingTooltip((GuiAuctionListing) screen, stack, tooltip); + } + // Check if we're in the trades GUI + else if (screen instanceof GuiAuctionTrades) { + addTradesTooltip((GuiAuctionTrades) screen, stack, tooltip); + } + } + + /** + * Add auction info when viewing the main listings page. + */ + private static void addListingTooltip(GuiAuctionListing gui, ItemStack stack, List tooltip) { + ContainerAuctionListing container = (ContainerAuctionListing) gui.inventorySlots; + if (container == null) return; + + // Find the listing that matches this ItemStack + AuctionListing listing = container.getListingForItem(stack); + if (listing == null) return; + + // Add separator + tooltip.add(""); + tooltip.add(EnumChatFormatting.DARK_GRAY + "--- " + EnumChatFormatting.GOLD + + StatCollector.translateToLocal("auction.title") + EnumChatFormatting.DARK_GRAY + " ---"); + + // Seller + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.seller") + .replace("%s", EnumChatFormatting.WHITE + listing.sellerName)); + + // Current bid or starting price + if (listing.hasBids()) { + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.currentBid") + .replace("%s", EnumChatFormatting.GOLD + formatCurrency(listing.currentBid))); + tooltip.add(EnumChatFormatting.GRAY + String.format(StatCollector.translateToLocal("auction.bids"), listing.bidCount)); + } else { + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.startingPrice") + .replace("%s", EnumChatFormatting.GOLD + formatCurrency(listing.startingPrice))); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.noBids")); + } + + // Buyout price + if (listing.hasBuyout()) { + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.buyout") + .replace("%s", EnumChatFormatting.GREEN + formatCurrency(listing.buyoutPrice))); + } + + // Time remaining + long timeRemaining = listing.getTimeRemaining(); + String timeText = StatCollector.translateToLocal("auction.timeLeft") + .replace("%s", formatTimeRemaining(timeRemaining)); + tooltip.add((timeRemaining < 3600000 ? EnumChatFormatting.RED : EnumChatFormatting.WHITE) + timeText); + } + + /** + * Add auction info when viewing the trades page. + */ + private static void addTradesTooltip(GuiAuctionTrades gui, ItemStack stack, List tooltip) { + ContainerAuctionTrades container = (ContainerAuctionTrades) gui.inventorySlots; + if (container == null) return; + + // Check if it's an active listing + AuctionListing listing = container.getListingForItem(stack); + if (listing != null) { + tooltip.add(""); + tooltip.add(EnumChatFormatting.DARK_GRAY + "--- " + EnumChatFormatting.GOLD + + StatCollector.translateToLocal("auction.trades.activeListing") + EnumChatFormatting.DARK_GRAY + " ---"); + + if (listing.hasBids()) { + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.currentBid") + .replace("%s", EnumChatFormatting.GOLD + formatCurrency(listing.currentBid))); + tooltip.add(EnumChatFormatting.GRAY + String.format(StatCollector.translateToLocal("auction.bids"), listing.bidCount)); + } else { + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.noBids")); + } + + long timeRemaining = listing.getTimeRemaining(); + String timeText = StatCollector.translateToLocal("auction.timeLeft") + .replace("%s", formatTimeRemaining(timeRemaining)); + tooltip.add((timeRemaining < 3600000 ? EnumChatFormatting.RED : EnumChatFormatting.WHITE) + timeText); + + tooltip.add(""); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.trades.rightClickToCancel")); + return; + } + + // Check if it's a claim + AuctionClaim claim = container.getClaimForItem(stack); + if (claim != null) { + tooltip.add(""); + + String claimTitle; + switch (claim.type) { + case ITEM: + claimTitle = StatCollector.translateToLocal("auction.claim.item"); + break; + case CURRENCY: + claimTitle = StatCollector.translateToLocal("auction.claim.currency"); + break; + case REFUND: + claimTitle = StatCollector.translateToLocal("auction.claim.refund"); + break; + default: + claimTitle = "Claim"; + } + + tooltip.add(EnumChatFormatting.DARK_GRAY + "--- " + EnumChatFormatting.GOLD + + claimTitle + EnumChatFormatting.DARK_GRAY + " ---"); + + if (claim.type.isCurrency()) { + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.claim.amount") + .replace("%s", EnumChatFormatting.GOLD + formatCurrency(claim.currency))); + } + + // Expiration + int expirationDays = AuctionClientConfig.getClaimExpirationDays(); + long daysLeft = claim.getDaysUntilExpiration(expirationDays); + if (daysLeft <= 1) { + tooltip.add(EnumChatFormatting.RED + StatCollector.translateToLocal("auction.claim.expiresSoon")); + } else { + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.claim.expires") + .replace("%s", String.valueOf(daysLeft))); + } + + tooltip.add(""); + tooltip.add(EnumChatFormatting.GREEN + StatCollector.translateToLocal("auction.claim.clickToClaim")); + } + } + + /** + * Format currency with commas. + */ + private static String formatCurrency(long amount) { + if (amount < 1000) { + return amount + " " + AuctionClientConfig.getCurrencyName(); + } + StringBuilder sb = new StringBuilder(); + String str = String.valueOf(amount); + int count = 0; + for (int i = str.length() - 1; i >= 0; i--) { + if (count > 0 && count % 3 == 0) { + sb.insert(0, ','); + } + sb.insert(0, str.charAt(i)); + count++; + } + return sb.toString() + " " + AuctionClientConfig.getCurrencyName(); + } + + /** + * Format time remaining. + */ + private static String formatTimeRemaining(long milliseconds) { + if (milliseconds <= 0) { + return StatCollector.translateToLocal("auction.ended"); + } + + long seconds = (milliseconds / 1000) % 60; + long minutes = (milliseconds / 60000) % 60; + long hours = (milliseconds / 3600000) % 24; + long days = milliseconds / 86400000; + + StringBuilder sb = new StringBuilder(); + if (days > 0) { + sb.append(days).append("d "); + } + if (hours > 0 || days > 0) { + sb.append(hours).append("h "); + } + if (minutes > 0 || hours > 0 || days > 0) { + sb.append(minutes).append("m"); + } else { + sb.append(seconds).append("s"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionBidding.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionBidding.java new file mode 100644 index 000000000..5159b09de --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionBidding.java @@ -0,0 +1,299 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.inventory.Slot; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.client.gui.util.GuiAuctionNavButton; +import noppes.npcs.client.gui.util.IGuiData; +import noppes.npcs.client.gui.util.ISubGuiListener; +import noppes.npcs.client.gui.util.SubGuiInterface; +import noppes.npcs.containers.ContainerAuctionBidding; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * GUI for viewing and bidding on a specific auction listing. + * Shows item, auction info, and bid/buyout buttons. + */ +public class GuiAuctionBidding extends GuiAuctionInterface implements ISubGuiListener, IGuiData { + // Icons + private static final ResourceLocation ICON_BID = new ResourceLocation("customnpcs", "textures/items/npcCoinDiamond.png"); + private static final ResourceLocation ICON_BUYOUT = new ResourceLocation("customnpcs", "textures/items/npcCoinEmerald.png"); + + // Layout - non-final for testing/positioning + protected int itemSlotX = 56; + protected int itemSlotY = 52; + protected int btnBidX = 56; + protected int btnBidY = 80; + protected int btnBuyoutX = 80; + protected int btnBuyoutY = 80; + protected int infoX = 110; + protected int infoY = 50; + protected int infoWidth = 130; + + // Button IDs + private static final int BTN_BID = 200; + private static final int BTN_BUYOUT = 201; + + private final ContainerAuctionBidding biddingContainer; + private AuctionListing listing; + private long playerBalance = 0; + private boolean dataLoaded = false; + private boolean isOwnListing = false; + + // Buttons + private GuiAuctionNavButton btnBid; + private GuiAuctionNavButton btnBuyout; + + public GuiAuctionBidding(EntityNPCInterface npc, ContainerAuctionBidding container) { + super(npc, container); + this.biddingContainer = container; + } + + @Override + public void initGui() { + super.initGui(); + + // Bid button (diamond coin) + btnBid = new GuiAuctionNavButton(BTN_BID, guiLeft + btnBidX, guiTop + btnBidY, + "auction.bid.place", ICON_BID); + addButton(btnBid); + + // Buy Now button (emerald coin) - only visible if listing has buyout + btnBuyout = new GuiAuctionNavButton(BTN_BUYOUT, guiLeft + btnBuyoutX, guiTop + btnBuyoutY, + "auction.bid.buyout", ICON_BUYOUT); + addButton(btnBuyout); + + updateButtonState(); + } + + @Override + protected int getCurrentPage() { + // Not a standard page - return -1 so nav buttons aren't selected + return -1; + } + + /** Update button visibility based on listing state */ + private void updateButtonState() { + if (listing == null) { + if (btnBid != null) btnBid.setVisible(false); + if (btnBuyout != null) btnBuyout.setVisible(false); + return; + } + + // Hide buttons for own listings - can view but not bid/buy + if (isOwnListing) { + if (btnBid != null) btnBid.setVisible(false); + if (btnBuyout != null) btnBuyout.setVisible(false); + return; + } + + // Bid button visible for other players' listings + if (btnBid != null) btnBid.setVisible(true); + + // Buyout button only if listing has buyout + if (btnBuyout != null) { + btnBuyout.setVisible(listing.hasBuyout()); + } + } + + @Override + public void actionPerformed(GuiButton button) { + super.actionPerformed(button); + + if (listing == null) return; + + switch (button.id) { + case BTN_BID: + openBidSubGui(); + break; + case BTN_BUYOUT: + if (listing.hasBuyout()) { + openBuyNowSubGui(); + } + break; + } + } + + private void openBidSubGui() { + if (listing == null) return; + long minBid = listing.getMinimumBid(AuctionClientConfig.getMinBidIncrement()); + setSubGui(new SubGuiAuctionBid(listing.id, minBid, playerBalance)); + } + + private void openBuyNowSubGui() { + if (listing == null || !listing.hasBuyout()) return; + setSubGui(new SubGuiAuctionBuyNow(listing.id, listing.buyoutPrice, playerBalance)); + } + + @Override + public void subGuiClosed(SubGuiInterface subgui) { + if (subgui instanceof SubGuiAuctionBid) { + SubGuiAuctionBid bidGui = (SubGuiAuctionBid) subgui; + if (bidGui.wasSuccessful()) { + // Bid placed - go to listings + AuctionActionPacket.openPage(PAGE_LISTINGS); + } + } else if (subgui instanceof SubGuiAuctionBuyNow) { + SubGuiAuctionBuyNow buyGui = (SubGuiAuctionBuyNow) subgui; + if (buyGui.wasSuccessful()) { + // Bought out - go to my trades + AuctionActionPacket.openPage(PAGE_CLAIMS); + } + } + } + + // ========== Data Handling ========== + + @Override + public void setGuiData(NBTTagCompound compound) { + if (compound.hasKey("BiddingData")) { + playerBalance = compound.getLong("Balance"); + isOwnListing = compound.getBoolean("IsOwnListing"); + + if (compound.hasKey("Listing")) { + listing = AuctionListing.fromNBT(compound.getCompoundTag("Listing")); + biddingContainer.setListing(listing); + } + + dataLoaded = true; + updateButtonState(); + } + } + + // ========== Drawing ========== + + @Override + protected void drawAuctionContent(float partialTicks, int mouseX, int mouseY) { + // Draw item slot background + drawAuctionSlot(guiLeft + itemSlotX, guiTop + itemSlotY); + + if (!dataLoaded || listing == null) { + // Loading text + String loading = StatCollector.translateToLocal("gui.loading"); + fontRendererObj.drawString(loading, guiLeft + infoX, guiTop + infoY, 0xFFFFFF); + return; + } + + // Draw auction info on the right side + drawAuctionInfo(); + } + + /** Draw auction listing information on the right side */ + private void drawAuctionInfo() { + int x = guiLeft + infoX; + int y = guiTop + infoY; + int lineHeight = 12; + String currencyName = AuctionClientConfig.getCurrencyName(); + + // Seller + String sellerLabel = StatCollector.translateToLocal("auction.seller").replace("%s", ""); + fontRendererObj.drawString(EnumChatFormatting.GRAY + sellerLabel, x, y, 0xFFFFFF); + y += lineHeight; + fontRendererObj.drawString(EnumChatFormatting.WHITE + listing.sellerName, x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + + // Current Bid or Starting Price + if (listing.hasBids()) { + String bidLabel = StatCollector.translateToLocal("auction.info.currentBid"); + fontRendererObj.drawString(EnumChatFormatting.YELLOW + bidLabel, x, y, 0xFFFFFF); + y += lineHeight; + fontRendererObj.drawString(EnumChatFormatting.GOLD + formatCurrency(listing.currentBid) + " " + currencyName, x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + + // Current Bidder + String bidderLabel = StatCollector.translateToLocal("auction.info.highBidder"); + fontRendererObj.drawString(EnumChatFormatting.GRAY + bidderLabel, x, y, 0xFFFFFF); + y += lineHeight; + String bidderName = listing.highBidderName != null ? listing.highBidderName : "???"; + fontRendererObj.drawString(EnumChatFormatting.WHITE + bidderName, x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + } else { + String startLabel = StatCollector.translateToLocal("auction.info.startingPrice"); + fontRendererObj.drawString(EnumChatFormatting.YELLOW + startLabel, x, y, 0xFFFFFF); + y += lineHeight; + fontRendererObj.drawString(EnumChatFormatting.GOLD + formatCurrency(listing.startingPrice) + " " + currencyName, x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + } + + // Buyout Price (if available) + if (listing.hasBuyout()) { + String buyoutLabel = StatCollector.translateToLocal("auction.info.buyoutPrice"); + fontRendererObj.drawString(EnumChatFormatting.GREEN + buyoutLabel, x, y, 0xFFFFFF); + y += lineHeight; + fontRendererObj.drawString(EnumChatFormatting.GREEN + formatCurrency(listing.buyoutPrice) + " " + currencyName, x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + } + + // Time Remaining + String timeLabel = StatCollector.translateToLocal("auction.info.timeLeft"); + fontRendererObj.drawString(EnumChatFormatting.GRAY + timeLabel, x, y, 0xFFFFFF); + y += lineHeight; + long timeRemaining = listing.getTimeRemaining(); + EnumChatFormatting timeColor = timeRemaining < 3600000 ? EnumChatFormatting.RED : EnumChatFormatting.WHITE; + fontRendererObj.drawString(timeColor + formatTimeRemaining(timeRemaining), x + 10, y, 0xFFFFFF); + y += lineHeight + 4; + + // Bid Count + String bidCountLabel = StatCollector.translateToLocal("auction.info.bidCount"); + fontRendererObj.drawString(EnumChatFormatting.GRAY + bidCountLabel, x, y, 0xFFFFFF); + y += lineHeight; + fontRendererObj.drawString(EnumChatFormatting.WHITE + String.valueOf(listing.bidCount), x + 10, y, 0xFFFFFF); + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + super.drawGuiContainerForegroundLayer(mouseX, mouseY); + if (hasSubGui()) return; + + // Draw button tooltips + drawBiddingPageTooltips(mouseX, mouseY); + } + + private void drawBiddingPageTooltips(int mouseX, int mouseY) { + List tooltip = null; + + if (btnBid != null && btnBid.visible && btnBid.isHovered()) { + tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GOLD + StatCollector.translateToLocal("auction.bid.place")); + if (listing != null) { + long minBid = listing.getMinimumBid(AuctionClientConfig.getMinBidIncrement()); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.bid.minimum") + .replace("%s", EnumChatFormatting.WHITE + formatCurrency(minBid) + " " + AuctionClientConfig.getCurrencyName())); + } + } else if (btnBuyout != null && btnBuyout.visible && btnBuyout.isHovered()) { + tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GREEN + StatCollector.translateToLocal("auction.bid.buyout")); + if (listing != null && listing.hasBuyout()) { + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.info.buyoutPrice") + ":"); + tooltip.add(EnumChatFormatting.GREEN + formatCurrency(listing.buyoutPrice) + " " + AuctionClientConfig.getCurrencyName()); + } + } + + if (tooltip != null && !tooltip.isEmpty()) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + drawHoveringText(tooltip, mouseX - guiLeft, mouseY - guiTop, fontRendererObj); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } + } + + // ========== Slot Handling ========== + + @Override + protected void handleMouseClick(Slot slot, int slotIndex, int mouseButton, int clickType) { + // Block all slot interactions - view only + } + + public AuctionListing getListing() { return listing; } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionInterface.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionInterface.java new file mode 100644 index 000000000..851c92263 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionInterface.java @@ -0,0 +1,232 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.util.ResourceLocation; +import noppes.npcs.client.gui.util.GuiAuctionNavButton; +import noppes.npcs.client.gui.util.GuiContainerNPCInterface; +import noppes.npcs.containers.ContainerAuction; +import noppes.npcs.entity.EntityNPCInterface; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; + +import java.util.List; + +/** + * Base GUI for all auction interfaces. + * 256x256 texture with player inventory at bottom. + */ +public abstract class GuiAuctionInterface extends GuiContainerNPCInterface { + // Textures + protected static final ResourceLocation AUCTION_BACKGROUND = new ResourceLocation("customnpcs", "textures/gui/auction/auction.png"); + protected static final ResourceLocation AUCTION_SLOT = new ResourceLocation("customnpcs", "textures/gui/auction/auction_slot.png"); + + // Navigation icons + protected static final ResourceLocation ICON_LISTINGS = new ResourceLocation("customnpcs", "textures/items/npcAncientScroll.png"); + protected static final ResourceLocation ICON_SELL = new ResourceLocation("customnpcs", "textures/gui/auction/sell.png"); + protected static final ResourceLocation ICON_CLAIMS = new ResourceLocation("customnpcs", "textures/gui/auction/my_auctions.png"); + protected static final ResourceLocation ICON_FILTER = new ResourceLocation("customnpcs", "textures/gui/auction/search.png"); + + // Page constants + public static final int PAGE_LISTINGS = 0; + public static final int PAGE_SELL = 1; + public static final int PAGE_CLAIMS = 2; + + // Navigation button IDs + protected static final int BTN_NAV_LISTINGS = 100; + protected static final int BTN_NAV_SELL = 101; + protected static final int BTN_NAV_CLAIMS = 102; + protected static final int BTN_NAV_FILTER = 103; + + // Navigation positions - non-final for testing/positioning + protected int navX = 35; + protected int navYListings = 46; + protected int navYSell = 67; + protected int navYClaims = 88; + + protected final ContainerAuction auctionContainer; + protected GuiAuctionNavButton btnListings; + protected GuiAuctionNavButton btnSell; + protected GuiAuctionNavButton btnClaims; + + public GuiAuctionInterface(EntityNPCInterface npc, ContainerAuction container) { + super(npc, container); + this.auctionContainer = container; + this.xSize = 256; + this.ySize = 256; + this.closeOnEsc = true; + this.title = ""; + } + + @Override + public void initGui() { + super.initGui(); + initNavigationButtons(); + } + + /** Initialize navigation buttons */ + protected void initNavigationButtons() { + int page = getCurrentPage(); + + btnListings = new GuiAuctionNavButton(BTN_NAV_LISTINGS, guiLeft + navX, guiTop + navYListings, + "auction.nav.listings", ICON_LISTINGS); + btnListings.setSelected(page == PAGE_LISTINGS); + addButton(btnListings); + + btnSell = new GuiAuctionNavButton(BTN_NAV_SELL, guiLeft + navX, guiTop + navYSell, + "auction.nav.sell", ICON_SELL); + btnSell.setSelected(page == PAGE_SELL); + addButton(btnSell); + + btnClaims = new GuiAuctionNavButton(BTN_NAV_CLAIMS, guiLeft + navX, guiTop + navYClaims, + "auction.nav.trades", ICON_CLAIMS); + btnClaims.setSelected(page == PAGE_CLAIMS); + addButton(btnClaims); + } + + /** Returns current page constant - implement in subclass */ + protected abstract int getCurrentPage(); + + @Override + public void actionPerformed(GuiButton button) { + super.actionPerformed(button); + int page = getCurrentPage(); + if (button.id == BTN_NAV_LISTINGS && page != PAGE_LISTINGS) { + AuctionActionPacket.openPage(PAGE_LISTINGS); + } else if (button.id == BTN_NAV_SELL && page != PAGE_SELL) { + AuctionActionPacket.openPage(PAGE_SELL); + } else if (button.id == BTN_NAV_CLAIMS && page != PAGE_CLAIMS) { + AuctionActionPacket.openPage(PAGE_CLAIMS); + } + } + + // ========== Background Drawing ========== + + @Override + protected void drawGuiContainerBackgroundLayer(float partialTicks, int mouseX, int mouseY) { + drawWorldBackground(0); + + GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + mc.renderEngine.bindTexture(AUCTION_BACKGROUND); + drawTexturedModalRect(guiLeft, guiTop, 0, 0, xSize, ySize); + + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL12.GL_RESCALE_NORMAL); + GL11.glEnable(GL11.GL_COLOR_MATERIAL); + GL11.glEnable(GL11.GL_LIGHTING); + + drawAuctionContent(partialTicks, mouseX, mouseY); + + GL11.glEnable(GL11.GL_LIGHTING); + GL11.glEnable(GL11.GL_DEPTH_TEST); + RenderHelper.enableStandardItemLighting(); + + super.drawGuiContainerBackgroundLayer(partialTicks, mouseX, mouseY); + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + if (!hasSubGui()) { + drawNavButtonTooltips(mouseX, mouseY); + } + } + + /** Draw navigation button tooltips */ + protected void drawNavButtonTooltips(int mouseX, int mouseY) { + List tooltip = null; + if (btnListings != null && btnListings.isHovered()) tooltip = btnListings.getTooltipLines(); + else if (btnSell != null && btnSell.isHovered()) tooltip = btnSell.getTooltipLines(); + else if (btnClaims != null && btnClaims.isHovered()) tooltip = btnClaims.getTooltipLines(); + + if (tooltip != null) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + drawHoveringText(tooltip, mouseX - guiLeft, mouseY - guiTop, fontRendererObj); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } + } + + // ========== Slot Drawing Utilities ========== + + /** Draw auction slot background (18x18) */ + protected void drawAuctionSlot(int x, int y) { + GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + mc.renderEngine.bindTexture(AUCTION_SLOT); + Gui.func_146110_a(x, y, 0, 0, 18, 18, 18, 18); + } + + /** Draw colored overlay on slot (ARGB format) */ + protected void drawColoredOverlay(int x, int y, int color) { + GL11.glDisable(GL11.GL_LIGHTING); + GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + Gui.drawRect(x + 1, y + 1, x + 17, y + 17, color); + GL11.glDisable(GL11.GL_BLEND); + GL11.glEnable(GL11.GL_DEPTH_TEST); + GL11.glEnable(GL11.GL_LIGHTING); + } + + /** Draw dark overlay for unavailable slots */ + protected void drawDarkenedOverlay(int x, int y) { + drawColoredOverlay(x, y, 0x80000000); + } + + /** Draw icon texture on slot (16x16) */ + protected void drawIconOverlay(int x, int y, ResourceLocation icon) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + GL11.glDisable(GL11.GL_LIGHTING); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + mc.renderEngine.bindTexture(icon); + Gui.func_146110_a(x + 1, y + 1, 0, 0, 16, 16, 16, 16); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } + + /** Subclasses implement this to draw specific content */ + protected abstract void drawAuctionContent(float partialTicks, int mouseX, int mouseY); + + // ========== Formatting Utilities ========== + + /** Format currency with commas (e.g., 1000 -> "1,000") */ + protected String formatCurrency(long amount) { + if (amount < 1000) return String.valueOf(amount); + StringBuilder sb = new StringBuilder(); + String str = String.valueOf(amount); + int count = 0; + for (int i = str.length() - 1; i >= 0; i--) { + if (count > 0 && count % 3 == 0) sb.insert(0, ','); + sb.insert(0, str.charAt(i)); + count++; + } + return sb.toString(); + } + + /** Format time remaining (e.g., "2d 5h 30m") */ + protected String formatTimeRemaining(long ms) { + if (ms <= 0) return "Ended"; + + long seconds = (ms / 1000) % 60; + long minutes = (ms / 60000) % 60; + long hours = (ms / 3600000) % 24; + long days = ms / 86400000; + + StringBuilder sb = new StringBuilder(); + if (days > 0) sb.append(days).append("d "); + if (hours > 0 || days > 0) sb.append(hours).append("h "); + if (minutes > 0 || hours > 0 || days > 0) sb.append(minutes).append("m"); + else sb.append(seconds).append("s"); + return sb.toString(); + } + + @Override + public void save() { + // Override in subclasses if needed + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionListing.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionListing.java new file mode 100644 index 000000000..381747c6a --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionListing.java @@ -0,0 +1,302 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.inventory.Slot; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.NoppesUtil; +import noppes.npcs.client.gui.util.GuiAuctionNavButton; +import noppes.npcs.client.gui.util.IGuiData; +import noppes.npcs.client.gui.util.ISubGuiListener; +import noppes.npcs.client.gui.util.SubGuiInterface; +import noppes.npcs.constants.EnumAuctionSort; +import noppes.npcs.containers.ContainerAuctionListing; +import noppes.npcs.controllers.data.AuctionFilter; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * GUI for browsing auction listings. + * Displays 9x5 grid with pagination and filter controls. + */ +public class GuiAuctionListing extends GuiAuctionInterface implements ISubGuiListener, IGuiData { + // Small button textures (10x10) + private static final ResourceLocation SMALL_BUTTON = new ResourceLocation("customnpcs", "textures/gui/auction/small_button.png"); + private static final ResourceLocation SMALL_BUTTON_PRESS = new ResourceLocation("customnpcs", "textures/gui/auction/small_button_press.png"); + + // Arrow icons (8x8) + private static final ResourceLocation ICON_UP = new ResourceLocation("customnpcs", "textures/gui/auction/up.png"); + private static final ResourceLocation ICON_DOWN = new ResourceLocation("customnpcs", "textures/gui/auction/down.png"); + + // Grid layout - non-final for testing/positioning + protected int gridX = 56; + protected int gridY = 46; + protected int cols = 9; + protected int rows = 5; + + // Pagination positions (side by side horizontally) + protected int pageUpX = 33; + protected int pageDownX = 45; + protected int pageBtnY = 128; + + // Filter button position + protected int navYFilter = 109; + + // Button IDs + private static final int BTN_PAGE_UP = 200; + private static final int BTN_PAGE_DOWN = 201; + + private final ContainerAuctionListing listingContainer; + private AuctionFilter filter; + private int currentPage = 0; + private int totalPages = 1; + private int totalListings = 0; + private boolean dataLoaded = false; + + // Buttons defined on this page + private GuiAuctionNavButton btnFilter; + private GuiAuctionNavButton btnPageUp; + private GuiAuctionNavButton btnPageDown; + + public GuiAuctionListing(EntityNPCInterface npc, ContainerAuctionListing container) { + super(npc, container); + this.listingContainer = container; + this.filter = new AuctionFilter(); + } + + @Override + public void initGui() { + super.initGui(); + + // Filter button (18x18 with 16x16 icon) + btnFilter = new GuiAuctionNavButton(BTN_NAV_FILTER, guiLeft + navX, guiTop + navYFilter, + "auction.nav.filter", ICON_FILTER); + addButton(btnFilter); + + // Page Up button (10x10 with 8x8 icon) + btnPageUp = new GuiAuctionNavButton(BTN_PAGE_UP, guiLeft + pageUpX, guiTop + pageBtnY, + 10, 8, "auction.page.prev", ICON_UP, SMALL_BUTTON, SMALL_BUTTON_PRESS); + addButton(btnPageUp); + + // Page Down button (10x10 with 8x8 icon) + btnPageDown = new GuiAuctionNavButton(BTN_PAGE_DOWN, guiLeft + pageDownX, guiTop + pageBtnY, + 10, 8, "auction.page.next", ICON_DOWN, SMALL_BUTTON, SMALL_BUTTON_PRESS); + addButton(btnPageDown); + + updateFilterTooltip(); + requestListings(); + } + + @Override + protected int getCurrentPage() { + return PAGE_LISTINGS; + } + + /** Request listings from server */ + private void requestListings() { + AuctionActionPacket.requestListings(filter, currentPage); + } + + /** Update pagination button visibility based on current page state */ + private void updatePaginationVisibility() { + if (btnPageUp != null) { + btnPageUp.setVisible(currentPage > 0); + } + if (btnPageDown != null) { + btnPageDown.setVisible(currentPage < totalPages - 1); + } + } + + // ========== Filter Handling ========== + + private void updateFilterTooltip() { + if (btnFilter == null) return; + + List tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GOLD + StatCollector.translateToLocal("auction.nav.filter")); + tooltip.add(""); + + // Sort mode + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.filter.sort") + ":"); + tooltip.add(EnumChatFormatting.WHITE + " " + filter.getSortBy().getDisplayName()); + tooltip.add(""); + + // Search term + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.filter.search") + ":"); + if (filter.hasSearchText()) { + tooltip.add(EnumChatFormatting.GREEN + " \"" + filter.getSearchText() + "\""); + } else { + tooltip.add(EnumChatFormatting.DARK_GRAY + " " + StatCollector.translateToLocal("auction.filter.none")); + } + tooltip.add(""); + + // Instructions + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.filter.leftClick")); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.filter.rightClick")); + + btnFilter.setCustomTooltip(tooltip); + } + + @Override + public void mouseEvent(int mouseX, int mouseY, int mouseButton) { + if (btnFilter != null && btnFilter.isMouseOver(mouseX, mouseY) && mouseButton == 1) { + cycleSortMode(); + } + } + + @Override + public void actionPerformed(GuiButton button) { + super.actionPerformed(button); + + switch (button.id) { + case BTN_NAV_FILTER: + setSubGui(new SubGuiAuctionSearch(filter.getSearchText())); + break; + case BTN_PAGE_UP: + if (currentPage > 0) { + currentPage--; + requestListings(); + NoppesUtil.clickSound(); + } + break; + case BTN_PAGE_DOWN: + if (currentPage < totalPages - 1) { + currentPage++; + requestListings(); + NoppesUtil.clickSound(); + } + break; + } + } + + private void cycleSortMode() { + EnumAuctionSort[] values = EnumAuctionSort.values(); + int next = (filter.getSortBy().ordinal() + 1) % values.length; + filter.setSortBy(values[next]); + updateFilterTooltip(); + currentPage = 0; + requestListings(); + NoppesUtil.clickSound(); + } + + public void onSearchSubmit(String searchText) { + filter.setSearchText(searchText); + updateFilterTooltip(); + currentPage = 0; + requestListings(); + } + + @Override + public void subGuiClosed(SubGuiInterface subgui) {} + + // ========== Data Handling ========== + + @Override + public void setGuiData(NBTTagCompound compound) { + if (compound.hasKey("ListingsData")) { + currentPage = compound.getInteger("Page"); + totalPages = compound.getInteger("TotalPages"); + totalListings = compound.getInteger("TotalListings"); + + // Load listings + listingContainer.displayInventory.clear(); + NBTTagList list = compound.getTagList("Listings", 10); + for (int i = 0; i < list.tagCount() && i < 45; i++) { + AuctionListing listing = AuctionListing.fromNBT(list.getCompoundTagAt(i)); + listingContainer.displayInventory.setListing(i, listing); + } + dataLoaded = true; + } else if (compound.hasKey("ListingsRefresh")) { + requestListings(); + } + } + + // ========== Drawing ========== + + @Override + protected void drawAuctionContent(float partialTicks, int mouseX, int mouseY) { + // Draw slot backgrounds + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + int x = guiLeft + gridX + col * 18; + int y = guiTop + gridY + row * 18; + drawAuctionSlot(x, y); + } + } + + // Draw page indicator below pagination buttons + drawPageIndicator(); + } + + private void drawPageIndicator() { + if (totalPages > 1) { + String text = (currentPage + 1) + "/" + totalPages; + // Center between the two buttons (horizontally centered), below buttons + int centerX = guiLeft + pageUpX + 11; + int x = centerX - fontRendererObj.getStringWidth(text) / 2; + fontRendererObj.drawString(text, x, guiTop + pageBtnY + 12, 0xFFFFFF); + } + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + super.drawGuiContainerForegroundLayer(mouseX, mouseY); + if (hasSubGui()) return; + + // Draw tooltips for buttons on this page + drawListingPageTooltips(mouseX, mouseY); + } + + private void drawListingPageTooltips(int mouseX, int mouseY) { + List tooltip = null; + + // Filter button tooltip + if (btnFilter != null && btnFilter.isHovered()) { + tooltip = btnFilter.getTooltipLines(); + } + // Page up tooltip + else if (btnPageUp != null && btnPageUp.visible && btnPageUp.isHovered()) { + tooltip = btnPageUp.getTooltipLines(); + } + // Page down tooltip + else if (btnPageDown != null && btnPageDown.visible && btnPageDown.isHovered()) { + tooltip = btnPageDown.getTooltipLines(); + } + + if (tooltip != null && !tooltip.isEmpty()) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + drawHoveringText(tooltip, mouseX - guiLeft, mouseY - guiTop, fontRendererObj); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } + } + + // ========== Slot Handling ========== + + @Override + protected void handleMouseClick(Slot slot, int slotIndex, int mouseButton, int clickType) { + // Check if clicking on a display slot with a listing + if (slot != null && listingContainer.isDisplaySlot(slot.slotNumber)) { + int listingIndex = slot.slotNumber - listingContainer.getDisplaySlotStart(); + AuctionListing listing = listingContainer.displayInventory.getListing(listingIndex); + if (listing != null) { + // Open bidding page for this listing + AuctionActionPacket.openBidding(listing.id); + NoppesUtil.clickSound(); + } + } + // Block other slot interactions + } + + public AuctionFilter getFilter() { return filter; } + public ContainerAuctionListing getListingContainer() { return listingContainer; } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java new file mode 100644 index 000000000..268ddc701 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java @@ -0,0 +1,400 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.NoppesUtil; +import noppes.npcs.client.gui.util.GuiAuctionNavButton; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.GuiNpcTextField; +import noppes.npcs.client.gui.util.ITextfieldListener; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.containers.ContainerAuctionSell; +import noppes.npcs.entity.EntityNPCInterface; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * GUI for creating auction listings. + * Sell slot interactions: left-click=clear, right-click=remove 1. + * Inventory interactions: left-click=add stack, right-click=add 1. + */ +public class GuiAuctionSell extends GuiAuctionInterface implements ITextfieldListener { + private static final ResourceLocation ICON_COIN = new ResourceLocation("customnpcs", "textures/items/npcCoinBronze.png"); + private static final ResourceLocation ICON_BAG = new ResourceLocation("customnpcs", "textures/items/npcBag.png"); + + // Layout - non-final for testing/positioning + protected int contentX = 50; + protected int contentY = 50; + protected int confirmBtnX = 180; + protected int confirmBtnY = 120; + protected int navYBuyout = 109; + + // Component IDs + private static final int TXT_STARTING_PRICE = 1; + private static final int TXT_BUYOUT_PRICE = 2; + private static final int BTN_CONFIRM = 10; + private static final int BTN_ALLOW_BUYOUT = 11; + + private final ContainerAuctionSell sellContainer; + private long startingPrice = 0; + private long buyoutPrice = 0; + private boolean allowBuyout = false; + private String errorMessage = null; + + // Buttons + private GuiAuctionNavButton btnAllowBuyout; + private GuiNpcLabel lblBuyout; + private GuiNpcTextField txtBuyout; + + public GuiAuctionSell(EntityNPCInterface npc, ContainerAuctionSell container) { + super(npc, container); + this.sellContainer = container; + } + + @Override + public void initGui() { + super.initGui(); + + int y = guiTop + contentY; + + // Starting Price label and field + addLabel(new GuiNpcLabel(1, "auction.sell.startingPrice", guiLeft + contentX, y + 5, 0xFFFFFF)); + GuiNpcTextField startingField = new GuiNpcTextField(TXT_STARTING_PRICE, this, fontRendererObj, + guiLeft + contentX + 80, y, 80, 18, startingPrice > 0 ? String.valueOf(startingPrice) : ""); + startingField.setIntegersOnly(); + addTextField(startingField); + + y += 25; + + // Buyout Price label and field (only visible when allowBuyout is true) + lblBuyout = new GuiNpcLabel(2, "auction.sell.buyoutPrice", guiLeft + contentX, y + 5, 0xFFFFFF); + addLabel(lblBuyout); + + txtBuyout = new GuiNpcTextField(TXT_BUYOUT_PRICE, this, fontRendererObj, + guiLeft + contentX + 80, y, 80, 18, buyoutPrice > 0 ? String.valueOf(buyoutPrice) : ""); + txtBuyout.setIntegersOnly(); + addTextField(txtBuyout); + + // Allow Buyout toggle button (18x18 with bag icon) + btnAllowBuyout = new GuiAuctionNavButton(BTN_ALLOW_BUYOUT, guiLeft + navX, guiTop + navYBuyout, + "auction.sell.allowBuyout", ICON_BAG); + btnAllowBuyout.setToggle(true); // This is a toggle, not a nav button + btnAllowBuyout.setSelected(allowBuyout); + addButton(btnAllowBuyout); + + // Confirm button (custom textured button - using GuiAuctionNavButton) + GuiAuctionNavButton btnConfirm = new GuiAuctionNavButton(BTN_CONFIRM, guiLeft + confirmBtnX, guiTop + confirmBtnY, + "auction.sell.confirm", ICON_COIN); + addButton(btnConfirm); + + updateBuyoutVisibility(); + updateBuyoutTooltip(); + } + + @Override + protected int getCurrentPage() { + return PAGE_SELL; + } + + /** Update visibility of buyout price field based on toggle state */ + private void updateBuyoutVisibility() { + if (lblBuyout != null) { + lblBuyout.enabled = allowBuyout; + } + if (txtBuyout != null) { + txtBuyout.setVisible(allowBuyout); + if (!allowBuyout) { + txtBuyout.setText(""); + buyoutPrice = 0; + } + } + } + + /** Update the buyout toggle button tooltip */ + private void updateBuyoutTooltip() { + if (btnAllowBuyout == null) return; + + List tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GOLD + StatCollector.translateToLocal("auction.sell.allowBuyout")); + tooltip.add(""); + if (allowBuyout) { + tooltip.add(EnumChatFormatting.GREEN + StatCollector.translateToLocal("auction.sell.buyoutEnabled")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.sell.buyoutEnabledDesc")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.sell.buyoutEnabledDesc2")); + } else { + tooltip.add(EnumChatFormatting.RED + StatCollector.translateToLocal("auction.sell.buyoutDisabled")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.sell.buyoutDisabledDesc")); + } + tooltip.add(""); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.sell.clickToToggle")); + + btnAllowBuyout.setCustomTooltip(tooltip); + } + + @Override + public void unFocused(GuiNpcTextField textfield) { + if (textfield.id == TXT_STARTING_PRICE) { + try { + startingPrice = Long.parseLong(textfield.getText()); + } catch (NumberFormatException e) { + startingPrice = 0; + } + } else if (textfield.id == TXT_BUYOUT_PRICE) { + try { + String text = textfield.getText().trim(); + buyoutPrice = text.isEmpty() ? 0 : Long.parseLong(text); + } catch (NumberFormatException e) { + buyoutPrice = 0; + } + } + errorMessage = null; + } + + @Override + public void actionPerformed(GuiButton button) { + super.actionPerformed(button); + + if (button.id == BTN_CONFIRM) { + createListing(); + } else if (button.id == BTN_ALLOW_BUYOUT) { + allowBuyout = !allowBuyout; + btnAllowBuyout.setSelected(allowBuyout); + updateBuyoutVisibility(); + updateBuyoutTooltip(); + NoppesUtil.clickSound(); + } + } + + private void createListing() { + // Get latest values from text fields + GuiNpcTextField startingField = getTextField(TXT_STARTING_PRICE); + GuiNpcTextField buyoutField = getTextField(TXT_BUYOUT_PRICE); + + if (startingField != null) { + try { + startingPrice = Long.parseLong(startingField.getText()); + } catch (NumberFormatException e) { + startingPrice = 0; + } + } + + if (buyoutField != null && allowBuyout) { + try { + String text = buyoutField.getText().trim(); + buyoutPrice = text.isEmpty() ? 0 : Long.parseLong(text); + } catch (NumberFormatException e) { + buyoutPrice = 0; + } + } else { + buyoutPrice = 0; + } + + // Validate + ItemStack item = sellContainer.getItemToSell(); + if (item == null) { + errorMessage = StatCollector.translateToLocal("auction.sell.noItem"); + return; + } + + if (startingPrice <= 0) { + errorMessage = StatCollector.translateToLocal("auction.sell.invalidPrice"); + return; + } + + if (allowBuyout && buyoutPrice > 0 && buyoutPrice < startingPrice) { + errorMessage = StatCollector.translateToLocal("auction.sell.buyoutTooLow"); + return; + } + + // Validate player has enough items + int inventoryCount = sellContainer.countItemInInventory(item); + if (item.stackSize > inventoryCount) { + errorMessage = StatCollector.translateToLocal("auction.sell.notEnoughItems"); + return; + } + + // Send create listing packet + AuctionActionPacket.createListing(item, startingPrice, allowBuyout ? buyoutPrice : 0); + + // Clear the sell slot (server will also clear it) + sellContainer.clearSellSlot(); + + // Navigate back to listings page + AuctionActionPacket.openPage(PAGE_LISTINGS); + } + + @Override + protected void drawAuctionContent(float partialTicks, int mouseX, int mouseY) { + // Draw sell slot background (centered in content area) + int slotX = guiLeft + contentX + 30; + int slotY = guiTop + contentY + 55; + drawAuctionSlot(slotX, slotY); + + // Draw listing fee info (uses cached config from server) + String feeText = StatCollector.translateToLocal("auction.sell.fee") + .replace("%s", formatCurrency(AuctionClientConfig.getListingFee()) + " " + AuctionClientConfig.getCurrencyName()); + fontRendererObj.drawString(EnumChatFormatting.GRAY + feeText, guiLeft + contentX, guiTop + contentY + 80, 0xFFFFFF); + + // Draw error message if any + if (errorMessage != null) { + fontRendererObj.drawString(EnumChatFormatting.RED + errorMessage, guiLeft + contentX, guiTop + contentY + 95, 0xFFFFFF); + } + + // Draw item count indicator for sell slot + drawSellSlotIndicator(slotX, slotY); + } + + /** Draw indicator showing staged vs available items */ + private void drawSellSlotIndicator(int slotX, int slotY) { + ItemStack sellItem = sellContainer.getItemToSell(); + if (sellItem != null) { + int inventoryCount = sellContainer.countItemInInventory(sellItem); + String countText = sellItem.stackSize + "/" + inventoryCount; + + // Color: green if valid, red if over limit + int color = sellItem.stackSize <= inventoryCount ? 0x55FF55 : 0xFF5555; + + int textWidth = fontRendererObj.getStringWidth(countText); + fontRendererObj.drawStringWithShadow(countText, slotX + 18 + 4, slotY + 5, color); + } else { + // Draw "Place item here" hint + String hint = StatCollector.translateToLocal("auction.sell.placeItem"); + int hintWidth = fontRendererObj.getStringWidth(hint); + fontRendererObj.drawString(EnumChatFormatting.GRAY + hint, slotX + 9 - hintWidth / 2 + 20, slotY + 5, 0xFFFFFF); + } + } + + /** + * Handle sell page click behavior. + * Sell slot: left=clear, right=remove 1. + * Inventory: left=add stack, right=add 1. + */ + @Override + protected void handleMouseClick(Slot slot, int slotIndex, int mouseButton, int clickType) { + if (slot == null) return; + + // Sell slot clicks + if (sellContainer.isSellSlot(slotIndex)) { + ItemStack sellItem = sellContainer.getItemToSell(); + if (sellItem != null) { + boolean removeAll = (mouseButton == 0); // Left=clear, Right=remove 1 + sellContainer.removeFromSellSlot(removeAll); + NoppesUtil.clickSound(); + } + return; + } + + // Inventory clicks + if (sellContainer.isPlayerInventorySlot(slotIndex)) { + ItemStack sourceStack = slot.getStack(); + if (sourceStack != null) { + boolean fullStack = (mouseButton == 0); // Left=stack, Right=1 item + sellContainer.addToSellSlot(slotIndex, fullStack); + NoppesUtil.clickSound(); + } + return; + } + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + super.drawGuiContainerForegroundLayer(mouseX, mouseY); + if (hasSubGui()) return; + + // Draw tooltips for buttons + drawSellPageTooltips(mouseX, mouseY); + + // Sell slot tooltip + drawSellSlotTooltip(mouseX, mouseY); + + // Inventory item tooltip (only add extra info, item tooltip is handled by AuctionTooltipHandler) + drawInventoryTooltip(mouseX, mouseY); + } + + /** Draw tooltips for sell page buttons */ + private void drawSellPageTooltips(int mouseX, int mouseY) { + List tooltip = null; + + // Check all buttons + GuiNpcButton confirmBtn = getButton(BTN_CONFIRM); + if (confirmBtn instanceof GuiAuctionNavButton) { + GuiAuctionNavButton btn = (GuiAuctionNavButton) confirmBtn; + if (btn.isHovered()) { + tooltip = btn.getTooltipLines(); + } + } + + if (btnAllowBuyout != null && btnAllowBuyout.isHovered()) { + tooltip = btnAllowBuyout.getTooltipLines(); + } + + if (tooltip != null && !tooltip.isEmpty()) { + drawTooltip(tooltip, mouseX - guiLeft, mouseY - guiTop); + } + } + + /** Draw tooltip for sell slot */ + private void drawSellSlotTooltip(int mouseX, int mouseY) { + int slotX = guiLeft + contentX + 30; + int slotY = guiTop + contentY + 55; + + if (mouseX >= slotX && mouseX < slotX + 18 && mouseY >= slotY && mouseY < slotY + 18) { + ItemStack sellItem = sellContainer.getItemToSell(); + if (sellItem != null) { + List tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GOLD + StatCollector.translateToLocal("auction.sell.slotTitle")); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.sell.leftClear")); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.sell.rightRemove")); + drawTooltip(tooltip, mouseX - guiLeft, mouseY - guiTop); + } + } + } + + /** Draw tooltip for inventory items */ + private void drawInventoryTooltip(int mouseX, int mouseY) { + Slot hoveredSlot = getSlotUnderMouse(mouseX, mouseY); + if (hoveredSlot != null && sellContainer.isPlayerInventorySlot(hoveredSlot.slotNumber)) { + if (hoveredSlot.getStack() != null) { + List tooltip = new ArrayList<>(); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.sell.leftAdd")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.sell.rightAddOne")); + drawTooltip(tooltip, mouseX - guiLeft, mouseY - guiTop + 40); + } + } + } + + /** Get slot under mouse position */ + private Slot getSlotUnderMouse(int mouseX, int mouseY) { + for (int i = 0; i < inventorySlots.inventorySlots.size(); i++) { + Slot slot = (Slot) inventorySlots.inventorySlots.get(i); + if (isMouseOverSlot(slot, mouseX, mouseY)) { + return slot; + } + } + return null; + } + + /** Check if mouse is over a slot */ + private boolean isMouseOverSlot(Slot slot, int mouseX, int mouseY) { + int x = guiLeft + slot.xDisplayPosition; + int y = guiTop + slot.yDisplayPosition; + return mouseX >= x && mouseX < x + 16 && mouseY >= y && mouseY < y + 16; + } + + /** Draw tooltip with proper GL state */ + private void drawTooltip(List tooltip, int x, int y) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + drawHoveringText(tooltip, x, y, fontRendererObj); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java new file mode 100644 index 000000000..df9298426 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/GuiAuctionTrades.java @@ -0,0 +1,291 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.audio.PositionedSoundRecord; +import net.minecraft.inventory.Slot; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.client.NoppesUtil; +import noppes.npcs.client.gui.util.IGuiData; +import noppes.npcs.constants.EnumClaimType; +import noppes.npcs.containers.ContainerAuctionTrades; +import noppes.npcs.controllers.data.AuctionClaim; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * GUI for viewing player's auction trades. + * Shows active listings, bids, and claims with confirmation system. + */ +public class GuiAuctionTrades extends GuiAuctionInterface implements IGuiData { + // Icon textures + private static final ResourceLocation ICON_X = new ResourceLocation("customnpcs", "textures/gui/auction/x_icon.png"); + private static final ResourceLocation ICON_CHECK = new ResourceLocation("customnpcs", "textures/gui/auction/check_icon.png"); + + // Grid layout + private static final int GRID_X = 56; + private static final int GRID_Y = 46; + private static final int COLS = 9; + private static final int ROWS = 5; + private static final int TOTAL_SLOTS = COLS * ROWS; + + // Slot tint colors (ARGB) + private static final int TINT_BLUE = 0x303060FF; // Active listing/bid + private static final int TINT_GREEN = 0x3030FF60; // Claimable + private static final int TINT_RED = 0x30FF4040; // Refund + + private final ContainerAuctionTrades tradesContainer; + private int maxTradeSlots; + + // Pending operation state + private int pendingSlot = -1; + private PendingOp pendingOp = PendingOp.NONE; + + private enum PendingOp { NONE, CANCEL, CLAIM } + + public GuiAuctionTrades(EntityNPCInterface npc, ContainerAuctionTrades container) { + super(npc, container); + this.tradesContainer = container; + this.maxTradeSlots = AuctionClientConfig.getMaxActiveListings() * 2; + } + + @Override + protected int getCurrentPage() { + return PAGE_CLAIMS; + } + + // ========== Mouse Handling ========== + + @Override + public void mouseEvent(int mouseX, int mouseY, int mouseButton) { + if (hasSubGui()) return; + + int slot = getSlotAt(mouseX, mouseY); + if (slot < 0 || slot >= TOTAL_SLOTS) { + clearPending(); + return; + } + + // Clicking on pending slot - confirm or cancel + if (slot == pendingSlot && pendingOp != PendingOp.NONE) { + handlePendingClick(mouseButton); + return; + } + + // Clear existing pending, start new operation + clearPending(); + + AuctionListing listing = tradesContainer.getListingAt(slot); + AuctionClaim claim = tradesContainer.getClaimAt(slot); + + if (listing != null && tradesContainer.isSellingAt(slot) && mouseButton == 1) { + // Right-click on own listing = show cancel + setPending(slot, PendingOp.CANCEL); + NoppesUtil.clickSound(); + } else if (claim != null && mouseButton == 0) { + // Left-click on claim = show claim + setPending(slot, PendingOp.CLAIM); + NoppesUtil.clickSound(); + } + } + + /** Handle click on pending operation slot */ + private void handlePendingClick(int mouseButton) { + if (pendingOp == PendingOp.CANCEL && mouseButton == 1) { + // Right-click confirms cancel + AuctionListing listing = tradesContainer.getListingAt(pendingSlot); + if (listing != null) { + AuctionActionPacket.cancelListing(listing.id); + playConfirmSound(); + } + clearPending(); + } else if (pendingOp == PendingOp.CLAIM && mouseButton == 0) { + // Left-click confirms claim + AuctionClaim claim = tradesContainer.getClaimAt(pendingSlot); + if (claim != null) { + if (claim.type.isItem()) { + AuctionActionPacket.claimItem(claim.id); + } else { + AuctionActionPacket.claimCurrency(claim.id); + } + playConfirmSound(); + } + clearPending(); + } else { + // Wrong button - cancel operation + clearPending(); + NoppesUtil.clickSound(); + } + } + + /** Set pending operation and hide slot item */ + private void setPending(int slot, PendingOp op) { + pendingSlot = slot; + pendingOp = op; + tradesContainer.setHiddenSlot(slot); + } + + /** Clear pending operation and restore slot */ + private void clearPending() { + if (pendingSlot >= 0) { + tradesContainer.clearHiddenSlot(); + } + pendingSlot = -1; + pendingOp = PendingOp.NONE; + } + + /** Play confirmation sound */ + private void playConfirmSound() { + mc.getSoundHandler().playSound(PositionedSoundRecord.func_147674_a( + new ResourceLocation("random.orb"), 1.0F)); + } + + /** Get slot index at mouse position */ + private int getSlotAt(int mouseX, int mouseY) { + int relX = mouseX - guiLeft - GRID_X; + int relY = mouseY - guiTop - GRID_Y; + if (relX < 0 || relY < 0) return -1; + + int col = relX / 18; + int row = relY / 18; + if (col >= COLS || row >= ROWS) return -1; + if (relX % 18 >= 18 || relY % 18 >= 18) return -1; + + return col + row * COLS; + } + + // ========== Drawing ========== + + @Override + protected void drawAuctionContent(float partialTicks, int mouseX, int mouseY) { + for (int row = 0; row < ROWS; row++) { + for (int col = 0; col < COLS; col++) { + int slot = col + row * COLS; + int x = guiLeft + GRID_X + col * 18; + int y = guiTop + GRID_Y + row * 18; + + drawAuctionSlot(x, y); + + // Color tint based on slot contents + int tint = getSlotTint(slot); + if (tint != 0 && slot != pendingSlot) { + drawColoredOverlay(x, y, tint); + } + + // Darken unavailable slots + if (slot >= maxTradeSlots) { + drawDarkenedOverlay(x, y); + } + + // Pending operation icon + if (slot == pendingSlot) { + if (pendingOp == PendingOp.CANCEL) { + drawIconOverlay(x, y, ICON_X); + } else if (pendingOp == PendingOp.CLAIM) { + drawIconOverlay(x, y, ICON_CHECK); + } + } + } + } + } + + /** Get tint color for slot based on contents */ + private int getSlotTint(int slot) { + AuctionListing listing = tradesContainer.getListingAt(slot); + AuctionClaim claim = tradesContainer.getClaimAt(slot); + + if (listing != null) { + return TINT_BLUE; + } else if (claim != null) { + return (claim.type == EnumClaimType.REFUND) ? TINT_RED : TINT_GREEN; + } + return 0; + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + super.drawGuiContainerForegroundLayer(mouseX, mouseY); + if (hasSubGui()) return; + + int slot = getSlotAt(mouseX, mouseY); + if (slot >= 0 && slot < TOTAL_SLOTS) { + drawTradeTooltip(slot, mouseX - guiLeft, mouseY - guiTop); + } + } + + /** Draw tooltip for a trade slot */ + private void drawTradeTooltip(int slot, int x, int y) { + List tooltip = new ArrayList<>(); + + if (slot == pendingSlot) { + // Pending operation tooltip + if (pendingOp == PendingOp.CANCEL) { + tooltip.add(EnumChatFormatting.RED + StatCollector.translateToLocal("auction.trades.confirmCancel")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.trades.rightClickConfirm")); + tooltip.add(EnumChatFormatting.DARK_GRAY + StatCollector.translateToLocal("auction.trades.otherClickCancel")); + } else if (pendingOp == PendingOp.CLAIM) { + tooltip.add(EnumChatFormatting.GREEN + StatCollector.translateToLocal("auction.trades.confirmClaim")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.trades.leftClickConfirm")); + tooltip.add(EnumChatFormatting.DARK_GRAY + StatCollector.translateToLocal("auction.trades.otherClickCancel")); + } + } else { + // Normal slot tooltip + AuctionListing listing = tradesContainer.getListingAt(slot); + AuctionClaim claim = tradesContainer.getClaimAt(slot); + + if (listing != null) { + if (tradesContainer.isSellingAt(slot)) { + tooltip.add(EnumChatFormatting.GOLD + StatCollector.translateToLocal("auction.trades.activeListing")); + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.trades.rightClickToCancel")); + } else if (tradesContainer.isBiddingAt(slot)) { + tooltip.add(EnumChatFormatting.AQUA + StatCollector.translateToLocal("auction.trades.activeBid")); + tooltip.add(EnumChatFormatting.GRAY + StatCollector.translateToLocal("auction.trades.winningBid")); + } + } else if (claim != null) { + if (claim.type == EnumClaimType.REFUND) { + tooltip.add(EnumChatFormatting.RED + StatCollector.translateToLocal("auction.trades.refundClaim")); + } else { + tooltip.add(EnumChatFormatting.GREEN + StatCollector.translateToLocal("auction.trades.pendingClaim")); + } + tooltip.add(EnumChatFormatting.YELLOW + StatCollector.translateToLocal("auction.trades.leftClickToClaim")); + } else if (slot >= maxTradeSlots) { + tooltip.add(EnumChatFormatting.RED + StatCollector.translateToLocal("auction.trades.unavailable")); + } + } + + if (!tooltip.isEmpty()) { + GL11.glPushMatrix(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + drawHoveringText(tooltip, x, y, fontRendererObj); + GL11.glPopAttrib(); + GL11.glPopMatrix(); + } + } + + // ========== Slot Handling ========== + + @Override + protected void handleMouseClick(Slot slot, int slotIndex, int mouseButton, int clickType) { + // Block all slot interactions + } + + // ========== Server Updates ========== + + @Override + public void setGuiData(NBTTagCompound compound) { + if (compound.hasKey("TradesUpdate")) { + tradesContainer.refreshData(); + } + } + + public ContainerAuctionTrades getTradesContainer() { + return tradesContainer; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBid.java b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBid.java new file mode 100644 index 000000000..da828b7a9 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBid.java @@ -0,0 +1,167 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.GuiNpcTextField; +import noppes.npcs.client.gui.util.ITextfieldListener; +import noppes.npcs.client.gui.util.SubGuiInterface; + +/** + * Sub GUI for placing a bid on an auction. + * Shows minimum bid, player balance, and input field. + */ +public class SubGuiAuctionBid extends SubGuiInterface implements ITextfieldListener { + private static final int TXT_BID = 1; + private static final int BTN_BID = 10; + private static final int BTN_CANCEL = 11; + + private final String listingId; + private final long minimumBid; + private final long playerBalance; + private long bidAmount; + private boolean successful = false; + private String errorMessage = null; + + public SubGuiAuctionBid(String listingId, long minimumBid, long playerBalance) { + this.listingId = listingId; + this.minimumBid = minimumBid; + this.playerBalance = playerBalance; + this.bidAmount = minimumBid; + + setBackground("menubg.png"); + xSize = 200; + ySize = 120; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int centerX = guiLeft + xSize / 2; + int y = guiTop + 15; + String currencyName = AuctionClientConfig.getCurrencyName(); + + // Title + addLabel(new GuiNpcLabel(0, "auction.bid.title", centerX - 50, y, 0xFFFFFF)); + y += 18; + + // Minimum bid info + String minText = StatCollector.translateToLocal("auction.bid.minimum") + .replace("%s", EnumChatFormatting.GOLD + formatCurrency(minimumBid) + " " + currencyName); + addLabel(new GuiNpcLabel(1, minText, guiLeft + 15, y, 0xAAAAAA)); + y += 14; + + // Your balance + String balanceText = StatCollector.translateToLocal("auction.bid.yourBalance") + .replace("%s", EnumChatFormatting.WHITE + formatCurrency(playerBalance) + " " + currencyName); + addLabel(new GuiNpcLabel(2, balanceText, guiLeft + 15, y, 0xAAAAAA)); + y += 18; + + // Bid amount field + addLabel(new GuiNpcLabel(3, "auction.bid.yourBid", guiLeft + 15, y + 4, 0xFFFFFF)); + GuiNpcTextField bidField = new GuiNpcTextField(TXT_BID, this, fontRendererObj, + guiLeft + 80, y, 100, 18, String.valueOf(minimumBid)); + bidField.setIntegersOnly(); + addTextField(bidField); + y += 28; + + // Error message area (empty initially) + addLabel(new GuiNpcLabel(4, "", guiLeft + 15, y, 0xFF5555)); + y += 16; + + // Buttons + int btnWidth = 70; + int btnSpacing = 20; + int totalBtnWidth = btnWidth * 2 + btnSpacing; + int btnX = centerX - totalBtnWidth / 2; + + addButton(new GuiNpcButton(BTN_BID, btnX, y, btnWidth, 20, "auction.bid.placeBid")); + addButton(new GuiNpcButton(BTN_CANCEL, btnX + btnWidth + btnSpacing, y, btnWidth, 20, "gui.cancel")); + } + + @Override + public void unFocused(GuiNpcTextField textfield) { + if (textfield.id == TXT_BID) { + try { + bidAmount = Long.parseLong(textfield.getText()); + } catch (NumberFormatException e) { + bidAmount = 0; + } + errorMessage = null; + updateErrorLabel(); + } + } + + @Override + public void buttonEvent(GuiButton button) { + switch (button.id) { + case BTN_BID: + placeBid(); + break; + case BTN_CANCEL: + close(); + break; + } + } + + private void placeBid() { + // Get latest value from text field + GuiNpcTextField bidField = getTextField(TXT_BID); + if (bidField != null) { + try { + bidAmount = Long.parseLong(bidField.getText()); + } catch (NumberFormatException e) { + bidAmount = 0; + } + } + + // Validate + if (bidAmount < minimumBid) { + errorMessage = StatCollector.translateToLocal("auction.error.bidTooLow") + .replace("%s", formatCurrency(minimumBid)); + updateErrorLabel(); + return; + } + + if (bidAmount > playerBalance) { + errorMessage = StatCollector.translateToLocal("auction.error.notEnoughCurrency"); + updateErrorLabel(); + return; + } + + // Send bid packet + AuctionActionPacket.placeBid(listingId, bidAmount); + successful = true; + close(); + } + + private void updateErrorLabel() { + GuiNpcLabel errorLabel = getLabel(4); + if (errorLabel != null) { + errorLabel.label = errorMessage != null ? EnumChatFormatting.RED + errorMessage : ""; + } + } + + public boolean wasSuccessful() { + return successful; + } + + private String formatCurrency(long amount) { + if (amount < 1000) return String.valueOf(amount); + StringBuilder sb = new StringBuilder(); + String str = String.valueOf(amount); + int count = 0; + for (int i = str.length() - 1; i >= 0; i--) { + if (count > 0 && count % 3 == 0) sb.insert(0, ','); + sb.insert(0, str.charAt(i)); + count++; + } + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBuyNow.java b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBuyNow.java new file mode 100644 index 000000000..4f20b4ded --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionBuyNow.java @@ -0,0 +1,124 @@ +package noppes.npcs.client.gui.player; + +import kamkeel.npcs.network.packets.player.AuctionActionPacket; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.AuctionClientConfig; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.SubGuiInterface; + +/** + * Sub GUI for confirming a buyout purchase. + * Shows buyout price, player balance, and confirmation buttons. + */ +public class SubGuiAuctionBuyNow extends SubGuiInterface { + private static final int BTN_CONFIRM = 10; + private static final int BTN_CANCEL = 11; + + private final String listingId; + private final long buyoutPrice; + private final long playerBalance; + private boolean successful = false; + + public SubGuiAuctionBuyNow(String listingId, long buyoutPrice, long playerBalance) { + this.listingId = listingId; + this.buyoutPrice = buyoutPrice; + this.playerBalance = playerBalance; + + setBackground("menubg.png"); + xSize = 220; + ySize = 130; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int centerX = guiLeft + xSize / 2; + int y = guiTop + 15; + String currencyName = AuctionClientConfig.getCurrencyName(); + + // Title + String title = StatCollector.translateToLocal("auction.buyout.title"); + int titleWidth = fontRendererObj.getStringWidth(title); + addLabel(new GuiNpcLabel(0, title, centerX - titleWidth / 2, y, 0xFFFFFF)); + y += 20; + + // Confirmation message + String confirmMsg = StatCollector.translateToLocal("auction.buyout.confirm"); + int confirmWidth = fontRendererObj.getStringWidth(confirmMsg); + addLabel(new GuiNpcLabel(1, confirmMsg, centerX - confirmWidth / 2, y, 0xAAAAAA)); + y += 16; + + // Buyout price + String priceText = EnumChatFormatting.GREEN + formatCurrency(buyoutPrice) + " " + currencyName; + int priceWidth = fontRendererObj.getStringWidth(priceText); + addLabel(new GuiNpcLabel(2, priceText, centerX - priceWidth / 2, y, 0xFFFFFF)); + y += 20; + + // Your balance + String balanceLabel = StatCollector.translateToLocal("auction.bid.yourBalance"); + String balanceText = balanceLabel.replace("%s", EnumChatFormatting.WHITE + formatCurrency(playerBalance) + " " + currencyName); + addLabel(new GuiNpcLabel(3, balanceText, guiLeft + 20, y, 0xAAAAAA)); + y += 14; + + // Insufficient funds warning + if (playerBalance < buyoutPrice) { + String warning = StatCollector.translateToLocal("auction.error.notEnoughCurrency"); + addLabel(new GuiNpcLabel(4, EnumChatFormatting.RED + warning, guiLeft + 20, y, 0xFFFFFF)); + } + y += 20; + + // Buttons + int btnWidth = 70; + int btnSpacing = 20; + int totalBtnWidth = btnWidth * 2 + btnSpacing; + int btnX = centerX - totalBtnWidth / 2; + + GuiNpcButton confirmBtn = new GuiNpcButton(BTN_CONFIRM, btnX, y, btnWidth, 20, "gui.yes"); + confirmBtn.enabled = playerBalance >= buyoutPrice; + addButton(confirmBtn); + addButton(new GuiNpcButton(BTN_CANCEL, btnX + btnWidth + btnSpacing, y, btnWidth, 20, "gui.cancel")); + } + + @Override + public void buttonEvent(GuiButton button) { + switch (button.id) { + case BTN_CONFIRM: + if (playerBalance >= buyoutPrice) { + confirmPurchase(); + } + break; + case BTN_CANCEL: + close(); + break; + } + } + + private void confirmPurchase() { + // Send buyout packet + AuctionActionPacket.buyout(listingId); + successful = true; + close(); + } + + public boolean wasSuccessful() { + return successful; + } + + private String formatCurrency(long amount) { + if (amount < 1000) return String.valueOf(amount); + StringBuilder sb = new StringBuilder(); + String str = String.valueOf(amount); + int count = 0; + for (int i = str.length() - 1; i >= 0; i--) { + if (count > 0 && count % 3 == 0) sb.insert(0, ','); + sb.insert(0, str.charAt(i)); + count++; + } + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionSearch.java b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionSearch.java new file mode 100644 index 000000000..57d52f95d --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/player/SubGuiAuctionSearch.java @@ -0,0 +1,85 @@ +package noppes.npcs.client.gui.player; + +import net.minecraft.client.gui.GuiButton; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.GuiNpcTextField; +import noppes.npcs.client.gui.util.ITextfieldListener; +import noppes.npcs.client.gui.util.SubGuiInterface; + +/** + * Small SubGui for entering auction search text. + */ +public class SubGuiAuctionSearch extends SubGuiInterface implements ITextfieldListener { + private static final int TXT_SEARCH = 0; + private static final int BTN_SUBMIT = 1; + private static final int BTN_CLEAR = 2; + private static final int BTN_CLOSE = 3; + + private String searchText; + + public SubGuiAuctionSearch(String currentSearchText) { + this.searchText = currentSearchText != null ? currentSearchText : ""; + setBackground("menubg.png"); + xSize = 180; + ySize = 80; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + // Close button + addButton(new GuiNpcButton(BTN_CLOSE, guiLeft + xSize - 22, guiTop + 4, 18, 18, "X")); + + // Search label + addLabel(new GuiNpcLabel(0, "auction.filter.search", guiLeft + 10, guiTop + 10, 0xFFFFFF)); + + // Search text field + addTextField(new GuiNpcTextField(TXT_SEARCH, this, fontRendererObj, guiLeft + 10, guiTop + 25, 160, 18, searchText)); + + // Submit and Clear buttons + addButton(new GuiNpcButton(BTN_SUBMIT, guiLeft + 10, guiTop + 50, 70, 20, "gui.apply")); + addButton(new GuiNpcButton(BTN_CLEAR, guiLeft + 85, guiTop + 50, 70, 20, "gui.clear")); + } + + @Override + public void unFocused(GuiNpcTextField textfield) { + if (textfield.id == TXT_SEARCH) { + searchText = textfield.getText(); + } + } + + @Override + protected void actionPerformed(GuiButton guibutton) { + if (guibutton.id == BTN_SUBMIT) { + GuiNpcTextField field = getTextField(TXT_SEARCH); + if (field != null) { + searchText = field.getText(); + } + // Notify parent and close + if (parent instanceof GuiAuctionListing) { + ((GuiAuctionListing) parent).onSearchSubmit(searchText); + } + close(); + } else if (guibutton.id == BTN_CLEAR) { + searchText = ""; + GuiNpcTextField field = getTextField(TXT_SEARCH); + if (field != null) { + field.setText(""); + } + // Notify parent and close + if (parent instanceof GuiAuctionListing) { + ((GuiAuctionListing) parent).onSearchSubmit(""); + } + close(); + } else if (guibutton.id == BTN_CLOSE) { + close(); + } + } + + public String getSearchText() { + return searchText; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiAuctionNavButton.java b/src/main/java/noppes/npcs/client/gui/util/GuiAuctionNavButton.java new file mode 100644 index 000000000..665dd2259 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/GuiAuctionNavButton.java @@ -0,0 +1,197 @@ +package noppes.npcs.client.gui.util; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.client.renderer.entity.RenderItem; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configurable textured button for auction GUI. + * Supports custom button sizes, icon sizes, and textures. + */ +public class GuiAuctionNavButton extends GuiNpcButton { + // Default auction button textures (18x18) + private static final ResourceLocation DEFAULT_NORMAL = new ResourceLocation("customnpcs", "textures/gui/auction/button.png"); + private static final ResourceLocation DEFAULT_PRESSED = new ResourceLocation("customnpcs", "textures/gui/auction/button_press.png"); + + // Textures + private ResourceLocation textureNormal; + private ResourceLocation texturePressed; + private ResourceLocation iconTexture; + private ItemStack iconItem; + + // Dimensions + private int buttonSize; + private int iconSize; + + // State + private boolean selected; + private boolean isToggle; // Toggle buttons can be clicked when selected, nav buttons cannot + private final String tooltipKey; + private List customTooltip; + + /** + * Create a button with default 18x18 size and 16x16 icon using default textures. + */ + public GuiAuctionNavButton(int id, int x, int y, String tooltipKey, ResourceLocation iconTexture) { + this(id, x, y, 18, 16, tooltipKey, iconTexture, DEFAULT_NORMAL, DEFAULT_PRESSED); + } + + /** + * Create a button with default 18x18 size and 16x16 icon with an item icon. + */ + public GuiAuctionNavButton(int id, int x, int y, String tooltipKey, ItemStack iconItem) { + super(id, x, y, 18, 18, ""); + this.tooltipKey = tooltipKey; + this.iconTexture = null; + this.iconItem = iconItem; + this.buttonSize = 18; + this.iconSize = 16; + this.textureNormal = DEFAULT_NORMAL; + this.texturePressed = DEFAULT_PRESSED; + this.selected = false; + this.customTooltip = null; + } + + /** + * Create a fully customizable button. + * + * @param id Button ID + * @param x X position + * @param y Y position + * @param buttonSize Size of the button background texture (e.g., 18 for 18x18, 10 for 10x10) + * @param iconSize Size of the icon texture (e.g., 16 for 16x16, 8 for 8x8) + * @param tooltipKey Lang key for tooltip + * @param iconTexture Icon texture to draw inside the button + * @param textureNormal Background texture for normal state + * @param texturePressed Background texture for pressed/hover state + */ + public GuiAuctionNavButton(int id, int x, int y, int buttonSize, int iconSize, String tooltipKey, + ResourceLocation iconTexture, ResourceLocation textureNormal, ResourceLocation texturePressed) { + super(id, x, y, buttonSize, buttonSize, ""); + this.tooltipKey = tooltipKey; + this.iconTexture = iconTexture; + this.iconItem = null; + this.buttonSize = buttonSize; + this.iconSize = iconSize; + this.textureNormal = textureNormal; + this.texturePressed = texturePressed; + this.selected = false; + this.customTooltip = null; + } + + public void setSelected(boolean selected) { + this.selected = selected; + } + + public boolean isSelected() { + return selected; + } + + /** + * Set whether this is a toggle button. + * Toggle buttons can be clicked when selected (to toggle off). + * Nav buttons cannot be clicked when selected. + */ + public GuiAuctionNavButton setToggle(boolean toggle) { + this.isToggle = toggle; + return this; + } + + public boolean isHovered() { + return field_146123_n; + } + + /** + * Set a custom multi-line tooltip. If null, uses the default tooltipKey translation. + */ + public void setCustomTooltip(List tooltip) { + this.customTooltip = tooltip; + } + + /** + * Get the tooltip lines for display. + */ + public List getTooltipLines() { + if (customTooltip != null && !customTooltip.isEmpty()) { + return customTooltip; + } + List lines = new ArrayList<>(); + if (tooltipKey != null && !tooltipKey.isEmpty()) { + lines.add(StatCollector.translateToLocal(tooltipKey)); + } + return lines; + } + + public String getTooltipText() { + return tooltipKey != null ? StatCollector.translateToLocal(tooltipKey) : ""; + } + + @Override + public void drawButton(Minecraft mc, int mouseX, int mouseY) { + if (!this.visible) { + return; + } + + // Check if mouse is hovering + boolean hovered = mouseX >= xPosition && mouseX < xPosition + width + && mouseY >= yPosition && mouseY < yPosition + height; + field_146123_n = hovered; + + // Draw button background + GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + if (selected || hovered) { + mc.getTextureManager().bindTexture(texturePressed); + } else { + mc.getTextureManager().bindTexture(textureNormal); + } + Gui.func_146110_a(xPosition, yPosition, 0, 0, buttonSize, buttonSize, buttonSize, buttonSize); + + // Calculate icon offset to center it + int iconOffset = (buttonSize - iconSize) / 2; + int iconX = xPosition + iconOffset; + int iconY = yPosition + iconOffset; + + if (iconItem != null) { + // Render item + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL11.GL_LIGHTING); + GL11.glEnable(GL12.GL_RESCALE_NORMAL); + RenderItem itemRenderer = RenderItem.getInstance(); + itemRenderer.renderItemAndEffectIntoGUI(mc.fontRenderer, mc.renderEngine, iconItem, iconX, iconY); + GL11.glDisable(GL11.GL_LIGHTING); + RenderHelper.disableStandardItemLighting(); + } else if (iconTexture != null) { + // Render texture icon + GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + mc.getTextureManager().bindTexture(iconTexture); + Gui.func_146110_a(iconX, iconY, 0, 0, iconSize, iconSize, iconSize, iconSize); + } + } + + @Override + public boolean mousePressed(Minecraft minecraft, int mouseX, int mouseY) { + // Don't allow clicking if selected (for nav buttons, not toggle buttons) + if (selected && !isToggle) { + return false; + } + return enabled && visible && mouseX >= xPosition && mouseX < xPosition + width + && mouseY >= yPosition && mouseY < yPosition + height; + } + + /** + * Check if this button contains the given mouse coordinates. + */ + public boolean isMouseOver(int mouseX, int mouseY) { + return mouseX >= xPosition && mouseX < xPosition + width + && mouseY >= yPosition && mouseY < yPosition + height; + } +} diff --git a/src/main/java/noppes/npcs/config/ConfigMarket.java b/src/main/java/noppes/npcs/config/ConfigMarket.java index c84ec3604..557a08b6f 100644 --- a/src/main/java/noppes/npcs/config/ConfigMarket.java +++ b/src/main/java/noppes/npcs/config/ConfigMarket.java @@ -14,6 +14,8 @@ public class ConfigMarket { 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 @@ -34,6 +36,31 @@ public class ConfigMarket { 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 double SalesTaxPercent = 0.05; + public static int DefaultMaxListings = 5; + public static int SnipeProtectionMinutes = 2; + public static int ClaimExpirationDays = 20; + public static double MinBidIncrementPercent = 0.05; + public static double CancellationPenaltyPercent = 0.10; + + // ========================================= + // Auction Logging Settings + // ========================================= + public static boolean AuctionLoggingEnabled = false; + public static boolean LogAuctionCreated = true; + public static boolean LogAuctionBid = true; + public static boolean LogAuctionBuyout = true; + public static boolean LogAuctionSold = true; + public static boolean LogAuctionExpired = true; + public static boolean LogAuctionCancelled = true; + public static boolean LogAuctionClaimed = true; + public static void init(File configFile) { config = new Configuration(configFile); @@ -74,6 +101,67 @@ public class ConfigMarket { 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."); + + AuctionEnabled = config.get(AUCTION, "Enable Auction House", true, + "Enable the Auction House system").getBoolean(true); + + AuctionDurationHours = config.get(AUCTION, "Auction Duration Hours", 24, + "Default duration for auctions in hours").getInt(24); + + ListingFee = config.get(AUCTION, "Listing Fee", 10, + "Flat fee charged when creating a listing").getInt(10); + + 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); + + 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); + + ClaimExpirationDays = config.get(AUCTION, "Claim Expiration Days", 20, + "Number of days before unclaimed items/currency are deleted").getInt(20); + + MinBidIncrementPercent = config.get(AUCTION, "Min Bid Increment Percent", 0.05, + "Minimum bid increment as a percentage of current bid (0.05 = 5%)").getDouble(0.05); + + CancellationPenaltyPercent = config.get(AUCTION, "Cancellation Penalty Percent", 0.10, + "Percentage of current bid taken as penalty when seller cancels an auction with bids (0.10 = 10%)").getDouble(0.10); + + // ========================================= + // Auction Logging Settings + // ========================================= + config.setCategoryComment(AUCTION_LOGGING, "Auction House 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); + + LogAuctionCreated = config.get(AUCTION_LOGGING, "Log Created", true, + "Log when auctions are created").getBoolean(true); + + LogAuctionBid = config.get(AUCTION_LOGGING, "Log Bid", true, + "Log when bids are placed").getBoolean(true); + + LogAuctionBuyout = config.get(AUCTION_LOGGING, "Log Buyout", true, + "Log when auctions are bought out").getBoolean(true); + + LogAuctionSold = config.get(AUCTION_LOGGING, "Log Sold", true, + "Log when auctions end with a winner").getBoolean(true); + + LogAuctionExpired = config.get(AUCTION_LOGGING, "Log Expired", true, + "Log when auctions expire with no bids").getBoolean(true); + + LogAuctionCancelled = config.get(AUCTION_LOGGING, "Log Cancelled", true, + "Log when auctions are cancelled").getBoolean(true); + + LogAuctionClaimed = config.get(AUCTION_LOGGING, "Log Claimed", true, + "Log when claims are collected").getBoolean(true); + // Set category order config.setCategoryPropertyOrder(MARKET, new ArrayList<>(Arrays.asList( "Enable Market System" @@ -91,6 +179,29 @@ public class ConfigMarket { "Default Reset Type" ))); + config.setCategoryPropertyOrder(AUCTION, new ArrayList<>(Arrays.asList( + "Enable Auction House", + "Auction Duration Hours", + "Listing Fee", + "Sales Tax Percent", + "Default Max Listings", + "Snipe Protection Minutes", + "Claim Expiration Days", + "Min Bid Increment Percent", + "Cancellation Penalty Percent" + ))); + + config.setCategoryPropertyOrder(AUCTION_LOGGING, new ArrayList<>(Arrays.asList( + "Enable Auction Logging", + "Log Created", + "Log Bid", + "Log Buyout", + "Log Sold", + "Log Expired", + "Log Cancelled", + "Log Claimed" + ))); + } catch (Exception e) { FMLLog.log(Level.ERROR, e, "CNPC+ has had a problem loading its market configuration"); } finally { diff --git a/src/main/java/noppes/npcs/constants/EnumAuctionSort.java b/src/main/java/noppes/npcs/constants/EnumAuctionSort.java new file mode 100644 index 000000000..115f13dc9 --- /dev/null +++ b/src/main/java/noppes/npcs/constants/EnumAuctionSort.java @@ -0,0 +1,40 @@ +package noppes.npcs.constants; + +import net.minecraft.util.StatCollector; + +public enum EnumAuctionSort { + NEWEST("auction.sort.newest"), + ENDING_SOON("auction.sort.endingSoon"), + PRICE_LOW("auction.sort.priceLow"), + PRICE_HIGH("auction.sort.priceHigh"), + MOST_BIDS("auction.sort.mostBids"); + + private final String langKey; + + EnumAuctionSort(String langKey) { + this.langKey = langKey; + } + + public String getLangKey() { + return langKey; + } + + public String getDisplayName() { + return StatCollector.translateToLocal(langKey); + } + + public static String[] getDisplayNames() { + String[] names = new String[values().length]; + for (int i = 0; i < values().length; i++) { + names[i] = values()[i].getDisplayName(); + } + return names; + } + + public static EnumAuctionSort fromOrdinal(int ordinal) { + if (ordinal >= 0 && ordinal < values().length) { + return values()[ordinal]; + } + return NEWEST; + } +} diff --git a/src/main/java/noppes/npcs/constants/EnumAuctionStatus.java b/src/main/java/noppes/npcs/constants/EnumAuctionStatus.java new file mode 100644 index 000000000..14fc0f4ff --- /dev/null +++ b/src/main/java/noppes/npcs/constants/EnumAuctionStatus.java @@ -0,0 +1,47 @@ +package noppes.npcs.constants; + +import net.minecraft.util.StatCollector; + +public enum EnumAuctionStatus { + ACTIVE("auction.status.active"), + ENDED("auction.status.ended"), + CANCELLED("auction.status.cancelled"), + CLAIMED("auction.status.claimed"); + + private final String langKey; + + EnumAuctionStatus(String langKey) { + this.langKey = langKey; + } + + public String getLangKey() { + return langKey; + } + + public String getDisplayName() { + return StatCollector.translateToLocal(langKey); + } + + public boolean isActive() { + return this == ACTIVE; + } + + public boolean isEnded() { + return this == ENDED || this == CANCELLED || this == CLAIMED; + } + + public boolean canBid() { + return this == ACTIVE; + } + + public boolean canCancel() { + return this == ACTIVE; + } + + public static EnumAuctionStatus fromOrdinal(int ordinal) { + if (ordinal >= 0 && ordinal < values().length) { + return values()[ordinal]; + } + return ACTIVE; + } +} diff --git a/src/main/java/noppes/npcs/constants/EnumClaimType.java b/src/main/java/noppes/npcs/constants/EnumClaimType.java new file mode 100644 index 000000000..35fbf2003 --- /dev/null +++ b/src/main/java/noppes/npcs/constants/EnumClaimType.java @@ -0,0 +1,38 @@ +package noppes.npcs.constants; + +import net.minecraft.util.StatCollector; + +public enum EnumClaimType { + ITEM("auction.claim.item"), + CURRENCY("auction.claim.currency"), + REFUND("auction.claim.refund"); + + private final String langKey; + + EnumClaimType(String langKey) { + this.langKey = langKey; + } + + public String getLangKey() { + return langKey; + } + + public String getDisplayName() { + return StatCollector.translateToLocal(langKey); + } + + public boolean isItem() { + return this == ITEM; + } + + public boolean isCurrency() { + return this == CURRENCY || this == REFUND; + } + + public static EnumClaimType fromOrdinal(int ordinal) { + if (ordinal >= 0 && ordinal < values().length) { + return values()[ordinal]; + } + return ITEM; + } +} diff --git a/src/main/java/noppes/npcs/constants/EnumGuiType.java b/src/main/java/noppes/npcs/constants/EnumGuiType.java index f2d911561..1859b2616 100644 --- a/src/main/java/noppes/npcs/constants/EnumGuiType.java +++ b/src/main/java/noppes/npcs/constants/EnumGuiType.java @@ -64,5 +64,10 @@ public enum EnumGuiType { ScriptBlock, Paintbrush, MagicBook, - ManageAbilities; + ManageAbilities, + PlayerAuction, + PlayerAuctionSell, + PlayerAuctionTrades, + PlayerAuctionBidding, + ManageAuction; } diff --git a/src/main/java/noppes/npcs/constants/EnumNotificationType.java b/src/main/java/noppes/npcs/constants/EnumNotificationType.java new file mode 100644 index 000000000..02130e710 --- /dev/null +++ b/src/main/java/noppes/npcs/constants/EnumNotificationType.java @@ -0,0 +1,32 @@ +package noppes.npcs.constants; + +import net.minecraft.util.StatCollector; + +public enum EnumNotificationType { + AUCTION_WON("auction.notify.won"), + AUCTION_OUTBID("auction.notify.outbid"), + AUCTION_SOLD("auction.notify.sold"), + AUCTION_EXPIRED("auction.notify.expired"), + CLAIM_READY("auction.notify.claim"); + + private final String langKey; + + EnumNotificationType(String langKey) { + this.langKey = langKey; + } + + public String getLangKey() { + return langKey; + } + + public String getDisplayName() { + return StatCollector.translateToLocal(langKey); + } + + public static EnumNotificationType fromOrdinal(int ordinal) { + if (ordinal >= 0 && ordinal < values().length) { + return values()[ordinal]; + } + return CLAIM_READY; + } +} diff --git a/src/main/java/noppes/npcs/constants/EnumRoleType.java b/src/main/java/noppes/npcs/constants/EnumRoleType.java index 624658fb3..33f36941a 100644 --- a/src/main/java/noppes/npcs/constants/EnumRoleType.java +++ b/src/main/java/noppes/npcs/constants/EnumRoleType.java @@ -2,5 +2,5 @@ package noppes.npcs.constants; public enum EnumRoleType { - None, Trader, Follower, Bank, Transporter, Postman, Companion, Mount + None, Trader, Follower, Bank, Transporter, Postman, Companion, Mount, Auctioneer } diff --git a/src/main/java/noppes/npcs/containers/ContainerAuction.java b/src/main/java/noppes/npcs/containers/ContainerAuction.java new file mode 100644 index 000000000..2ccaa2e52 --- /dev/null +++ b/src/main/java/noppes/npcs/containers/ContainerAuction.java @@ -0,0 +1,81 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; +import noppes.npcs.entity.EntityNPCInterface; + +/** + * Base container for all auction GUIs. + * Provides player inventory at the correct position for the 256x256 auction texture. + * Player inventory is display-only - no modifications allowed. + */ +public class ContainerAuction extends ContainerNpcInterface { + public final EntityNPCInterface npc; + + // Player inventory layout for 256x256 auction texture + // Top-left slot background at (47, 163) + // Item renders at +1 offset inside the 18x18 slot background + public static final int PLAYER_INV_X = 48; // 47 + 1 for item offset + public static final int PLAYER_INV_Y = 164; // 163 + 1 for item offset + public static final int HOTBAR_Y = 222; // PLAYER_INV_Y + (3 * 18) + 4 gap + + // Player inventory slots are added first (36 slots: 0-35) + public static final int PLAYER_INV_SLOT_COUNT = 36; + + public ContainerAuction(EntityNPCInterface npc, EntityPlayer player) { + super(player); + this.npc = npc; + this.player = player; + + addPlayerInventory(); + } + + protected void addPlayerInventory() { + // Main inventory (3 rows of 9) + for (int row = 0; row < 3; row++) { + for (int col = 0; col < 9; col++) { + int slotIndex = col + row * 9 + 9; // Slots 9-35 + int x = PLAYER_INV_X + col * 18; + int y = PLAYER_INV_Y + row * 18; + addSlotToContainer(new Slot(player.inventory, slotIndex, x, y)); + } + } + + // Hotbar (1 row of 9) + for (int col = 0; col < 9; col++) { + int x = PLAYER_INV_X + col * 18; + addSlotToContainer(new Slot(player.inventory, col, x, HOTBAR_Y)); + } + } + + @Override + public ItemStack transferStackInSlot(EntityPlayer player, int slotIndex) { + return null; // Disable shift-clicking + } + + @Override + public ItemStack slotClick(int slotIndex, int mouseButton, int mode, EntityPlayer player) { + // Block all slot interactions by default in auction containers + // This prevents picking up, placing, and swapping items + // Subclasses can override to allow specific interactions + return null; + } + + /** + * Check if the slot index is a player inventory slot. + */ + public boolean isPlayerInventorySlot(int slotIndex) { + return slotIndex >= 0 && slotIndex < PLAYER_INV_SLOT_COUNT; + } + + /** + * Get the ItemStack in a player inventory slot. + * @param containerSlotIndex The container slot index (0-35 for player inv) + */ + public ItemStack getPlayerInventoryStack(int containerSlotIndex) { + if (!isPlayerInventorySlot(containerSlotIndex)) return null; + Slot slot = (Slot) inventorySlots.get(containerSlotIndex); + return slot != null ? slot.getStack() : null; + } +} diff --git a/src/main/java/noppes/npcs/containers/ContainerAuctionBidding.java b/src/main/java/noppes/npcs/containers/ContainerAuctionBidding.java new file mode 100644 index 000000000..1d1faebe9 --- /dev/null +++ b/src/main/java/noppes/npcs/containers/ContainerAuctionBidding.java @@ -0,0 +1,63 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.inventory.InventoryBasic; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; + +/** + * Container for the auction bidding/purchase view. + * Displays a single item slot showing the listing's item. + */ +public class ContainerAuctionBidding extends ContainerAuction { + // Display slot layout + public static final int ITEM_SLOT_X = 75; + public static final int ITEM_SLOT_Y = 70; + public static final int ITEM_SLOT_INDEX = PLAYER_INV_SLOT_COUNT; + + private final IInventory displayInventory; + private AuctionListing listing; + + public ContainerAuctionBidding(EntityNPCInterface npc, EntityPlayer player) { + super(npc, player); + displayInventory = new InventoryBasic("Display", false, 1); + + // Display slot (read-only) + addSlotToContainer(new Slot(displayInventory, 0, ITEM_SLOT_X, ITEM_SLOT_Y) { + @Override + public boolean isItemValid(ItemStack stack) { return false; } + @Override + public boolean canTakeStack(EntityPlayer player) { return false; } + }); + } + + /** + * Set the listing to display. + */ + public void setListing(AuctionListing listing) { + this.listing = listing; + if (listing != null && listing.item != null) { + displayInventory.setInventorySlotContents(0, listing.item.copy()); + } else { + displayInventory.setInventorySlotContents(0, null); + } + } + + public AuctionListing getListing() { + return listing; + } + + public ItemStack getDisplayItem() { + return displayInventory.getStackInSlot(0); + } + + /** + * Check if this is the display slot. + */ + public boolean isDisplaySlot(int slotIndex) { + return slotIndex == ITEM_SLOT_INDEX; + } +} diff --git a/src/main/java/noppes/npcs/containers/ContainerAuctionListing.java b/src/main/java/noppes/npcs/containers/ContainerAuctionListing.java new file mode 100644 index 000000000..9691935be --- /dev/null +++ b/src/main/java/noppes/npcs/containers/ContainerAuctionListing.java @@ -0,0 +1,133 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.ItemStack; +import noppes.npcs.controllers.AuctionController; +import noppes.npcs.controllers.data.AuctionFilter; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; + +import java.util.List; + +/** + * Container for the auction listing browse view. + * Displays a 9x5 grid of auction listings (45 slots). + */ +public class ContainerAuctionListing extends ContainerAuction { + // Listing grid layout + public static final int LISTING_START_X = 57; // 56 + 1 for item offset + public static final int LISTING_START_Y = 47; // 46 + 1 for item offset + public static final int LISTING_COLS = 9; + public static final int LISTING_ROWS = 5; + public static final int LISTING_SLOT_COUNT = LISTING_COLS * LISTING_ROWS; + + public final InventoryAuctionDisplay displayInventory; + private int currentPage = 0; + private AuctionFilter filter = new AuctionFilter(); + + public ContainerAuctionListing(EntityNPCInterface npc, EntityPlayer player) { + super(npc, player); + + // Create display inventory for listings + displayInventory = new InventoryAuctionDisplay(LISTING_SLOT_COUNT); + + // Add listing display slots (9x5 grid) + for (int row = 0; row < LISTING_ROWS; row++) { + for (int col = 0; col < LISTING_COLS; col++) { + int slotIndex = col + row * LISTING_COLS; + int x = LISTING_START_X + col * 18; + int y = LISTING_START_Y + row * 18; + addSlotToContainer(new SlotAuctionDisplay(displayInventory, slotIndex, x, y)); + } + } + + // Load initial listings + refreshListings(); + } + + public void refreshListings() { + if (AuctionController.Instance == null) return; + + List listings = AuctionController.Instance.getActiveListings( + filter, currentPage, LISTING_SLOT_COUNT); + + displayInventory.clear(); + for (int i = 0; i < listings.size() && i < LISTING_SLOT_COUNT; i++) { + displayInventory.setListing(i, listings.get(i)); + } + } + + public AuctionListing getListingAt(int slotIndex) { + return displayInventory.getListing(slotIndex); + } + + /** + * Find a listing by matching ItemStack. + */ + public AuctionListing getListingForItem(ItemStack stack) { + if (stack == null) return null; + + for (int i = 0; i < LISTING_SLOT_COUNT; i++) { + AuctionListing listing = displayInventory.getListing(i); + if (listing != null && listing.item != null && ItemStack.areItemStacksEqual(listing.item, stack)) { + return listing; + } + } + return null; + } + + public int getCurrentPage() { + return currentPage; + } + + public void setPage(int page) { + this.currentPage = Math.max(0, page); + refreshListings(); + } + + public void nextPage() { + setPage(currentPage + 1); + } + + public void prevPage() { + if (currentPage > 0) { + setPage(currentPage - 1); + } + } + + public int getTotalPages() { + if (AuctionController.Instance == null) return 1; + int totalListings = AuctionController.Instance.getTotalActiveListings(filter); + return Math.max(1, (int) Math.ceil((double) totalListings / LISTING_SLOT_COUNT)); + } + + public int getTotalListings() { + if (AuctionController.Instance == null) return 0; + return AuctionController.Instance.getTotalActiveListings(filter); + } + + public AuctionFilter getFilter() { + return filter; + } + + public void setFilter(AuctionFilter filter) { + this.filter = filter; + this.currentPage = 0; + refreshListings(); + } + + /** + * Check if a slot index is a display slot. + */ + public boolean isDisplaySlot(int slotIndex) { + int start = getDisplaySlotStart(); + return slotIndex >= start && slotIndex < start + LISTING_SLOT_COUNT; + } + + /** + * Get the starting slot index for display slots. + */ + public int getDisplaySlotStart() { + return PLAYER_INV_SLOT_COUNT; + } +} diff --git a/src/main/java/noppes/npcs/containers/ContainerAuctionSell.java b/src/main/java/noppes/npcs/containers/ContainerAuctionSell.java new file mode 100644 index 000000000..a4bab6efe --- /dev/null +++ b/src/main/java/noppes/npcs/containers/ContainerAuctionSell.java @@ -0,0 +1,125 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.inventory.InventoryBasic; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; +import noppes.npcs.NoppesUtilPlayer; +import noppes.npcs.entity.EntityNPCInterface; + +/** + * Container for creating auction listings with a single sell slot. + * Items are cloned from inventory, not moved. Validation happens on submit. + */ +public class ContainerAuctionSell extends ContainerAuction { + public static final int SELL_SLOT_X = 101; + public static final int SELL_SLOT_Y = 106; + public static final int SELL_SLOT_INDEX = PLAYER_INV_SLOT_COUNT; + private static final int MAX_STACK = 64; + + private final IInventory sellInventory; + + public ContainerAuctionSell(EntityNPCInterface npc, EntityPlayer player) { + super(npc, player); + sellInventory = new InventoryBasic("Sell", false, 1); + + // Sell slot blocks direct interaction + addSlotToContainer(new Slot(sellInventory, 0, SELL_SLOT_X, SELL_SLOT_Y) { + @Override + public boolean isItemValid(ItemStack stack) { return false; } + @Override + public boolean canTakeStack(EntityPlayer player) { return false; } + }); + } + + public ItemStack getItemToSell() { + return sellInventory.getStackInSlot(0); + } + + public void clearSellSlot() { + sellInventory.setInventorySlotContents(0, null); + } + + /** + * Add items from inventory to sell slot. + * Not limited by inventory - just stages items for sale. Validation on submit. + * @param sourceSlot Inventory slot index + * @param fullStack True=full stack, False=1 item + */ + public void addToSellSlot(int sourceSlot, boolean fullStack) { + if (!isPlayerInventorySlot(sourceSlot)) return; + + ItemStack source = getPlayerInventoryStack(sourceSlot); + if (source == null) return; + + ItemStack sellStack = sellInventory.getStackInSlot(0); + int toAdd = fullStack ? source.stackSize : 1; + + if (sellStack == null) { + // Empty slot - create new stack + ItemStack newStack = source.copy(); + newStack.stackSize = Math.min(toAdd, MAX_STACK); + sellInventory.setInventorySlotContents(0, newStack); + } else if (itemsMatch(sellStack, source)) { + // Same item - add to existing (capped at 64) + int space = MAX_STACK - sellStack.stackSize; + int add = Math.min(toAdd, space); + if (add > 0) { + sellStack.stackSize += add; + } + } else { + // Different item - replace + ItemStack newStack = source.copy(); + newStack.stackSize = Math.min(toAdd, MAX_STACK); + sellInventory.setInventorySlotContents(0, newStack); + } + } + + /** + * Remove items from sell slot. + * @param removeAll True=clear slot, False=remove 1 item + */ + public void removeFromSellSlot(boolean removeAll) { + ItemStack sellStack = sellInventory.getStackInSlot(0); + if (sellStack == null) return; + + if (removeAll || sellStack.stackSize <= 1) { + sellInventory.setInventorySlotContents(0, null); + } else { + sellStack.stackSize--; + } + } + + /** Check if sell slot is this container slot index */ + public boolean isSellSlot(int slotIndex) { + return slotIndex == SELL_SLOT_INDEX; + } + + /** Check if two stacks match using NoppesUtilPlayer.compareItems (same item, damage, and NBT) */ + private boolean itemsMatch(ItemStack a, ItemStack b) { + return NoppesUtilPlayer.compareItems(a, b, false, false); + } + + /** + * Count total items of a specific type across player inventory. + * Used for submit validation and UI indicator. + */ + public int countItemInInventory(ItemStack target) { + if (target == null) return 0; + int total = 0; + for (int i = 0; i < PLAYER_INV_SLOT_COUNT; i++) { + ItemStack stack = getPlayerInventoryStack(i); + if (stack != null && itemsMatch(stack, target)) { + total += stack.stackSize; + } + } + return total; + } + + @Override + public void onContainerClosed(EntityPlayer player) { + super.onContainerClosed(player); + clearSellSlot(); + } +} diff --git a/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java b/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java new file mode 100644 index 000000000..8481612d5 --- /dev/null +++ b/src/main/java/noppes/npcs/containers/ContainerAuctionTrades.java @@ -0,0 +1,184 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.ItemStack; +import noppes.npcs.controllers.AuctionController; +import noppes.npcs.controllers.data.AuctionClaim; +import noppes.npcs.controllers.data.AuctionListing; +import noppes.npcs.entity.EntityNPCInterface; + +import java.util.ArrayList; +import java.util.List; + +/** + * Container for viewing player's auction trades. + * Shows active listings, bids, and claims in a 9x5 grid. + */ +public class ContainerAuctionTrades extends ContainerAuction { + // Grid layout + public static final int GRID_X = 57; + public static final int GRID_Y = 47; + public static final int COLS = 9; + public static final int ROWS = 5; + public static final int SLOT_COUNT = COLS * ROWS; + + public final InventoryAuctionDisplay displayInventory; + private List activeListings = new ArrayList<>(); + private List activeBids = new ArrayList<>(); + private List claims = new ArrayList<>(); + private int hiddenSlot = -1; + + public ContainerAuctionTrades(EntityNPCInterface npc, EntityPlayer player) { + super(npc, player); + displayInventory = new InventoryAuctionDisplay(SLOT_COUNT); + + // Add display slots (9x5 grid) + for (int row = 0; row < ROWS; row++) { + for (int col = 0; col < COLS; col++) { + int idx = col + row * COLS; + int x = GRID_X + col * 18; + int y = GRID_Y + row * 18; + addSlotToContainer(new SlotAuctionDisplay(displayInventory, idx, x, y)); + } + } + refreshData(); + } + + /** Refresh data from controller */ + public void refreshData() { + activeListings.clear(); + activeBids.clear(); + claims.clear(); + displayInventory.clear(); + + if (AuctionController.Instance == null) return; + + activeListings = AuctionController.Instance.getPlayerActiveListings(player.getUniqueID()); + activeBids = AuctionController.Instance.getPlayerActiveBids(player.getUniqueID()); + claims = AuctionController.Instance.getPlayerClaims(player.getUniqueID()); + + // Populate display: listings first, then bids, then claims + int slot = 0; + for (AuctionListing listing : activeListings) { + if (slot >= SLOT_COUNT) break; + if (listing.item != null) displayInventory.setInventorySlotContents(slot, listing.item.copy()); + slot++; + } + for (AuctionListing bid : activeBids) { + if (slot >= SLOT_COUNT) break; + if (bid.item != null) displayInventory.setInventorySlotContents(slot, bid.item.copy()); + slot++; + } + for (AuctionClaim claim : claims) { + if (slot >= SLOT_COUNT) break; + displayInventory.setClaimItem(slot, claim); + slot++; + } + } + + // ========== Slot Data Accessors ========== + + /** Get listing at slot (or null if claim/empty) */ + public AuctionListing getListingAt(int slot) { + if (slot >= 0 && slot < activeListings.size()) { + return activeListings.get(slot); + } + int bidIdx = slot - activeListings.size(); + if (bidIdx >= 0 && bidIdx < activeBids.size()) { + return activeBids.get(bidIdx); + } + return null; + } + + /** Get claim at slot (or null if listing/empty) */ + public AuctionClaim getClaimAt(int slot) { + int claimIdx = slot - activeListings.size() - activeBids.size(); + if (claimIdx >= 0 && claimIdx < claims.size()) { + return claims.get(claimIdx); + } + return null; + } + + /** Check if slot is player's active listing */ + public boolean isSellingAt(int slot) { + return slot >= 0 && slot < activeListings.size(); + } + + /** Check if slot is player's active bid */ + public boolean isBiddingAt(int slot) { + int bidIdx = slot - activeListings.size(); + return bidIdx >= 0 && bidIdx < activeBids.size(); + } + + /** Find listing matching item */ + public AuctionListing getListingForItem(ItemStack stack) { + if (stack == null) return null; + for (AuctionListing listing : activeListings) { + if (listing.item != null && ItemStack.areItemStacksEqual(listing.item, stack)) return listing; + } + for (AuctionListing bid : activeBids) { + if (bid.item != null && ItemStack.areItemStacksEqual(bid.item, stack)) return bid; + } + return null; + } + + /** Find claim matching item */ + public AuctionClaim getClaimForItem(ItemStack stack) { + if (stack == null) return null; + for (AuctionClaim claim : claims) { + if (claim.item != null && ItemStack.areItemStacksEqual(claim.item, stack)) return claim; + } + return null; + } + + // ========== List Getters ========== + + public List getActiveListings() { return activeListings; } + public List getActiveBids() { return activeBids; } + public List getClaims() { return claims; } + public int getTotalTradeCount() { return activeListings.size() + activeBids.size() + claims.size(); } + + // ========== Slot Hiding (for pending operations) ========== + + /** Hide slot for pending operation display */ + public void setHiddenSlot(int slot) { + if (hiddenSlot >= 0 && hiddenSlot < SLOT_COUNT) { + restoreSlotItem(hiddenSlot); + } + hiddenSlot = slot; + if (hiddenSlot >= 0 && hiddenSlot < SLOT_COUNT) { + displayInventory.setInventorySlotContents(hiddenSlot, null); + } + } + + /** Restore hidden slot */ + public void clearHiddenSlot() { + if (hiddenSlot >= 0 && hiddenSlot < SLOT_COUNT) { + restoreSlotItem(hiddenSlot); + } + hiddenSlot = -1; + } + + /** Restore item at slot from data */ + private void restoreSlotItem(int slot) { + if (slot < activeListings.size()) { + AuctionListing listing = activeListings.get(slot); + if (listing != null && listing.item != null) { + displayInventory.setInventorySlotContents(slot, listing.item.copy()); + } + } else if (slot < activeListings.size() + activeBids.size()) { + int bidIdx = slot - activeListings.size(); + AuctionListing bid = activeBids.get(bidIdx); + if (bid != null && bid.item != null) { + displayInventory.setInventorySlotContents(slot, bid.item.copy()); + } + } else { + int claimIdx = slot - activeListings.size() - activeBids.size(); + if (claimIdx < claims.size()) { + displayInventory.setClaimItem(slot, claims.get(claimIdx)); + } + } + } + + public int getHiddenSlot() { return hiddenSlot; } +} diff --git a/src/main/java/noppes/npcs/containers/InventoryAuctionDisplay.java b/src/main/java/noppes/npcs/containers/InventoryAuctionDisplay.java new file mode 100644 index 000000000..20c03f18f --- /dev/null +++ b/src/main/java/noppes/npcs/containers/InventoryAuctionDisplay.java @@ -0,0 +1,153 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import noppes.npcs.CustomItems; +import noppes.npcs.constants.EnumClaimType; +import noppes.npcs.controllers.data.AuctionClaim; +import noppes.npcs.controllers.data.AuctionListing; + +import java.util.ArrayList; +import java.util.List; + +/** + * Virtual inventory for displaying auction listings and claims. + * This inventory holds ItemStacks from AuctionListings/Claims for display purposes. + */ +public class InventoryAuctionDisplay implements IInventory { + private final ItemStack[] items; + private final List listings; + private final List claims; + private final int size; + + public InventoryAuctionDisplay(int size) { + this.size = size; + this.items = new ItemStack[size]; + this.listings = new ArrayList<>(size); + this.claims = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + listings.add(null); + claims.add(null); + } + } + + public void setListing(int slot, AuctionListing listing) { + if (slot >= 0 && slot < size) { + listings.set(slot, listing); + claims.set(slot, null); + items[slot] = listing != null ? listing.item : null; + } + } + + public AuctionListing getListing(int slot) { + if (slot >= 0 && slot < size) { + return listings.get(slot); + } + return null; + } + + /** + * Set a claim item in the inventory. + * For ITEM claims, shows the item. + * For CURRENCY/REFUND claims, shows a gold coin as visual representation. + */ + public void setClaimItem(int slot, AuctionClaim claim) { + if (slot >= 0 && slot < size) { + claims.set(slot, claim); + listings.set(slot, null); + if (claim != null) { + if (claim.type == EnumClaimType.ITEM && claim.item != null) { + items[slot] = claim.item; + } else { + // For currency claims, show a gold coin + items[slot] = new ItemStack(CustomItems.coinGold); + } + } else { + items[slot] = null; + } + } + } + + public AuctionClaim getClaim(int slot) { + if (slot >= 0 && slot < size) { + return claims.get(slot); + } + return null; + } + + public void clear() { + for (int i = 0; i < size; i++) { + items[i] = null; + listings.set(i, null); + claims.set(i, null); + } + } + + @Override + public int getSizeInventory() { + return size; + } + + @Override + public ItemStack getStackInSlot(int slot) { + if (slot >= 0 && slot < size) { + return items[slot]; + } + return null; + } + + @Override + public ItemStack decrStackSize(int slot, int amount) { + return null; // Display only + } + + @Override + public ItemStack getStackInSlotOnClosing(int slot) { + return null; + } + + @Override + public void setInventorySlotContents(int slot, ItemStack stack) { + if (slot >= 0 && slot < size) { + items[slot] = stack; + } + } + + @Override + public String getInventoryName() { + return "Auction Display"; + } + + @Override + public boolean hasCustomInventoryName() { + return false; + } + + @Override + public int getInventoryStackLimit() { + return 64; + } + + @Override + public void markDirty() { + } + + @Override + public boolean isUseableByPlayer(EntityPlayer player) { + return true; + } + + @Override + public void openInventory() { + } + + @Override + public void closeInventory() { + } + + @Override + public boolean isItemValidForSlot(int slot, ItemStack stack) { + return false; + } +} diff --git a/src/main/java/noppes/npcs/containers/SlotAuctionDisplay.java b/src/main/java/noppes/npcs/containers/SlotAuctionDisplay.java new file mode 100644 index 000000000..43b745051 --- /dev/null +++ b/src/main/java/noppes/npcs/containers/SlotAuctionDisplay.java @@ -0,0 +1,31 @@ +package noppes.npcs.containers; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; + +/** + * A display-only slot for auction listings. + * Items cannot be placed or taken from this slot. + */ +public class SlotAuctionDisplay extends Slot { + public SlotAuctionDisplay(IInventory inventory, int slotIndex, int x, int y) { + super(inventory, slotIndex, x, y); + } + + @Override + public boolean isItemValid(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeStack(EntityPlayer player) { + return false; + } + + @Override + public int getSlotStackLimit() { + return 64; + } +} diff --git a/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java b/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java new file mode 100644 index 000000000..58e4b8d86 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/AuctionConfigSync.java @@ -0,0 +1,49 @@ +package noppes.npcs.controllers; + +import kamkeel.npcs.util.VaultUtil; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.config.ConfigMarket; + +/** + * Server-side utility for syncing auction configuration to clients. + * Handles Vault integration for currency name resolution. + * + * This class is SERVER-SIDE ONLY. Client reads via AuctionClientConfig. + */ +public class AuctionConfigSync { + + /** + * Write auction config to NBT for sending to client. + * Called on server side when building login packet. + * + * Currency name is resolved from Vault if enabled and available, + * otherwise falls back to ConfigMarket.CurrencyName. + */ + public static NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setBoolean("AuctionEnabled", ConfigMarket.AuctionEnabled); + compound.setLong("ListingFee", ConfigMarket.ListingFee); + compound.setString("CurrencyName", getEffectiveCurrencyName()); + compound.setInteger("AuctionDurationHours", ConfigMarket.AuctionDurationHours); + compound.setDouble("MinBidIncrement", ConfigMarket.MinBidIncrementPercent); + compound.setInteger("MaxActiveListings", ConfigMarket.DefaultMaxListings); + compound.setInteger("ClaimExpirationDays", ConfigMarket.ClaimExpirationDays); + return compound; + } + + /** + * Get the effective currency name for display. + * Uses Vault currency name if Vault is enabled and available, + * otherwise uses ConfigMarket.CurrencyName. + * + * @return The currency name to display + */ + public static String getEffectiveCurrencyName() { + if (ConfigMarket.UseVault && VaultUtil.isEnabled()) { + String vaultName = VaultUtil.getCurrencyNamePlural(); + if (vaultName != null && !vaultName.isEmpty() && !vaultName.equals("coins")) { + return vaultName; + } + } + return ConfigMarket.CurrencyName; + } +} diff --git a/src/main/java/noppes/npcs/controllers/AuctionController.java b/src/main/java/noppes/npcs/controllers/AuctionController.java new file mode 100644 index 000000000..92f790b99 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/AuctionController.java @@ -0,0 +1,1243 @@ +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 listings = new ConcurrentHashMap<>(); + private final Map claims = new ConcurrentHashMap<>(); + private final List pendingNotifications = Collections.synchronizedList(new ArrayList<>()); + + // ========================================= + // Player Indices for O(1) Lookups + // ========================================= + + /** Listings by seller UUID - for quick "my listings" lookups */ + private final Map> playerListingIds = new ConcurrentHashMap<>(); + + /** 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<>(); + + // ========================================= + // 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); + } + + if (listing.hasBids()) { + // Auction sold - create claims + AuctionClaim itemClaim = AuctionClaim.createItemClaim( + 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( + listing.sellerUUID, listing.sellerName, listing.id, sellerReceives); + addClaim(currencyClaim); + + // Notifications + queueNotification(listing.highBidderUUID, EnumNotificationType.AUCTION_WON, listing.id, + "You won the auction for " + listing.item.getDisplayName() + "!"); + queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_SOLD, listing.id, + "Your " + listing.item.getDisplayName() + " sold for " + saleAmount + " " + ConfigMarket.CurrencyName + "!"); + + logAuction("SOLD", listing.sellerName, listing.item.getDisplayName(), saleAmount, + "Winner: " + listing.highBidderName + ", Tax: " + tax); + } else { + // No bids - return item to seller + AuctionClaim returnClaim = AuctionClaim.createItemClaim( + listing.sellerUUID, listing.sellerName, listing.id, listing.item); + addClaim(returnClaim); + + queueNotification(listing.sellerUUID, EnumNotificationType.AUCTION_EXPIRED, listing.id, + "Your auction for " + listing.item.getDisplayName() + " expired with no bids."); + + logAuction("EXPIRED", listing.sellerName, listing.item.getDisplayName(), 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 + // ========================================= + + private void addToPlayerListings(UUID playerUUID, String listingId) { + playerListingIds.computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()).add(listingId); + } + + private void removeFromPlayerListings(UUID playerUUID, String listingId) { + Set 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 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 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( + listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid); + 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.createItemClaim( + 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.createItemClaim( + 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( + previousBidder, listing.highBidderName, listing.id, previousBid); + 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( + listing.highBidderUUID, listing.highBidderName, listing.id, listing.currentBid); + 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.createItemClaim(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); + 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 getPlayerClaims(UUID playerUUID) { + Set claimIds = playerClaimIds.get(playerUUID); + if (claimIds == null || claimIds.isEmpty()) { + return new ArrayList<>(); + } + + List 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 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 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; + + // 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 list, EnumAuctionSort sortBy) { + Comparator 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 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 getPlayerActiveListings(UUID playerUUID) { + Set listingIds = playerListingIds.get(playerUUID); + if (listingIds == null || listingIds.isEmpty()) { + return new ArrayList<>(); + } + + List 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 getPlayerActiveBids(UUID playerUUID) { + Set bidListingIds = playerBidIds.get(playerUUID); + if (bidListingIds == null || bidListingIds.isEmpty()) { + return new ArrayList<>(); + } + + List 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 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 + Set 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 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 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); + } +} diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java b/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java new file mode 100644 index 000000000..7a4e45a5d --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/AuctionClaim.java @@ -0,0 +1,120 @@ +package noppes.npcs.controllers.data; + +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.constants.EnumClaimType; + +import java.util.UUID; + +public class AuctionClaim { + public String id; + public UUID playerUUID; + public String playerName; + public String listingId; + public EnumClaimType type; + public ItemStack item; + public long currency; + public long createdTime; + public boolean claimed; + + public AuctionClaim() { + this.id = UUID.randomUUID().toString(); + this.createdTime = System.currentTimeMillis(); + this.claimed = false; + } + + public static AuctionClaim createItemClaim(UUID playerUUID, String playerName, String listingId, ItemStack item) { + AuctionClaim claim = new AuctionClaim(); + claim.playerUUID = playerUUID; + claim.playerName = playerName; + claim.listingId = listingId; + claim.type = EnumClaimType.ITEM; + claim.item = item != null ? item.copy() : null; + claim.currency = 0; + return claim; + } + + public static AuctionClaim createCurrencyClaim(UUID playerUUID, String playerName, String listingId, long currency) { + AuctionClaim claim = new AuctionClaim(); + claim.playerUUID = playerUUID; + claim.playerName = playerName; + claim.listingId = listingId; + claim.type = EnumClaimType.CURRENCY; + claim.item = null; + claim.currency = currency; + return claim; + } + + public static AuctionClaim createRefundClaim(UUID playerUUID, String playerName, String listingId, long currency) { + AuctionClaim claim = new AuctionClaim(); + claim.playerUUID = playerUUID; + claim.playerName = playerName; + claim.listingId = listingId; + claim.type = EnumClaimType.REFUND; + claim.item = null; + claim.currency = currency; + return claim; + } + + public boolean isExpired(int expirationDays) { + long expirationMs = expirationDays * 24L * 60L * 60L * 1000L; + return System.currentTimeMillis() - createdTime > expirationMs; + } + + public long getDaysUntilExpiration(int expirationDays) { + long expirationMs = expirationDays * 24L * 60L * 60L * 1000L; + long remaining = (createdTime + expirationMs) - System.currentTimeMillis(); + return Math.max(0, remaining / (24L * 60L * 60L * 1000L)); + } + + public boolean isForPlayer(UUID uuid) { + return playerUUID != null && playerUUID.equals(uuid); + } + + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setString("ID", id); + compound.setString("PlayerUUID", playerUUID != null ? playerUUID.toString() : ""); + compound.setString("PlayerName", playerName != null ? playerName : ""); + compound.setString("ListingID", listingId != null ? listingId : ""); + compound.setInteger("Type", type.ordinal()); + + if (item != null) { + NBTTagCompound itemTag = new NBTTagCompound(); + item.writeToNBT(itemTag); + compound.setTag("Item", itemTag); + } + + compound.setLong("Currency", currency); + compound.setLong("CreatedTime", createdTime); + compound.setBoolean("Claimed", claimed); + + return compound; + } + + public void readFromNBT(NBTTagCompound compound) { + id = compound.getString("ID"); + + String playerUUIDStr = compound.getString("PlayerUUID"); + playerUUID = playerUUIDStr.isEmpty() ? null : UUID.fromString(playerUUIDStr); + playerName = compound.getString("PlayerName"); + + listingId = compound.getString("ListingID"); + type = EnumClaimType.fromOrdinal(compound.getInteger("Type")); + + if (compound.hasKey("Item")) { + item = ItemStack.loadItemStackFromNBT(compound.getCompoundTag("Item")); + } else { + item = null; + } + + currency = compound.getLong("Currency"); + createdTime = compound.getLong("CreatedTime"); + claimed = compound.getBoolean("Claimed"); + } + + public static AuctionClaim fromNBT(NBTTagCompound compound) { + AuctionClaim claim = new AuctionClaim(); + claim.readFromNBT(compound); + return claim; + } +} diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java b/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java new file mode 100644 index 000000000..49ed0a29c --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/AuctionFilter.java @@ -0,0 +1,102 @@ +package noppes.npcs.controllers.data; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumChatFormatting; +import noppes.npcs.constants.EnumAuctionSort; + +import java.util.regex.Pattern; + +/** + * Filter for auction listings. + * Supports search by item name and sorting options. + */ +public class AuctionFilter { + private static final Pattern COLOR_CODE_PATTERN = Pattern.compile("§[0-9a-fk-or]", Pattern.CASE_INSENSITIVE); + + public String searchText; + public EnumAuctionSort sortBy; + + // Cached normalized search for efficiency + private String normalizedSearch; + + public AuctionFilter() { + this.searchText = ""; + this.sortBy = EnumAuctionSort.NEWEST; + this.normalizedSearch = ""; + } + + public boolean hasSearchText() { + return searchText != null && !searchText.trim().isEmpty(); + } + + /** Normalize string for search (strip colors, lowercase, trim) */ + public static String normalizeForSearch(String input) { + if (input == null || input.isEmpty()) return ""; + String stripped = COLOR_CODE_PATTERN.matcher(input).replaceAll(""); + stripped = EnumChatFormatting.getTextWithoutFormattingCodes(stripped); + return stripped.toLowerCase().trim(); + } + + /** Check if item name matches search text */ + public boolean matchesSearch(String itemName) { + if (!hasSearchText()) return true; + String normalized = normalizeForSearch(itemName); + return normalized.contains(normalizedSearch); + } + + /** Advanced search - all words must be present */ + public boolean matchesSearchAdvanced(String itemName) { + if (!hasSearchText()) return true; + String normalized = normalizeForSearch(itemName); + String[] words = normalizedSearch.split("\\s+"); + for (String word : words) { + if (!word.isEmpty() && !normalized.contains(word)) { + return false; + } + } + return true; + } + + public void reset() { + searchText = ""; + sortBy = EnumAuctionSort.NEWEST; + normalizedSearch = ""; + } + + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setString("SearchText", searchText != null ? searchText : ""); + compound.setInteger("SortBy", sortBy.ordinal()); + return compound; + } + + public void readFromNBT(NBTTagCompound compound) { + searchText = compound.getString("SearchText"); + normalizedSearch = normalizeForSearch(searchText); + sortBy = EnumAuctionSort.fromOrdinal(compound.getInteger("SortBy")); + } + + public static AuctionFilter fromNBT(NBTTagCompound compound) { + AuctionFilter filter = new AuctionFilter(); + filter.readFromNBT(compound); + return filter; + } + + public AuctionFilter copy() { + AuctionFilter copy = new AuctionFilter(); + copy.searchText = this.searchText; + copy.normalizedSearch = this.normalizedSearch; + copy.sortBy = this.sortBy; + return copy; + } + + // Getters and setters + public String getSearchText() { return searchText; } + + public void setSearchText(String searchText) { + this.searchText = searchText; + this.normalizedSearch = normalizeForSearch(searchText); + } + + public EnumAuctionSort getSortBy() { return sortBy; } + public void setSortBy(EnumAuctionSort sortBy) { this.sortBy = sortBy; } +} diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionListing.java b/src/main/java/noppes/npcs/controllers/data/AuctionListing.java new file mode 100644 index 000000000..3106a9fb2 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/AuctionListing.java @@ -0,0 +1,147 @@ +package noppes.npcs.controllers.data; + +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.constants.EnumAuctionStatus; + +import java.util.UUID; + +public class AuctionListing { + public String id; + public UUID sellerUUID; + public String sellerName; + public ItemStack item; + public long startingPrice; + public long buyoutPrice; + public long currentBid; + public UUID highBidderUUID; + public String highBidderName; + public int bidCount; + public long createdTime; + public long endTime; + public EnumAuctionStatus status; + + public AuctionListing() { + this.id = UUID.randomUUID().toString(); + this.status = EnumAuctionStatus.ACTIVE; + this.bidCount = 0; + this.currentBid = 0; + this.createdTime = System.currentTimeMillis(); + } + + public AuctionListing(UUID sellerUUID, String sellerName, ItemStack item, long startingPrice, long buyoutPrice, long durationMs) { + this(); + this.sellerUUID = sellerUUID; + this.sellerName = sellerName; + this.item = item.copy(); + this.startingPrice = startingPrice; + this.buyoutPrice = buyoutPrice; + this.currentBid = 0; + 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()); + } + + public long getEffectivePrice() { + return hasBids() ? currentBid : startingPrice; + } + + public long getMinimumBid(double minIncrementPercent) { + if (!hasBids()) { + return startingPrice; + } + long increment = (long) Math.ceil(currentBid * minIncrementPercent); + return currentBid + Math.max(1, increment); + } + + public boolean isSeller(UUID playerUUID) { + return sellerUUID != null && sellerUUID.equals(playerUUID); + } + + public boolean isHighBidder(UUID playerUUID) { + return highBidderUUID != null && highBidderUUID.equals(playerUUID); + } + + public void extendForSnipeProtection(int snipeProtectionMinutes) { + long snipeProtectionMs = snipeProtectionMinutes * 60 * 1000L; + long timeRemaining = getTimeRemaining(); + if (timeRemaining < snipeProtectionMs) { + endTime = System.currentTimeMillis() + snipeProtectionMs; + } + } + + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setString("ID", id); + compound.setString("SellerUUID", sellerUUID != null ? sellerUUID.toString() : ""); + compound.setString("SellerName", sellerName != null ? sellerName : ""); + + if (item != null) { + NBTTagCompound itemTag = new NBTTagCompound(); + item.writeToNBT(itemTag); + compound.setTag("Item", itemTag); + } + + compound.setLong("StartingPrice", startingPrice); + compound.setLong("BuyoutPrice", buyoutPrice); + compound.setLong("CurrentBid", currentBid); + + compound.setString("HighBidderUUID", highBidderUUID != null ? highBidderUUID.toString() : ""); + compound.setString("HighBidderName", highBidderName != null ? highBidderName : ""); + + compound.setInteger("BidCount", bidCount); + compound.setLong("CreatedTime", createdTime); + compound.setLong("EndTime", endTime); + compound.setInteger("Status", status.ordinal()); + + return compound; + } + + public void readFromNBT(NBTTagCompound compound) { + id = compound.getString("ID"); + + String sellerUUIDStr = compound.getString("SellerUUID"); + sellerUUID = sellerUUIDStr.isEmpty() ? null : UUID.fromString(sellerUUIDStr); + sellerName = compound.getString("SellerName"); + + if (compound.hasKey("Item")) { + item = ItemStack.loadItemStackFromNBT(compound.getCompoundTag("Item")); + } + + startingPrice = compound.getLong("StartingPrice"); + buyoutPrice = compound.getLong("BuyoutPrice"); + currentBid = compound.getLong("CurrentBid"); + + String highBidderUUIDStr = compound.getString("HighBidderUUID"); + highBidderUUID = highBidderUUIDStr.isEmpty() ? null : UUID.fromString(highBidderUUIDStr); + highBidderName = compound.getString("HighBidderName"); + + bidCount = compound.getInteger("BidCount"); + createdTime = compound.getLong("CreatedTime"); + endTime = compound.getLong("EndTime"); + status = EnumAuctionStatus.fromOrdinal(compound.getInteger("Status")); + } + + public static AuctionListing fromNBT(NBTTagCompound compound) { + AuctionListing listing = new AuctionListing(); + listing.readFromNBT(compound); + return listing; + } +} diff --git a/src/main/java/noppes/npcs/controllers/data/AuctionNotification.java b/src/main/java/noppes/npcs/controllers/data/AuctionNotification.java new file mode 100644 index 000000000..01ebd1e47 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/AuctionNotification.java @@ -0,0 +1,64 @@ +package noppes.npcs.controllers.data; + +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.constants.EnumNotificationType; + +import java.util.UUID; + +public class AuctionNotification { + public String id; + public UUID playerUUID; + public EnumNotificationType type; + public String listingId; + public String message; + public long timestamp; + public boolean sent; + + public AuctionNotification() { + this.id = UUID.randomUUID().toString(); + this.timestamp = System.currentTimeMillis(); + this.sent = false; + } + + public AuctionNotification(UUID playerUUID, EnumNotificationType type, String listingId, String message) { + this(); + this.playerUUID = playerUUID; + this.type = type; + this.listingId = listingId; + this.message = message; + } + + public boolean isForPlayer(UUID uuid) { + return playerUUID != null && playerUUID.equals(uuid); + } + + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setString("ID", id); + compound.setString("PlayerUUID", playerUUID != null ? playerUUID.toString() : ""); + compound.setInteger("Type", type.ordinal()); + compound.setString("ListingID", listingId != null ? listingId : ""); + compound.setString("Message", message != null ? message : ""); + compound.setLong("Timestamp", timestamp); + compound.setBoolean("Sent", sent); + return compound; + } + + public void readFromNBT(NBTTagCompound compound) { + id = compound.getString("ID"); + + String playerUUIDStr = compound.getString("PlayerUUID"); + playerUUID = playerUUIDStr.isEmpty() ? null : UUID.fromString(playerUUIDStr); + + type = EnumNotificationType.fromOrdinal(compound.getInteger("Type")); + listingId = compound.getString("ListingID"); + message = compound.getString("Message"); + timestamp = compound.getLong("Timestamp"); + sent = compound.getBoolean("Sent"); + } + + public static AuctionNotification fromNBT(NBTTagCompound compound) { + AuctionNotification notification = new AuctionNotification(); + notification.readFromNBT(compound); + return notification; + } +} diff --git a/src/main/java/noppes/npcs/mixin/CustomNPCsMixinPlugin.java b/src/main/java/noppes/npcs/mixin/CustomNPCsMixinPlugin.java index 9b09759c0..c997729ca 100644 --- a/src/main/java/noppes/npcs/mixin/CustomNPCsMixinPlugin.java +++ b/src/main/java/noppes/npcs/mixin/CustomNPCsMixinPlugin.java @@ -56,6 +56,7 @@ public class CustomNPCsMixinPlugin implements IMixinConfigPlugin { } mixins.add("MixinItemStack"); + mixins.add("MixinItem"); } return mixins; } diff --git a/src/main/java/noppes/npcs/mixin/MixinItem.java b/src/main/java/noppes/npcs/mixin/MixinItem.java new file mode 100644 index 000000000..7f2156919 --- /dev/null +++ b/src/main/java/noppes/npcs/mixin/MixinItem.java @@ -0,0 +1,27 @@ +package noppes.npcs.mixin; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import noppes.npcs.client.gui.player.AuctionTooltipHandler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; + +/** + * Mixin to inject auction information into item tooltips when viewing auction GUIs. + */ +@Mixin(Item.class) +public class MixinItem { + + @SideOnly(Side.CLIENT) + @Inject(method = "addInformation", at = @At("HEAD")) + public void onAddInformation(ItemStack stack, EntityPlayer player, List tooltip, boolean advanced, CallbackInfo ci) { + AuctionTooltipHandler.addAuctionInfo(stack, tooltip); + } +} diff --git a/src/main/java/noppes/npcs/mixin/MixinItemStack.java b/src/main/java/noppes/npcs/mixin/MixinItemStack.java index 8615573c0..054269e23 100644 --- a/src/main/java/noppes/npcs/mixin/MixinItemStack.java +++ b/src/main/java/noppes/npcs/mixin/MixinItemStack.java @@ -1,9 +1,20 @@ package noppes.npcs.mixin; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.util.AttributeItemUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.StatCollector; +import noppes.npcs.client.gui.player.GuiAuctionInterface; +import noppes.npcs.client.gui.player.GuiAuctionListing; +import noppes.npcs.config.ConfigMarket; +import noppes.npcs.containers.ContainerAuctionListing; +import noppes.npcs.controllers.data.AuctionListing; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; diff --git a/src/main/java/noppes/npcs/roles/RoleAuctioneer.java b/src/main/java/noppes/npcs/roles/RoleAuctioneer.java new file mode 100644 index 000000000..727e72378 --- /dev/null +++ b/src/main/java/noppes/npcs/roles/RoleAuctioneer.java @@ -0,0 +1,41 @@ +package noppes.npcs.roles; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.NoppesUtilServer; +import noppes.npcs.config.ConfigMarket; +import noppes.npcs.constants.EnumGuiType; +import noppes.npcs.entity.EntityNPCInterface; + +public class RoleAuctioneer extends RoleInterface { + + public RoleAuctioneer(EntityNPCInterface npc) { + super(npc); + } + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + // Simple role - no custom data needed + return compound; + } + + @Override + public void readFromNBT(NBTTagCompound compound) { + // Simple role - no custom data needed + } + + @Override + public void interact(EntityPlayer player) { + if (!ConfigMarket.AuctionEnabled) { + return; + } + + // Open the auction house GUI + NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerAuction, npc); + } + + @Override + public void delete() { + // No cleanup needed + } +} diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 715ca4884..6862e5455 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -1705,3 +1705,183 @@ condition.hit_count=Hit Count condition.percent=Threshold condition.hits=Required Hits condition.within=Within (ticks) + +# ========================================= +# Auction House +# ========================================= + +# Auction Status +auction.status.active=Active +auction.status.ended=Ended +auction.status.cancelled=Cancelled +auction.status.claimed=Claimed + +# Auction Sort +auction.sort.newest=Newest +auction.sort.endingSoon=Ending Soon +auction.sort.priceLow=Price: Low to High +auction.sort.priceHigh=Price: High to Low +auction.sort.mostBids=Most Bids + +# Claim Types +auction.claim.item=Item +auction.claim.currency=Sale Proceeds +auction.claim.refund=Bid Refund + +# Notifications +auction.notify.won=You won an auction! +auction.notify.outbid=You were outbid! +auction.notify.sold=Your item sold! +auction.notify.expired=Your auction expired. +auction.notify.claim=You have items to claim. + +# Main GUI +auction.title=Auction House +auction.browse=Browse +auction.sell=Sell +auction.trades=My Trades +auction.filter=Filter + +# Browse +auction.nextPage=Next > +auction.prevPage=< Prev +auction.page=Page %d of %d +auction.noResults=No auctions found +auction.refresh=Refresh + +# Listing Display +auction.seller=Seller: %s +auction.currentBid=Current Bid: %s +auction.buyout=Buyout: %s +auction.timeLeft=Time Left: %s +auction.bids=%d bids +auction.noBids=No bids +auction.startingPrice=Starting: %s + +# Create Listing +auction.sell.title=Create Listing +auction.sell.startingPrice=Starting Price +auction.sell.buyoutPrice=Buyout Price +auction.sell.noBuyout=(0 = no buyout) +auction.sell.fee=Listing Fee: %s +auction.sell.create=Create Listing +auction.sell.noItem=Place an item to sell + +# Trades +auction.trades.title=My Trades +auction.trades.active=Active Listings +auction.trades.activeBids=Active Bids +auction.trades.pending=Pending Claims +auction.trades.claimAll=Claim All +auction.trades.cancel=Cancel Listing +auction.trades.empty=No active trades + +# Filter +auction.filter.title=Filter Auctions +auction.filter.search=Search +auction.filter.minPrice=Min Price +auction.filter.maxPrice=Max Price +auction.filter.myOnly=My Listings Only +auction.filter.apply=Apply +auction.filter.reset=Reset + +# Bid Dialog +auction.bid.title=Place Bid +auction.bid.current=Current Bid: %s +auction.bid.minimum=Minimum Bid: %s +auction.bid.yourBid=Your Bid: +auction.bid.place=Place Bid +auction.bid.buyout=Buyout Now + +# Errors +auction.error.notEnoughCurrency=Not enough currency! +auction.error.auctionEnded=This auction has ended. +auction.error.bidTooLow=Bid must be at least %s. +auction.error.maxListings=You have reached your maximum listings. +auction.error.blacklisted=This item cannot be auctioned. +auction.error.noAuctioneer=You must visit an Auctioneer NPC. +auction.error.inventoryFull=Your inventory is full. +auction.error.cannotAfford=You cannot afford this. + +# Navigation +auction.nav.listings=Browse Auctions +auction.nav.sell=Sell Item +auction.nav.trades=My Trades +auction.nav.filter=Filter + +# Claim Details +auction.claim.amount=Amount: %s +auction.claim.expires=Expires in %s days +auction.claim.expiresSoon=Expires soon! +auction.claim.clickToClaim=Click to claim + +# Create Listing +auction.createListing=Create Listing + +# Filter +auction.filter.sort=Sort By +auction.filter.none=(none) +auction.filter.leftClick=Left-click: Search +auction.filter.rightClick=Right-click: Cycle Sort + +# Pagination +auction.page.prev=Previous Page +auction.page.next=Next Page +auction.listings.count=%d Listings + +# Sell Page +auction.sell.confirm=Confirm Listing +auction.sell.invalidPrice=Enter a valid starting price +auction.sell.buyoutTooLow=Buyout must be higher than starting price +auction.sell.placeItem=Place item here +auction.sell.slotTitle=Item to Sell +auction.sell.leftClear=Left-click to clear +auction.sell.rightRemove=Right-click to remove 1 +auction.sell.leftAdd=Left-click to add stack +auction.sell.rightAddOne=Right-click to add 1 + +# Trades Page +auction.trades.activeListing=Active Listing +auction.trades.activeBid=Active Bid +auction.trades.unavailable=Slot Unavailable +auction.trades.rightClickToCancel=Right-click to cancel +auction.trades.leftClickToClaim=Left-click to claim +auction.trades.winningBid=You are the highest bidder +auction.trades.pendingClaim=Pending Claim +auction.trades.refundClaim=Bid Refund +auction.trades.confirmCancel=Cancel Listing? +auction.trades.confirmClaim=Claim Item? +auction.trades.rightClickConfirm=Right-click again to confirm +auction.trades.leftClickConfirm=Left-click again to confirm +auction.trades.otherClickCancel=Any other click to cancel + +# Sell Page - Buyout Toggle +auction.sell.allowBuyout=Allow Buy Now +auction.sell.buyoutEnabled=Enabled +auction.sell.buyoutEnabledDesc=Buyers can purchase immediately +auction.sell.buyoutEnabledDesc2=at the buyout price +auction.sell.buyoutDisabled=Disabled +auction.sell.buyoutDisabledDesc=Auction only - no instant buy +auction.sell.clickToToggle=Click to toggle +auction.sell.notEnoughItems=Not enough items in inventory + +# Auction Bidding Info +auction.info.currentBid=Current Bid +auction.info.highBidder=High Bidder +auction.info.startingPrice=Starting Price +auction.info.buyoutPrice=Buyout Price +auction.info.timeLeft=Time Left +auction.info.bidCount=Total Bids + +# Auction Bid SubGui +auction.bid.yourBalance=Your Balance: %s + +# Auction Buyout SubGui +auction.buyout.title=Buy Now +auction.buyout.confirm=Purchase this item for: + +# Auction Status +auction.ended=Ended + +# Role +role.auctioneer=Auctioneer diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/auction.png b/src/main/resources/assets/customnpcs/textures/gui/auction/auction.png new file mode 100644 index 000000000..e7b299093 Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/auction.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/auction_slot.png b/src/main/resources/assets/customnpcs/textures/gui/auction/auction_slot.png new file mode 100644 index 000000000..76833ca40 Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/auction_slot.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/button.png b/src/main/resources/assets/customnpcs/textures/gui/auction/button.png new file mode 100644 index 000000000..275629c32 Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/button.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/button_press.png b/src/main/resources/assets/customnpcs/textures/gui/auction/button_press.png new file mode 100644 index 000000000..7152e278e Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/button_press.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/check_icon.png b/src/main/resources/assets/customnpcs/textures/gui/auction/check_icon.png new file mode 100644 index 000000000..b4ecd939e Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/check_icon.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/down.png b/src/main/resources/assets/customnpcs/textures/gui/auction/down.png new file mode 100644 index 000000000..f76c57e1b Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/down.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/full_concept.png b/src/main/resources/assets/customnpcs/textures/gui/auction/full_concept.png new file mode 100644 index 000000000..917c7474e Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/full_concept.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/my_auctions.png b/src/main/resources/assets/customnpcs/textures/gui/auction/my_auctions.png new file mode 100644 index 000000000..67d0faa4c Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/my_auctions.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/search.png b/src/main/resources/assets/customnpcs/textures/gui/auction/search.png new file mode 100644 index 000000000..4887af00c Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/search.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/sell.png b/src/main/resources/assets/customnpcs/textures/gui/auction/sell.png new file mode 100644 index 000000000..9f4a1eecd Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/sell.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/small_button.png b/src/main/resources/assets/customnpcs/textures/gui/auction/small_button.png new file mode 100644 index 000000000..4ca4aedfe Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/small_button.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/small_button_press.png b/src/main/resources/assets/customnpcs/textures/gui/auction/small_button_press.png new file mode 100644 index 000000000..aca760502 Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/small_button_press.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/up.png b/src/main/resources/assets/customnpcs/textures/gui/auction/up.png new file mode 100644 index 000000000..f81ba084f Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/up.png differ diff --git a/src/main/resources/assets/customnpcs/textures/gui/auction/x_icon.png b/src/main/resources/assets/customnpcs/textures/gui/auction/x_icon.png new file mode 100644 index 000000000..90253fc9a Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/auction/x_icon.png differ